Compare commits

...

83 Commits

Author SHA1 Message Date
Arthur
1fb6507cc1 #14329 Improve diffs for custom_fields
CI / build (20.x, 3.13) (push) Failing after 16s
CI / build (20.x, 3.12) (push) Failing after 18s
CI / build (20.x, 3.14) (push) Failing after 14s
2026-03-17 09:44:01 -07:00
Arthur
ca021e808b #14329 Improve diffs for custom_fields
CI / build (20.x, 3.13) (push) Failing after 15s
CI / build (20.x, 3.12) (push) Failing after 17s
CI / build (20.x, 3.14) (push) Failing after 14s
2026-03-17 09:14:41 -07:00
Arthur
45b53ee036 #14329 Improve diffs for custom_fields 2026-03-17 09:03:57 -07:00
Arthur
992630d670 #14329 Improve diffs for custom_fields 2026-03-17 08:44:18 -07:00
Arthur
c8cd5fd6cd #14329 Improve diffs for custom_fields 2026-03-16 17:14:26 -07:00
Martin Hauser
268ef4f59f chore(ci): Pin CodeQL action to commit SHA
Pin GitHub/codeql-action references to full commit SHA v4.33.0 instead
of version tag to reduce supply chain risk from tag retargeting.
2026-03-16 15:14:23 +01:00
Martin Hauser
671b1cd470 chore(ci): Pin GitHub Actions to commit SHAs
Pin GitHub Actions references to full commit SHAs instead of version
tags to reduce supply chain risk from tag retargeting.

Update actions/checkout to v6.0.2, actions/setup-python to v6.2.0,
actions/setup-node to v6.3.0, actions/stale to v10.2.0, and
dessant/lock-threads to v6.0.0.
2026-03-16 14:35:51 +01: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
bctiemann
2f5543933e Merge pull request #21670 from netbox-community/15513-add-bulk-create-for-prefixes
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 1m5s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m12s
CodeQL / Analyze (python) (push) Failing after 1m13s
Closes #15513: Add bulk creation support for IP prefixes
2026-03-13 18:25:13 -04: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
Martin Hauser
1fc43026d0 Closes #20698: Expose total_vlan_ids on VLAN groups (#21574)
Fixes #20698
2026-03-13 15:10:56 -05:00
Martin Hauser
5804b53bb1 fix(utilities): Add atomic group in expandable field regex pattern
CI / build (20.x, 3.13) (push) Failing after 16s
CI / build (20.x, 3.14) (push) Failing after 15s
CI / build (20.x, 3.12) (push) Failing after 19s
Replace non-capturing group with atomic group in expansion bracket regex
to prevent excessive backtracking. Add missing 'object' key to bulk view
context for template compatibility.
2026-03-13 15:50:27 +01:00
Martin Hauser
775d6aa936 feat(ipam): Add HTMX support to prefix bulk add form
CI / build (20.x, 3.13) (push) Failing after 24s
CI / build (20.x, 3.12) (push) Failing after 27s
CI / build (20.x, 3.14) (push) Failing after 23s
Enable dynamic form updates in the prefix bulk add view by introducing
HTMX partial rendering. Inherit from PrefixForm to support scope and
VLAN fields, and add htmx_template_name for efficient field updates.
2026-03-13 15:10:46 +01:00
Martin Hauser
639a739b5b feat(ipam): Add bulk creation support for prefixes
Implement bulk prefix creation using network patterns
(e.g., 10.[0-2].0/2). Refactor bulk creation views to support reusable
context and templates. Rename IPAddressBulkCreateForm to
IPNetworkBulkCreateForm for IPv4/IPv6 support.
2026-03-13 15:10:18 +01:00
bctiemann
b01d92c98b Fixes: #19953 - ConfigTemplate debug rendering mode (#21652)
CI / build (20.x, 3.12) (push) Failing after 22s
CI / build (20.x, 3.13) (push) Failing after 23s
CI / build (20.x, 3.14) (push) Failing after 56s
CodeQL / Analyze (actions) (push) Failing after 1m30s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m31s
CodeQL / Analyze (python) (push) Failing after 1m23s
Add debug field to ConfigTemplate and (if True) render template errors
with a full traceback.
2026-03-13 08:19:45 +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
bctiemann
02165a28a0 Closes #20151: Add support for cable bundles (#21636)
CI / build (20.x, 3.12) (push) Failing after 11s
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 1m25s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m29s
CodeQL / Analyze (python) (push) Failing after 1m28s
2026-03-11 11:43:40 -05:00
Jason Novinger
80cc7e0d91 Closes #21157: Add public models to export template context
Move shared get_context() logic from ConfigTemplate into
RenderTemplateMixin so ExportTemplate also gets access to all
public model classes. This enables export templates to perform
cross-model lookups (e.g. resolving parent Prefix from IPAddress).
2026-03-11 12:26:17 -04: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
Martin Hauser
e2665ef211 Closes #20961: Introduce RackGroup for physical rack placement (#21624)
CI / build (20.x, 3.12) (push) Failing after 11s
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 1m11s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m18s
CodeQL / Analyze (python) (push) Failing after 1m21s
Fixes #20961
2026-03-10 10:19:12 -05:00
bctiemann
c384cec453 Closes #21331: Emit deprecation warning on use of querystring template tag (#21476) 2026-03-10 10:10:40 -05: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
Arthur Hanson
e3d9fe622d Fix #17654: Add Role to ASN (#21582)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Jason Novinger <jnovinger@gmail.com>
Closes #21571: Bump minimatch and markdown-it to resolve security alerts (#21573)
2026-03-10 10:00:28 -05: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
bctiemann
719effb548 Fixes: #20123 - Add replicate_components and adopt_components write_only fields to ModuleSerializer (#21600)
CI / build (20.x, 3.12) (push) Failing after 14s
CI / build (20.x, 3.13) (push) Failing after 15s
CI / build (20.x, 3.14) (push) Failing after 12s
CodeQL / Analyze (actions) (push) Failing after 1m10s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m15s
CodeQL / Analyze (python) (push) Failing after 1m17s
2026-03-09 11:11:40 -07: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
6659bb3abe Closes #21363: Implement cursor-based pagination for the REST API (#21594)
CI / build (20.x, 3.12) (push) Failing after 17s
CI / build (20.x, 3.13) (push) Failing after 15s
CI / build (20.x, 3.14) (push) Failing after 15s
CodeQL / Analyze (actions) (push) Failing after 10m25s
CodeQL / Analyze (javascript-typescript) (push) Failing after 10m40s
CodeQL / Analyze (python) (push) Failing after 10m40s
2026-03-06 17:13:08 -08: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
0a5f40338d Merge pull request #21584 from netbox-community/21409-introduce-an-option-to-retain-the-original-create-and-latest
CI / build (20.x, 3.12) (push) Failing after 14s
CI / build (20.x, 3.13) (push) Failing after 13s
CodeQL / Analyze (actions) (push) Failing after 13s
CodeQL / Analyze (javascript-typescript) (push) Failing after 12s
CodeQL / Analyze (python) (push) Failing after 11s
CI / build (20.x, 3.14) (push) Failing after 24s
Closes #21409: Add option to retain create & last update changelog records when pruning
2026-03-06 09:26:58 -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
fd6e0e9784 feat(core): Retain create & last update changelog records
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 13s
Introduce a new configuration parameter,
`CHANGELOG_RETAIN_CREATE_LAST_UPDATE`, to retain each object's create
record and most recent update record when pruning expired changelog
entries (per `CHANGELOG_RETENTION`).
Update documentation, templates, and forms to reflect this change.

