Compare commits

...

64 Commits

Author SHA1 Message Date
Étienne Brunel
1f336eee2e Closes #21575: Implement {vc_position} template variable on component template name/label (#21601)
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 9s
CodeQL / Analyze (actions) (push) Failing after 1m0s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m8s
CodeQL / Analyze (python) (push) Failing after 1m7s
2026-03-18 10:15:11 -07:00
Jeremy Stretch
6030fc383a Merge branch 'main' into feature
CI / build (20.x, 3.12) (push) Failing after 13s
CI / build (20.x, 3.13) (push) Failing after 15s
CI / build (20.x, 3.14) (push) Failing after 28s
CodeQL / Analyze (actions) (push) Failing after 1m8s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m15s
CodeQL / Analyze (python) (push) Failing after 1m10s
2026-03-18 10:16:21 -04:00
github-actions
c3c7cf15b2 Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 1m3s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m9s
CodeQL / Analyze (python) (push) Failing after 1m10s
2026-03-18 05:28:51 +00:00
Jeremy Stretch
2b7049c39c Release v4.5.5 (#21672)
CI / build (20.x, 3.12) (push) Failing after 36s
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 49s
CodeQL / Analyze (javascript-typescript) (push) Failing after 49s
CodeQL / Analyze (python) (push) Failing after 57s
* Release v4.5.5

* Pin django-rq to <4.0
2026-03-17 14:58:14 -04:00
Martin Hauser
3ededeb0e7 fix(circuits): Clear Circuit Termination cache on change
Move cache update logic from signal to model save method and track
original values to properly clear old cache when circuit_id or term_side
changes. Add comprehensive tests for all cache update scenarios.

Fixes #21686
2026-03-17 13:16:22 -04:00
Arthur Hanson
753fedf5e7 Revert "#14329 Improve diffs for custom_fields" (#21692)
CI / build (20.x, 3.12) (push) Failing after 10s
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 1m2s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m9s
CodeQL / Analyze (python) (push) Failing after 1m13s
This reverts commit 38afed60ef.
2026-03-17 17:35:30 +01:00
Arthur
38afed60ef #14329 Improve diffs for custom_fields
CI / build (20.x, 3.12) (push) Failing after 10s
CI / build (20.x, 3.13) (push) Failing after 11s
CI / build (20.x, 3.14) (push) Failing after 10s
CodeQL / Analyze (actions) (push) Failing after 1m1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m7s
CodeQL / Analyze (python) (push) Failing after 1m13s
2026-03-17 09:09:03 -07:00
bctiemann
66f6b2b6f9 Merge pull request #21649 from netbox-community/21556-fix-dropdown-clearing
CI / build (20.x, 3.12) (push) Failing after 39s
CI / build (20.x, 3.13) (push) Failing after 8s
CI / build (20.x, 3.14) (push) Failing after 9s
CodeQL / Analyze (actions) (push) Failing after 48s
CodeQL / Analyze (javascript-typescript) (push) Failing after 53s
CodeQL / Analyze (python) (push) Failing after 54s
Fixes #21556: Restore previous value (if applicable) after clearing related dropdown
2026-03-17 12:06:14 -04:00
Jeremy Stretch
61cef9400d Fixes #21556: Restore previous value (if applicable) after clearing related dropdown 2026-03-17 11:33:53 -04:00
Jonathan Senecal
d57f230f37 Fixes #21653: Fix multi-position tracing in CablePath.from_origin() (#21681)
* Add failing tests for multi-position cable path tracing

* Fix multi-position tracing in CablePath.from_origin()

* Add failing test for multi-connector trunk cable tracing through patch panel

* Fix multi-connector profiled cable tracing in CablePath.from_origin()
2026-03-17 14:16:03 +01:00
Rob Duffy
472dc3882e Fixes #21673: UI Bug with Displaying Primary IP Address with NAT IP on a VM
CI / build (20.x, 3.12) (push) Failing after 9s
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 1m17s
CodeQL / Analyze (python) (push) Failing after 1m19s
2026-03-17 08:54:03 +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
625c4eb5bb feat(dcim): Add enabled field to Module and Device bays
Add an `enabled` boolean field to ModuleBay, ModuleBayTemplate,
DeviceBay, and DeviceBayTemplate models. Disabled bays prevent component
installation and display accordingly in the UI. Update serializers,
filters, forms, and tables to support the new field.

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

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

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

* ruff fixes

* optimize

* review changes

* fix

* Update netbox/extras/managers.py

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

---------

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

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

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

Fixes #21586
2026-03-05 13:59:18 -05:00
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
21 changed files with 423 additions and 88 deletions
+5
View File
@@ -23,3 +23,8 @@ The device bay's name. Must be unique to the parent device.
### Label
An alternative physical label identifying the device bay.
### Enabled
Whether this device bay is enabled. Disabled device bays are not available for installation.
+6 -1
View File
@@ -1,6 +1,6 @@
# Module Bays
Module bays represent a space or slot within a device in which a field-replaceable [module](./module.md) may be installed. A common example is that of a chassis-based switch such as the Cisco Nexus 9000 or Juniper EX9200. Modules in turn hold additional components that become available to the parent device.
Module bays represent a space or slot within a device in which a field-replaceable [module](./module.md) may be installed. A common example is that of a chassis-based switch such as the Cisco Nexus 9000 or Juniper EX9200. Modules, in turn, hold additional components that become available to the parent device.
!!! note
If you need to model child devices rather than modules, use a [device bay](./devicebay.md) instead.
@@ -29,3 +29,8 @@ An alternative physical label identifying the module bay.
### Position
The numeric position in which this module bay is situated. For example, this would be the number assigned to a slot within a chassis-based switch.
### Enabled
Whether this module bay is enabled. Disabled module bays are not available for installation.
@@ -423,27 +423,29 @@ class ModuleBaySerializer(OwnerMixin, NetBoxModelSerializer):
required=False,
allow_null=True
)
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = ModuleBay
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'installed_module', 'label', 'position',
'description', 'owner', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'position', 'enabled',
'description', 'installed_module', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')
brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'enabled', 'description', '_occupied')
class DeviceBaySerializer(OwnerMixin, NetBoxModelSerializer):
device = DeviceSerializer(nested=True)
installed_device = DeviceSerializer(nested=True, required=False, allow_null=True)
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = DeviceBay
fields = [
'id', 'url', 'display_url', 'display', 'device', 'name', 'label', 'description', 'installed_device',
'owner', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'device', 'name', 'label', 'enabled', 'description',
'installed_device', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
brief_fields = ('id', 'url', 'display', 'device', 'name', 'enabled', 'description', '_occupied',)
class InventoryItemSerializer(OwnerMixin, NetBoxModelSerializer):
@@ -317,10 +317,10 @@ class ModuleBayTemplateSerializer(ComponentTemplateSerializer):
class Meta:
model = ModuleBayTemplate
fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'position', 'description',
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'position', 'enabled', 'description',
'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
brief_fields = ('id', 'url', 'display', 'name', 'enabled', 'description')
class DeviceBayTemplateSerializer(ComponentTemplateSerializer):
@@ -331,10 +331,10 @@ class DeviceBayTemplateSerializer(ComponentTemplateSerializer):
class Meta:
model = DeviceBayTemplate
fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'description',
'id', 'url', 'display', 'device_type', 'name', 'label', 'enabled', 'description',
'created', 'last_updated'
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
brief_fields = ('id', 'url', 'display', 'name', 'enabled', 'description')
class InventoryItemTemplateSerializer(ComponentTemplateSerializer):
+4 -4
View File
@@ -1032,7 +1032,7 @@ class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
class Meta:
model = ModuleBayTemplate
fields = ('id', 'name', 'label', 'position', 'description')
fields = ('id', 'name', 'label', 'position', 'enabled', 'description')
@register_filterset
@@ -1040,7 +1040,7 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent
class Meta:
model = DeviceBayTemplate
fields = ('id', 'name', 'label', 'description')
fields = ('id', 'name', 'label', 'enabled', 'description')
@register_filterset
@@ -2397,7 +2397,7 @@ class ModuleBayFilterSet(ModularDeviceComponentFilterSet):
class Meta:
model = ModuleBay
fields = ('id', 'name', 'label', 'position', 'description')
fields = ('id', 'name', 'label', 'position', 'enabled', 'description')
@register_filterset
@@ -2417,7 +2417,7 @@ class DeviceBayFilterSet(DeviceComponentFilterSet):
class Meta:
model = DeviceBay
fields = ('id', 'name', 'label', 'description')
fields = ('id', 'name', 'label', 'enabled', 'description')
@register_filterset
+11 -5
View File
@@ -108,10 +108,13 @@ class RearPortBulkCreateForm(
field_order = ('name', 'label', 'type', 'positions', 'mark_connected', 'description', 'tags')
class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
class ModuleBayBulkCreateForm(
form_from_model(ModuleBay, ['enabled']),
DeviceBulkAddComponentForm
):
model = ModuleBay
field_order = ('name', 'label', 'position', 'description', 'tags')
replication_fields = ('name', 'label', 'position')
field_order = ('name', 'label', 'position', 'enabled', 'description', 'tags')
replication_fields = ('name', 'label', 'position', 'enabled')
position = ExpandableNameField(
label=_('Position'),
required=False,
@@ -119,9 +122,12 @@ class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
)
class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm):
class DeviceBayBulkCreateForm(
form_from_model(DeviceBay, ['enabled']),
DeviceBulkAddComponentForm
):
model = DeviceBay
field_order = ('name', 'label', 'description', 'tags')
field_order = ('name', 'label', 'enabled', 'description', 'tags')
class InventoryItemBulkCreateForm(
+14 -4
View File
@@ -1245,6 +1245,11 @@ class ModuleBayTemplateBulkEditForm(ComponentTemplateBulkEditForm):
label=_('Description'),
required=False
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=BulkEditNullBooleanSelect,
)
nullable_fields = ('label', 'position', 'description')
@@ -1263,6 +1268,11 @@ class DeviceBayTemplateBulkEditForm(ComponentTemplateBulkEditForm):
label=_('Description'),
required=False
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=BulkEditNullBooleanSelect,
)
nullable_fields = ('label', 'description')
@@ -1687,23 +1697,23 @@ class RearPortBulkEditForm(
class ModuleBayBulkEditForm(
form_from_model(ModuleBay, ['label', 'position', 'description']),
form_from_model(ModuleBay, ['label', 'position', 'enabled', 'description']),
NetBoxModelBulkEditForm
):
model = ModuleBay
fieldsets = (
FieldSet('label', 'position', 'description'),
FieldSet('label', 'position', 'enabled', 'description'),
)
nullable_fields = ('label', 'position', 'description')
class DeviceBayBulkEditForm(
form_from_model(DeviceBay, ['label', 'description']),
form_from_model(DeviceBay, ['label', 'enabled', 'description']),
NetBoxModelBulkEditForm
):
model = DeviceBay
fieldsets = (
FieldSet('label', 'description'),
FieldSet('label', 'enabled', 'description'),
)
nullable_fields = ('label', 'description')
+14 -2
View File
@@ -1154,7 +1154,13 @@ class ModuleBayImportForm(OwnerCSVMixin, NetBoxModelImportForm):
class Meta:
model = ModuleBay
fields = ('device', 'name', 'label', 'position', 'description', 'owner', 'tags')
fields = ('device', 'name', 'label', 'position', 'enabled', 'description', 'owner', 'tags')
def clean_enabled(self):
# Make sure enabled is True when it's not included in the uploaded data
if 'enabled' not in self.data:
return True
return self.cleaned_data['enabled']
class DeviceBayImportForm(OwnerCSVMixin, NetBoxModelImportForm):
@@ -1176,7 +1182,7 @@ class DeviceBayImportForm(OwnerCSVMixin, NetBoxModelImportForm):
class Meta:
model = DeviceBay
fields = ('device', 'name', 'label', 'installed_device', 'description', 'owner', 'tags')
fields = ('device', 'name', 'label', 'enabled', 'installed_device', 'description', 'owner', 'tags')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -1204,6 +1210,12 @@ class DeviceBayImportForm(OwnerCSVMixin, NetBoxModelImportForm):
else:
self.fields['installed_device'].queryset = Device.objects.none()
def clean_enabled(self):
# Make sure enabled is True when it's not included in the uploaded data
if 'enabled' not in self.data:
return True
return self.cleaned_data['enabled']
class InventoryItemImportForm(OwnerCSVMixin, NetBoxModelImportForm):
device = CSVModelChoiceField(
+25 -5
View File
@@ -1870,7 +1870,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
model = ModuleBay
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'position', name=_('Attributes')),
FieldSet('name', 'label', 'position', 'enabled', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
@@ -1878,31 +1878,41 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
tag = TagFilterField(model)
position = forms.CharField(
label=_('Position'),
required=False
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES),
)
tag = TagFilterField(model)
class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
model = ModuleBayTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'position', name=_('Attributes')),
FieldSet('name', 'label', 'position', 'enabled', name=_('Attributes')),
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
)
position = forms.CharField(
label=_('Position'),
required=False,
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES),
)
class DeviceBayFilterForm(DeviceComponentFilterForm):
model = DeviceBay
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', name=_('Attributes')),
FieldSet('name', 'label', 'enabled', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
@@ -1910,6 +1920,11 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES),
)
tag = TagFilterField(model)
@@ -1917,9 +1932,14 @@ class DeviceBayTemplateFilterForm(DeviceComponentTemplateFilterForm):
model = DeviceBayTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', name=_('Attributes')),
FieldSet('name', 'label', 'enabled', name=_('Attributes')),
FieldSet('device_type_id', name=_('Device')),
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES),
)
class InventoryItemFilterForm(DeviceComponentFilterForm):
+9 -9
View File
@@ -777,7 +777,7 @@ class ModuleForm(ModuleCommonForm, PrimaryModelForm):
'device_id': '$device',
},
context={
'disabled': 'installed_module',
'disabled': '_occupied',
},
)
module_type = DynamicModelChoiceField(
@@ -1233,26 +1233,26 @@ class ModuleBayTemplateForm(ModularComponentTemplateForm):
FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')),
),
'name', 'label', 'position', 'description',
'name', 'label', 'position', 'enabled', 'description',
),
)
class Meta:
model = ModuleBayTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'position', 'description',
'device_type', 'module_type', 'name', 'label', 'position', 'enabled', 'description',
]
class DeviceBayTemplateForm(ComponentTemplateForm):
fieldsets = (
FieldSet('device_type', 'name', 'label', 'description'),
FieldSet('device_type', 'name', 'label', 'enabled', 'description'),
)
class Meta:
model = DeviceBayTemplate
fields = [
'device_type', 'name', 'label', 'description',
'device_type', 'name', 'label', 'enabled', 'description',
]
@@ -1698,25 +1698,25 @@ class RearPortForm(ModularDeviceComponentForm):
class ModuleBayForm(ModularDeviceComponentForm):
fieldsets = (
FieldSet('device', 'module', 'name', 'label', 'position', 'description', 'tags',),
FieldSet('device', 'module', 'name', 'label', 'position', 'enabled', 'description', 'tags',),
)
class Meta:
model = ModuleBay
fields = [
'device', 'module', 'name', 'label', 'position', 'description', 'owner', 'tags',
'device', 'module', 'name', 'label', 'position', 'enabled', 'description', 'owner', 'tags',
]
class DeviceBayForm(DeviceComponentForm):
fieldsets = (
FieldSet('device', 'name', 'label', 'description', 'tags',),
FieldSet('device', 'name', 'label', 'enabled', 'description', 'tags',),
)
class Meta:
model = DeviceBay
fields = [
'device', 'name', 'label', 'description', 'owner', 'tags',
'device', 'name', 'label', 'enabled', 'description', 'owner', 'tags',
]
+4 -1
View File
@@ -318,6 +318,7 @@ class DeviceFilter(
@strawberry_django.filter_type(models.DeviceBay, lookups=True)
class DeviceBayFilter(ComponentModelFilterMixin, NetBoxModelFilter):
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
installed_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -326,7 +327,7 @@ class DeviceBayFilter(ComponentModelFilterMixin, NetBoxModelFilter):
@strawberry_django.filter_type(models.DeviceBayTemplate, lookups=True)
class DeviceBayTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedModelFilter):
pass
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.InventoryItemTemplate, lookups=True)
@@ -742,11 +743,13 @@ class ModuleBayFilter(ModularComponentFilterMixin, NetBoxModelFilter):
)
parent_id: ID | None = strawberry_django.filter_field()
position: StrFilterLookup[str] | None = strawberry_django.filter_field()
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True)
class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
position: StrFilterLookup[str] | None = strawberry_django.filter_field()
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True)
@@ -0,0 +1,30 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0228_cable_bundle'),
]
operations = [
migrations.AddField(
model_name='devicebay',
name='enabled',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='devicebaytemplate',
name='enabled',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='modulebay',
name='enabled',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='modulebaytemplate',
name='enabled',
field=models.BooleanField(default=True),
),
]
@@ -722,6 +722,10 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
blank=True,
help_text=_('Identifier to reference when renaming installed components')
)
enabled = models.BooleanField(
verbose_name=_('enabled'),
default=True,
)
component_model = ModuleBay
@@ -734,6 +738,7 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
name=self.resolve_name(kwargs.get('module')),
label=self.resolve_label(kwargs.get('module')),
position=self.position,
enabled=self.enabled,
**kwargs
)
instantiate.do_not_call_in_templates = True
@@ -743,6 +748,7 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
'name': self.name,
'label': self.label,
'position': self.position,
'enabled': self.enabled,
'description': self.description,
}
@@ -751,6 +757,11 @@ class DeviceBayTemplate(ComponentTemplateModel):
"""
A template for a DeviceBay to be created for a new parent Device.
"""
enabled = models.BooleanField(
verbose_name=_('enabled'),
default=True,
)
component_model = DeviceBay
class Meta(ComponentTemplateModel.Meta):
@@ -761,7 +772,8 @@ class DeviceBayTemplate(ComponentTemplateModel):
return self.component_model(
device=device,
name=self.name,
label=self.label
label=self.label,
enabled=self.enabled,
)
instantiate.do_not_call_in_templates = True
@@ -777,6 +789,7 @@ class DeviceBayTemplate(ComponentTemplateModel):
return {
'name': self.name,
'label': self.label,
'enabled': self.enabled,
'description': self.description,
}
+34 -2
View File
@@ -1257,10 +1257,14 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
blank=True,
help_text=_('Identifier to reference when renaming installed components')
)
enabled = models.BooleanField(
verbose_name=_('enabled'),
default=True,
)
objects = TreeManager()
clone_fields = ('device',)
clone_fields = ('device', 'enabled')
class Meta(ModularComponentModel.Meta):
# Empty tuple triggers Django migration detection for MPTT indexes
@@ -1299,6 +1303,13 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
self.parent = None
super().save(*args, **kwargs)
@property
def _occupied(self):
"""
Indicates whether the module bay is occupied by a module.
"""
return bool(not self.enabled or hasattr(self, 'installed_module'))
class DeviceBay(ComponentModel, TrackingModelMixin):
"""
@@ -1311,8 +1322,12 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
blank=True,
null=True
)
enabled = models.BooleanField(
verbose_name=_('enabled'),
default=True,
)
clone_fields = ('device',)
clone_fields = ('device', 'enabled')
class Meta(ComponentModel.Meta):
verbose_name = _('device bay')
@@ -1327,6 +1342,16 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
device_type=self.device.device_type
))
# Prevent installing a device into a disabled bay
if self.installed_device and not self.enabled:
current_installed_device_id = (
DeviceBay.objects.filter(pk=self.pk).values_list('installed_device_id', flat=True).first()
)
if self.pk is None or current_installed_device_id != self.installed_device_id:
raise ValidationError({
'installed_device': _("Cannot install a device in a disabled device bay.")
})
# Cannot install a device into itself, obviously
if self.installed_device and getattr(self, 'device', None) == self.installed_device:
raise ValidationError(_("Cannot install a device into itself."))
@@ -1341,6 +1366,13 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
).format(bay=current_bay)
})
@property
def _occupied(self):
"""
Indicates whether the device bay is occupied by a child device.
"""
return bool(not self.enabled or self.installed_device_id)
#
# Inventory items
+8
View File
@@ -258,6 +258,14 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
)
)
# Prevent module from being installed in a disabled bay
if hasattr(self, 'module_bay') and self.module_bay and not self.module_bay.enabled:
current_module_bay_id = Module.objects.filter(pk=self.pk).values_list('module_bay_id', flat=True).first()
if self.pk is None or current_module_bay_id != self.module_bay_id:
raise ValidationError({
'module_bay': _("Cannot install a module in a disabled module bay.")
})
# Check for recursion
module = self
module_bays = []
+23 -11
View File
@@ -888,6 +888,9 @@ class DeviceBayTable(DeviceComponentTable):
'args': [Accessor('device_id')],
}
)
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
status = tables.TemplateColumn(
verbose_name=_('Status'),
template_code=DEVICEBAY_STATUS,
@@ -925,12 +928,12 @@ class DeviceBayTable(DeviceComponentTable):
class Meta(DeviceComponentTable.Meta):
model = models.DeviceBay
fields = (
'pk', 'id', 'name', 'device', 'label', 'status', 'description', 'installed_device', 'installed_role',
'installed_device_type', 'installed_description', 'installed_serial', 'installed_asset_tag', 'tags',
'created', 'last_updated',
'pk', 'id', 'name', 'device', 'label', 'enabled', 'status', 'description', 'installed_device',
'installed_role', 'installed_device_type', 'installed_description', 'installed_serial',
'installed_asset_tag', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'status', 'installed_device', 'description')
class DeviceDeviceBayTable(DeviceBayTable):
@@ -940,6 +943,9 @@ class DeviceDeviceBayTable(DeviceBayTable):
'"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
attrs={'td': {'class': 'text-nowrap'}}
)
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
actions = columns.ActionsColumn(
extra_buttons=DEVICEBAY_BUTTONS
)
@@ -947,9 +953,9 @@ class DeviceDeviceBayTable(DeviceBayTable):
class Meta(DeviceComponentTable.Meta):
model = models.DeviceBay
fields = (
'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions',
'pk', 'id', 'name', 'label', 'enabled', 'status', 'installed_device', 'description', 'tags', 'actions',
)
default_columns = ('pk', 'name', 'label', 'status', 'installed_device', 'description')
default_columns = ('pk', 'name', 'label', 'enabled', 'status', 'installed_device', 'description')
class ModuleBayTable(ModularDeviceComponentTable):
@@ -960,6 +966,9 @@ class ModuleBayTable(ModularDeviceComponentTable):
'args': [Accessor('device_id')],
}
)
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
parent = tables.Column(
linkify=True,
verbose_name=_('Parent'),
@@ -988,11 +997,11 @@ class ModuleBayTable(ModularDeviceComponentTable):
class Meta(ModularDeviceComponentTable.Meta):
model = models.ModuleBay
fields = (
'pk', 'id', 'name', 'device', 'parent', 'label', 'position', 'installed_module', 'module_status',
'pk', 'id', 'name', 'device', 'enabled', 'parent', 'label', 'position', 'installed_module', 'module_status',
'module_serial', 'module_asset_tag', 'description', 'tags',
)
default_columns = (
'pk', 'name', 'device', 'parent', 'label', 'installed_module', 'module_status', 'description',
'pk', 'name', 'device', 'enabled', 'parent', 'label', 'installed_module', 'module_status', 'description',
)
def render_parent_bay(self, value):
@@ -1007,6 +1016,9 @@ class DeviceModuleBayTable(ModuleBayTable):
verbose_name=_('Name'),
linkify=True,
)
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
actions = columns.ActionsColumn(
extra_buttons=MODULEBAY_BUTTONS
)
@@ -1014,10 +1026,10 @@ class DeviceModuleBayTable(ModuleBayTable):
class Meta(ModuleBayTable.Meta):
model = models.ModuleBay
fields = (
'pk', 'id', 'parent', 'name', 'label', 'position', 'installed_module', 'module_status', 'module_serial',
'module_asset_tag', 'description', 'tags', 'actions',
'pk', 'id', 'parent', 'name', 'label', 'enabled', 'position', 'installed_module', 'module_status',
'module_serial', 'module_asset_tag', 'description', 'tags', 'actions',
)
default_columns = ('pk', 'name', 'label', 'installed_module', 'module_status', 'description')
default_columns = ('pk', 'name', 'label', 'enabled', 'installed_module', 'module_status', 'description')
class InventoryItemTable(DeviceComponentTable):
+8 -2
View File
@@ -289,24 +289,30 @@ class RearPortTemplateTable(ComponentTemplateTable):
class ModuleBayTemplateTable(ComponentTemplateTable):
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
actions = columns.ActionsColumn(
actions=('edit', 'delete')
)
class Meta(ComponentTemplateTable.Meta):
model = models.ModuleBayTemplate
fields = ('pk', 'name', 'label', 'position', 'description', 'actions')
fields = ('pk', 'name', 'label', 'position', 'enabled', 'description', 'actions')
empty_text = "None"
class DeviceBayTemplateTable(ComponentTemplateTable):
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
actions = columns.ActionsColumn(
actions=('edit', 'delete')
)
class Meta(ComponentTemplateTable.Meta):
model = models.DeviceBayTemplate
fields = ('pk', 'name', 'label', 'description', 'actions')
fields = ('pk', 'name', 'label', 'enabled', 'description', 'actions')
empty_text = "None"
+2 -2
View File
@@ -565,7 +565,7 @@ DEVICEBAY_BUTTONS = """
<a href="{% url 'dcim:devicebay_depopulate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-server-minus" aria-hidden="true" title="Remove device"></i>
</a>
{% else %}
{% elif record.enabled %}
<a href="{% url 'dcim:devicebay_populate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-success btn-sm">
<i class="mdi mdi-server-plus" aria-hidden="true" title="Install device"></i>
</a>
@@ -579,7 +579,7 @@ MODULEBAY_BUTTONS = """
<a href="{% url 'dcim:module_delete' pk=record.installed_module.pk %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-server-minus" aria-hidden="true" title="Remove module"></i>
</a>
{% else %}
{% elif record.enabled %}
<a href="{% url 'dcim:module_add' %}?device={{ record.device_id }}&module_bay={{ record.pk }}&manufacturer={{ object.device_type.manufacturer_id }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm">
<i class="mdi mdi-server-plus" aria-hidden="true" title="Install module"></i>
</a>
+19 -16
View File
@@ -1226,7 +1226,7 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
model = ModuleBayTemplate
brief_fields = ['description', 'display', 'id', 'name', 'url']
brief_fields = ['description', 'display', 'enabled', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1243,9 +1243,9 @@ class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
)
module_bay_templates = (
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 1'),
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 2'),
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 3'),
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 1', enabled=True),
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 2', enabled=False),
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 3', enabled=True),
)
ModuleBayTemplate.objects.bulk_create(module_bay_templates)
@@ -1253,6 +1253,7 @@ class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
{
'device_type': devicetype.pk,
'name': 'Module Bay Template 4',
'enabled': False,
},
{
'device_type': devicetype.pk,
@@ -1267,7 +1268,7 @@ class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
model = DeviceBayTemplate
brief_fields = ['description', 'display', 'id', 'name', 'url']
brief_fields = ['description', 'display', 'enabled', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1284,9 +1285,9 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
)
device_bay_templates = (
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 1'),
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 2'),
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 3'),
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 1', enabled=True),
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 2', enabled=False),
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 3', enabled=True),
)
DeviceBayTemplate.objects.bulk_create(device_bay_templates)
@@ -1294,6 +1295,7 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
{
'device_type': devicetype.pk,
'name': 'Device Bay Template 4',
'enabled': False,
},
{
'device_type': devicetype.pk,
@@ -2594,7 +2596,7 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
class ModuleBayTest(APIViewTestCases.APIViewTestCase):
model = ModuleBay
brief_fields = ['description', 'display', 'id', 'installed_module', 'name', 'url']
brief_fields = ['_occupied', 'description', 'display', 'enabled', 'id', 'installed_module', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -2610,9 +2612,9 @@ class ModuleBayTest(APIViewTestCases.APIViewTestCase):
device = Device.objects.create(device_type=device_type, role=role, name='Device 1', site=site)
module_bays = (
ModuleBay(device=device, name='Device Bay 1'),
ModuleBay(device=device, name='Device Bay 2'),
ModuleBay(device=device, name='Device Bay 3'),
ModuleBay(device=device, name='Device Bay 1', enabled=True),
ModuleBay(device=device, name='Device Bay 2', enabled=False),
ModuleBay(device=device, name='Device Bay 3', enabled=True),
)
for module_bay in module_bays:
module_bay.save()
@@ -2621,6 +2623,7 @@ class ModuleBayTest(APIViewTestCases.APIViewTestCase):
{
'device': device.pk,
'name': 'Device Bay 4',
'enabled': False,
},
{
'device': device.pk,
@@ -2635,7 +2638,7 @@ class ModuleBayTest(APIViewTestCases.APIViewTestCase):
class DeviceBayTest(APIViewTestCases.APIViewTestCase):
model = DeviceBay
brief_fields = ['description', 'device', 'display', 'id', 'name', 'url']
brief_fields = ['_occupied', 'description', 'device', 'display', 'enabled', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -2672,9 +2675,9 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase):
Device.objects.bulk_create(devices)
device_bays = (
DeviceBay(device=devices[0], name='Device Bay 1'),
DeviceBay(device=devices[0], name='Device Bay 2'),
DeviceBay(device=devices[0], name='Device Bay 3'),
DeviceBay(device=devices[0], name='Device Bay 1', enabled=True),
DeviceBay(device=devices[0], name='Device Bay 2', enabled=False),
DeviceBay(device=devices[0], name='Device Bay 3', enabled=True),
)
DeviceBay.objects.bulk_create(device_bays)
+56 -13
View File
@@ -2247,13 +2247,21 @@ class ModuleBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests,
ModuleBayTemplate.objects.bulk_create(
(
ModuleBayTemplate(
device_type=device_types[0], name='Module Bay 1', description='foobar1'
device_type=device_types[0], name='Module Bay 1', enabled=True, description='foobar1'
),
ModuleBayTemplate(
device_type=device_types[1], name='Module Bay 2', description='foobar2', module_type=module_types[0]
device_type=device_types[1],
name='Module Bay 2',
enabled=False,
description='foobar2',
module_type=module_types[0],
),
ModuleBayTemplate(
device_type=device_types[2], name='Module Bay 3', description='foobar3', module_type=module_types[1]
device_type=device_types[2],
name='Module Bay 3',
enabled=True,
description='foobar3',
module_type=module_types[1],
),
)
)
@@ -2262,6 +2270,12 @@ class ModuleBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests,
params = {'name': ['Module Bay 1', 'Module Bay 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_enabled(self):
params = {'enabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'enabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_module_type(self):
module_types = ModuleType.objects.all()[:2]
params = {'module_type_id': [module_types[0].pk, module_types[1].pk]}
@@ -2284,16 +2298,30 @@ class DeviceBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests,
)
DeviceType.objects.bulk_create(device_types)
DeviceBayTemplate.objects.bulk_create((
DeviceBayTemplate(device_type=device_types[0], name='Device Bay 1', description='foobar1'),
DeviceBayTemplate(device_type=device_types[1], name='Device Bay 2', description='foobar2'),
DeviceBayTemplate(device_type=device_types[2], name='Device Bay 3', description='foobar3'),
))
DeviceBayTemplate.objects.bulk_create(
(
DeviceBayTemplate(
device_type=device_types[0], name='Device Bay 1', enabled=True, description='foobar1'
),
DeviceBayTemplate(
device_type=device_types[1], name='Device Bay 2', enabled=False, description='foobar2'
),
DeviceBayTemplate(
device_type=device_types[2], name='Device Bay 3', enabled=True, description='foobar3'
),
)
)
def test_name(self):
params = {'name': ['Device Bay 1', 'Device Bay 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_enabled(self):
params = {'enabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'enabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class InventoryItemTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests):
queryset = InventoryItemTemplate.objects.all()
@@ -5778,11 +5806,11 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
Device.objects.bulk_create(devices)
module_bays = (
ModuleBay(device=devices[0], name='Module Bay 1', label='A', description='First'),
ModuleBay(device=devices[1], name='Module Bay 2', label='B', description='Second'),
ModuleBay(device=devices[2], name='Module Bay 3', label='C', description='Third'),
ModuleBay(device=devices[2], name='Module Bay 4', label='D', description='Fourth'),
ModuleBay(device=devices[2], name='Module Bay 5', label='E', description='Fifth'),
ModuleBay(device=devices[0], name='Module Bay 1', label='A', enabled=True, description='First'),
ModuleBay(device=devices[1], name='Module Bay 2', label='B', enabled=False, description='Second'),
ModuleBay(device=devices[2], name='Module Bay 3', label='C', enabled=True, description='Third'),
ModuleBay(device=devices[2], name='Module Bay 4', label='D', enabled=False, description='Fourth'),
ModuleBay(device=devices[2], name='Module Bay 5', label='E', enabled=True, description='Fifth'),
)
for module_bay in module_bays:
module_bay.save()
@@ -5806,6 +5834,12 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
params = {'label': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_enabled(self):
params = {'enabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'enabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['First', 'Second']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -5965,6 +5999,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
device=devices[0],
name='Device Bay 1',
label='A',
enabled=True,
description='First',
_site=devices[0].site,
_location=devices[0].location,
@@ -5974,6 +6009,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
device=devices[1],
name='Device Bay 2',
label='B',
enabled=False,
description='Second',
_site=devices[1].site,
_location=devices[1].location,
@@ -5983,6 +6019,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
device=devices[2],
name='Device Bay 3',
label='C',
enabled=True,
description='Third',
_site=devices[2].site,
_location=devices[2].location,
@@ -5999,6 +6036,12 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
params = {'label': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_enabled(self):
params = {'enabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'enabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_description(self):
params = {'description': ['First', 'Second']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+125
View File
@@ -712,6 +712,112 @@ class DeviceTestCase(TestCase):
).full_clean()
class DeviceBayTestCase(TestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
# Parent device type must support device bays (is_parent_device=True)
parent_device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='Parent Device Type',
slug='parent-device-type',
subdevice_role=SubdeviceRoleChoices.ROLE_PARENT
)
# Child device type for installation
child_device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='Child Device Type',
slug='child-device-type',
u_height=0,
subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
)
device_role = DeviceRole.objects.create(name='Test Role 1', slug='test-role-1')
cls.parent_device = Device.objects.create(
name='Parent Device',
device_type=parent_device_type,
role=device_role,
site=site
)
cls.child_device = Device.objects.create(
name='Child Device',
device_type=child_device_type,
role=device_role,
site=site
)
cls.child_device_2 = Device.objects.create(
name='Child Device 2',
device_type=child_device_type,
role=device_role,
site=site
)
def test_cannot_install_device_in_disabled_bay(self):
"""
Test that a device cannot be installed into a disabled DeviceBay.
"""
# Create a disabled device bay with a device being installed
device_bay = DeviceBay(
device=self.parent_device,
name='Disabled Bay',
enabled=False,
installed_device=self.child_device
)
with self.assertRaises(ValidationError) as cm:
device_bay.clean()
self.assertIn('installed_device', cm.exception.message_dict)
self.assertIn('disabled device bay', str(cm.exception.message_dict['installed_device']))
def test_can_disable_bay_with_existing_device(self):
"""
Test that disabling a bay that already has a device installed does NOT raise an error
(same installed_device_id).
"""
# First, create an enabled device bay with a device installed
device_bay = DeviceBay.objects.create(
device=self.parent_device,
name='Bay To Disable',
enabled=True,
installed_device=self.child_device
)
# Now disable the bay while keeping the same installed device
device_bay.enabled = False
# This should NOT raise a ValidationError
device_bay.clean()
device_bay.save()
device_bay.refresh_from_db()
self.assertFalse(device_bay.enabled)
self.assertEqual(device_bay.installed_device, self.child_device)
def test_cannot_change_installed_device_in_disabled_bay(self):
"""
Test that changing the installed device in a disabled bay raises a ValidationError.
"""
# Create an enabled device bay with a device installed
device_bay = DeviceBay.objects.create(
device=self.parent_device,
name='Bay With Device',
enabled=True,
installed_device=self.child_device
)
# Disable the bay and try to change the installed device
device_bay.enabled = False
device_bay.installed_device = self.child_device_2
with self.assertRaises(ValidationError) as cm:
device_bay.clean()
self.assertIn('installed_device', cm.exception.message_dict)
class ModuleBayTestCase(TestCase):
@classmethod
@@ -1011,6 +1117,25 @@ class ModuleBayTestCase(TestCase):
self.assertEqual(RearPort.objects.filter(module=module).count(), 1)
self.assertEqual(PortMapping.objects.filter(front_port__module=module).count(), 0)
def test_cannot_install_module_in_disabled_bay(self):
"""
Test that a Module cannot be installed into a disabled ModuleBay.
"""
device = Device.objects.first()
manufacturer = Manufacturer.objects.first()
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Test Module Type Disabled')
# Create a disabled module bay
disabled_bay = ModuleBay.objects.create(device=device, name='Disabled Bay', enabled=False)
# Attempt to install a module into the disabled bay
module = Module(device=device, module_bay=disabled_bay, module_type=module_type)
with self.assertRaises(ValidationError) as cm:
module.clean()
self.assertIn('module_bay', cm.exception.message_dict)
self.assertIn('disabled module bay', str(cm.exception.message_dict['module_bay']))
class CableTestCase(TestCase):