Fixes #21409
2026-03-05 22:05:07 +01: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
2a176df28a Merge branch 'main' into feature
CI / build (20.x, 3.12) (push) Failing after 11s
CI / build (20.x, 3.13) (push) Failing after 12s
CI / build (20.x, 3.14) (push) Failing after 12s
CodeQL / Analyze (actions) (push) Failing after 12s
CodeQL / Analyze (javascript-typescript) (push) Failing after 13s
CodeQL / Analyze (python) (push) Failing after 12s
2026-03-05 12:39:09 -05:00
bctiemann
cd5d88ff8a Merge pull request #21522 from netbox-community/21356-etags
Closes #21356: Implement ETag support for REST API
2026-03-05 12:06:11 -05:00
bctiemann
6e3fd9d4b2 Merge pull request #21581 from netbox-community/20916-jobs-log-stack-trace
Closes #20916: Record a stack trace in the job log for unhandled exceptions
2026-03-05 11:52:41 -05:00
bctiemann
53ae164c75 Fixes: #20984 - Django 6.0 (#21583) 2026-03-05 08:36:47 -08:00
Jeremy Stretch
c40640af81 Omit the system filepath north of the installation root 2026-03-04 13:47:54 -05:00
Jeremy Stretch
3c6596de8f Closes #20916: Record a stack trace in the job log for unhandled exceptions 2026-03-04 13:39:08 -05:00
Jeremy Stretch
b3de0b9bee Enforce IF-Match for DELETE requests as well
CI / build (20.x, 3.12) (push) Failing after 17s
CI / build (20.x, 3.13) (push) Failing after 20s
CI / build (20.x, 3.14) (push) Failing after 17s
2026-03-04 10:49:09 -05:00
Jeremy Stretch
ec0fe62df5 Include the current ETag in the 412 response 2026-03-04 10:44:37 -05:00
Jeremy Stretch
d3a0566ee3 Address TOCTOU race condition 2026-03-04 10:38:12 -05:00
Jeremy Stretch
694e3765dd Use weak ETags 2026-03-04 10:04:30 -05:00
Jeremy Stretch
303199dc8f Closes #21356: Implement ETag support for REST API 2026-03-04 09:57:59 -05: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
100 changed files with 2237 additions and 2390 deletions
+4 -4
View File
@@ -53,7 +53,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check Python linting & PEP8 compliance
uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 # v3.6.1
@@ -63,12 +63,12 @@ jobs:
src: "netbox/"
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ matrix.python-version }}
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: ${{ matrix.node-version }}
@@ -76,7 +76,7 @@ jobs:
run: npm install -g yarn
- name: Setup Node.js with Yarn Caching
uses: actions/setup-node@v4
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: ${{ matrix.node-version }}
cache: yarn
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
+32 -2
View File
@@ -26,13 +26,43 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
# Workaround for claude-code-action bug with fork PRs: The action fetches by branch name
# (git fetch origin --depth=N <branch>), but fork PR branches don't exist on origin.
# Fix: redirect origin to the fork's URL so the action can fetch the branch directly.
- name: Configure git remote for fork PRs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Determine PR number based on event type
if [ "${{ github.event_name }}" = "issue_comment" ]; then
PR_NUMBER="${{ github.event.issue.number }}"
elif [ "${{ github.event_name }}" = "pull_request_review_comment" ] || [ "${{ github.event_name }}" = "pull_request_review" ]; then
PR_NUMBER="${{ github.event.pull_request.number }}"
else
exit 0 # issues event — no PR branch to worry about
fi
# Fetch fork info in one API call; silently skip if this is not a PR
PR_INFO=$(gh pr view "${PR_NUMBER}" --json isCrossRepository,headRepositoryOwner,headRepository 2>/dev/null || echo "")
if [ -z "$PR_INFO" ]; then
exit 0
fi
IS_FORK=$(echo "$PR_INFO" | jq -r '.isCrossRepository')
if [ "$IS_FORK" = "true" ]; then
FORK_OWNER=$(echo "$PR_INFO" | jq -r '.headRepositoryOwner.login')
FORK_REPO=$(echo "$PR_INFO" | jq -r '.headRepository.name')
echo "Fork PR detected from ${FORK_OWNER}/${FORK_REPO}: updating origin to fork URL"
git remote set-url origin "https://github.com/${FORK_OWNER}/${FORK_REPO}.git"
fi
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
uses: anthropics/claude-code-action@e763fe78de2db7389e04818a00b5ff8ba13d1360 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
if: github.repository == 'netbox-community/netbox'
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
close-issue-message: >
This issue is being closed as no further information has been provided. If
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
if: github.repository == 'netbox-community/netbox'
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
# General parameters
operations-per-run: 200
+3 -3
View File
@@ -27,16 +27,16 @@ jobs:
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
config-file: .github/codeql/codeql-config.yml
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
with:
category: "/language:${{matrix.language}}"
+4 -4
View File
@@ -11,14 +11,14 @@ permissions:
pull-requests: write
discussions: write
concurrency:
group: lock-threads
jobs:
lock:
if: github.repository == 'netbox-community/netbox'
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
with:
issue-inactive-days: 90
pr-inactive-days: 30
discussion-inactive-days: 180
issue-lock-reason: 'resolved'
+2 -2
View File
@@ -27,12 +27,12 @@ jobs:
private-key: ${{ secrets.HOUSEKEEPING_SECRET_KEY }}
- name: Check out repo
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.app-token.outputs.token }}
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: 3.12
+4 -2
View File
@@ -84,6 +84,8 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
* It's very important that you not submit a pull request until a relevant issue has been opened **and** assigned to you. Otherwise, you risk wasting time on work that may ultimately not be needed.
* Community members are limited to a maximum of **three open PRs** at any time. This is to avoid the accumulation of too much parallel work and maintain focus on already PRs under review. If you already have three NetBox PRs open, please wait for at least one of them to be merged (or closed) before opening another.
* New pull requests should generally be based off of the `main` branch. This branch, in keeping with the [trunk-based development](https://trunkbaseddevelopment.com/) approach, is used for ongoing development and bug fixes and always represents the newest stable code, from which releases are periodically branched. (If you're developing for an upcoming minor release, use `feature` instead.)
* In most cases, it is not necessary to add a changelog entry: A maintainer will take care of this when the PR is merged. (This helps avoid merge conflicts resulting from multiple PRs being submitted simultaneously.)
@@ -96,10 +98,10 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
greater than 80 characters in length
> [!CAUTION]
> Any contributions which include AI-generated or reproduced content will be rejected.
> Any contributions which include solely AI-generated or reproduced content will be rejected. All PRs must be submitted by a human.
* Some other tips to keep in mind:
* If you'd like to volunteer for someone else's issue, please post a comment on that issue letting us know. (This will allow the maintainers to assign it to you.)
* If you'd like to volunteer for someone else's issue, please post a comment on that issue letting us know. (GitHub allows only people who have commented on an issue to be assigned as its owner.)
* Check out our [developer docs](https://docs.netbox.dev/en/stable/development/getting-started/) for tips on setting up your development environment.
* All new functionality must include relevant tests where applicable.
+1
View File
@@ -215,6 +215,7 @@ if obj.pk and hasattr(obj, 'snapshot'):
obj.snapshot()
obj.property = "New Value"
obj._changelog_message = 'Example Message Text' # Optional
obj.full_clean()
obj.save()
```
+15 -11
View File
@@ -23,9 +23,9 @@ For example, you might create a NetBox webhook to [trigger a Slack message](http
The following data is available as context for Jinja2 templates:
* `event` - The type of event which triggered the webhook: created, updated, or deleted.
* `model` - The NetBox model which triggered the change.
* `event` - The type of event which triggered the webhook: `created`, `updated`, or `deleted`.
* `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format).
* `object_type` - The NetBox model which triggered the change in the form `app_label.model_name`.
* `username` - The name of the user account associated with the change.
* `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
* `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.
@@ -38,18 +38,20 @@ If no body template is specified, the request body will be populated with a JSON
```json
{
"event": "created",
"timestamp": "2021-03-09 17:55:33.968016+00:00",
"model": "site",
"timestamp": "2026-03-06T15:11:23.503186+00:00",
"object_type": "dcim.site",
"username": "jstretch",
"request_id": "fdbca812-3142-4783-b364-2e2bd5c16c6a",
"request_id": "17af32f0-852a-46ca-a7d4-33ecd0c13de6",
"data": {
"id": 19,
"id": 4,
"url": "/api/dcim/sites/4/",
"display_url": "/dcim/sites/4/",
"display": "Site 1",
"name": "Site 1",
"slug": "site-1",
"status":
"status": {
"value": "active",
"label": "Active",
"id": 1
"label": "Active"
},
"region": null,
...
@@ -57,8 +59,10 @@ If no body template is specified, the request body will be populated with a JSON
"snapshots": {
"prechange": null,
"postchange": {
"created": "2021-03-09",
"last_updated": "2021-03-09T17:55:33.851Z",
"created": "2026-03-06T15:11:23.484Z",
"owner": null,
"description": "",
"comments": "",
"name": "Site 1",
"slug": "site-1",
"status": "active",
+9 -6
View File
@@ -36,13 +36,16 @@ If false, synchronization will be disabled.
### Ignore Rules
A set of rules (one per line) identifying filenames to ignore during synchronization. Some examples are provided below. See Python's [`fnmatch()` documentation](https://docs.python.org/3/library/fnmatch.html) for a complete reference.
A set of rules (one per line) identifying files or paths to ignore during synchronization. Rules are matched against both the full relative path (e.g. `subdir/file.txt`) and the bare filename, so path-based patterns can be used to exclude entire directories. Some examples are provided below. See Python's [`fnmatch()` documentation](https://docs.python.org/3/library/fnmatch.html) for a complete reference.
| Rule | Description |
|----------------|------------------------------------------|
| `README` | Ignore any files named `README` |
| `*.txt` | Ignore any files with a `.txt` extension |
| `data???.json` | Ignore e.g. `data123.json` |
| Rule | Description |
|-----------------------|------------------------------------------------------|
| `README` | Ignore any files named `README` |
| `*.txt` | Ignore any files with a `.txt` extension |
| `data???.json` | Ignore e.g. `data123.json` |
| `subdir/*` | Ignore all files within `subdir/` |
| `subdir/*/*` | Ignore all files one level deep within `subdir/` |
| `*/dev/*` | Ignore files inside any directory named `dev/` |
### Sync Interval
+10 -10
View File
@@ -77,14 +77,14 @@ The file path to a particular certificate authority (CA) file to use when valida
## Context Data
The following context variables are available in to the text and link templates.
The following context variables are available to the text and link templates.
| Variable | Description |
|--------------|----------------------------------------------------|
| `event` | The event type (`create`, `update`, or `delete`) |
| `timestamp` | The time at which the event occured |
| `model` | The type of object impacted |
| `username` | The name of the user associated with the change |
| `request_id` | The unique request ID |
| `data` | A complete serialized representation of the object |
| `snapshots` | Pre- and post-change snapshots of the object |
| Variable | Description |
|---------------|------------------------------------------------------|
| `event` | The event type (`created`, `updated`, or `deleted`) |
| `timestamp` | The time at which the event occurred |
| `object_type` | The type of object impacted (`app_label.model_name`) |
| `username` | The name of the user associated with the change |
| `request_id` | The unique request ID |
| `data` | A complete serialized representation of the object |
| `snapshots` | Pre- and post-change snapshots of the object |
+1 -1
View File
@@ -43,7 +43,7 @@ class DataSourceForm(PrimaryModelForm):
attrs={
'rows': 5,
'class': 'font-monospace',
'placeholder': '.cache\n*.txt'
'placeholder': '.cache\n*.txt\nsubdir/*'
}
),
}
+8 -7
View File
@@ -69,7 +69,7 @@ class DataSource(JobsMixin, PrimaryModel):
ignore_rules = models.TextField(
verbose_name=_('ignore rules'),
blank=True,
help_text=_("Patterns (one per line) matching files to ignore when syncing")
help_text=_("Patterns (one per line) matching files or paths to ignore when syncing")
)
parameters = models.JSONField(
verbose_name=_('parameters'),
@@ -258,21 +258,22 @@ class DataSource(JobsMixin, PrimaryModel):
if path.startswith('.'):
continue
for file_name in file_names:
if not self._ignore(file_name):
paths.add(os.path.join(path, file_name))
file_path = os.path.join(path, file_name)
if not self._ignore(file_path):
paths.add(file_path)
logger.debug(f"Found {len(paths)} files")
return paths
def _ignore(self, filename):
def _ignore(self, file_path):
"""
Returns a boolean indicating whether the file should be ignored per the DataSource's configured
ignore rules.
ignore rules. file_path is the full relative path (e.g. "subdir/file.txt").
"""
if filename.startswith('.'):
if os.path.basename(file_path).startswith('.'):
return True
for rule in self.ignore_rules.splitlines():
if fnmatchcase(filename, rule):
if fnmatchcase(file_path, rule) or fnmatchcase(os.path.basename(file_path), rule):
return True
return False
+20
View File
@@ -10,6 +10,26 @@ from dcim.models import Device, Location, Site
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
class DataSourceIgnoreRulesTestCase(TestCase):
def test_no_ignore_rules(self):
ds = DataSource(ignore_rules='')
self.assertFalse(ds._ignore('README.md'))
self.assertFalse(ds._ignore('subdir/file.py'))
def test_ignore_by_filename(self):
ds = DataSource(ignore_rules='*.txt')
self.assertTrue(ds._ignore('notes.txt'))
self.assertTrue(ds._ignore('subdir/notes.txt'))
self.assertFalse(ds._ignore('notes.py'))
def test_ignore_by_subdirectory(self):
ds = DataSource(ignore_rules='dev/*')
self.assertTrue(ds._ignore('dev/README.md'))
self.assertTrue(ds._ignore('dev/script.py'))
self.assertFalse(ds._ignore('prod/script.py'))
class DataSourceChangeLoggingTestCase(TestCase):
def test_password_added_on_create(self):
+1
View File
@@ -405,6 +405,7 @@ class DeviceViewSet(
NetBoxModelViewSet
):
queryset = Device.objects.prefetch_related(
'device_type__manufacturer', # Referenced by Device.__str__() for unnamed devices
'parent_bay', # Referenced by DeviceSerializer.get_parent_device()
)
filterset_class = filtersets.DeviceFilterSet
+2 -5
View File
@@ -306,12 +306,9 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupMode
fields = ('id', 'name', 'slug', 'facility', 'description')
def search(self, queryset, name, value):
# extended in order to include querying on Location.facility
queryset = super().search(queryset, name, value)
# Extend `search()` to include querying on Location.facility
if value.strip():
queryset = queryset | queryset.model.objects.filter(facility__icontains=value)
return super().search(queryset, name, value) | queryset.filter(facility__icontains=value)
return queryset
+5 -2
View File
@@ -1529,8 +1529,11 @@ class CableImportForm(PrimaryModelImportForm):
model = content_type.model_class()
try:
if device.virtual_chassis and device.virtual_chassis.master == device and \
model.objects.filter(device=device, name=name).count() == 0:
if (
device.virtual_chassis and
device.virtual_chassis.master == device and
not model.objects.filter(device=device, name=name).exists()
):
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
else:
termination_object = model.objects.get(device=device, name=name)
+40 -1
View File
@@ -121,13 +121,52 @@ class ScopedImportForm(forms.Form):
required=False,
label=_('Scope type (app & model)')
)
scope_name = forms.CharField(
required=False,
label=_('Scope name'),
help_text=_('Name of the assigned scope object (if not using ID)')
)
def clean(self):
super().clean()
scope_id = self.cleaned_data.get('scope_id')
scope_name = self.cleaned_data.get('scope_name')
scope_type = self.cleaned_data.get('scope_type')
if scope_type and not scope_id:
# Cannot specify both scope_name and scope_id
if scope_name and scope_id:
raise ValidationError(_("scope_name and scope_id are mutually exclusive."))
# Must specify scope_type with scope_name or scope_id
if scope_name and not scope_type:
raise ValidationError(_("scope_type must be specified when using scope_name"))
if scope_id and not scope_type:
raise ValidationError(_("scope_type must be specified when using scope_id"))
# Look up the scope object by name
if scope_type and scope_name:
model = scope_type.model_class()
try:
scope_obj = model.objects.get(name=scope_name)
except model.DoesNotExist:
raise ValidationError({
'scope_name': _('{scope_type} "{name}" not found.').format(
scope_type=bettertitle(model._meta.verbose_name),
name=scope_name
)
})
except model.MultipleObjectsReturned:
raise ValidationError({
'scope_name': _(
'Multiple {scope_type} objects match "{name}". Use scope_id to specify the intended object.'
).format(
scope_type=bettertitle(model._meta.verbose_name),
name=scope_name,
)
})
self.cleaned_data['scope_id'] = scope_obj.pk
elif scope_type and not scope_id:
raise ValidationError({
'scope_id': _(
"Please select a {scope_type}."
+87 -90
View File
@@ -267,32 +267,32 @@ class DeviceFilter(
longitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
console_ports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
consoleports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_ports')
)
console_server_ports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
consoleserverports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_server_ports')
)
power_outlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
poweroutlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_outlets')
)
power_ports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
powerports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_ports')
)
interfaces: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
front_ports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
frontports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='front_ports')
)
rear_ports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
rearports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='rear_ports')
)
device_bays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
devicebays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='device_bays')
)
module_bays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
modulebays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='module_bays')
)
modules: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -383,36 +383,36 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
rear_image: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = (
strawberry_django.filter_field()
)
console_port_templates: (
Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
console_server_port_templates: (
consoleporttemplates: Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_port_templates')
)
consoleserverporttemplates: (
Annotated['ConsoleServerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
power_port_templates: (
Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
power_outlet_templates: (
Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
interface_templates: (
Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
front_port_templates: (
Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
rear_port_templates: (
Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
device_bay_templates: (
Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
module_bay_templates: (
Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
inventory_item_templates: (
Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
) = strawberry_django.filter_field(name='console_server_port_templates')
powerporttemplates: Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_port_templates')
)
poweroutlettemplates: Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_outlet_templates')
)
interfacetemplates: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='interface_templates')
)
frontporttemplates: Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='front_port_templates')
)
rearporttemplates: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='rear_port_templates')
)
devicebaytemplates: Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='device_bay_templates')
)
modulebaytemplates: Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='module_bay_templates')
)
inventoryitemtemplates: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='inventory_item_templates')
)
console_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
console_server_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
power_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
@@ -696,32 +696,32 @@ class ModuleFilter(ConfigContextFilterMixin, PrimaryModelFilter):
)
serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
console_ports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
consoleports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_ports')
)
console_server_ports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
consoleserverports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_server_ports')
)
power_outlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
poweroutlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_outlets')
)
power_ports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
powerports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_ports')
)
interfaces: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
front_ports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
frontports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='front_ports')
)
rear_ports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
rearports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='rear_ports')
)
device_bays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
devicebays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='device_bays')
)
module_bays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
modulebays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='module_bays')
)
modules: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -765,36 +765,33 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
airflow: BaseFilterLookup[Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
console_port_templates: (
Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
console_server_port_templates: (
consoleporttemplates: Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_port_templates')
)
consoleserverporttemplates: (
Annotated['ConsoleServerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
power_port_templates: (
Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
power_outlet_templates: (
Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
interface_templates: (
Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
front_port_templates: (
Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
rear_port_templates: (
Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
device_bay_templates: (
Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
module_bay_templates: (
Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
inventory_item_templates: (
Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
) = strawberry_django.filter_field(name='console_server_port_templates')
powerporttemplates: Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_port_templates')
)
poweroutlettemplates: Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_outlet_templates')
)
interfacetemplates: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='interface_templates')
)
frontporttemplates: Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='front_port_templates')
)
rearporttemplates: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='rear_port_templates')
)
devicebaytemplates: Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='device_bay_templates')
)
modulebaytemplates: Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='module_bay_templates')
)
module_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
+9 -1
View File
@@ -293,7 +293,6 @@ class Cable(PrimaryModel):
self._pk = self.pk
if self._orig_profile != self.profile:
print(f'profile changed from {self._orig_profile} to {self.profile}')
self.update_terminations(force=True)
elif self._terminations_modified:
self.update_terminations()
@@ -403,6 +402,15 @@ class Cable(PrimaryModel):
"""
a_terminations, b_terminations = self.get_terminations()
# When force-recreating terminations (e.g. after a profile change), cache the termination objects
# from the database before deleting, so they are available for recreation. Without this, the
# a_terminations/b_terminations properties would query the DB after deletion and return empty lists.
if force:
if not hasattr(self, '_a_terminations'):
self._a_terminations = list(a_terminations.keys())
if not hasattr(self, '_b_terminations'):
self._b_terminations = list(b_terminations.keys())
# Delete any stale CableTerminations
for termination, ct in a_terminations.items():
if force or (termination.pk and termination not in self.a_terminations):
+2 -1
View File
@@ -1205,7 +1205,8 @@ class MACAddressTable(PrimaryModelTable):
verbose_name=_('Parent')
)
is_primary = columns.BooleanColumn(
verbose_name=_('Primary')
verbose_name=_('Primary'),
orderable=False,
)
tags = columns.TagColumn(
url_name='dcim:macaddress_list'
+1 -1
View File
@@ -218,7 +218,7 @@ class RackReservationTable(TenancyColumnsMixin, PrimaryModelTable):
class Meta(PrimaryModelTable.Meta):
model = RackReservation
fields = (
'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'status', 'user', 'created', 'tenant',
'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'status', 'user', 'tenant',
'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'status', 'user', 'description')
+29
View File
@@ -1201,6 +1201,35 @@ class CableTestCase(TestCase):
with self.assertRaises(ValidationError):
cable.clean()
def test_cable_profile_change_preserves_terminations(self):
"""
When a Cable's profile is changed via save() without explicitly setting terminations (as happens during
bulk edit), the existing termination points must be preserved.
"""
cable = Cable.objects.first()
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
# Verify initial state: cable has terminations and no profile
self.assertEqual(cable.profile, '')
self.assertEqual(CableTermination.objects.filter(cable=cable).count(), 2)
# Simulate what bulk edit does: load the cable from DB, set profile via setattr, and save.
# Crucially, do NOT set a_terminations or b_terminations on the instance.
cable_from_db = Cable.objects.get(pk=cable.pk)
cable_from_db.profile = CableProfileChoices.SINGLE_1C1P
cable_from_db.save()
# Verify terminations are preserved
self.assertEqual(CableTermination.objects.filter(cable=cable).count(), 2)
# Verify the correct interfaces are still terminated
cable_from_db.refresh_from_db()
a_terms = [ct.termination for ct in CableTermination.objects.filter(cable=cable, cable_end='A')]
b_terms = [ct.termination for ct in CableTermination.objects.filter(cable=cable, cable_end='B')]
self.assertEqual(a_terms, [interface1])
self.assertEqual(b_terms, [interface2])
class VirtualDeviceContextTestCase(TestCase):
+2 -18
View File
@@ -16,7 +16,7 @@ from circuits.models import Circuit, CircuitTermination
from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import ASN, VLAN, IPAddress, Prefix, VLANGroup
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from ipam.tables import VLANTranslationRuleTable
from netbox.object_actions import *
from netbox.ui import actions, layout
from netbox.ui.panels import (
@@ -389,7 +389,7 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
title=_('Child Groups'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}),
actions.AddObject('dcim.SiteGroup', url_params={'parent': lambda ctx: ctx['object'].pk}),
],
),
]
@@ -3230,21 +3230,6 @@ class InterfaceView(generic.ObjectView):
)
lag_interfaces_table.configure(request)
# Get assigned VLANs and annotate whether each is tagged or untagged
vlans = []
if instance.untagged_vlan is not None:
vlans.append(instance.untagged_vlan)
vlans[0].tagged = False
for vlan in instance.tagged_vlans.restrict(request.user).prefetch_related('site', 'group', 'tenant', 'role'):
vlan.tagged = True
vlans.append(vlan)
vlan_table = InterfaceVLANTable(
interface=instance,
data=vlans,
orderable=False
)
vlan_table.configure(request)
# Get VLAN translation rules
vlan_translation_table = None
if instance.vlan_translation_policy:
@@ -3260,7 +3245,6 @@ class InterfaceView(generic.ObjectView):
'bridge_interfaces_table': bridge_interfaces_table,
'child_interfaces_table': child_interfaces_table,
'lag_interfaces_table': lag_interfaces_table,
'vlan_table': vlan_table,
'vlan_translation_table': vlan_translation_table,
}
@@ -81,7 +81,7 @@ class Command(BaseCommand):
logger.error(f'\t{field}: {error.get("message")}')
raise CommandError()
# Remove extra fields from ScriptForm before passng data to script
# Remove extra fields from ScriptForm before passing data to script
form.cleaned_data.pop('_schedule_at')
form.cleaned_data.pop('_interval')
form.cleaned_data.pop('_commit')
@@ -94,10 +94,12 @@ class Command(BaseCommand):
data=form.cleaned_data,
request=NetBoxFakeRequest({
'META': {},
'COOKIES': {},
'POST': data,
'GET': {},
'FILES': {},
'user': user,
'method': 'POST',
'path': '',
'id': uuid.uuid4()
}),
+67
View File
@@ -0,0 +1,67 @@
from django.db import router
from django.db.models import signals
from taggit.managers import _TaggableManager
from taggit.utils import require_instance_manager
__all__ = (
'NetBoxTaggableManager',
)
class NetBoxTaggableManager(_TaggableManager):
"""
Extends taggit's _TaggableManager to replace the per-tag get_or_create loop in add() with a
single bulk_create() call, reducing SQL queries from O(N) to O(1) when assigning tags.
"""
@require_instance_manager
def add(self, *tags, through_defaults=None, tag_kwargs=None, **kwargs):
self._remove_prefetched_objects()
if tag_kwargs is None:
tag_kwargs = {}
db = router.db_for_write(self.through, instance=self.instance)
tag_objs = self._to_tag_model_instances(tags, tag_kwargs)
new_ids = {t.pk for t in tag_objs}
# Determine which tags are not already assigned to this object
lookup = self._lookup_kwargs()
vals = set(
self.through._default_manager.using(db)
.values_list("tag_id", flat=True)
.filter(**lookup, tag_id__in=new_ids)
)
new_ids -= vals
if not new_ids:
return
signals.m2m_changed.send(
sender=self.through,
action="pre_add",
instance=self.instance,
reverse=False,
model=self.through.tag_model(),
pk_set=new_ids,
using=db,
)
# Use a single bulk INSERT instead of one get_or_create per tag.
self.through._default_manager.using(db).bulk_create(
[
self.through(tag=tag, **lookup, **(through_defaults or {}))
for tag in tag_objs
if tag.pk in new_ids
],
ignore_conflicts=True,
)
signals.m2m_changed.send(
sender=self.through,
action="post_add",
instance=self.instance,
reverse=False,
model=self.through.tag_model(),
pk_set=new_ids,
using=db,
)
+33 -14
View File
@@ -210,8 +210,8 @@ class PrefixImportForm(ScopedImportForm, PrimaryModelImportForm):
class Meta:
model = Prefix
fields = (
'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan_site', 'vlan', 'status', 'role', 'scope_type', 'scope_id',
'is_pool', 'mark_utilized', 'description', 'owner', 'comments', 'tags',
'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan_site', 'vlan', 'status', 'role', 'scope_type', 'scope_name',
'scope_id', 'is_pool', 'mark_utilized', 'description', 'owner', 'comments', 'tags',
)
labels = {
'scope_id': _('Scope ID'),
@@ -424,19 +424,36 @@ class IPAddressImportForm(PrimaryModelImportForm):
# Set as primary for device/VM
if self.cleaned_data.get('is_primary') is not None:
parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine')
parent.snapshot()
if self.instance.address.version == 4:
parent.primary_ip4 = ipaddress if self.cleaned_data.get('is_primary') else None
elif self.instance.address.version == 6:
parent.primary_ip6 = ipaddress if self.cleaned_data.get('is_primary') else None
parent.save()
if self.cleaned_data.get('is_primary'):
parent.snapshot()
if self.instance.address.version == 4:
parent.primary_ip4 = ipaddress
elif self.instance.address.version == 6:
parent.primary_ip6 = ipaddress
parent.save()
else:
# Only clear the primary IP if this IP is currently set as primary
if self.instance.address.version == 4 and parent.primary_ip4 == ipaddress:
parent.snapshot()
parent.primary_ip4 = None
parent.save()
elif self.instance.address.version == 6 and parent.primary_ip6 == ipaddress:
parent.snapshot()
parent.primary_ip6 = None
parent.save()
# Set as OOB for device
if self.cleaned_data.get('is_oob') is not None:
parent = self.cleaned_data.get('device')
parent.snapshot()
parent.oob_ip = ipaddress if self.cleaned_data.get('is_oob') else None
parent.save()
if self.cleaned_data.get('is_oob'):
parent.snapshot()
parent.oob_ip = ipaddress
parent.save()
elif parent.oob_ip == ipaddress:
# Only clear OOB if this IP is currently set as the OOB IP
parent.snapshot()
parent.oob_ip = None
parent.save()
return ipaddress
@@ -457,7 +474,8 @@ class FHRPGroupImportForm(PrimaryModelImportForm):
fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description', 'owner', 'comments', 'tags')
class VLANGroupImportForm(OrganizationalModelImportForm):
class VLANGroupImportForm(ScopedImportForm, OrganizationalModelImportForm):
# Override ScopedImportForm.scope_type to set custom queryset
scope_type = CSVContentTypeField(
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
required=False,
@@ -477,10 +495,11 @@ class VLANGroupImportForm(OrganizationalModelImportForm):
class Meta:
model = VLANGroup
fields = (
'name', 'slug', 'scope_type', 'scope_id', 'vid_ranges', 'tenant', 'description', 'owner', 'comments', 'tags'
'name', 'slug', 'scope_type', 'scope_name', 'scope_id', 'vid_ranges', 'tenant', 'description', 'owner',
'comments', 'tags',
)
labels = {
'scope_id': 'Scope ID',
'scope_id': _('Scope ID'),
}
+1 -44
View File
@@ -1,19 +1,17 @@
import django_tables2 as tables
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django_tables2.utils import Accessor
from dcim.models import Interface
from dcim.tables.template_code import INTERFACE_LINKTERMINATION, LINKTERMINATION
from ipam.models import *
from netbox.tables import NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns
from tenancy.tables import TenancyColumnsMixin, TenantColumn
from tenancy.tables import TenancyColumnsMixin
from virtualization.models import VMInterface
from .template_code import *
__all__ = (
'InterfaceVLANTable',
'VLANDevicesTable',
'VLANGroupTable',
'VLANMembersTable',
@@ -198,47 +196,6 @@ class VLANVirtualMachinesTable(VLANMembersTable):
exclude = ('id', )
class InterfaceVLANTable(NetBoxTable):
"""
List VLANs assigned to a specific Interface.
"""
vid = tables.Column(
linkify=True,
verbose_name=_('VID')
)
tagged = columns.BooleanColumn(
verbose_name=_('Tagged'),
false_mark=None
)
site = tables.Column(
verbose_name=_('Site'),
linkify=True
)
group = tables.Column(
accessor=Accessor('group__name'),
verbose_name=_('Group')
)
tenant = TenantColumn(
verbose_name=_('Tenant'),
)
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
)
role = tables.Column(
verbose_name=_('Role'),
linkify=True
)
class Meta(NetBoxTable.Meta):
model = VLAN
fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
exclude = ('id', )
def __init__(self, interface, *args, **kwargs):
self.interface = interface
super().__init__(*args, **kwargs)
#
# VLAN Translation
#
+56 -1
View File
@@ -1,8 +1,10 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from dcim.models import Location, Region, Site, SiteGroup
from dcim.constants import InterfaceTypeChoices
from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Region, Site, SiteGroup
from ipam.forms import PrefixForm
from ipam.forms.bulk_import import IPAddressImportForm
class PrefixFormTestCase(TestCase):
@@ -41,3 +43,56 @@ class PrefixFormTestCase(TestCase):
})
assert 'data-dynamic-params' not in form.fields['vlan'].widget.attrs
class IPAddressImportFormTestCase(TestCase):
"""Tests for IPAddressImportForm bulk import behavior."""
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
cls.device = Device.objects.create(
name='Device 1',
site=site,
device_type=device_type,
role=device_role,
)
cls.interface = Interface.objects.create(
device=cls.device,
name='eth0',
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
)
def test_oob_import_not_cleared_by_subsequent_non_oob_row(self):
"""
Regression test for #21440: importing a second IP with is_oob=False should
not clear the OOB IP set by a previous row with is_oob=True.
"""
form1 = IPAddressImportForm(data={
'address': '10.10.10.1/24',
'status': 'active',
'device': 'Device 1',
'interface': 'eth0',
'is_oob': True,
})
self.assertTrue(form1.is_valid(), form1.errors)
ip1 = form1.save()
self.device.refresh_from_db()
self.assertEqual(self.device.oob_ip, ip1)
form2 = IPAddressImportForm(data={
'address': '2001:db8::1/64',
'status': 'active',
'device': 'Device 1',
'interface': 'eth0',
'is_oob': False,
})
self.assertTrue(form2.is_valid(), form2.errors)
form2.save()
self.device.refresh_from_db()
self.assertEqual(self.device.oob_ip, ip1, "OOB IP was incorrectly cleared by a row with is_oob=False")
+55 -13
View File
@@ -435,13 +435,21 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'tags': [t.pk for t in tags],
}
site = sites[0].pk
cls.csv_data = (
"vrf,prefix,status,scope_type,scope_id",
f"VRF 1,10.4.0.0/16,active,dcim.site,{site}",
f"VRF 1,10.5.0.0/16,active,dcim.site,{site}",
f"VRF 1,10.6.0.0/16,active,dcim.site,{site}",
)
site = sites[0]
cls.csv_data = {
'default': (
"vrf,prefix,status,scope_type,scope_id",
f"VRF 1,10.4.0.0/16,active,dcim.site,{site.pk}",
f"VRF 1,10.5.0.0/16,active,dcim.site,{site.pk}",
f"VRF 1,10.6.0.0/16,active,dcim.site,{site.pk}",
),
'scope_name': (
"vrf,prefix,status,scope_type,scope_name",
f"VRF 1,10.4.0.0/16,active,dcim.site,{site.name}",
f"VRF 1,10.5.0.0/16,active,dcim.site,{site.name}",
f"VRF 1,10.6.0.0/16,active,dcim.site,{site.name}",
),
}
cls.csv_update_data = (
"id,description,status",
@@ -532,6 +540,32 @@ scope_id: {site.pk}
self.assertEqual(prefix.vlan.vid, 101)
self.assertEqual(prefix.scope, site)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_import_with_scope_name(self):
"""
Test YAML-based import using scope_name instead of scope_id.
"""
site = Site.objects.get(name='Site 1')
IMPORT_DATA = """
prefix: 10.1.3.0/24
status: active
scope_type: dcim.site
scope_name: Site 1
"""
# Add all required permissions to the test user
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
form_data = {
'data': IMPORT_DATA,
'format': 'yaml'
}
response = self.client.post(reverse('ipam:prefix_bulk_import'), data=form_data, follow=True)
self.assertHttpStatus(response, 200)
prefix = Prefix.objects.get(prefix='10.1.3.0/24')
self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
self.assertEqual(prefix.scope, site)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_import_with_vlan_group(self):
"""
@@ -884,12 +918,20 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"name,slug,scope_type,scope_id,description",
"VLAN Group 4,vlan-group-4,,,Fourth VLAN group",
f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].pk},Fifth VLAN group",
f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group",
)
cls.csv_data = {
'default': (
"name,slug,scope_type,scope_id,description",
"VLAN Group 4,vlan-group-4,,,Fourth VLAN group",
f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].pk},Fifth VLAN group",
f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group",
),
'scope_name': (
"name,slug,scope_type,scope_name,description",
"VLAN Group 4,vlan-group-4,,,Fourth VLAN group",
f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].name},Fifth VLAN group",
f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].name},Sixth VLAN group",
),
}
cls.csv_update_data = (
"id,name,description",
@@ -53,8 +53,11 @@ class TaggableModelSerializer(serializers.Serializer):
def _save_tags(self, instance, tags):
if tags:
# Cache tags on instance so serialize_object() can reuse them without a DB query
instance._tags = tags
instance.tags.set([t.name for t in tags])
else:
instance._tags = []
instance.tags.clear()
return instance
+12
View File
@@ -2,6 +2,8 @@ import strawberry
from strawberry.types.unset import UNSET
from strawberry_django.pagination import _QS, apply
from netbox.config import get_config
__all__ = (
'OffsetPaginationInfo',
'OffsetPaginationInput',
@@ -47,4 +49,14 @@ def apply_pagination(
# Ignore `offset` when `start` is set
pagination.offset = 0
# Enforce MAX_PAGE_SIZE on the pagination limit
max_page_size = get_config().MAX_PAGE_SIZE
if max_page_size:
if pagination is None:
pagination = OffsetPaginationInput(limit=max_page_size)
elif pagination.limit in (None, UNSET) or pagination.limit > max_page_size:
pagination.limit = max_page_size
elif pagination.limit <= 0:
pagination.limit = max_page_size
return apply(pagination, queryset, related_field_id=related_field_id)
+18 -9
View File
@@ -40,15 +40,24 @@ class CoreMiddleware:
with apply_request_processors(request):
response = self.get_response(request)
# Check if language cookie should be renewed
if request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
if language := request.user.config.get('locale.language'):
response.set_cookie(
key=settings.LANGUAGE_COOKIE_NAME,
value=language,
max_age=request.session.get_expiry_age(),
secure=settings.SESSION_COOKIE_SECURE,
)
# Set or renew the language cookie based on the user's preference. This handles two cases:
# 1. The user just logged in (via any auth backend): the user_logged_in signal stores the preferred language on
# the request so we set the cookie here on the login response.
# 2. SESSION_SAVE_EVERY_REQUEST is enabled: renew the language cookie on every request to keep it in sync with
# the session expiry.
if hasattr(request, '_language_cookie'):
language = request._language_cookie
elif request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
language = request.user.config.get('locale.language')
else:
language = None
if language:
response.set_cookie(
key=settings.LANGUAGE_COOKIE_NAME,
value=language,
max_age=request.session.get_expiry_age(),
secure=settings.SESSION_COOKIE_SECURE,
)
# Attach the unique request ID as an HTTP header.
response['X-Request-ID'] = request.id
+3 -1
View File
@@ -15,6 +15,7 @@ from core.choices import JobStatusChoices, ObjectChangeActionChoices
from core.models import ObjectType
from extras.choices import *
from extras.constants import CUSTOMFIELD_EMPTY_VALUES
from extras.managers import NetBoxTaggableManager
from extras.utils import is_taggable
from netbox.config import get_config
from netbox.constants import CORE_APPS
@@ -487,11 +488,12 @@ class JournalingMixin(models.Model):
class TagsMixin(models.Model):
"""
Enables support for tag assignment. Assigned tags can be managed via the `tags` attribute,
which is a `TaggableManager` instance.
which is a `NetBoxTaggableManager` instance.
"""
tags = TaggableManager(
through='extras.TaggedItem',
ordering=('weight', 'name'),
manager=NetBoxTaggableManager,
)
class Meta:
+47
View File
@@ -283,6 +283,53 @@ class GraphQLAPITestCase(APITestCase):
self.assertEqual(len(data['data']['site_list']), 1)
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 7')
@override_settings(MAX_PAGE_SIZE=3)
def test_max_page_size(self):
self.add_permissions('dcim.view_site')
url = reverse('graphql')
# Request without explicit limit should be capped by MAX_PAGE_SIZE
query = """
{
site_list {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site_list']), 3)
# Request with limit exceeding MAX_PAGE_SIZE should be capped
query = """
{
site_list(pagination: {limit: 100}) {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site_list']), 3)
# Request with limit under MAX_PAGE_SIZE should be respected
query = """
{
site_list(pagination: {limit: 2}) {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site_list']), 2)
def test_pagination_conflict(self):
url = reverse('graphql')
query = """
+21
View File
@@ -10,6 +10,7 @@ __all__ = (
'BooleanAttr',
'ChoiceAttr',
'ColorAttr',
'DateTimeAttr',
'GPSCoordinatesAttr',
'GenericForeignKeyAttr',
'ImageAttr',
@@ -367,6 +368,26 @@ class GPSCoordinatesAttr(ObjectAttribute):
})
class DateTimeAttr(ObjectAttribute):
"""
A date or datetime attribute.
Parameters:
spec (str): Controls the rendering format. Use 'date' for date-only rendering,
or 'seconds'/'minutes' for datetime rendering with the given precision.
"""
template_name = 'ui/attrs/datetime.html'
def __init__(self, *args, spec='seconds', **kwargs):
super().__init__(*args, **kwargs)
self.spec = spec
def get_context(self, obj, context):
return {
'spec': self.spec,
}
class TimezoneAttr(ObjectAttribute):
"""
A timezone value. Includes the numeric offset from UTC.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -6
View File
@@ -20,12 +20,7 @@ function storeColorMode(mode: ColorMode): void {
}
function updateElements(targetMode: ColorMode): void {
const body = document.querySelector('body');
if (body && targetMode == 'dark') {
body.setAttribute('data-bs-theme', 'dark');
} else if (body) {
body.setAttribute('data-bs-theme', 'light');
}
document.documentElement.setAttribute('data-bs-theme', targetMode);
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
const svg = elevation.firstElementChild ?? null;
@@ -1,16 +1,16 @@
import type { RecursivePartial, TomOption, TomSettings, TomInput } from 'tom-select/dist/cjs/types';
import { addClasses } from 'tom-select/src/vanilla.ts';
import queryString from 'query-string';
import TomSelect from 'tom-select';
import type { Stringifiable } from 'query-string';
import { DynamicParamsMap } from './dynamicParamsMap';
import { NetBoxTomSelect } from './netboxTomSelect';
// Transitional
import { QueryFilter, PathFilter } from '../types';
import { getElement, replaceAll } from '../../util';
// Extends TomSelect to provide enhanced fetching of options via the REST API
export class DynamicTomSelect extends TomSelect {
// Extends NetBoxTomSelect to provide enhanced fetching of options via the REST API
export class DynamicTomSelect extends NetBoxTomSelect {
public readonly nullOption: Nullable<TomOption> = null;
// Transitional code from APISelect
@@ -0,0 +1,39 @@
import TomSelect from 'tom-select';
/**
* Extends TomSelect to work around a browser autofill bug where Edge's "last used" autofill
* simultaneously focuses multiple inputs, triggering a cascading focus/open/blur loop between
* TomSelect instances.
*
* Root cause: TomSelect's open() method calls focus(), which synchronously moves browser focus
* to this instance's control input, then schedules setTimeout(onFocus, 0). When Edge autofill
* has moved focus to a *different* select before the timeout fires, the delayed onFocus() call
* re-steals browser focus back, causing the other instance to blur and close. Each instance's
* deferred callback then repeats this, creating an infinite ping-pong loop.
*
* Fix: in the setTimeout callback, only proceed with onFocus() if this instance's element is
* still the active element. If focus has already moved elsewhere, skip the call.
*
* Upstream bug: https://github.com/orchidjs/tom-select/issues/806
* NetBox issue: https://github.com/netbox-community/netbox/issues/20077
*/
export class NetBoxTomSelect extends TomSelect {
focus(): void {
if (this.isDisabled || this.isReadOnly) return;
this.ignoreFocus = true;
const focusTarget = this.control_input.offsetWidth ? this.control_input : this.focus_node;
focusTarget.focus();
setTimeout(() => {
this.ignoreFocus = false;
// Only proceed if this instance's element is still the active element. If Edge autofill
// (or anything else) has moved focus to a different element in the interim, calling
// onFocus() here would steal focus back and restart the cascade loop.
if (document.activeElement === focusTarget || this.control.contains(document.activeElement)) {
this.onFocus();
}
}, 0);
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { TomOption } from 'tom-select/src/types';
import TomSelect from 'tom-select';
import { escape_html } from 'tom-select/src/utils';
import { NetBoxTomSelect } from './classes/netboxTomSelect';
import { getPlugins } from './config';
import { getElements } from '../util';
@@ -9,7 +9,7 @@ export function initStaticSelects(): void {
for (const select of getElements<HTMLSelectElement>(
'select:not(.tomselected):not(.no-ts):not([size]):not(.api-select):not(.color-select)',
)) {
new TomSelect(select, {
new NetBoxTomSelect(select, {
...getPlugins(select),
maxOptions: undefined,
});
@@ -25,7 +25,7 @@ export function initColorSelects(): void {
}
for (const select of getElements<HTMLSelectElement>('select.color-select:not(.tomselected)')) {
new TomSelect(select, {
new NetBoxTomSelect(select, {
...getPlugins(select),
maxOptions: undefined,
render: {
@@ -112,7 +112,7 @@ img.plugin-icon {
}
body[data-bs-theme=dark] {
html[data-bs-theme=dark] {
// Assuming icon is black/white line art, invert it and tone down brightness
img.plugin-icon {
filter: grayscale(100%) invert(100%) brightness(80%);
@@ -93,7 +93,7 @@ pre {
}
// Dark mode overrides
body[data-bs-theme=dark] {
html[data-bs-theme=dark] {
// Override background color alpha value
::selection {
background-color: rgba(var(--tblr-primary-rgb),.48);
@@ -174,16 +174,11 @@ pre code {
}
// Theme-based visibility utilities
// Tabler's .hide-theme-* utilities expect data-bs-theme on :root, but NetBox applies
// it to body. These overrides use higher specificity selectors to ensure theme-based
// visibility works correctly. The :root:not(.dummy) pattern provides the additional
// specificity needed to override Tabler's :root:not() rules.
:root:not(.dummy) body[data-bs-theme='light'] .hide-theme-light,
:root:not(.dummy) body[data-bs-theme='dark'] .hide-theme-dark {
:root:not(.dummy)[data-bs-theme='light'] .hide-theme-light,
:root:not(.dummy)[data-bs-theme='dark'] .hide-theme-dark {
display: none !important;
}
:root:not(.dummy) body[data-bs-theme='dark'] .hide-theme-light,
:root:not(.dummy) body[data-bs-theme='light'] .hide-theme-dark {
:root:not(.dummy)[data-bs-theme='dark'] .hide-theme-light,
:root:not(.dummy)[data-bs-theme='light'] .hide-theme-dark {
display: inline-flex !important;
}
@@ -77,13 +77,13 @@
}
// Light theme styling
body[data-bs-theme=light] .navbar-vertical.navbar-expand-lg {
html[data-bs-theme=light] .navbar-vertical.navbar-expand-lg {
// Background Gradient
background: linear-gradient(180deg, rgba(0, 133, 125, 0.00) 0%, rgba(0, 133, 125, 0.10) 100%), #FFF;
}
// Dark theme styling
body[data-bs-theme=dark] .navbar-vertical.navbar-expand-lg {
html[data-bs-theme=dark] .navbar-vertical.navbar-expand-lg {
// Background Gradient
background: linear-gradient(180deg, rgba(0, 242, 212, 0.00) 0%, rgba(0, 242, 212, 0.10) 100%), #001423;
@@ -59,7 +59,7 @@ table th.orderable a {
color: var(--#{$prefix}body-color);
}
body[data-bs-theme=dark] {
html[data-bs-theme=dark] {
// Adjust table header background color
.table thead th, .markdown>table thead th {
background: $rich-black !important;
+1 -1
View File
@@ -28,7 +28,7 @@
</div>
</div>
<div class="card">
<div class="card table-responsive">
{% render_table table %}
</div>
{% endblock content %}
+9 -1
View File
@@ -86,6 +86,11 @@
<th scope="row">{% trans "Q-in-Q SVLAN" %}</th>
<td>{{ object.qinq_svlan|linkify|placeholder }}</td>
</tr>
{% elif object.mode %}
<tr>
<th scope="row">{% trans "Untagged VLAN" %}</th>
<td>{{ object.untagged_vlan|linkify|placeholder }}</td>
</tr>
{% endif %}
<tr>
<th scope="row">{% trans "Transmit power (dBm)" %}</th>
@@ -411,7 +416,10 @@
</div>
<div class="row mb-3">
<div class="col col-md-12">
{% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
<div class="card">
<h2 class="card-header">{% trans "VLANs" %}</h2>
{% htmx_table 'ipam:vlan_list' interface_id=object.pk %}
</div>
</div>
</div>
{% if object.is_lag %}
+3 -1
View File
@@ -15,7 +15,9 @@
{% endblock tabs %}
{% block controls %}
{% add_button model %}
{% if perms.core.add_managedfile and perms.extras.add_scriptmodule %}
{% add_button model %}
{% endif %}
{% endblock controls %}
{% block content %}
+1
View File
@@ -0,0 +1 @@
{% load helpers %}{% if spec == 'date' %}{{ value|isodate }}{% else %}{{ value|isodatetime:spec }}{% endif %}
@@ -0,0 +1 @@
{% load helpers %}{{ object.get_full_name|placeholder }}
-57
View File
@@ -1,60 +1,3 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block title %}{% trans "Group" %} {{ object.name }}{% endblock %}
{% block subtitle %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Group" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
</div>
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Users" %}</h2>
<div class="list-group list-group-flush">
{% for user in object.users.all %}
<a href="{% url 'users:user' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
<div class="card">
<h2 class="card-header">{% trans "Assigned Permissions" %}</h2>
<div class="list-group list-group-flush">
{% for perm in object.object_permissions.all %}
<a href="{% url 'users:objectpermission' pk=perm.pk %}" class="list-group-item list-group-item-action">{{ perm }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
<div class="card">
<h2 class="card-header">{% trans "Owner Membership" %}</h2>
<div class="list-group list-group-flush">
{% for owner in object.owners.all %}
<a href="{% url 'users:owner' pk=owner.pk %}" class="list-group-item list-group-item-action">{{ owner }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}
@@ -1,93 +1,5 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block title %}{% trans "Permission" %} {{ object.name }}{% endblock %}
{% block subtitle %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Permission" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Enabled" %}</th>
<td>{% checkmark object.enabled %}</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Actions" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "View" %}</th>
<td>{% checkmark object.can_view %}</td>
</tr>
<tr>
<th scope="row">{% trans "Add" %}</th>
<td>{% checkmark object.can_add %}</td>
</tr>
<tr>
<th scope="row">{% trans "Change" %}</th>
<td>{% checkmark object.can_change %}</td>
</tr>
<tr>
<th scope="row">{% trans "Delete" %}</th>
<td>{% checkmark object.can_delete %}</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Constraints" %}</h2>
<div class="card-body">
{% if object.constraints %}
<pre>{{ object.constraints|json }}</pre>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Object Types" %}</h2>
<ul class="list-group list-group-flush">
{% for user in object.object_types.all %}
<li class="list-group-item">{{ user }}</li>
{% endfor %}
</ul>
</div>
<div class="card">
<h2 class="card-header">{% trans "Assigned Users" %}</h2>
<div class="list-group list-group-flush">
{% for user in object.users.all %}
<a href="{% url 'users:user' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
<div class="card">
<h2 class="card-header">{% trans "Assigned Groups" %}</h2>
<div class="list-group list-group-flush">
{% for group in object.groups.all %}
<a href="{% url 'users:group' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}
-47
View File
@@ -11,50 +11,3 @@
{% endblock %}
{% block subtitle %}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Owner" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Group" %}</th>
<td>{{ object.group|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Groups" %}</h2>
<div class="list-group list-group-flush">
{% for group in object.user_groups.all %}
<a href="{% url 'users:group' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
<div class="card">
<h2 class="card-header">{% trans "Users" %}</h2>
<div class="list-group list-group-flush">
{% for user in object.users.all %}
<a href="{% url 'users:user' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
</div>
<div class="col-md-6">
{% include 'inc/panels/related_objects.html' with filter_name='owner_id' %}
</div>
</div>
{% endblock %}
-43
View File
@@ -1,46 +1,3 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block subtitle %}{% endblock %}
{% block extra_controls %}
{% if perms.users.add_owner %}
<a href="{% url 'users:owner_add' %}?group={{ object.pk }}" class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Owner" %}
</a>
{% endif %}
{% endblock extra_controls %}
{% block content %}
<div class="row mb-3">
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Group" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
</div>
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Members" %}</h2>
<div class="list-group list-group-flush">
{% for owner in object.members.all %}
<a href="{% url 'users:owner' pk=owner.pk %}" class="list-group-item list-group-item-action">{{ owner }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,11 @@
{% load i18n %}
<div class="card">
<h2 class="card-header">{% trans "Object Types" %}</h2>
<ul class="list-group list-group-flush">
{% for object_type in object.object_types.all %}
<li class="list-group-item">{{ object_type }}</li>
{% empty %}
<li class="list-group-item text-muted">{% trans "None" %}</li>
{% endfor %}
</ul>
</div>
-82
View File
@@ -1,85 +1,3 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% block title %}{% trans "User" %} {{ object.username }}{% endblock %}
{% block subtitle %}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "User" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Username" %}</th>
<td>{{ object.username }}</td>
</tr>
<tr>
<th scope="row">{% trans "Full Name" %}</th>
<td>{{ object.get_full_name|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Email" %}</th>
<td>{{ object.email|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Account Created" %}</th>
<td>{{ object.date_joined|isodate }}</td>
</tr>
<tr>
<th scope="row">{% trans "Last Login" %}</th>
<td>{{ object.last_login|isodatetime:"minutes"|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Active" %}</th>
<td>{% checkmark object.is_active %}</td>
</tr>
<tr>
<th scope="row">{% trans "Superuser" %}</th>
<td>{% checkmark object.is_superuser %}</td>
</tr>
</table>
</div>
</div>
<div class="col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Assigned Groups" %}</h2>
<div class="list-group list-group-flush">
{% for group in object.groups.all %}
<a href="{% url 'users:group' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
<div class="card">
<h2 class="card-header">{% trans "Assigned Permissions" %}</h2>
<div class="list-group list-group-flush">
{% for perm in object.object_permissions.all %}
<a href="{% url 'users:objectpermission' pk=perm.pk %}" class="list-group-item list-group-item-action">{{ perm }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
<div class="card">
<h2 class="card-header">{% trans "Owner Membership" %}</h2>
<div class="list-group list-group-flush">
{% for owner in object.owners.all %}
<a href="{% url 'users:owner' pk=owner.pk %}" class="list-group-item list-group-item-action">{{ owner }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None" %}</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% if perms.core.view_objectchange %}
<div class="row">
<div class="col-md-12">
{% include 'users/inc/user_activity.html' with user=object table=changelog_table %}
</div>
</div>
{% endif %}
{% endblock %}
@@ -16,23 +16,23 @@
{% endblock %}
{% block extra_controls %}
<div class="dropdown">
{% if perms.virtualization.change_virtualmachine %}
<div class="dropdown">
<button id="add-components" type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
</button>
<ul class="dropdown-menu" aria-labeled-by="add-components">
{% if perms.virtualization.add_vminterface %}
<li><a class="dropdown-item" href="{% url 'virtualization:vminterface_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">
{% trans "Interfaces" %}
</a></li>
{% endif %}
{% if perms.virtualization.add_virtualdisk %}
<li><a class="dropdown-item" href="{% url 'virtualization:virtualdisk_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_disks' pk=object.pk %}">
{% trans "Virtual Disks" %}
</a></li>
{% endif %}
{% if perms.virtualization.add_vminterface %}
<li><a class="dropdown-item" href="{% url 'virtualization:vminterface_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">
{% trans "Interfaces" %}
</a></li>
{% endif %}
{% if perms.virtualization.add_virtualdisk %}
<li><a class="dropdown-item" href="{% url 'virtualization:virtualdisk_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_disks' pk=object.pk %}">
{% trans "Virtual Disks" %}
</a></li>
{% endif %}
</ul>
</div>
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,3 @@
{% load i18n %}
<span id="secret" class="font-monospace" data-secret="{{ value }}">{{ value }}</span>
<button type="button" class="btn btn-primary toggle-secret float-end" data-bs-toggle="button">{% trans "Show Secret" %}</button>
-62
View File
@@ -1,63 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "IKE Policy" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "IKE Version" %}</th>
<td>{{ object.get_version_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Mode" %}</th>
<td>{{ object.get_mode_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Pre-Shared Key" %}</th>
<td>
<span id="secret" class="font-monospace" data-secret="{{ object.preshared_key }}">{{ object.preshared_key|placeholder }}</span>
{% if object.preshared_key %}
<button type="button" class="btn btn-primary toggle-secret float-end" data-bs-toggle="button">{% trans "Show Secret" %}</button>
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "IPSec Profiles" %}</th>
<td>
<a href="{% url 'vpn:ipsecprofile_list' %}?ike_policy_id={{ object.pk }}">{{ object.ipsec_profiles.count }}</a>
</td>
</tr>
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Proposals" %}</h2>
{% htmx_table 'vpn:ikeproposal_list' ike_policy_id=object.pk %}
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
-61
View File
@@ -1,62 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "IKE Proposal" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Authentication method" %}</th>
<td>{{ object.get_authentication_method_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Encryption algorithm" %}</th>
<td>{{ object.get_encryption_algorithm_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Authentication algorithm" %}</th>
<td>{{ object.get_authentication_algorithm_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "DH group" %}</th>
<td>{{ object.get_group_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "SA lifetime (seconds)" %}</th>
<td>{{ object.sa_lifetime|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "IKE Policies" %}</th>
<td>
<a href="{% url 'vpn:ikepolicy_list' %}?proposal_id={{ object.pk }}">{{ object.ike_policies.count }}</a>
</td>
</tr>
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
-50
View File
@@ -1,51 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "IPSec Policy" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "PFS group" %}</th>
<td>{{ object.get_pfs_group_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "IPSec Profiles" %}</th>
<td>
<a href="{% url 'vpn:ipsecprofile_list' %}?ipsec_policy_id={{ object.pk }}">{{ object.ipsec_profiles.count }}</a>
</td>
</tr>
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Proposals" %}</h2>
{% htmx_table 'vpn:ipsecproposal_list' ipsec_policy_id=object.pk %}
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
-101
View File
@@ -1,102 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "IPSec Profile" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Mode" %}</th>
<td>{{ object.get_mode_display }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "IKE Policy" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.ike_policy|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.ike_policy.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Version" %}</th>
<td>{{ object.ike_policy.get_version_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Mode" %}</th>
<td>{{ object.ike_policy.get_mode_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Proposals" %}</th>
<td>
<ul class="list-unstyled mb-0">
{% for proposal in object.ike_policy.proposals.all %}
<li>
<a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a>
</li>
{% endfor %}
</ul>
</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "IPSec Policy" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.ipsec_policy|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.ipsec_policy.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Proposals" %}</th>
<td>
<ul class="list-unstyled mb-0">
{% for proposal in object.ipsec_policy.proposals.all %}
<li>
<a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a>
</li>
{% endfor %}
</ul>
</td>
</tr>
<tr>
<th scope="row">{% trans "PFS Group" %}</th>
<td>{{ object.ipsec_policy.get_pfs_group_display }}</td>
</tr>
</table>
</div>
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
-57
View File
@@ -1,58 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "IPSec Proposal" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Encryption algorithm" %}</th>
<td>{{ object.get_encryption_algorithm_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Authentication algorithm" %}</th>
<td>{{ object.get_authentication_algorithm_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "SA lifetime (seconds)" %}</th>
<td>{{ object.sa_lifetime_seconds|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "SA lifetime (KB)" %}</th>
<td>{{ object.sa_lifetime_data|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "IPSec Policies" %}</th>
<td>
<a href="{% url 'vpn:ipsecpolicy_list' %}?proposal_id={{ object.pk }}">{{ object.ipsec_policies.count }}</a>
</td>
</tr>
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
-77
View File
@@ -1,78 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "L2VPN Attributes" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Identifier" %}</th>
<td>{{ object.identifier|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Type" %}</th>
<td>{{ object.get_type_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>{{ object.tenant|linkify|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' with tags=object.tags.all url='vpn:l2vpn_list' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-12 col-md-6">
{% include 'inc/panel_table.html' with table=import_targets_table heading="Import Route Targets" %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panel_table.html' with table=export_targets_table heading="Export Route Targets" %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">
{% trans "Terminations" %}
{% if perms.vpn.add_l2vpntermination %}
<div class="card-actions">
<a href="{% url 'vpn:l2vpntermination_add' %}?l2vpn={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm{% if not object.can_add_termination %} disabled" aria-disabled="true{% endif %}">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Termination" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'vpn:l2vpntermination_list' l2vpn_id=object.pk %}
</div>
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -1,28 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "L2VPN Attributes" %}</h2>
<table class="table table-hover">
<tr>
<th scope="row">{% trans "L2VPN" %}</th>
<td>{{ object.l2vpn|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Assigned Object" %}</th>
<td>{{ object.assigned_object|linkify }}</td>
</tr>
</table>
</div>
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' with tags=object.tags.all url='vpn:l2vpntermination_list' %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,34 @@
{% load helpers %}
{% load i18n %}
<div class="card">
<h2 class="card-header">{% trans "IKE Policy" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.ike_policy|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.ike_policy.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Version" %}</th>
<td>{{ object.ike_policy.get_version_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Mode" %}</th>
<td>{{ object.ike_policy.get_mode_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Proposals" %}</th>
<td>
<ul class="list-unstyled mb-0">
{% for proposal in object.ike_policy.proposals.all %}
<li><a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a></li>
{% endfor %}
</ul>
</td>
</tr>
</table>
</div>
@@ -0,0 +1,30 @@
{% load helpers %}
{% load i18n %}
<div class="card">
<h2 class="card-header">{% trans "IPSec Policy" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.ipsec_policy|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.ipsec_policy.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Proposals" %}</th>
<td>
<ul class="list-unstyled mb-0">
{% for proposal in object.ipsec_policy.proposals.all %}
<li><a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a></li>
{% endfor %}
</ul>
</td>
</tr>
<tr>
<th scope="row">{% trans "PFS Group" %}</th>
<td>{{ object.ipsec_policy.get_pfs_group_display }}</td>
</tr>
</table>
</div>
-76
View File
@@ -1,6 +1,4 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block extra_controls %}
@@ -10,77 +8,3 @@
</a>
{% endif %}
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Tunnel" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Group" %}</th>
<td>{{ object.group|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Encapsulation" %}</th>
<td>{{ object.get_encapsulation_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "IPSec profile" %}</th>
<td>{{ object.ipsec_profile|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tunnel ID" %}</th>
<td>{{ object.tunnel_id|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>
{% if object.tenant.group %}
{{ object.tenant.group|linkify }} /
{% endif %}
{{ object.tenant|linkify|placeholder }}
</td>
</tr>
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">
{% trans "Terminations" %}
{% if perms.vpn.add_tunneltermination %}
<div class="card-actions">
<a href="{% url 'vpn:tunneltermination_add' %}?tunnel={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Termination" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'vpn:tunneltermination_list' tunnel_id=object.pk %}
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
-40
View File
@@ -1,13 +1,6 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'vpn:tunnelgroup_list' %}">{% trans "Tunnel Groups" %}</a></li>
{% endblock %}
{% block extra_controls %}
{% if perms.vpn.add_tunnel %}
<a href="{% url 'vpn:tunnel_add' %}?group={{ object.pk }}" class="btn btn-primary">
@@ -15,36 +8,3 @@
</a>
{% endif %}
{% endblock extra_controls %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Tunnel Group" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -1,57 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Tunnel Termination" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Tunnel" %}</th>
<td>{{ object.tunnel|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Role" %}</th>
<td>{% badge object.get_role_display bg_color=object.get_role_color %}</td>
</tr>
<tr>
<th scope="row">
{% if object.termination.device %}
{% trans "Device" %}
{% elif object.termination.virtual_machine %}
{% trans "Virtual Machine" %}
{% endif %}
</th>
<td>{{ object.termination.parent_object|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Interface" %}</th>
<td>{{ object.termination|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Outside IP" %}</th>
<td>{{ object.outside_ip|linkify|placeholder }}</td>
</tr>
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Peer Terminations" %}</h2>
{% htmx_table 'vpn:tunneltermination_list' tunnel_id=object.tunnel.pk id__n=object.pk %}
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,3 @@
{% load i18n %}
<span id="secret" class="font-monospace" data-secret="{{ value }}">{{ value }}</span>
<button type="button" class="btn btn-primary toggle-secret float-end" data-bs-toggle="button">{% trans "Show Secret" %}</button>
@@ -1,25 +0,0 @@
{% load helpers %}
{% load i18n %}
<div class="card">
<h2 class="card-header">{% trans "Authentication" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Type" %}</th>
<td>{{ object.get_auth_type_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Cipher" %}</th>
<td>{{ object.get_auth_cipher_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "PSK" %}</th>
<td>
<span id="secret" class="font-monospace" data-secret="{{ object.auth_psk }}">{{ object.auth_psk|placeholder }}</span>
{% if object.auth_psk %}
<button type="button" class="btn btn-primary toggle-secret float-end" data-bs-toggle="button">{% trans "Show Secret" %}</button>
{% endif %}
</td>
</tr>
</table>
</div>
@@ -1,51 +0,0 @@
{% load helpers %}
{% load i18n %}
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Device" %}</th>
<td>{{ interface.device|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Interface" %}</th>
<td>{{ interface|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Type" %}</th>
<td>
{{ interface.get_type_display }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Role" %}</th>
<td>
{{ interface.get_rf_role_display|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Channel" %}</th>
<td>
{{ interface.get_rf_channel_display|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Channel Frequency" %}</th>
<td>
{% if interface.rf_channel_frequency %}
{{ interface.rf_channel_frequency|floatformat:"-2" }} {% trans "MHz" context "Abbreviation for megahertz" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Channel Width" %}</th>
<td>
{% if interface.rf_channel_width %}
{{ interface.rf_channel_width|floatformat:"-3" }} {% trans "MHz" context "Abbreviation for megahertz" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
@@ -0,0 +1,48 @@
{% extends "ui/panels/_base.html" %}
{% load helpers %}
{% load i18n %}
{% block panel_content %}
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Device" %}</th>
<td>{{ interface.device|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Interface" %}</th>
<td>{{ interface|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Type" %}</th>
<td>{{ interface.get_type_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Role" %}</th>
<td>{{ interface.get_rf_role_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Channel" %}</th>
<td>{{ interface.get_rf_channel_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Channel Frequency" %}</th>
<td>
{% if interface.rf_channel_frequency %}
{{ interface.rf_channel_frequency|floatformat:"-2" }} {% trans "MHz" context "Abbreviation for megahertz" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Channel Width" %}</th>
<td>
{% if interface.rf_channel_width %}
{{ interface.rf_channel_width|floatformat:"-3" }} {% trans "MHz" context "Abbreviation for megahertz" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
{% endblock panel_content %}
@@ -1,74 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Wireless LAN" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "SSID" %}</th>
<td>{{ object.ssid }}</td>
</tr>
<tr>
<th scope="row">{% trans "Group" %}</th>
<td>{{ object.group|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Scope" %}</th>
{% if object.scope %}
<td>{{ object.scope|linkify }} ({% trans object.scope_type.name %})</td>
{% else %}
<td>{{ ''|placeholder }}</td>
{% endif %}
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "VLAN" %}</th>
<td>{{ object.vlan|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>
{% if object.tenant.group %}
{{ object.tenant.group|linkify }} /
{% endif %}
{{ object.tenant|linkify|placeholder }}
</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'wireless/inc/authentication_attrs.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Attached Interfaces" %}</h2>
<div class="card-body table-responsive">
{% render_table interfaces_table 'inc/table.html' %}
{% include 'inc/paginator.html' with paginator=interfaces_table.paginator page=interfaces_table.page %}
</div>
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -1,7 +1,4 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block breadcrumbs %}
@@ -18,53 +15,3 @@
</a>
{% endif %}
{% endblock extra_controls %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Wireless LAN Group" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Parent" %}</th>
<td>{{ object.parent|linkify|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">
{% trans "Child Groups" %}
{% if perms.wireless.add_wirelesslangroup %}
<div class="card-actions">
<a href="{% url 'wireless:wirelesslangroup_add' %}?parent={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Wireless LAN Group" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'wireless:wirelesslangroup_list' parent_id=object.pk %}
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -1,68 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Interface" %} A</h2>
{% include 'wireless/inc/wirelesslink_interface.html' with interface=object.interface_a %}
</div>
<div class="card">
<h2 class="card-header">{% trans "Link Properties" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "SSID" %}</th>
<td>{{ object.ssid|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>
{% if object.tenant.group %}
{{ object.tenant.group|linkify }} /
{% endif %}
{{ object.tenant|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Distance" %}</th>
<td>
{% if object.distance is not None %}
{{ object.distance|floatformat }} {{ object.get_distance_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Interface" %} B</h2>
{% include 'wireless/inc/wirelesslink_interface.html' with interface=object.interface_b %}
</div>
{% include 'wireless/inc/authentication_attrs.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -1,6 +1,6 @@
import logging
from django.contrib.auth.signals import user_login_failed
from django.contrib.auth.signals import user_logged_in, user_login_failed
from django.db.models.signals import post_save
from django.dispatch import receiver
@@ -23,6 +23,18 @@ def log_user_login_failed(sender, credentials, request, **kwargs):
logger.info(f"Failed login attempt for username: {username}")
@receiver(user_logged_in)
def set_language_on_login(sender, user, request, **kwargs):
"""
Store the user's preferred language on the request so that middleware can set the language cookie. This ensures the
language preference is applied even when logging in via an external auth provider (e.g. social-app-django) that
does not go through NetBox's LoginView.
"""
if hasattr(user, 'config'):
if language := user.config.get('locale.language'):
request._language_cookie = language
@receiver(post_save, sender=User)
def create_userconfig(instance, created, raw=False, **kwargs):
"""
+35
View File
@@ -23,3 +23,38 @@ class TokenExamplePanel(panels.Panel):
actions = [
actions.CopyContent('token-example')
]
class UserPanel(panels.ObjectAttributesPanel):
username = attrs.TextAttr('username')
full_name = attrs.TemplatedAttr(
'get_full_name',
label=_('Full name'),
template_name='users/attrs/full_name.html',
)
email = attrs.TextAttr('email')
date_joined = attrs.DateTimeAttr('date_joined', label=_('Account created'), spec='date')
last_login = attrs.DateTimeAttr('last_login', label=_('Last login'), spec='minutes')
is_active = attrs.BooleanAttr('is_active', label=_('Active'))
is_superuser = attrs.BooleanAttr('is_superuser', label=_('Superuser'))
class ObjectPermissionPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
enabled = attrs.BooleanAttr('enabled')
class ObjectPermissionActionsPanel(panels.ObjectAttributesPanel):
title = _('Actions')
can_view = attrs.BooleanAttr('can_view', label=_('View'))
can_add = attrs.BooleanAttr('can_add', label=_('Add'))
can_change = attrs.BooleanAttr('can_change', label=_('Change'))
can_delete = attrs.BooleanAttr('can_delete', label=_('Delete'))
class OwnerPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
group = attrs.RelatedObjectAttr('group', linkify=True)
description = attrs.TextAttr('description')
+105 -13
View File
@@ -1,9 +1,18 @@
from django.db.models import Count
from django.utils.translation import gettext_lazy as _
from core.models import ObjectChange
from core.tables import ObjectChangeTable
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename
from netbox.ui import layout
from netbox.ui import actions, layout
from netbox.ui.panels import (
ContextTablePanel,
JSONPanel,
ObjectsTablePanel,
OrganizationalObjectPanel,
RelatedObjectsPanel,
TemplatePanel,
)
from netbox.views import generic
from users.ui import panels
from utilities.query import count_related
@@ -86,7 +95,39 @@ class UserListView(generic.ObjectListView):
@register_model_view(User)
class UserView(generic.ObjectView):
queryset = User.objects.all()
template_name = 'users/user.html'
layout = layout.SimpleLayout(
left_panels=[
panels.UserPanel(),
],
right_panels=[
ObjectsTablePanel(
'users.Group', title=_('Assigned Groups'), filters={'user_id': lambda ctx: ctx['object'].pk}
),
ObjectsTablePanel(
'users.ObjectPermission',
title=_('Assigned Permissions'),
filters={'user_id': lambda ctx: ctx['object'].pk},
),
ObjectsTablePanel(
'users.Owner', title=_('Owner Membership'), filters={'user_id': lambda ctx: ctx['object'].pk}
),
],
bottom_panels=[
ContextTablePanel(
'changelog_table',
title=_('Recent Activity'),
actions=[
actions.LinkAction(
view_name='core:objectchange_list',
url_params={'user_id': lambda ctx: ctx['object'].pk},
label=_('View All'),
button_icon='arrow-right-thick',
permissions=['core.view_objectchange'],
),
],
),
],
)
def get_extra_context(self, request, instance):
changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(user=instance)[:20]
@@ -154,7 +195,22 @@ class GroupListView(generic.ObjectListView):
@register_model_view(Group)
class GroupView(generic.ObjectView):
queryset = Group.objects.all()
template_name = 'users/group.html'
layout = layout.SimpleLayout(
left_panels=[
OrganizationalObjectPanel(),
],
right_panels=[
ObjectsTablePanel('users.User', filters={'group_id': lambda ctx: ctx['object'].pk}),
ObjectsTablePanel(
'users.ObjectPermission',
title=_('Assigned Permissions'),
filters={'group_id': lambda ctx: ctx['object'].pk},
),
ObjectsTablePanel(
'users.Owner', title=_('Owner Membership'), filters={'user_group_id': lambda ctx: ctx['object'].pk}
),
],
)
@register_model_view(Group, 'add', detail=False)
@@ -212,7 +268,22 @@ class ObjectPermissionListView(generic.ObjectListView):
@register_model_view(ObjectPermission)
class ObjectPermissionView(generic.ObjectView):
queryset = ObjectPermission.objects.all()
template_name = 'users/objectpermission.html'
layout = layout.SimpleLayout(
left_panels=[
panels.ObjectPermissionPanel(),
panels.ObjectPermissionActionsPanel(),
JSONPanel('constraints', title=_('Constraints')),
],
right_panels=[
TemplatePanel('users/panels/object_types.html'),
ObjectsTablePanel(
'users.User', title=_('Assigned Users'), filters={'permission_id': lambda ctx: ctx['object'].pk}
),
ObjectsTablePanel(
'users.Group', title=_('Assigned Groups'), filters={'permission_id': lambda ctx: ctx['object'].pk}
),
],
)
@register_model_view(ObjectPermission, 'add', detail=False)
@@ -255,7 +326,7 @@ class ObjectPermissionBulkDeleteView(generic.BulkDeleteView):
@register_model_view(OwnerGroup, 'list', path='', detail=False)
class OwnerGroupListView(generic.ObjectListView):
queryset = OwnerGroup.objects.annotate(
owner_count=count_related(Owner, 'group')
owner_count=count_related(Owner, 'group')
)
filterset = filtersets.OwnerGroupFilterSet
filterset_form = forms.OwnerGroupFilterForm
@@ -263,14 +334,26 @@ class OwnerGroupListView(generic.ObjectListView):
@register_model_view(OwnerGroup)
class OwnerGroupView(GetRelatedModelsMixin, generic.ObjectView):
class OwnerGroupView(generic.ObjectView):
queryset = OwnerGroup.objects.all()
template_name = 'users/ownergroup.html'
def get_extra_context(self, request, instance):
return {
'related_models': self.get_related_models(request, instance),
}
layout = layout.SimpleLayout(
left_panels=[
OrganizationalObjectPanel(),
],
right_panels=[
ObjectsTablePanel(
'users.Owner',
filters={'group_id': lambda ctx: ctx['object'].pk},
title=_('Members'),
actions=[
actions.AddObject(
'users.Owner',
url_params={'group': lambda ctx: ctx['object'].pk},
),
],
),
],
)
@register_model_view(OwnerGroup, 'add', detail=False)
@@ -326,7 +409,16 @@ class OwnerListView(generic.ObjectListView):
@register_model_view(Owner)
class OwnerView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Owner.objects.all()
template_name = 'users/owner.html'
layout = layout.SimpleLayout(
left_panels=[
panels.OwnerPanel(),
ObjectsTablePanel('users.Group', filters={'owner_id': lambda ctx: ctx['object'].pk}),
ObjectsTablePanel('users.User', filters={'owner_id': lambda ctx: ctx['object'].pk}),
],
right_panels=[
RelatedObjectsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
+8
View File
@@ -38,6 +38,7 @@ FILTER_TREENODE_NEGATION_LOOKUP_MAP = dict(
# HTTP Request META safe copy
#
# Non-HTTP_ META keys to include when copying a request (whitelist)
HTTP_REQUEST_META_SAFE_COPY = [
'CONTENT_LENGTH',
'CONTENT_TYPE',
@@ -61,6 +62,13 @@ HTTP_REQUEST_META_SAFE_COPY = [
'SERVER_PORT',
]
# HTTP_ META keys known to carry sensitive data; excluded when copying a request (denylist)
HTTP_REQUEST_META_SENSITIVE = {
'HTTP_AUTHORIZATION',
'HTTP_COOKIE',
'HTTP_PROXY_AUTHORIZATION',
}
#
# CSV-style format delimiters
+9 -6
View File
@@ -8,7 +8,7 @@ from netaddr import AddrFormatError, IPAddress
from netbox.registry import registry
from .constants import HTTP_REQUEST_META_SAFE_COPY
from .constants import HTTP_REQUEST_META_SAFE_COPY, HTTP_REQUEST_META_SENSITIVE
__all__ = (
'NetBoxFakeRequest',
@@ -45,11 +45,14 @@ def copy_safe_request(request, include_files=True):
request: The original request object
include_files: Whether to include request.FILES.
"""
meta = {
k: request.META[k]
for k in HTTP_REQUEST_META_SAFE_COPY
if k in request.META and isinstance(request.META[k], str)
}
meta = {}
for k, v in request.META.items():
if not isinstance(v, str):
continue
if k in HTTP_REQUEST_META_SAFE_COPY:
meta[k] = v
elif k.startswith('HTTP_') and k not in HTTP_REQUEST_META_SENSITIVE:
meta[k] = v
data = {
'META': meta,
'COOKIES': request.COOKIES,
+36 -1
View File
@@ -1,7 +1,42 @@
from django.contrib.auth.models import AnonymousUser
from django.test import RequestFactory, TestCase
from netaddr import IPAddress
from utilities.request import get_client_ip
from utilities.request import copy_safe_request, get_client_ip
class CopySafeRequestTests(TestCase):
def setUp(self):
self.factory = RequestFactory()
def _make_request(self, **kwargs):
request = self.factory.get('/', **kwargs)
request.user = AnonymousUser()
return request
def test_standard_meta_keys_copied(self):
request = self._make_request(HTTP_USER_AGENT='TestAgent/1.0')
fake = copy_safe_request(request)
self.assertEqual(fake.META.get('HTTP_USER_AGENT'), 'TestAgent/1.0')
def test_arbitrary_http_headers_copied(self):
"""Arbitrary HTTP_ headers (e.g. X-NetBox-*) should be included."""
request = self._make_request(HTTP_X_NETBOX_BRANCH='my-branch')
fake = copy_safe_request(request)
self.assertEqual(fake.META.get('HTTP_X_NETBOX_BRANCH'), 'my-branch')
def test_sensitive_headers_excluded(self):
"""Authorization and Cookie headers must not be copied."""
request = self._make_request(HTTP_AUTHORIZATION='Bearer secret')
fake = copy_safe_request(request)
self.assertNotIn('HTTP_AUTHORIZATION', fake.META)
def test_non_string_meta_values_excluded(self):
"""Non-string META values must not be copied."""
request = self._make_request()
request.META['HTTP_X_CUSTOM_INT'] = 42
fake = copy_safe_request(request)
self.assertNotIn('HTTP_X_CUSTOM_INT', fake.META)
class GetClientIPTests(TestCase):
+2 -2
View File
@@ -74,8 +74,8 @@ class ClusterImportForm(ScopedImportForm, PrimaryModelImportForm):
class Meta:
model = Cluster
fields = (
'name', 'type', 'group', 'status', 'scope_type', 'scope_id', 'tenant', 'description', 'owner', 'comments',
'tags',
'name', 'type', 'group', 'status', 'scope_type', 'scope_name', 'scope_id', 'tenant', 'description', 'owner',
'comments', 'tags',
)
labels = {
'scope_id': _('Scope ID'),
+14 -6
View File
@@ -157,12 +157,20 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"name,type,status",
"Cluster 4,Cluster Type 1,active",
"Cluster 5,Cluster Type 1,active",
"Cluster 6,Cluster Type 1,active",
)
cls.csv_data = {
'default': (
"name,type,status,scope_type,scope_id",
f"Cluster 4,Cluster Type 1,active,dcim.site,{sites[0].pk}",
f"Cluster 5,Cluster Type 1,active,dcim.site,{sites[0].pk}",
f"Cluster 6,Cluster Type 1,active,dcim.site,{sites[0].pk}",
),
'scope_name': (
"name,type,status,scope_type,scope_name",
f"Cluster 4,Cluster Type 1,active,dcim.site,{sites[0].name}",
f"Cluster 5,Cluster Type 1,active,dcim.site,{sites[0].name}",
f"Cluster 6,Cluster Type 1,active,dcim.site,{sites[0].name}",
),
}
cls.csv_update_data = (
"id,name,comments",
+6 -18
View File
@@ -13,7 +13,7 @@ from dcim.tables import DeviceTable
from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import IPAddress, VLANGroup
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from ipam.tables import VLANTranslationRuleTable
from ipam.ui.panels import FHRPGroupAssignmentsPanel
from netbox.object_actions import (
AddObject,
@@ -594,7 +594,11 @@ class VMInterfaceView(generic.ObjectView):
),
],
),
ContextTablePanel('vlan_table', title=_('Assigned VLANs')),
ObjectsTablePanel(
model='ipam.VLAN',
title=_('Assigned VLANs'),
filters={'vminterface_id': lambda ctx: ctx['object'].pk},
),
ContextTablePanel('vlan_translation_table', title=_('VLAN Translation')),
ContextTablePanel('child_interfaces_table', title=_('Child Interfaces')),
],
@@ -620,24 +624,8 @@ class VMInterfaceView(generic.ObjectView):
)
vlan_translation_table.configure(request)
# Get assigned VLANs and annotate whether each is tagged or untagged
vlans = []
if instance.untagged_vlan is not None:
vlans.append(instance.untagged_vlan)
vlans[0].tagged = False
for vlan in instance.tagged_vlans.restrict(request.user).prefetch_related('site', 'group', 'tenant', 'role'):
vlan.tagged = True
vlans.append(vlan)
vlan_table = InterfaceVLANTable(
interface=instance,
data=vlans,
orderable=False
)
vlan_table.configure(request)
return {
'child_interfaces_table': child_interfaces_tables,
'vlan_table': vlan_table,
'vlan_translation_table': vlan_translation_table,
}
+2 -2
View File
@@ -126,8 +126,8 @@ class L2VPNTermination(NetBoxModel):
if self.assigned_object:
obj_id = self.assigned_object.pk
obj_type = ObjectType.objects.get_for_model(self.assigned_object)
if L2VPNTermination.objects.filter(assigned_object_id=obj_id, assigned_object_type=obj_type).\
exclude(pk=self.pk).count() > 0:
terminations = L2VPNTermination.objects.filter(assigned_object_id=obj_id, assigned_object_type=obj_type)
if terminations.exclude(pk=self.pk).exists():
raise ValidationError(
_('L2VPN Termination already assigned ({assigned_object})').format(
assigned_object=self.assigned_object
View File
+85
View File
@@ -0,0 +1,85 @@
from django.utils.translation import gettext_lazy as _
from netbox.ui import attrs, panels
class TunnelGroupPanel(panels.OrganizationalObjectPanel):
pass
class TunnelPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
status = attrs.ChoiceAttr('status')
group = attrs.RelatedObjectAttr('group', linkify=True)
description = attrs.TextAttr('description')
encapsulation = attrs.ChoiceAttr('encapsulation')
ipsec_profile = attrs.RelatedObjectAttr('ipsec_profile', linkify=True, label=_('IPSec profile'))
tunnel_id = attrs.TextAttr('tunnel_id', label=_('Tunnel ID'))
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
class TunnelTerminationPanel(panels.ObjectAttributesPanel):
tunnel = attrs.RelatedObjectAttr('tunnel', linkify=True)
role = attrs.ChoiceAttr('role')
parent_object = attrs.RelatedObjectAttr(
'termination.parent_object', linkify=True, label=_('Parent')
)
termination = attrs.RelatedObjectAttr('termination', linkify=True, label=_('Interface'))
outside_ip = attrs.RelatedObjectAttr('outside_ip', linkify=True, label=_('Outside IP'))
class IKEProposalPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
authentication_method = attrs.ChoiceAttr('authentication_method', label=_('Authentication method'))
encryption_algorithm = attrs.ChoiceAttr('encryption_algorithm', label=_('Encryption algorithm'))
authentication_algorithm = attrs.ChoiceAttr('authentication_algorithm', label=_('Authentication algorithm'))
group = attrs.ChoiceAttr('group', label=_('DH group'))
sa_lifetime = attrs.TextAttr('sa_lifetime', label=_('SA lifetime (seconds)'))
class IKEPolicyPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
version = attrs.ChoiceAttr('version', label=_('IKE version'))
mode = attrs.ChoiceAttr('mode')
preshared_key = attrs.TemplatedAttr(
'preshared_key',
label=_('Pre-shared key'),
template_name='vpn/attrs/preshared_key.html',
)
class IPSecProposalPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
encryption_algorithm = attrs.ChoiceAttr('encryption_algorithm', label=_('Encryption algorithm'))
authentication_algorithm = attrs.ChoiceAttr('authentication_algorithm', label=_('Authentication algorithm'))
sa_lifetime_seconds = attrs.TextAttr('sa_lifetime_seconds', label=_('SA lifetime (seconds)'))
sa_lifetime_data = attrs.TextAttr('sa_lifetime_data', label=_('SA lifetime (KB)'))
class IPSecPolicyPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
pfs_group = attrs.ChoiceAttr('pfs_group', label=_('PFS group'))
class IPSecProfilePanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
mode = attrs.ChoiceAttr('mode')
class L2VPNPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
identifier = attrs.TextAttr('identifier')
type = attrs.ChoiceAttr('type')
status = attrs.ChoiceAttr('status')
description = attrs.TextAttr('description')
tenant = attrs.RelatedObjectAttr('tenant', linkify=True)
class L2VPNTerminationPanel(panels.ObjectAttributesPanel):
l2vpn = attrs.RelatedObjectAttr('l2vpn', linkify=True, label=_('L2VPN'))
assigned_object = attrs.RelatedObjectAttr('assigned_object', linkify=True, label=_('Assigned object'))
+209 -2
View File
@@ -1,11 +1,24 @@
from django.utils.translation import gettext_lazy as _
from extras.ui.panels import CustomFieldsPanel, TagsPanel
from ipam.tables import RouteTargetTable
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
from netbox.ui import actions, layout
from netbox.ui.panels import (
CommentsPanel,
ContextTablePanel,
ObjectsTablePanel,
PluginContentPanel,
RelatedObjectsPanel,
TemplatePanel,
)
from netbox.views import generic
from utilities.query import count_related
from utilities.views import GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables
from .models import *
from .ui import panels
#
# Tunnel groups
@@ -25,6 +38,17 @@ class TunnelGroupListView(generic.ObjectListView):
@register_model_view(TunnelGroup)
class TunnelGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = TunnelGroup.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.TunnelGroupPanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CommentsPanel(),
CustomFieldsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
@@ -92,6 +116,30 @@ class TunnelListView(generic.ObjectListView):
@register_model_view(Tunnel)
class TunnelView(generic.ObjectView):
queryset = Tunnel.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.TunnelPanel(),
],
right_panels=[
CustomFieldsPanel(),
TagsPanel(),
CommentsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
'vpn.tunneltermination',
filters={'tunnel_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject(
'vpn.tunneltermination',
url_params={'tunnel': lambda ctx: ctx['object'].pk},
label=_('Add a Termination'),
),
],
title=_('Terminations'),
),
],
)
@register_model_view(Tunnel, 'add', detail=False)
@@ -160,6 +208,25 @@ class TunnelTerminationListView(generic.ObjectListView):
@register_model_view(TunnelTermination)
class TunnelTerminationView(generic.ObjectView):
queryset = TunnelTermination.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.TunnelTerminationPanel(),
],
right_panels=[
CustomFieldsPanel(),
TagsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
'vpn.tunneltermination',
filters={
'tunnel_id': lambda ctx: ctx['object'].tunnel.pk,
'id__n': lambda ctx: ctx['object'].pk,
},
title=_('Peer Terminations'),
),
],
)
@register_model_view(TunnelTermination, 'add', detail=False)
@@ -210,6 +277,23 @@ class IKEProposalListView(generic.ObjectListView):
@register_model_view(IKEProposal)
class IKEProposalView(generic.ObjectView):
queryset = IKEProposal.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.IKEProposalPanel(),
],
right_panels=[
CustomFieldsPanel(),
CommentsPanel(),
TagsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
'vpn.ikepolicy',
filters={'ike_proposal_id': lambda ctx: ctx['object'].pk},
title=_('IKE Policies'),
),
],
)
@register_model_view(IKEProposal, 'add', detail=False)
@@ -264,8 +348,31 @@ class IKEPolicyListView(generic.ObjectListView):
@register_model_view(IKEPolicy)
class IKEPolicyView(generic.ObjectView):
class IKEPolicyView(GetRelatedModelsMixin, generic.ObjectView):
queryset = IKEPolicy.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.IKEPolicyPanel(),
],
right_panels=[
CustomFieldsPanel(),
CommentsPanel(),
TagsPanel(),
RelatedObjectsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
'vpn.ikeproposal',
filters={'ike_policy_id': lambda ctx: ctx['object'].pk},
title=_('Proposals'),
),
],
)
def get_extra_context(self, request, instance):
return {
'related_models': self.get_related_models(request, instance),
}
@register_model_view(IKEPolicy, 'add', detail=False)
@@ -322,6 +429,23 @@ class IPSecProposalListView(generic.ObjectListView):
@register_model_view(IPSecProposal)
class IPSecProposalView(generic.ObjectView):
queryset = IPSecProposal.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.IPSecProposalPanel(),
],
right_panels=[
CustomFieldsPanel(),
CommentsPanel(),
TagsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
'vpn.ipsecpolicy',
filters={'ipsec_proposal_id': lambda ctx: ctx['object'].pk},
title=_('IPSec Policies'),
),
],
)
@register_model_view(IPSecProposal, 'add', detail=False)
@@ -376,8 +500,31 @@ class IPSecPolicyListView(generic.ObjectListView):
@register_model_view(IPSecPolicy)
class IPSecPolicyView(generic.ObjectView):
class IPSecPolicyView(GetRelatedModelsMixin, generic.ObjectView):
queryset = IPSecPolicy.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.IPSecPolicyPanel(),
],
right_panels=[
CustomFieldsPanel(),
CommentsPanel(),
TagsPanel(),
RelatedObjectsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
'vpn.ipsecproposal',
filters={'ipsec_policy_id': lambda ctx: ctx['object'].pk},
title=_('Proposals'),
),
],
)
def get_extra_context(self, request, instance):
return {
'related_models': self.get_related_models(request, instance),
}
@register_model_view(IPSecPolicy, 'add', detail=False)
@@ -434,6 +581,18 @@ class IPSecProfileListView(generic.ObjectListView):
@register_model_view(IPSecProfile)
class IPSecProfileView(generic.ObjectView):
queryset = IPSecProfile.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.IPSecProfilePanel(),
TagsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
],
right_panels=[
TemplatePanel('vpn/panels/ipsecprofile_ike_policy.html'),
TemplatePanel('vpn/panels/ipsecprofile_ipsec_policy.html'),
],
)
@register_model_view(IPSecProfile, 'add', detail=False)
@@ -490,6 +649,45 @@ class L2VPNListView(generic.ObjectListView):
@register_model_view(L2VPN)
class L2VPNView(generic.ObjectView):
queryset = L2VPN.objects.all()
layout = layout.Layout(
layout.Row(
layout.Column(
panels.L2VPNPanel(),
TagsPanel(),
PluginContentPanel('left_page'),
),
layout.Column(
CustomFieldsPanel(),
CommentsPanel(),
PluginContentPanel('right_page'),
),
),
layout.Row(
layout.Column(
ContextTablePanel('import_targets_table', title=_('Import Route Targets')),
),
layout.Column(
ContextTablePanel('export_targets_table', title=_('Export Route Targets')),
),
),
layout.Row(
layout.Column(
ObjectsTablePanel(
'vpn.l2vpntermination',
filters={'l2vpn_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject(
'vpn.l2vpntermination',
url_params={'l2vpn': lambda ctx: ctx['object'].pk},
label=_('Add a Termination'),
),
],
title=_('Terminations'),
),
PluginContentPanel('full_width_page'),
),
),
)
def get_extra_context(self, request, instance):
import_targets_table = RouteTargetTable(
@@ -564,6 +762,15 @@ class L2VPNTerminationListView(generic.ObjectListView):
@register_model_view(L2VPNTermination)
class L2VPNTerminationView(generic.ObjectView):
queryset = L2VPNTermination.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.L2VPNTerminationPanel(),
],
right_panels=[
CustomFieldsPanel(),
TagsPanel(),
],
)
@register_model_view(L2VPNTermination, 'add', detail=False)
+1 -1
View File
@@ -76,7 +76,7 @@ class WirelessLANImportForm(ScopedImportForm, PrimaryModelImportForm):
model = WirelessLAN
fields = (
'ssid', 'group', 'status', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'scope_type',
'scope_id', 'description', 'owner', 'comments', 'tags',
'scope_name', 'scope_id', 'description', 'owner', 'comments', 'tags',
)
labels = {
'scope_id': _('Scope ID'),
+34 -15
View File
@@ -116,23 +116,42 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"group,ssid,status,tenant,scope_type,scope_id",
"Wireless LAN Group 2,WLAN4,{status},{tenant},,".format(
status=WirelessLANStatusChoices.STATUS_ACTIVE,
tenant=tenants[0].name
cls.csv_data = {
'default': (
"group,ssid,status,tenant,scope_type,scope_id",
"Wireless LAN Group 2,WLAN4,{status},{tenant},,".format(
status=WirelessLANStatusChoices.STATUS_ACTIVE,
tenant=tenants[0].name
),
"Wireless LAN Group 2,WLAN5,{status},{tenant},dcim.site,{site}".format(
status=WirelessLANStatusChoices.STATUS_DISABLED,
tenant=tenants[1].name,
site=sites[0].pk
),
"Wireless LAN Group 2,WLAN6,{status},{tenant},dcim.site,{site}".format(
status=WirelessLANStatusChoices.STATUS_RESERVED,
tenant=tenants[2].name,
site=sites[1].pk
),
),
"Wireless LAN Group 2,WLAN5,{status},{tenant},dcim.site,{site}".format(
status=WirelessLANStatusChoices.STATUS_DISABLED,
tenant=tenants[1].name,
site=sites[0].pk
'scope_name': (
"group,ssid,status,tenant,scope_type,scope_name",
"Wireless LAN Group 2,WLAN4,{status},{tenant},,".format(
status=WirelessLANStatusChoices.STATUS_ACTIVE,
tenant=tenants[0].name
),
"Wireless LAN Group 2,WLAN5,{status},{tenant},dcim.site,{site}".format(
status=WirelessLANStatusChoices.STATUS_DISABLED,
tenant=tenants[1].name,
site=sites[0].name
),
"Wireless LAN Group 2,WLAN6,{status},{tenant},dcim.site,{site}".format(
status=WirelessLANStatusChoices.STATUS_RESERVED,
tenant=tenants[2].name,
site=sites[1].name
),
),
"Wireless LAN Group 2,WLAN6,{status},{tenant},dcim.site,{site}".format(
status=WirelessLANStatusChoices.STATUS_RESERVED,
tenant=tenants[2].name,
site=sites[1].pk
),
)
}
cls.csv_update_data = (
"id,ssid",
View File
+51
View File
@@ -0,0 +1,51 @@
from django.utils.translation import gettext_lazy as _
from netbox.ui import attrs, panels
class WirelessLANGroupPanel(panels.NestedGroupObjectPanel):
pass
class WirelessLANPanel(panels.ObjectAttributesPanel):
ssid = attrs.TextAttr('ssid', label=_('SSID'))
group = attrs.RelatedObjectAttr('group', linkify=True)
status = attrs.ChoiceAttr('status')
scope = attrs.GenericForeignKeyAttr('scope', linkify=True)
description = attrs.TextAttr('description')
vlan = attrs.RelatedObjectAttr('vlan', label=_('VLAN'), linkify=True)
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
class WirelessAuthenticationPanel(panels.ObjectAttributesPanel):
title = _('Authentication')
auth_type = attrs.ChoiceAttr('auth_type', label=_('Type'))
auth_cipher = attrs.ChoiceAttr('auth_cipher', label=_('Cipher'))
auth_psk = attrs.TemplatedAttr('auth_psk', label=_('PSK'), template_name='wireless/attrs/auth_psk.html')
class WirelessLinkInterfacePanel(panels.ObjectPanel):
template_name = 'wireless/panels/wirelesslink_interface.html'
def __init__(self, interface_attr, title, **kwargs):
super().__init__(**kwargs)
self.interface_attr = interface_attr
self.title = title
def get_context(self, context):
obj = context['object']
return {
**super().get_context(context),
'interface': getattr(obj, self.interface_attr),
}
class WirelessLinkPropertiesPanel(panels.ObjectAttributesPanel):
title = _('Link Properties')
status = attrs.ChoiceAttr('status')
ssid = attrs.TextAttr('ssid', label=_('SSID'))
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
description = attrs.TextAttr('description')
distance = attrs.NumericAttr('distance', unit_accessor='get_distance_unit_display')
+68 -11
View File
@@ -1,10 +1,20 @@
from django.utils.translation import gettext_lazy as _
from dcim.models import Interface
from extras.ui.panels import CustomFieldsPanel, TagsPanel
from netbox.ui import actions, layout
from netbox.ui.panels import (
CommentsPanel,
ObjectsTablePanel,
RelatedObjectsPanel,
)
from netbox.views import generic
from utilities.query import count_related
from utilities.views import GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables
from .models import *
from .ui import panels
#
# Wireless LAN groups
@@ -28,6 +38,33 @@ class WirelessLANGroupListView(generic.ObjectListView):
@register_model_view(WirelessLANGroup)
class WirelessLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = WirelessLANGroup.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.WirelessLANGroupPanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CustomFieldsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='wireless.WirelessLANGroup',
title=_('Child Groups'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject(
'wireless.WirelessLANGroup',
label=_('Add Wireless LAN Group'),
url_params={
'parent': lambda ctx: ctx['object'].pk,
}
),
],
),
],
)
def get_extra_context(self, request, instance):
groups = instance.get_descendants(include_self=True)
@@ -105,17 +142,24 @@ class WirelessLANListView(generic.ObjectListView):
@register_model_view(WirelessLAN)
class WirelessLANView(generic.ObjectView):
queryset = WirelessLAN.objects.all()
def get_extra_context(self, request, instance):
attached_interfaces = Interface.objects.restrict(request.user, 'view').filter(
wireless_lans=instance
)
interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces)
interfaces_table.configure(request)
return {
'interfaces_table': interfaces_table,
}
layout = layout.SimpleLayout(
left_panels=[
panels.WirelessLANPanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
panels.WirelessAuthenticationPanel(),
CustomFieldsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='dcim.Interface',
title=_('Attached Interfaces'),
filters={'wireless_lan_id': lambda ctx: ctx['object'].pk},
),
],
)
@register_model_view(WirelessLAN, 'add', detail=False)
@@ -173,6 +217,19 @@ class WirelessLinkListView(generic.ObjectListView):
@register_model_view(WirelessLink)
class WirelessLinkView(generic.ObjectView):
queryset = WirelessLink.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.WirelessLinkInterfacePanel('interface_a', title=_('Interface A')),
panels.WirelessLinkPropertiesPanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
panels.WirelessLinkInterfacePanel('interface_b', title=_('Interface B')),
panels.WirelessAuthenticationPanel(),
CustomFieldsPanel(),
],
)
@register_model_view(WirelessLink, 'add', detail=False)