Compare commits

..

182 Commits

Author SHA1 Message Date
Martin Hauser
442a2ead86 feat(utilities): Align EnhancedURLValidator auth regex with Django
Tighten the HTTP basic auth portion of EnhancedURLValidator to match
Django's URLValidator. Exclude `:`, `@`, and `/` from username/password
segments so malformed credential strings are no longer accepted.

Fixes #21720
2026-04-02 15:29:10 +02:00
Mark Robert Coleman
a06a300913 Implement {module} position inheritance for nested module bays (#21753)
* Implement {module} position inheritance for nested module bays (#19796)

Enables a single ModuleType to produce correctly named components at any
nesting depth by resolving {module} in module bay position fields during
tree traversal. The user controls the separator through the position
field template itself (e.g. {module}/1 vs {module}-1 vs {module}.1).

Model layer:
- Add _get_inherited_positions() to resolve {module} in positions as
  the module tree is walked from root to leaf
- Update _resolve_module_placeholder() with single-token logic: one
  {module} resolves to the leaf bay's inherited position; multi-token
  continues level-by-level replacement for backwards compatibility

Form layer:
- Update _get_module_bay_tree() to resolve {module} in positions during
  traversal, propagating parent positions through the tree
- Extract validation into _validate_module_tokens() private method

Tests:
- Position inheritance at depth 2 and 3
- Custom separator (dot notation)
- Multi-token backwards compatibility
- Documentation for position inheritance

Fixes: #19796

* Consolidate {module} placeholder logic into shared utilities and add API validation

Extract get_module_bay_positions() and resolve_module_placeholder() into
dcim/utils.py as shared routines used by the model, form, and API serializer.
This eliminates duplicated traversal and resolution logic across three layers.

Key changes:
- Add position inheritance: {module} tokens in bay position fields resolve
  using the parent bay's position during hierarchy traversal
- Single {module} token now resolves to the leaf bay's inherited position
- Mismatched token count vs tree depth now raises ValueError instead of
  silently producing partial strings
- API serializer validation uses shared utilities for parity with the form
- Fix error message wording ("levels deep" instead of "in tree")
2026-04-01 17:58:16 -07:00
Jeremy Stretch
b62c5e1ac4 Merge branch 'main' into feature 2026-04-01 13:22:52 -04:00
bctiemann
1277bb6138 Merge pull request #21806 from netbox-community/21771-rest-api-add-remove-tags
Closes #21771: Add `add_tags` & `remove_tags` fields for taggable objects
2026-04-01 13:02:19 -04:00
Fabi
e98e5e11a7 Fixes #21784: Fix AttributeError when an AnonymousUser tries to sort a table (#21817) 2026-04-01 18:36:21 +02:00
Johannes Rueschel
3ce2bf75b4 Fixes #21533: Fix missing family/mask_length in API when creating IP-related objects (#21546) 2026-04-01 11:25:00 -05:00
Martin Hauser
b1af9a7218 fix(dcim): Use hasattr check for virtual_circuit_termination (#21811)
Replace direct attribute access with hasattr() to prevent AttributeError
when the virtual_circuit_termination relation doesn't exist on the
object.

Fixes #21808
2026-04-01 18:06:18 +02:00
Artem Kotik
b73f7f7d00 Fixes #21655: Fix duplicate SQL queries on serializing custom fields (#21750)
Co-authored-by: Jason Novinger <jnovinger@gmail.com>
Co-authored-by: Artem Kotik <artem.i.kotik@ringcentral.com>
2026-04-01 09:52:38 -05:00
Martin Hauser
9492b55f4b fix(dcim): Fix Virtual Chassis Member add action context
Fix context variable references in VirtualChassMembersPanel add action
to use 'virtual_chassis' instead of 'object'. Add safe checks for
master_id existence to prevent errors when master is not set.

Fixes #21810
2026-04-01 08:59:39 -04:00
github-actions
2563122352 Update source translation strings 2026-04-01 05:39:05 +00:00
Martin Hauser
0455e14c29 docs(plugins): Use @register_search in plugin search docs
Align the plugin search example with the recommended registration
pattern used in the general search documentation and NetBox core.

Replace the legacy `indexes = [...]` example with decorator-based
registration to make the preferred approach clearer for plugin authors.
2026-03-31 16:55:27 -04:00
Jeremy Stretch
76c02d5aa9 Raise a validation error if the same tag is present in both add_tags and remove_tags 2026-03-31 16:44:37 -04:00
Jeremy Stretch
8bc691099c Raise a validation error if remove_tags is specified when creating an object 2026-03-31 16:38:15 -04:00
Jeremy Stretch
95011821bb Closes #21771: Add add_tags & remove_tags fields for taggable objects 2026-03-31 16:02:32 -04:00
bctiemann
b8b12f3f90 #20923 - Convert extras to new declarative UI layout (#21765) 2026-03-31 20:28:16 +02:00
Jeremy Stretch
e5b9e5a279 Closes #19025: Add schema validation for JSON custom fields (#21746) 2026-03-31 12:41:49 -05:00
Jeremy Stretch
05059f4a86 Release v4.5.6 2026-03-31 12:43:26 -04:00
Martin Hauser
2389feea6b feat(virtualization): Add Virtual Machine Type model
Introduce `VirtualMachineType` to classify virtual machines and apply
default platform, vCPU, and memory values when creating a VM.

This adds the new model and its relationship to `VirtualMachine`, and
wires it through forms, filtersets, tables, views, the REST API,
GraphQL, navigation, search, documentation, and tests.

Explicit values set on a virtual machine continue to take precedence,
and changes to a type do not retroactively update existing VMs.
2026-03-31 09:10:02 -04:00
Martin Hauser
e4e4c1c56d feat(dcim): Add 50G, 800G, and 1.6T interface speed options (#21796)
Adds support for 50 Gbps, 800 Gbps, and 1.6 Tbps interface speeds to
the InterfaceSpeedChoices to cover newer high-speed networking hardware.
2026-03-31 14:33:23 +02:00
Martin Hauser
c99d8481b2 refactor(ui): Improve object change diff styling and layout
Update change data diff styling with CSS custom properties, better color
contrast, and consistent borders. Replace btn-group with card-actions
for navigation buttons and improve spacing.
2026-03-31 08:26:01 -04:00
Martin Hauser
0923a3dec8 fix(tables): Disable ordering on non-orderable accessor columns
Mark provider, member, and action_object columns as non-orderable since
they use complex accessors that cannot be sorted. Add regression tests
to verify all orderable columns render without exceptions.

Fixes table rendering errors when attempting to sort columns with
multi-level field accessors that don't support database ordering.
2026-03-31 08:18:36 -04:00
Martin Hauser
80b9c25674 feat(dcim): Add 2.5GE SFP interface type (#21794)
Add the `SFP (2.5GE)` interface type for devices with dedicated 2.5G SFP
slots that do not fit the existing SFP or SFP+ options.
2026-03-31 14:09:44 +02:00
github-actions
6d13bc8b96 Update source translation strings 2026-03-31 05:31:31 +00:00
Jeremy Stretch
ee17e83da6 Update CLAUDE.md (#21777) 2026-03-30 16:33:10 -05:00
Jeremy Stretch
5ab9608e38 Revert "Fixes #21747: Skip search caching when encountering an invalid schema during migrations (#21748)" (#21787)
This reverts commit 296b89ae02.
2026-03-30 23:31:41 +02:00
Martin Hauser
c7504628bd feat(dcim): Add changelog message support to bulk component creation (#21769)
Add ChangelogMessageMixin to DeviceBulkAddComponentForm and capture
changelog_message during bulk component creation. Ensure message is
applied to each created component instance. Add test coverage for
changelog message propagation.
2026-03-30 08:42:05 -07:00
bctiemann
e54ed87863 Merge pull request #21778 from netbox-community/21763-m2m-form-fields
Fixes #21763: Replace M2M selection field with separate add/remove fields
2026-03-30 11:23:36 -04:00
Jeremy Stretch
55daf4c52f Add/fix tests 2026-03-30 10:02:38 -04:00
Jeremy Stretch
a45e8571da Revert changes to ASNForm 2026-03-30 09:29:08 -04:00
Jeremy Stretch
0154a09856 Limit 'add' field choices to objects not already assigned 2026-03-30 09:22:56 -04:00
Jeremy Stretch
757c4f69d2 Annotate current number of assignments if >100 2026-03-30 09:15:35 -04:00
Jeremy Stretch
d5f37d7a87 Use add/remove fields only when assignment count is 100+ 2026-03-30 09:07:15 -04:00
Jeremy Stretch
f30786d8fe Fixes #21763: Replace M2M selection field with separate add/remove fields 2026-03-27 16:45:36 -04:00
bctiemann
74aa822b27 Merge pull request #21762 from netbox-community/20162-background
#20162 allow background job when adding components to devices in bulk
2026-03-27 13:02:40 -04:00
github-actions
bb73601d80 Update source translation strings 2026-03-27 05:31:05 +00:00
Arthur
9bc66ee0bf cleanup 2026-03-26 15:00:52 -07:00
Arthur Hanson
99e9d96787 #20923: Migrate IPAM views to declarative layouts (#21695)
* #20923: Migrate IPAM views to declarative layouts

* #20923: Migrate IPAM views to declarative layouts

* fix VRF view

* fix Route Target view

* fix addressing details modal

* fix add prefix button

* fix add aggregate button

* fix add VLAN button

* fix breadcrumb on Application Service

* fix breadcrumb on ANS

* move attrs to separate file

* review feedback

* review feedback

* review feedback

* review feedback
2026-03-26 16:55:12 -04:00
Jeremy Stretch
296b89ae02 Fixes #21747: Skip search caching when encountering an invalid schema during migrations (#21748) 2026-03-26 16:46:41 -04:00
Arthur
3ec0551680 cleanup 2026-03-26 13:37:40 -07:00
Arthur
8a58d760fa cleanup 2026-03-26 13:25:49 -07:00
bctiemann
f5c97e367c Merge pull request #21754 from netbox-community/20923-core-ui-layouts
#20923: Migrate core app to the new UI layouts
2026-03-26 13:53:20 -04:00
Arthur
84670af18b #20162 allow background job when adding components to devices in bulk 2026-03-26 09:56:21 -07:00
Arthur Hanson
a3a204f2fd Fix regression from #14329 (#21759) 2026-03-26 17:31:00 +01:00
Arthur Hanson
ea756b29e9 #20923 - Convert tenancy to new UI layout (#21745) 2026-03-26 17:16:31 +01:00
Jeremy Stretch
b929e1aa1b Fixes #21747: Skip search caching when encountering an invalid schema during migrations (#21748) 2026-03-26 09:13:28 -07:00
github-actions
91d5382a61 Update source translation strings 2026-03-26 05:30:51 +00:00
Mark Robert Coleman
e76203238d Fix {module} placeholder resolution in module bay position field (#21752)
* Fix {module} placeholder resolution in module bay position field (#20467)

The {module} placeholder in ModuleBayTemplate's position field was not
being resolved when a module was installed, leaving the literal string
"{module}" in the position. This adds a resolve_position() method and
calls it in instantiate(), consistent with how resolve_name() and
resolve_label() already work.

Consolidates the shared resolution logic into _resolve_module_placeholder()
to eliminate duplication across resolve_name, resolve_label, and the new
resolve_position.

Fixes: #20467

* Move resolve_position() to ModuleBayTemplate

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2026-03-25 15:45:49 -04:00
Jeremy Stretch
3f58648115 Convert DataFileView to a single-column layout 2026-03-25 13:55:07 -04:00
Jeremy Stretch
b904dc5c75 Support translation of headings for embedded table panels 2026-03-25 13:50:41 -04:00
Martin Hauser
2c0b6c4d55 feat(virtualization): Allow VMs to be assigned directly to devices (#21731)
Enable VMs to be assigned to a standalone device without requiring a
cluster. Add device-scoped uniqueness constraints, update validation
logic, and enhance placement flexibility. Site is now auto-inherited
from the cluster or device.
2026-03-25 10:20:00 -07:00
Jeremy Stretch
bf27ff9593 #20923: Initial work on migrating the core app 2026-03-25 12:57:10 -04:00
Jeremy Stretch
29239ca58a Closes #21635: Migrate from mkdocs to Zensical (#21742)
* Drop mkdocs from `requirements.txt` and add Zensical
* Replace mkdocs with Zensical in CI and pre-commit tasks
* Remove `.info` from the `docs/` build directory (obsolete)
* Update the legacy ReadTheDocs configuration
* Update upgrade script to use Zensical
* Remove custom docs footer
* Remove obsolete CSS
2026-03-25 16:48:29 +01:00
Martin Hauser
981f31304d Closes #21735: Replace deprecated Strawberry scalar for BigInt (#21736) 2026-03-25 09:36:30 -05:00
Martin Hauser
2a39ab47d6 feat(circuits): Add UI layout panels for circuits app
Implement comprehensive UI panel layouts for all circuit models using
the new panel system. Add panels for providers, circuits, terminations,
groups, and virtual circuits with proper attribute rendering and
actions.
2026-03-25 10:19:26 -04:00
Jeremy Stretch
aa01c16db0 #20923: Migrate remaining DCIM views to new UI layouts (#21706) 2026-03-25 09:08:54 -05:00
bctiemann
2a78c05984 Closes #19034: Add calculated RackReservation.unit_count, with min/max filtering (#21665) 2026-03-25 08:50:53 -05:00
github-actions
e04986617c Update source translation strings 2026-03-25 05:28:00 +00:00
Jeremy Stretch
bc66d9f136 Closes #21702: Include originating HTTP request in outbound webhook context data (#21726)
Adds a `request` key to the webhook data if a request is associated with the origination of the webhook.

Note: We're not attaching a complete representation of the request in the interest of both security and brevity.
2026-03-24 23:00:21 +01:00
Jeremy Stretch
b8ce81c8fe Fix migration conflict 2026-03-24 16:25:49 -04:00
bctiemann
41d05490fc Merge pull request #21691 from netbox-community/14329-cf
#14329 Improve diffs for custom_fields
2026-03-24 14:37:19 -04:00
bctiemann
83cf193cdc Merge pull request #21680 from netbox-community/21664-update-github-actions-for-nodejs-24-compatibility
Closes #21664: Update and pin GitHub Actions for Node 24 compatibility
2026-03-24 14:34:57 -04:00
bctiemann
d497198f49 Merge pull request #21721 from netbox-community/21698-custom-field-url-filter-is-too-restrictive-for-weird-ports
Fixes #21698: Fix validation of custom field URLs with single-digit ports
2026-03-24 14:25:00 -04:00
bctiemann
82df20a8a9 Merge pull request #21648 from netbox-community/20152-support-for-marking-module-bays-and-device-bays-as-disabled
Closes #20152: Add support for disabling Device and Module bays
2026-03-24 13:12:00 -04:00
Arthur Hanson
f303ae2cd7 Closes #21662: Increase rf_channel_frequency Precision (#21690)
Increase `rf_channel_frequency` precision from two to three decimal
places.

Update the field definition and migration to use `max_digits=8` and
`decimal_places=3`, preserving support for higher channel frequencies
while allowing more precise values to be stored.
2026-03-24 17:36:20 +01:00
pobradovic08
4e479c547f Closes #21480: Add 1.6T Ethernet interface types (#21723)
Add support for IEEE 802.3dj 1.6T fixed interface types and
published 1.6T pluggable form factors.

This adds 1.6TBASE-CR8, 1.6TBASE-KR8, 1.6TBASE-DR8, and
1.6TBASE-DR8-2, plus OSFP1600, OSFP1600-RHS, and QSFP-DD1600
transceiver types.
2026-03-24 10:51:26 +01:00
github-actions
e44c0a2119 Update source translation strings 2026-03-24 05:27:47 +00:00
Martin Hauser
3ab0613708 fix(circuits): Add ProviderAccount fieldsets (#21708) 2026-03-23 16:07:20 -07:00
Martin Hauser
9f16734266 fix(utilities): Allow single-digit port numbers in URL validator
Change port number regex from `\d{2,5}` to `\d{1,5}` to permit valid
single-digit ports (1-9). This aligns with RFC 3986 and fixes
validation for URLs using ports like :8 or :9.

Fixes #21698
2026-03-20 13:40:40 +01:00
Étienne Brunel
1f336eee2e Closes #21575: Implement {vc_position} template variable on component template name/label (#21601) 2026-03-18 10:15:11 -07:00
Jeremy Stretch
6030fc383a Merge branch 'main' into feature 2026-03-18 10:16:21 -04:00
github-actions
c3c7cf15b2 Update source translation strings 2026-03-18 05:28:51 +00:00
Jeremy Stretch
2b7049c39c Release v4.5.5 (#21672)
* 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
1fb6507cc1 #14329 Improve diffs for custom_fields 2026-03-17 09:44:01 -07:00
Arthur Hanson
753fedf5e7 Revert "#14329 Improve diffs for custom_fields" (#21692)
This reverts commit 38afed60ef.
2026-03-17 17:35:30 +01:00
Arthur
ca021e808b #14329 Improve diffs for custom_fields 2026-03-17 09:14:41 -07:00
Arthur
38afed60ef #14329 Improve diffs for custom_fields 2026-03-17 09:09:03 -07:00
bctiemann
66f6b2b6f9 Merge pull request #21649 from netbox-community/21556-fix-dropdown-clearing
Fixes #21556: Restore previous value (if applicable) after clearing related dropdown
2026-03-17 12:06:14 -04: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
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 2026-03-17 08:54:03 +01: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 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
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)
* 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
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
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)
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 2026-03-13 05:20:12 +00:00
Jeremy Stretch
6f5fd26183 Fixes #20077: Fix form field focus bug on Edge 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
#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) 2026-03-11 11:49:01 -05:00
bctiemann
02165a28a0 Closes #20151: Add support for cable bundles (#21636) 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 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) 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)
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 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) 2026-03-09 11:11:40 -07:00
Arthur Hanson
b5bd8905ca #21330 optimize the assignment of tags when saving an object (#21595)
* #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 2026-03-07 05:14:28 +00:00
Jeremy Stretch
6659bb3abe Closes #21363: Implement cursor-based pagination for the REST API (#21594) 2026-03-06 17:13:08 -08:00
Jeremy Stretch
67defb3228 Fixes #21531: Fix search functionality for location when combined with other filters (#21599) 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
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)
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
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
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 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
fa5f9430fc Fixes #20468: Fix range lookups for numeric GraphQL filters (#21589)
* Fixes #20468: Fix range lookups for numeric GraphQL filters

* Update netbox/netbox/tests/test_graphql.py

---------

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

* Define UI layout for ModuleType view

* Define UI layout for DeviceRole view

* Define UI layout for Platform view

* Define UI layout for Module view

* Misc cleanup

* Linkify module bay
2026-03-05 08:43:46 -05:00
Jeremy Stretch
eaad5cc26f Fixes #19867: Retain the per_page URL parameter after editing an object 2026-03-05 08:26:47 -05:00
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 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
Jason Novinger
a1d82e45a0 Closes #21571: Bump minimatch and markdown-it to resolve security alerts (#21573)
Add yarn resolutions to force patched versions of two transitive
dependencies flagged by dependabot:

- minimatch 3.1.2 → 3.1.5 (GHSA-7r86-cg39-jmmj, high severity ReDoS)
- markdown-it 14.1.0 → 14.1.1 (CVE-2026-2327, medium severity ReDoS)
2026-03-04 16:08:02 +01:00
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
github-actions
e4f7f080b3 Update source translation strings 2026-03-04 05:17:48 +00:00
bctiemann
6eafffb497 Closes: #21304 - Add stronger deprecation warning on use of housekeeping management command (#21483)
* 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
bctiemann
983ba4fda8 Merge pull request #21562 from netbox-community/release-v4.5.4
Release v4.5.4
2026-03-03 15:07:18 -05:00
Jeremy Stretch
54462595a6 Release v4.5.4 2026-03-03 12:46:15 -05:00
Jeremy Stretch
8ab752b9ad Closes #21451: Upgrade tom-select to v2.5.2 (#21563) 2026-03-03 18:35:36 +01:00
Jeremy Stretch
b11cc31f9d Closes #21559: Add CLAUDE.md 2026-03-03 12:01:33 -05:00
Martin Hauser
3f02309538 fix(ipam): Avoid allocating IPv6 subnet-router anycast address (#21547)
Ensure available IP selection for IPv6 non-pool prefixes excludes the
subnet-router anycast address (RFC 4291), so allocation starts at ::1
for typical prefixes (e.g. /64).
Add tests for IPv4/IPv6 pools and special cases (/31-/32, /127-/128).

Fixes #21347
2026-03-03 08:26:44 -08:00
Martin Hauser
53345f194a refactor(graphql): Replace FilterLookup[str] with StrFilterLookup
Replace usages of FilterLookup[str] with StrFilterLookup in GraphQL
filter definitions to align with strawberry-graphql-django v0.75.1.
This silences upstream warnings and helps avoid DuplicatedTypeName
errors.

Fixes #21450
2026-03-03 11:17:13 -05:00
Jeremy Stretch
139557b8dd Fixes #21524: Fix IndexError when serializing stale cable paths (#21525) 2026-03-03 16:37:45 +01:00
bctiemann
fcf02bd8bb Merge pull request #21453 from netbox-community/21429-cable-create-add-another-does-not-carry-over-termination
Fixes #21429: Add Cable cloning and fix "Create & Add Another" to preserve Termination Types
2026-03-03 09:44:35 -05:00
Martin Hauser
7d6989ff34 Closes #21477: Add cached relation filters to GraphQL for Cable (#21506) 2026-03-03 08:01:45 -06: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
Arthur Hanson
3b0b95c265 Closes #21550: Call snapshot() before saving related objects (#21551)
Add missing pre-change `snapshot()` calls in views/forms before updating
and saving related objects (device bays, virtual chassis members, and
bulk-import primary MAC/IP assignments), so changelog entries include
pre-change data.
2026-03-03 14:01:04 +01:00
github-actions
cdc2fb2f06 Update source translation strings 2026-03-03 05:20:47 +00:00
Jeremy Stretch
1a404f5c0f Merge branch 'main' into feature 2026-02-25 17:07:26 -05:00
bctiemann
3320e07b70 Closes #21284: Add deprecation note to webhooks documentation (#21491)
* 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
Martin Hauser
951d856c3c feat(dcim): Add Cable cloning with Termination mapping
Introduce `clone()` method for the Cable model to enable cloning
its attributes, including termination type and parent selectors.
Updates mappings to align with CableForm workflows, supporting
"Clone" and "Create & Add Another" actions.

Fixes #21429
2026-02-17 18:30:36 +01:00
533 changed files with 81767 additions and 84089 deletions

View File

@@ -15,7 +15,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v4.5.3
placeholder: v4.5.6
validations:
required: true
- type: dropdown

View File

@@ -27,7 +27,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v4.5.3
placeholder: v4.5.6
validations:
required: true
- type: dropdown

View File

@@ -8,7 +8,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v4.5.3
placeholder: v4.5.6
validations:
required: true
- type: dropdown

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
@@ -92,7 +92,7 @@ jobs:
pip install coverage tblib
- name: Build documentation
run: mkdocs build
run: zensical build
- name: Collect static files
run: python netbox/manage.py collectstatic --no-input

View File

@@ -3,20 +3,14 @@ name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
# Only run for PRs submitted by organization members or owners
if: |
github.repository == 'netbox-community/netbox' &&
(github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'OWNER')
runs-on: ubuntu-latest
permissions:
@@ -27,13 +21,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
uses: anthropics/claude-code-action@e763fe78de2db7389e04818a00b5ff8ba13d1360 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
@@ -41,4 +35,3 @@ jobs:
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options

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

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

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

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}}"

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'

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

View File

@@ -21,11 +21,11 @@ repos:
language: system
pass_filenames: false
types: [python]
- id: mkdocs-build
- id: zensical-build
name: "Build documentation"
description: "Build the documentation with mkdocs"
description: "Build the documentation with Zensical"
files: 'docs/'
entry: mkdocs build
entry: zensical build
language: system
pass_filenames: false
- id: yarn-validate

View File

@@ -1,10 +1,10 @@
version: 2
build:
os: ubuntu-22.04
os: ubuntu-24.04
tools:
python: "3.12"
mkdocs:
configuration: mkdocs.yml
python:
install:
- requirements: requirements.txt
commands:
- pip install -r requirements.txt
- python -m zensical build --config-file mkdocs.yml
- mkdir -p $READTHEDOCS_OUTPUT/html/
- cp -r netbox/project-static/docs/* $READTHEDOCS_OUTPUT/html/

87
CLAUDE.md Normal file
View File

@@ -0,0 +1,87 @@
# NetBox
Network source-of-truth and infrastructure resource modeling (IRM) tool combining DCIM and IPAM. Built on Django + PostgreSQL + Redis.
## Tech Stack
- Python 3.12+ / Django / Django REST Framework
- PostgreSQL (required), Redis (required for caching/queuing)
- GraphQL via Strawberry, background jobs via RQ
- Docs: MkDocs (in `docs/`)
## Repository Layout
- `netbox/` — Django project root; run all `manage.py` commands from here
- `netbox/netbox/` — Core settings, URLs, WSGI entrypoint
- `netbox/<app>/` — Django apps: `circuits`, `core`, `dcim`, `ipam`, `extras`, `tenancy`, `virtualization`, `wireless`, `users`, `vpn`
- `docs/` — MkDocs documentation source
- `contrib/` — Example configs (systemd, nginx, etc.) and other resources
## Development Setup
```bash
python -m venv ~/.venv/netbox
source ~/.venv/netbox/bin/activate
pip install -r requirements.txt
# Copy and configure
cp netbox/netbox/configuration.example.py netbox/netbox/configuration.py
# Edit configuration.py: set DATABASE, REDIS, SECRET_KEY, ALLOWED_HOSTS
cd netbox/
python manage.py migrate
python manage.py runserver
```
## Key Commands
All commands run from the `netbox/` subdirectory with venv active.
```bash
# Development server
python manage.py runserver
# Run full test suite
export NETBOX_CONFIGURATION=netbox.configuration_testing
python manage.py test
# Faster test runs (no DB rebuild, parallel)
python manage.py test --keepdb --parallel 4
# Migrations
python manage.py makemigrations
python manage.py migrate
# Shell
python manage.py nbshell # NetBox-enhanced shell
```
## Architecture Conventions
- **Apps**: Each Django app owns its models, views, API serializers, filtersets, forms, and tests.
- **Views**: Use `register_model_view()` to register model views by action (e.g. "add", "list", etc.). List views typically don't need to add `select_related()` or `prefetch_related()` on their querysets: Prefetching is handled dynamically by the table class so that only relevant fields are prefetched.
- **REST API**: DRF serializers live in `<app>/api/serializers.py`; viewsets in `<app>/api/views.py`; URLs auto-registered in `<app>/api/urls.py`. REST API views typically don't need to add `select_related()` or `prefetch_related()` on their querysets: Prefetching is handled dynamically by the serializer so that only relevant fields are prefetched.
- **GraphQL**: Strawberry types in `<app>/graphql/types.py`.
- **Filtersets**: `<app>/filtersets.py` — used for both UI filtering and API `?filter=` params.
- **Tables**: `django-tables2` used for all object list views (`<app>/tables.py`).
- **Templates**: Django templates in `netbox/templates/<app>/`.
- **Tests**: Mirror the app structure in `<app>/tests/`. Use `netbox.configuration_testing` for test config.
## Coding Standards
- Follow existing Django conventions; don't reinvent patterns already present in the codebase.
- New models must include `created`, `last_updated` fields (inherit from `NetBoxModel` where appropriate).
- Every model exposed in the UI needs: model, serializer, filterset, form, table, views, URL route, and tests.
- API serializers must include a `url` field (absolute URL of the object).
- Use `FeatureQuery` for generic relations (config contexts, custom fields, tags, etc.).
- Avoid adding new dependencies without strong justification.
- Avoid running `ruff format` on existing files, as this tends to introduce unnecessary style changes.
- Don't craft Django database migrations manually: Prompt the user to run `manage.py makemigrations` instead.
## Branch & PR Conventions
- Branch naming: `<issue-number>-short-description` (e.g., `1234-device-typerror`)
- Use the `main` branch for patch releases; `feature` tracks work for the upcoming minor/major release.
- Every PR must reference an approved GitHub issue.
- PRs must include tests for new functionality.
## Gotchas
- `configuration.py` is gitignored — never commit it.
- `manage.py` lives in `netbox/`, NOT the repo root. Running from the wrong directory is a common mistake.
- `NETBOX_CONFIGURATION` env var controls which settings module loads; set to `netbox.configuration_testing` for tests.
- The `extras` app is a catch-all for cross-cutting features (custom fields, tags, webhooks, scripts).
- Plugins API: only documented public APIs are stable. Internal NetBox code is subject to change without notice.
- See `docs/development/` for the full contributing guide and code style details.

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.

View File

@@ -4,7 +4,7 @@ colorama
# The Python web framework on which NetBox is built
# https://docs.djangoproject.com/en/stable/releases/
Django==5.2.*
Django==6.0.*
# Django middleware which permits cross-domain API requests
# https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst
@@ -35,7 +35,9 @@ django-pglocks
# Prometheus metrics library for Django
# https://github.com/korfuri/django-prometheus/blob/master/CHANGELOG.md
django-prometheus
# TODO: 2.4.1 is incompatible with Django>=6.0, but a fixed release is expected
# https://github.com/django-commons/django-prometheus/issues/494
django-prometheus>=2.4.0,<2.5.0,!=2.4.1
# Django caching backend using Redis
# https://github.com/jazzband/django-redis/blob/master/CHANGELOG.rst
@@ -47,7 +49,8 @@ django-rich
# Django integration for RQ (Reqis queuing)
# https://github.com/rq/django-rq/blob/master/CHANGELOG.md
django-rq
# See https://github.com/netbox-community/netbox/issues/21696
django-rq<4.0
# Provides a variety of storage backends
# https://github.com/jschneier/django-storages/blob/master/CHANGELOG.rst
@@ -157,8 +160,7 @@ strawberry-graphql
# Strawberry GraphQL Django extension
# https://github.com/strawberry-graphql/strawberry-django/releases
# Blocked by #21450
strawberry-graphql-django==0.75.0
strawberry-graphql-django
# SVG image rendering (used for rack elevations)
# https://github.com/mozman/svgwrite/blob/master/NEWS.rst
@@ -172,3 +174,7 @@ tablib
# Timezone data (required by django-timezone-field on Python 3.9+)
# https://github.com/python/tzdata/blob/master/NEWS.md
tzdata
# Documentation builder (succeeds mkdocs)
# https://github.com/zensical/zensical
zensical

View File

@@ -349,6 +349,7 @@
"5gbase-t",
"10gbase-br-d",
"10gbase-br-u",
"10gbase-cu",
"10gbase-cx4",
"10gbase-er",
"10gbase-lr",
@@ -367,6 +368,7 @@
"40gbase-fr4",
"40gbase-lr4",
"40gbase-sr4",
"40gbase-sr4-bd",
"50gbase-cr",
"50gbase-er",
"50gbase-fr",
@@ -414,9 +416,13 @@
"800gbase-dr8",
"800gbase-sr8",
"800gbase-vr8",
"1.6tbase-cr8",
"1.6tbase-dr8",
"1.6tbase-dr8-2",
"100base-x-sfp",
"1000base-x-gbic",
"1000base-x-sfp",
"2.5gbase-x-sfp",
"10gbase-x-sfpp",
"10gbase-x-xenpak",
"10gbase-x-xfp",
@@ -446,6 +452,9 @@
"400gbase-x-osfp-rhs",
"800gbase-x-osfp",
"800gbase-x-qsfpdd",
"1.6tbase-x-osfp1600",
"1.6tbase-x-osfp1600-rhs",
"1.6tbase-x-qsfpdd1600",
"1000base-kx",
"2.5gbase-kx",
"5gbase-kr",
@@ -457,6 +466,7 @@
"100gbase-kp4",
"100gbase-kr2",
"100gbase-kr4",
"1.6tbase-kr8",
"ieee802.11a",
"ieee802.11g",
"ieee802.11n",

File diff suppressed because one or more lines are too long

View File

@@ -1,18 +0,0 @@
<div class="md-copyright">
{% if config.copyright %}
<div class="md-copyright__highlight">
{{ config.copyright }}
</div>
{% endif %}
{% if not config.extra.generator == false %}
Made with
<a href="https://squidfunk.github.io/mkdocs-material/" target="_blank" rel="noopener">
Material for MkDocs
</a>
{% endif %}
</div>
{% if not config.extra.build_public %}
<div class="md-copyright">
Documentation is being served locally
</div>
{% endif %}

View File

@@ -21,6 +21,7 @@ Some configuration parameters are primarily controlled via NetBox's admin interf
* [`BANNER_BOTTOM`](./miscellaneous.md#banner_bottom)
* [`BANNER_LOGIN`](./miscellaneous.md#banner_login)
* [`BANNER_TOP`](./miscellaneous.md#banner_top)
* [`CHANGELOG_RETAIN_CREATE_LAST_UPDATE`](./miscellaneous.md#changelog_retain_create_last_update)
* [`CHANGELOG_RETENTION`](./miscellaneous.md#changelog_retention)
* [`CUSTOM_VALIDATORS`](./data-validation.md#custom_validators)
* [`DEFAULT_USER_PREFERENCES`](./default-values.md#default_user_preferences)

View File

@@ -73,6 +73,27 @@ This data enables the project maintainers to estimate how many NetBox deployment
---
## CHANGELOG_RETAIN_CREATE_LAST_UPDATE
!!! tip "Dynamic Configuration Parameter"
Default: `True`
When pruning expired changelog entries (per `CHANGELOG_RETENTION`), retain each non-deleted object's original `create`
change record and its most recent `update` change record. If an object has a `delete` change record, its changelog
entries are pruned normally according to `CHANGELOG_RETENTION`.
!!! note
For objects without a `delete` change record, the original `create` record and most recent `update` record are
exempt from pruning. All other changelog records (including intermediate `update` records and all `delete` records)
remain subject to pruning per `CHANGELOG_RETENTION`.
!!! warning
This setting is enabled by default. Upgrading deployments that rely on complete pruning of expired changelog entries
should explicitly set `CHANGELOG_RETAIN_CREATE_LAST_UPDATE = False` to preserve the previous behavior.
---
## CHANGELOG_RETENTION
!!! tip "Dynamic Configuration Parameter"

View File

@@ -63,6 +63,7 @@ NetBox supports limited custom validation for custom field values. Following are
* Text: Regular expression (optional)
* Integer: Minimum and/or maximum value (optional)
* Selection: Must exactly match one of the prescribed choices
* JSON: Must adhere to the defined validation schema (if any)
### Custom Selection Fields

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()
```

View File

@@ -97,7 +97,7 @@ NetBox uses [`pre-commit`](https://pre-commit.com/) to automatically validate co
* Run the `ruff` Python linter
* Run Django's internal system check
* Check for missing database migrations
* Validate any changes to the documentation with `mkdocs`
* Validate any changes to the documentation with `zensical`
* Validate Typescript & Sass styling with `yarn`
* Ensure that any modified static front end assets have been recompiled

View File

@@ -45,6 +45,7 @@ These are considered the "core" application models which are used to model netwo
* [core.DataSource](../models/core/datasource.md)
* [core.Job](../models/core/job.md)
* [dcim.Cable](../models/dcim/cable.md)
* [dcim.CableBundle](../models/dcim/cablebundle.md)
* [dcim.Device](../models/dcim/device.md)
* [dcim.DeviceType](../models/dcim/devicetype.md)
* [dcim.Module](../models/dcim/module.md)
@@ -73,6 +74,7 @@ These are considered the "core" application models which are used to model netwo
* [tenancy.Tenant](../models/tenancy/tenant.md)
* [virtualization.Cluster](../models/virtualization/cluster.md)
* [virtualization.VirtualMachine](../models/virtualization/virtualmachine.md)
* [virtualization.VirtualMachineType](../models/virtualization/virtualmachinetype.md)
* [vpn.IKEPolicy](../models/vpn/ikepolicy.md)
* [vpn.IKEProposal](../models/vpn/ikeproposal.md)
* [vpn.IPSecPolicy](../models/vpn/ipsecpolicy.md)
@@ -92,6 +94,7 @@ Organization models are used to organize and classify primary models.
* [dcim.DeviceRole](../models/dcim/devicerole.md)
* [dcim.Manufacturer](../models/dcim/manufacturer.md)
* [dcim.Platform](../models/dcim/platform.md)
* [dcim.RackGroup](../models/dcim/rackgroup.md)
* [dcim.RackRole](../models/dcim/rackrole.md)
* [ipam.ASNRange](../models/ipam/asnrange.md)
* [ipam.RIR](../models/ipam/rir.md)

View File

@@ -47,7 +47,7 @@ If a new Django release is adopted or other major dependencies (Python, PostgreS
Start the documentation server and navigate to the current version of the installation docs:
```no-highlight
mkdocs serve
zensical serve
```
Follow these instructions to perform a new installation of NetBox in a temporary environment. This process must not be automated: The goal of this step is to catch any errors or omissions in the documentation and ensure that it is kept up to date for each release. Make any necessary changes to the documentation before proceeding with the release.

View File

@@ -5,10 +5,6 @@ img {
margin-right: auto;
}
.md-content img {
background-color: rgba(255, 255, 255, 0.64);
}
/* Tables */
table {
margin-bottom: 24px;

View File

@@ -1,26 +1,44 @@
# Virtualization
Virtual machines and clusters can be modeled in NetBox alongside physical infrastructure. IP addresses and other resources are assigned to these objects just like physical objects, providing a seamless integration between physical and virtual networks.
Virtual machines, clusters, and standalone hypervisors can be modeled in NetBox alongside physical infrastructure. IP addresses and other resources are assigned to these objects just like physical objects, providing a seamless integration between physical and virtual networks.
```mermaid
flowchart TD
ClusterGroup & ClusterType --> Cluster
VirtualMachineType --> VirtualMachine
Device --> VirtualMachine
Cluster --> VirtualMachine
Platform --> VirtualMachine
VirtualMachine --> VMInterface
click Cluster "../../models/virtualization/cluster/"
click ClusterGroup "../../models/virtualization/clustergroup/"
click ClusterType "../../models/virtualization/clustertype/"
click Platform "../../models/dcim/platform/"
click VirtualMachine "../../models/virtualization/virtualmachine/"
click VMInterface "../../models/virtualization/vminterface/"
click Cluster "../../models/virtualization/cluster/"
click ClusterGroup "../../models/virtualization/clustergroup/"
click ClusterType "../../models/virtualization/clustertype/"
click VirtualMachineType "../../models/virtualization/virtualmachinetype/"
click Device "../../models/dcim/device/"
click Platform "../../models/dcim/platform/"
click VirtualMachine "../../models/virtualization/virtualmachine/"
click VMInterface "../../models/virtualization/vminterface/"
```
## Clusters
A cluster is one or more physical host devices on which virtual machines can run. Each cluster must have a type and operational status, and may be assigned to a group. (Both types and groups are user-defined.) Each cluster may designate one or more devices as hosts, however this is optional.
A cluster is one or more physical host devices on which virtual machines can run.
Each cluster must have a type and operational status, and may be assigned to a group. (Both types and groups are user-defined.) Each cluster may designate one or more devices as hosts, however this is optional.
## Virtual Machine Types
A virtual machine type provides reusable classification for virtual machines and can define create-time defaults for platform, vCPUs, and memory. This is useful when multiple virtual machines share a common sizing or profile while still allowing per-instance overrides after creation.
## Virtual Machines
A virtual machine is a virtualized compute instance. These behave in NetBox very similarly to device objects, but without any physical attributes. For example, a VM may have interfaces assigned to it with IP addresses and VLANs, however its interfaces cannot be connected via cables (because they are virtual). Each VM may also define its compute, memory, and storage resources as well.
A virtual machine is a virtualized compute instance. These behave in NetBox very similarly to device objects, but without any physical attributes.
For example, a VM may have interfaces assigned to it with IP addresses and VLANs, however its interfaces cannot be connected via cables (because they are virtual). Each VM may define its compute, memory, and storage resources as well. A VM can optionally be assigned a [virtual machine type](../models/virtualization/virtualmachinetype.md) to classify it and provide default values for selected attributes at creation time.
A VM can be placed in one of three ways:
- Assigned to a site alone for logical grouping.
- Assigned to a cluster and optionally pinned to a specific host device within that cluster.
- Assigned directly to a standalone device that does not belong to any cluster.

View File

@@ -341,7 +341,7 @@ When retrieving devices and virtual machines via the REST API, each will include
## Pagination
API responses which contain a list of many objects will be paginated for efficiency. The root JSON object returned by a list endpoint contains the following attributes:
API responses which contain a list of many objects will be paginated for efficiency. NetBox employs offset-based pagination by default, which forms a page by skipping the number of objects indicated by the `offset` URL parameter. The root JSON object returned by a list endpoint contains the following attributes:
* `count`: The total number of all objects matching the query
* `next`: A hyperlink to the next page of results (if applicable)
@@ -398,6 +398,49 @@ The maximum number of objects that can be returned is limited by the [`MAX_PAGE_
!!! warning
Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database.
### Cursor-Based Pagination
For large datasets, offset-based pagination can become inefficient because the database must scan all rows up to the offset. As an alternative, cursor-based pagination uses the `start` query parameter to filter results by primary key (PK), enabling efficient keyset pagination.
To use cursor-based pagination, pass `start` (the minimum PK value) and `limit` (the page size):
```
http://netbox/api/dcim/devices/?start=0&limit=100
```
This returns objects with an `id` greater than or equal to zero, ordered by PK, limited to 100 results. Below is an example showing an arbitrary `start` value.
```json
{
"count": null,
"next": "http://netbox/api/dcim/devices/?start=356&limit=100",
"previous": null,
"results": [
{
"id": 109,
"name": "dist-router07",
...
},
...
{
"id": 356,
"name": "acc-switch492",
...
}
]
}
```
To iterate through all results, use the `id` of the last object in each response plus one as the `start` value for the next request. Continue until `next` is null.
!!! info
Some important differences from offset-based pagination:
* `start` and `offset` are **mutually exclusive**; specifying both will result in a 400 error.
* Results are always ordered by primary key when using `start`. This is required to ensure deterministic behavior.
* `count` is always `null` in cursor mode, as counting all matching rows would partially negate its performance benefit.
* `previous` is always `null`: cursor-based pagination supports only forward navigation.
## Interacting with Objects
### Retrieving Multiple Objects

View File

@@ -23,14 +23,19 @@ 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.
* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
!!! warning "Deprecation of legacy fields"
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.
### Default Request Body
If no body template is specified, the request body will be populated with a JSON object containing the context data. For example, a newly created site might appear as follows:
@@ -38,18 +43,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 +64,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",

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

View File

@@ -0,0 +1,15 @@
# Cable Bundles
A cable bundle is a logical grouping of individual [cables](./cable.md). Bundles can be used to organize cables that share a common purpose, route, or physical grouping (such as a conduit or harness).
Assigning cables to a bundle is optional and does not affect cable tracing or connectivity. Bundles persist independently of their member cables: deleting a cable clears its bundle assignment but does not delete the bundle itself.
## Fields
### Name
A unique name for the cable bundle.
### Description
A brief description of the bundle's purpose or contents.

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.

View File

@@ -7,6 +7,18 @@ Device types are instantiated as devices installed within sites and/or equipment
!!! note
This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. Instead, line cards and similarly non-autonomous hardware should be modeled as modules or inventory items within a device.
## Automatic Component Renaming
When adding component templates to a device type, the string `{vc_position}` can be used in component template names to reference the
`vc_position` field of the device being provisioned, when that device is a member of a Virtual Chassis.
For example, an interface template named `Gi{vc_position}/0/0` installed on a Virtual Chassis
member with position `2` will be rendered as `Gi2/0/0`.
If the device is not a member of a Virtual Chassis, `{vc_position}` defaults to `0`. A custom
fallback value can be specified using the syntax `{vc_position:X}`, where `X` is the desired default.
For example, `{vc_position:1}` will render as `1` when no Virtual Chassis position is set.
## Fields
### Manufacturer

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.

View File

@@ -20,8 +20,38 @@ When adding component templates to a module type, the string `{module}` can be u
For example, you can create a module type with interface templates named `Gi{module}/0/[1-48]`. When a new module of this type is "installed" to a module bay with a position of "3", NetBox will automatically name these interfaces `Gi3/0/[1-48]`.
Similarly, the string `{vc_position}` can be used in component template names to reference the
`vc_position` field of the device being provisioned, when that device is a member of a Virtual Chassis.
For example, an interface template named `Gi{vc_position}/{module}/0` installed on a Virtual Chassis
member with position `2` and module bay position `3` will be rendered as `Gi2/3/0`.
If the device is not a member of a Virtual Chassis, `{vc_position}` defaults to `0`. A custom
fallback value can be specified using the syntax `{vc_position:X}`, where `X` is the desired default.
For example, `{vc_position:1}` will render as `1` when no Virtual Chassis position is set.
Automatic renaming is supported for all modular component types (those listed above).
### Position Inheritance for Nested Modules
When using nested module bays (modules installed inside other modules), the `{module}` placeholder
can also be used in the **position** field of module bay templates to inherit the parent bay's
position. This allows a single module type to produce correctly named components at any nesting
depth, with a user-controlled separator.
For example, a line card module type might define sub-bay positions as `{module}/1`, `{module}/2`,
etc. When the line card is installed in a device bay with position `3`, these sub-bay positions
resolve to `3/1`, `3/2`, etc. An SFP module type with interface template `SFP {module}` installed
in sub-bay `3/2` then produces interface `SFP 3/2`.
The separator between levels is defined by the user in the position field template itself. Using
`{module}-1` produces positions like `3-1`, while `{module}.1` produces `3.1`. This provides
full flexibility without requiring a global separator configuration.
!!! note
If the position field does not contain `{module}`, no inheritance occurs and behavior is
unchanged from previous versions.
## Fields
### Manufacturer

View File

@@ -1,6 +1,6 @@
# Racks
The rack model represents a physical two- or four-post equipment rack in which [devices](./device.md) can be installed. Each rack must be assigned to a [site](./site.md), and may optionally be assigned to a [location](./location.md) within that site. Racks can also be organized by user-defined functional roles. The name and facility ID of each rack within a location must be unique.
The rack model represents a physical two- or four-post equipment rack in which [devices](./device.md) can be installed. Each rack must be assigned to a [site](./site.md), and may optionally be assigned to a [location](./location.md) within that site. Racks can also be organized by user-defined functional roles or by [rack groups](./rackgroup.md). The name and facility ID of each rack within a location must be unique.
Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U tall, but NetBox allows you to define racks of arbitrary height. A toggle is provided to indicate whether rack units are in ascending (from the ground up) or descending order.
@@ -16,6 +16,10 @@ The [site](./site.md) to which the rack is assigned.
The [location](./location.md) within a site where the rack has been installed (optional).
### Rack Group
The [group](./rackgroup.md) used to organize racks by physical placement (optional).
### Name
The rack's name or identifier. Must be unique to the rack's location, if assigned.

View File

@@ -0,0 +1,15 @@
# Rack Groups
Racks can optionally be assigned to rack groups to reflect their physical placement. Rack groups provide a secondary means of categorization alongside [locations](./location.md), which is particularly useful for datacenter operators who need to group racks by row, aisle, or similar physical arrangement while keeping them assigned to the same location, such as a cage or room. Rack groups are flat and do not form a hierarchy.
Rack groups can also be used to scope [VLAN groups](../ipam/vlangroup.md), which can help model L2 domains spanning rows or pairs of racks.
## Fields
### Name
A unique human-friendly name.
### Slug
A unique URL-friendly identifier. (This value can be used for filtering.)

View File

@@ -12,6 +12,10 @@ The [rack](./rack.md) being reserved.
The rack unit or units being reserved. Multiple units can be expressed using commas and/or hyphens. For example, `1,3,5-7` specifies units 1, 3, 5, 6, and 7.
### Total U's
A calculated (read-only) field that reflects the total number of units in the reservation. Can be filtered upon using `unit_count_min` and `unit_count_max` parameters in the UI or API.
### Status
The current status of the reservation. (This is for documentation only: The status of a reservation has no impact on the installation of devices within a reserved rack unit.)

View File

@@ -118,3 +118,7 @@ For numeric custom fields only. The maximum valid value (optional).
### Validation Regex
For string-based custom fields only. A regular expression used to validate the field's value (optional).
### Validation Schema
For JSON custom fields, users have the option of defining a [validation schema](https://json-schema.org). Any value applied to this custom field on a model will be validated against the provided schema, if any.

View File

@@ -77,14 +77,17 @@ 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 (`create`, `update`, or `delete`) |
| `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 |
!!! warning "Deprecation of legacy fields"
The `request_id` and `username` fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0. Use `request.user` and `request.id` from the `request` object included in the callback context instead. (Note that `request` is populated in the context only when the webhook is associated with a triggering request.)

View File

@@ -14,6 +14,10 @@ The 16- or 32-bit AS number.
The [Regional Internet Registry](./rir.md) or similar authority responsible for the allocation of this particular ASN.
### Role
The user-defined functional [role](./role.md) assigned to this ASN.
### Sites
The [site(s)](../dcim/site.md) to which this ASN is assigned.

View File

@@ -18,6 +18,10 @@ A unique URL-friendly identifier. (This value can be used for filtering.)
The set of VLAN IDs which are encompassed by the group. By default, this will be the entire range of valid IEEE 802.1Q VLAN IDs (1 to 4094, inclusive). VLANs created within a group must have a VID that falls within one of these ranges. Ranges may not overlap.
### Total VLAN IDs
A read-only integer indicating the total count of VLAN IDs available within the group, calculated from the configured VLAN ID Ranges. For example, a group with ranges `100-199` and `300-399` would have a total of 200 VLAN IDs. This value is automatically computed and updated whenever the VLAN ID ranges are modified.
### Scope
The domain covered by a VLAN group, defined as one of the supported object types. This conveys the context in which a VLAN group applies.

View File

@@ -1,18 +1,27 @@
# Virtual Machines
A virtual machine (VM) represents a virtual compute instance hosted within a [cluster](./cluster.md). Each VM must be assigned to a [site](../dcim/site.md) and/or cluster, and may optionally be assigned to a particular host [device](../dcim/device.md) within a cluster.
A virtual machine (VM) represents a virtual compute instance hosted within a cluster or directly on a device. Each VM must be assigned to at least one of: a [site](../dcim/site.md), a [cluster](./cluster.md), or a [device](../dcim/device.md).
Virtual machines may have virtual [interfaces](./vminterface.md) assigned to them, but do not support any physical component. When a VM has one or more interfaces with IP addresses assigned, a primary IP for the device can be designated, for both IPv4 and IPv6.
Virtual machines may have virtual [interfaces](./vminterface.md) assigned to them, but do not support any physical component. When a VM has one or more interfaces with IP addresses assigned, a primary IP for the VM can be designated, for both IPv4 and IPv6.
## Fields
### Name
The virtual machine's configured name. Must be unique to the assigned cluster and tenant.
The virtual machine's configured name. Must be unique within its scoping context:
- If assigned to a **cluster**: unique within the cluster and tenant.
- If assigned to a **device** (no cluster): unique within the device and tenant.
### Type
The [virtual machine type](./virtualmachinetype.md) assigned to the VM. A type classifies a virtual machine and can provide default values for platform, vCPUs, and memory when the VM is created.
Changes made to a virtual machine type do **not** apply retroactively to existing virtual machines.
### Role
The functional [role](../dcim/devicerole.md) assigned to the VM.
The functional role assigned to the VM.
### Status
@@ -21,24 +30,28 @@ The VM's operational status.
!!! tip
Additional statuses may be defined by setting `VirtualMachine.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
### Start on boot
### Start on Boot
The start on boot setting from the hypervisor.
!!! tip
Additional statuses may be defined by setting `VirtualMachine.start_on_boot` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
### Site & Cluster
### Site / Cluster / Device
The [site](../dcim/site.md) and/or [cluster](./cluster.md) to which the VM is assigned.
The location or host for this VM. At least one must be specified:
### Device
- **Site only**: The VM exists at a site but is not assigned to a specific cluster or device.
- **Cluster only**: The VM belongs to a virtualization cluster. The site is automatically inferred from the cluster's scope.
- **Device only**: The VM runs directly on a physical host device without a cluster (e.g. containers). The site is automatically inferred from the device's site.
- **Cluster + Device**: The VM belongs to a cluster and is pinned to a specific host device within that cluster. The device must be a registered host of the assigned cluster.
The physical host [device](../dcim/device.md) within the assigned site/cluster on which this VM resides.
!!! info "New in NetBox v4.6"
Virtual machines can now be assigned directly to a device without requiring a cluster. This is particularly useful for modeling VMs running on standalone hosts outside of a cluster.
### Platform
A VM may be associated with a particular [platform](../dcim/platform.md) to indicate its operating system.
A VM may be associated with a particular [platform](../dcim/platform.md) to indicate its operating system. If a virtual machine type defines a default platform, it will be applied when the VM is created unless an explicit platform is specified.
### Primary IPv4 & IPv6 Addresses
@@ -49,11 +62,11 @@ Each VM may designate one primary IPv4 address and/or one primary IPv6 address f
### vCPUs
The number of virtual CPUs provisioned. A VM may be allocated a partial vCPU count (e.g. 1.5 vCPU).
The number of virtual CPUs provisioned. A VM may be allocated a partial vCPU count (e.g. 1.5 vCPU). If a virtual machine type defines a default vCPU allocation, it will be applied when the VM is created unless an explicit value is specified.
### Memory
The amount of running memory provisioned, in megabytes.
The amount of running memory provisioned, in megabytes. If a virtual machine type defines a default memory allocation, it will be applied when the VM is created unless an explicit value is specified.
### Disk
@@ -64,4 +77,7 @@ The amount of disk storage provisioned, in megabytes.
### Serial Number
Optional serial number assigned to this virtual machine. Unlike devices, uniqueness is not enforced for virtual machine serial numbers.
Optional serial number assigned to this virtual machine.
!!! info
Unlike devices, uniqueness is not enforced for virtual machine serial numbers.

View File

@@ -0,0 +1,27 @@
# Virtual Machine Types
A virtual machine type defines a reusable classification and default configuration for [virtual machines](./virtualmachine.md).
A type can optionally provide default values for a VM's [platform](../dcim/platform.md), vCPU allocation, and memory allocation. When a virtual machine is created with an assigned type, any unset values among these fields will inherit their defaults from the type. Changes made to a virtual machine type do **not** apply retroactively to existing virtual machines.
## Fields
### Name
A unique human-friendly name.
### Slug
A unique URL-friendly identifier. (This value can be used for filtering.)
### Default Platform
If defined, virtual machines instantiated with this type will automatically inherit the selected platform when no explicit platform is provided.
### Default vCPUs
The default number of vCPUs to assign when creating a virtual machine from this type.
### Default Memory
The default amount of memory, in megabytes, to assign when creating a virtual machine from this type.

View File

@@ -1,12 +1,14 @@
# Search
Plugins can define and register their own models to extend NetBox's core search functionality. Typically, a plugin will include a file named `search.py`, which holds all search indexes for its models (see the example below).
Plugins can define and register their own models to extend NetBox's core search functionality. Typically, a plugin will include a file named `search.py`, which holds all search indexes for its models.
```python
```python title="search.py"
# search.py
from netbox.search import SearchIndex
from netbox.search import SearchIndex, register_search
from .models import MyModel
@register_search
class MyModelIndex(SearchIndex):
model = MyModel
fields = (
@@ -17,15 +19,11 @@ class MyModelIndex(SearchIndex):
display_attrs = ('site', 'device', 'status', 'description')
```
Fields listed in `display_attrs` will not be cached for search, but will be displayed alongside the object when it appears in global search results. This is helpful for conveying to the user additional information about an object.
Decorate each `SearchIndex` subclass with `@register_search` to register it with NetBox. When using the default `search.py` module, no additional `indexes = [...]` list is required.
To register one or more indexes with NetBox, define a list named `indexes` at the end of this file:
```python
indexes = [MyModelIndex]
```
Fields listed in `display_attrs` are not cached for matching, but they are displayed alongside the object in global search results to provide additional context.
!!! tip
The path to the list of search indexes can be modified by setting `search_indexes` in the PluginConfig instance.
The legacy `indexes = [...]` list remains supported via `PluginConfig.search_indexes` for backward compatibility and custom loading patterns.
::: netbox.search.SearchIndex

View File

@@ -43,6 +43,11 @@ The resulting webhook payload will look like the following:
}
```
!!! warning "Deprecation of legacy fields"
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.
!!! note "Consider namespacing webhook data"
The data returned from all webhook callbacks will be compiled into a single `context` dictionary. Any existing keys within this dictionary will be overwritten by subsequent callbacks which include those keys. To avoid collisions with webhook data provided by other plugins, consider namespacing your plugin's data within a nested dictionary as such:

View File

@@ -1,5 +1,93 @@
# NetBox v4.5
## v4.5.6 (2026-03-31)
### Enhancements
* [#21480](https://github.com/netbox-community/netbox/issues/21480) - Add OSFP224 (1.6T) interface type
* [#21727](https://github.com/netbox-community/netbox/issues/21727) - Add 2.5GBASE-X SFP modular interface type
* [#21743](https://github.com/netbox-community/netbox/issues/21743) - Improve object change diff styling and layout
* [#21793](https://github.com/netbox-community/netbox/issues/21793) - Add 50 Gbps, 800 Gbps, and 1.6 Tbps interface speed options
### Bug Fixes
* [#20467](https://github.com/netbox-community/netbox/issues/20467) - Fix resolution of the `{module}` variable for position fields in nested modules
* [#21698](https://github.com/netbox-community/netbox/issues/21698) - Adjust custom field URL filter to support non-standard port numbers
* [#21707](https://github.com/netbox-community/netbox/issues/21707) - Fix grouping of owner fields in provider account add/edit forms
* [#21749](https://github.com/netbox-community/netbox/issues/21749) - Fix `FieldError` exception when sorting the circuit group assignment table by the member column
* [#21763](https://github.com/netbox-community/netbox/issues/21763) - Use separate add/remove form fields when editing a site or provider with a large number of ASNs assigned
---
## v4.5.5 (2026-03-17)
### Enhancements
* [#21114](https://github.com/netbox-community/netbox/issues/21114) - Support path exclusions for data source synchronization
* [#21578](https://github.com/netbox-community/netbox/issues/21578) - Support identifying scope object by name or slug when bulk importing scoped objects
### Performance Improvements
* [#21330](https://github.com/netbox-community/netbox/issues/21330) - Optimize the assignment of tags when saving objects
* [#21402](https://github.com/netbox-community/netbox/issues/21402) - Avoid excessive database queries when rendering unnamed devices via the REST API
* [#21611](https://github.com/netbox-community/netbox/issues/21611) - Replace inefficient calls to `.count()` with `.exists()`
### Bug Fixes
* [#19867](https://github.com/netbox-community/netbox/issues/19867) - Preserve the "per page" pagination setting when returning from object edit forms
* [#20077](https://github.com/netbox-community/netbox/issues/20077) - Fix form field focus bug in Microsoft Edge
* [#20385](https://github.com/netbox-community/netbox/issues/20385) - Enforce `MAX_PAGE_SIZE` limit for GraphQL API requests
* [#20468](https://github.com/netbox-community/netbox/issues/20468) - Fix range-based filter lookups for integer fields in GraphQL API
* [#20915](https://github.com/netbox-community/netbox/issues/20915) - Restore user language preference after login via social authentication
* [#20934](https://github.com/netbox-community/netbox/issues/20934) - Fix dark mode flicker on page load
* [#21012](https://github.com/netbox-community/netbox/issues/21012) - Add pagination for VLAN table on interface view to prevent silent truncation at 100 entries
* [#21380](https://github.com/netbox-community/netbox/issues/21380) - Fix display of the background tasks table on mobile
* [#21440](https://github.com/netbox-community/netbox/issues/21440) - Avoid erroneously clearing primary/OOB IP assignments during bulk import/update
* [#21468](https://github.com/netbox-community/netbox/issues/21468) - Preserve safe custom HTTP headers when copying requests for background job processing
* [#21486](https://github.com/netbox-community/netbox/issues/21486) - Fix `AttributeError` exception caused by missing `COOKIES` attribute on `NetBoxFakeRequest`
* [#21512](https://github.com/netbox-community/netbox/issues/21512) - Fix GraphQL filter field name mismatch for device component types (e.g. `console_ports`)
* [#21531](https://github.com/netbox-community/netbox/issues/21531) - Fix search functionality for location when combined with other filters
* [#21556](https://github.com/netbox-community/netbox/issues/21556) - Avoid clearing the platform field when changing device type in the device edit form
* [#21579](https://github.com/netbox-community/netbox/issues/21579) - Hide the script "Add" button for users lacking the required permission
* [#21580](https://github.com/netbox-community/netbox/issues/21580) - Hide the virtual machine "Add components" dropdown for users lacking change permission
* [#21586](https://github.com/netbox-community/netbox/issues/21586) - Fix broken "Add child group" link in site group view (was pointing to the region endpoint)
* [#21618](https://github.com/netbox-community/netbox/issues/21618) - Fix cable termination points being lost when bulk-editing the cable profile
* [#21651](https://github.com/netbox-community/netbox/issues/21651) - Disable sorting by the `is_primary` column in the MAC address list view
* [#21653](https://github.com/netbox-community/netbox/issues/21653) - Fix profile-based cable tracing when a single origin carries multiple positions
* [#21673](https://github.com/netbox-community/netbox/issues/21673) - Fix display of primary IP address with associated NAT IP on virtual machine view
* [#21686](https://github.com/netbox-community/netbox/issues/21686) - Clean up cached circuit attributes when reassigning a circuit termination
---
## v4.5.4 (2026-03-03)
### Enhancements
* [#21369](https://github.com/netbox-community/netbox/issues/21369) - Support lazy-loading of image attachments
* [#21385](https://github.com/netbox-community/netbox/issues/21385) - Add contact assignment support for virtual circuits
* [#21394](https://github.com/netbox-community/netbox/issues/21394) - Add 10GBASE-CU and 40GBASE-SR4 BiDi interface types
* [#21477](https://github.com/netbox-community/netbox/issues/21477) - Extend GraphQL API filters for cables
### Performance Improvements
* [#21456](https://github.com/netbox-community/netbox/issues/21456) - Improve performance of config context resolution via GraphQL API
* [#21459](https://github.com/netbox-community/netbox/issues/21459) - Avoid prefetching data for hidden table columns
### Bug Fixes
* [#20490](https://github.com/netbox-community/netbox/issues/20490) - Restrict visibility of scripts in list view to users with view permission
* [#20911](https://github.com/netbox-community/netbox/issues/20911) - Sort module bay options alphabetically when installing a module
* [#21347](https://github.com/netbox-community/netbox/issues/21347) - The allocation of IPv6 addresses from a non-pool prefix should start at one, not zero
* [#21429](https://github.com/netbox-community/netbox/issues/21429) - Termination type should persist when employing "create & add another" workflow for cables
* [#21478](https://github.com/netbox-community/netbox/issues/21478) - Fix GraphQL union type resolution for connected console ports
* [#21481](https://github.com/netbox-community/netbox/issues/21481) - Fix display of facility ID on rack view
* [#21518](https://github.com/netbox-community/netbox/issues/21518) - Fix decimal custom field displaying as unset when value is zero
* [#21524](https://github.com/netbox-community/netbox/issues/21524) - Avoid `IndexError` exception when encountering stale cable paths
* [#21527](https://github.com/netbox-community/netbox/issues/21527) - Fix display of primary IP address with associated NAT IP on device view
* [#21550](https://github.com/netbox-community/netbox/issues/21550) - Ensure pre-change snapshots are recorded for related objects
---
## v4.5.3 (2026-02-17)
### Enhancements

View File

@@ -1,3 +1,4 @@
# Note: NetBox has migrated from MkDocs to Zensical
site_name: NetBox Documentation
site_dir: netbox/project-static/docs
site_url: https://docs.netbox.dev/
@@ -189,6 +190,7 @@ nav:
- Job: 'models/core/job.md'
- DCIM:
- Cable: 'models/dcim/cable.md'
- CableBundle: 'models/dcim/cablebundle.md'
- ConsolePort: 'models/dcim/consoleport.md'
- ConsolePortTemplate: 'models/dcim/consoleporttemplate.md'
- ConsoleServerPort: 'models/dcim/consoleserverport.md'
@@ -221,6 +223,7 @@ nav:
- PowerPort: 'models/dcim/powerport.md'
- PowerPortTemplate: 'models/dcim/powerporttemplate.md'
- Rack: 'models/dcim/rack.md'
- RackGroup: 'models/dcim/rackgroup.md'
- RackReservation: 'models/dcim/rackreservation.md'
- RackRole: 'models/dcim/rackrole.md'
- RackType: 'models/dcim/racktype.md'
@@ -285,6 +288,7 @@ nav:
- VMInterface: 'models/virtualization/vminterface.md'
- VirtualDisk: 'models/virtualization/virtualdisk.md'
- VirtualMachine: 'models/virtualization/virtualmachine.md'
- VirtualMachineType: 'models/virtualization/virtualmachinetype.md'
- VPN:
- IKEPolicy: 'models/vpn/ikepolicy.md'
- IKEProposal: 'models/vpn/ikeproposal.md'

View File

@@ -22,7 +22,7 @@ from utilities.forms.fields import (
SlugField,
)
from utilities.forms.mixins import DistanceValidationMixin
from utilities.forms.rendering import FieldSet, InlineFields
from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields
from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
from utilities.templatetags.builtins.filters import bettertitle
@@ -48,17 +48,42 @@ class ProviderForm(PrimaryModelForm):
label=_('ASNs'),
required=False
)
add_asns = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
label=_('Add ASNs'),
required=False
)
remove_asns = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
label=_('Remove ASNs'),
required=False
)
fieldsets = (
FieldSet('name', 'slug', 'asns', 'description', 'tags'),
FieldSet('name', 'slug', M2MAddRemoveFields('asns'), 'description', 'tags'),
)
class Meta:
model = Provider
fields = [
'name', 'slug', 'asns', 'description', 'owner', 'comments', 'tags',
'name', 'slug', 'description', 'owner', 'comments', 'tags',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk and (count := self.instance.asns.count()) >= M2MAddRemoveFields.THRESHOLD:
# Add/remove mode for large M2M sets
self.fields.pop('asns')
self.fields['add_asns'].widget.add_query_param('provider_id__n', self.instance.pk)
self.fields['remove_asns'].widget.add_query_param('provider_id', self.instance.pk)
self.fields['remove_asns'].help_text = _("{count} ASNs currently assigned").format(count=count)
else:
# Simple mode for new objects or small M2M sets
self.fields.pop('add_asns')
self.fields.pop('remove_asns')
if self.instance.pk:
self.initial['asns'] = list(self.instance.asns.values_list('pk', flat=True))
class ProviderAccountForm(PrimaryModelForm):
provider = DynamicModelChoiceField(
@@ -68,10 +93,14 @@ class ProviderAccountForm(PrimaryModelForm):
quick_add=True
)
fieldsets = (
FieldSet('provider', 'account', 'name', 'description', 'tags'),
)
class Meta:
model = ProviderAccount
fields = [
'provider', 'name', 'account', 'description', 'owner', 'comments', 'tags',
'provider', 'account', 'name', 'description', 'owner', 'comments', 'tags',
]

View File

@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Annotated
import strawberry
import strawberry_django
from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, DateFilterLookup, FilterLookup
from strawberry_django import BaseFilterLookup, DateFilterLookup, StrFilterLookup
from circuits import models
from circuits.graphql.filter_mixins import CircuitTypeFilterMixin
@@ -62,9 +62,9 @@ class CircuitTerminationFilter(
upstream_speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
xconnect_id: FilterLookup[str] | None = strawberry_django.filter_field()
pp_info: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
xconnect_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
pp_info: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
# Cached relations
_provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@@ -92,7 +92,7 @@ class CircuitFilter(
TenancyFilterMixin,
PrimaryModelFilter
):
cid: FilterLookup[str] | None = strawberry_django.filter_field()
cid: StrFilterLookup[str] | None = strawberry_django.filter_field()
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -145,8 +145,8 @@ class CircuitGroupAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, Cha
@strawberry_django.filter_type(models.Provider, lookups=True)
class ProviderFilter(ContactFilterMixin, PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
circuits: Annotated['CircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -159,18 +159,18 @@ class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilter):
strawberry_django.filter_field()
)
provider_id: ID | None = strawberry_django.filter_field()
account: FilterLookup[str] | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
account: StrFilterLookup[str] | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ProviderNetwork, lookups=True)
class ProviderNetworkFilter(PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field()
)
provider_id: ID | None = strawberry_django.filter_field()
service_id: FilterLookup[str] | None = strawberry_django.filter_field()
service_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.VirtualCircuitType, lookups=True)
@@ -180,7 +180,7 @@ class VirtualCircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter
@strawberry_django.filter_type(models.VirtualCircuit, lookups=True)
class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilter):
cid: FilterLookup[str] | None = strawberry_django.filter_field()
cid: StrFilterLookup[str] | None = strawberry_django.filter_field()
provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -218,4 +218,4 @@ class VirtualCircuitTerminationFilter(CustomFieldsFilterMixin, TagsFilterMixin,
strawberry_django.filter_field()
)
interface_id: ID | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()

View File

@@ -347,6 +347,13 @@ class CircuitTermination(
verbose_name = _('circuit termination')
verbose_name_plural = _('circuit terminations')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Cache original values to detect changes
self._orig_circuit_id = self.__dict__.get('circuit_id')
self._orig_term_side = self.__dict__.get('term_side')
def __str__(self):
return f'{self.circuit}: Termination {self.term_side}'
@@ -360,11 +367,39 @@ class CircuitTermination(
raise ValidationError(_("A circuit termination must attach to a terminating object."))
def save(self, *args, **kwargs):
is_new = self._state.adding
update_fields = kwargs.get('update_fields')
# Only consider circuit/term_side changes if those fields
# are actually being persisted
if update_fields is not None:
tracking_relevant = 'circuit' in update_fields or 'term_side' in update_fields
else:
tracking_relevant = True
circuit_changed = tracking_relevant and self._orig_circuit_id and self._orig_circuit_id != self.circuit_id
term_side_changed = tracking_relevant and self._orig_term_side and self._orig_term_side != self.term_side
# Cache objects associated with the terminating object (for filtering)
self.cache_related_objects()
super().save(*args, **kwargs)
# Clear the old termination reference if circuit or term_side changed
if circuit_changed or term_side_changed:
old_termination_name = f'termination_{self._orig_term_side.lower()}'
Circuit.objects.filter(pk=self._orig_circuit_id).update(**{old_termination_name: None})
# Update the cache if this is a new termination or circuit/term_side changed
if is_new or circuit_changed or term_side_changed:
# Update the new circuit's termination reference
termination_name = f'termination_{self.term_side.lower()}'
Circuit.objects.filter(pk=self.circuit_id).update(**{termination_name: self.pk})
# Update cached values for subsequent saves
self._orig_circuit_id = self.circuit_id
self._orig_term_side = self.term_side
def cache_related_objects(self):
self._provider_network = self._region = self._site_group = self._site = self._location = None
if self.termination_type:

View File

@@ -6,17 +6,6 @@ from dcim.signals import rebuild_paths
from .models import CircuitTermination
@receiver(post_save, sender=CircuitTermination)
def update_circuit(instance, **kwargs):
"""
When a CircuitTermination has been modified, update its parent Circuit.
"""
termination_name = f'termination_{instance.term_side.lower()}'
instance.circuit.refresh_from_db()
setattr(instance.circuit, termination_name, instance)
instance.circuit.save()
@receiver((post_save, post_delete), sender=CircuitTermination)
def rebuild_cablepaths(instance, raw=False, **kwargs):
"""

View File

@@ -190,14 +190,16 @@ class CircuitGroupAssignmentTable(NetBoxTable):
provider = tables.Column(
accessor='member__provider',
verbose_name=_('Provider'),
linkify=True
orderable=False,
linkify=True,
)
member_type = columns.ContentTypeColumn(
verbose_name=_('Type')
)
member = tables.Column(
verbose_name=_('Circuit'),
linkify=True
orderable=False,
linkify=True,
)
priority = tables.Column(
verbose_name=_('Priority'),

View File

@@ -0,0 +1,148 @@
from django.test import TestCase
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider, ProviderNetwork
from dcim.models import Site
class CircuitTerminationTestCase(TestCase):
@classmethod
def setUpTestData(cls):
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
cls.sites = (
Site.objects.create(name='Site 1', slug='site-1'),
Site.objects.create(name='Site 2', slug='site-2'),
)
cls.circuits = (
Circuit.objects.create(cid='Circuit 1', provider=provider, type=circuit_type),
Circuit.objects.create(cid='Circuit 2', provider=provider, type=circuit_type),
)
cls.provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider)
def test_circuit_termination_creation_populates_circuit_cache(self):
"""
When a CircuitTermination is created, the parent Circuit's termination_a or termination_z
cache field should be populated.
"""
# Create A termination
termination_a = CircuitTermination.objects.create(
circuit=self.circuits[0],
term_side='A',
termination=self.sites[0],
)
self.circuits[0].refresh_from_db()
self.assertEqual(self.circuits[0].termination_a, termination_a)
self.assertIsNone(self.circuits[0].termination_z)
# Create Z termination
termination_z = CircuitTermination.objects.create(
circuit=self.circuits[0],
term_side='Z',
termination=self.sites[1],
)
self.circuits[0].refresh_from_db()
self.assertEqual(self.circuits[0].termination_a, termination_a)
self.assertEqual(self.circuits[0].termination_z, termination_z)
def test_circuit_termination_circuit_change_clears_old_cache(self):
"""
When a CircuitTermination's circuit is changed, the old Circuit's cache should be cleared
and the new Circuit's cache should be populated.
"""
# Create termination on self.circuits[0]
termination = CircuitTermination.objects.create(
circuit=self.circuits[0],
term_side='A',
termination=self.sites[0],
)
self.circuits[0].refresh_from_db()
self.assertEqual(self.circuits[0].termination_a, termination)
# Move termination to self.circuits[1]
termination.circuit = self.circuits[1]
termination.save()
self.circuits[0].refresh_from_db()
self.circuits[1].refresh_from_db()
# Old circuit's cache should be cleared
self.assertIsNone(self.circuits[0].termination_a)
# New circuit's cache should be populated
self.assertEqual(self.circuits[1].termination_a, termination)
def test_circuit_termination_term_side_change_clears_old_cache(self):
"""
When a CircuitTermination's term_side is changed, the old side's cache should be cleared
and the new side's cache should be populated.
"""
# Create A termination
termination = CircuitTermination.objects.create(
circuit=self.circuits[0],
term_side='A',
termination=self.sites[0],
)
self.circuits[0].refresh_from_db()
self.assertEqual(self.circuits[0].termination_a, termination)
self.assertIsNone(self.circuits[0].termination_z)
# Change from A to Z
termination.term_side = 'Z'
termination.save()
self.circuits[0].refresh_from_db()
# A side should be cleared, Z side should be populated
self.assertIsNone(self.circuits[0].termination_a)
self.assertEqual(self.circuits[0].termination_z, termination)
def test_circuit_termination_circuit_and_term_side_change(self):
"""
When both circuit and term_side are changed, the old Circuit's old side cache should be
cleared and the new Circuit's new side cache should be populated.
"""
# Create A termination on self.circuits[0]
termination = CircuitTermination.objects.create(
circuit=self.circuits[0],
term_side='A',
termination=self.sites[0],
)
self.circuits[0].refresh_from_db()
self.assertEqual(self.circuits[0].termination_a, termination)
# Change to self.circuits[1] Z side
termination.circuit = self.circuits[1]
termination.term_side = 'Z'
termination.save()
self.circuits[0].refresh_from_db()
self.circuits[1].refresh_from_db()
# Old circuit's A side should be cleared
self.assertIsNone(self.circuits[0].termination_a)
self.assertIsNone(self.circuits[0].termination_z)
# New circuit's Z side should be populated
self.assertIsNone(self.circuits[1].termination_a)
self.assertEqual(self.circuits[1].termination_z, termination)
def test_circuit_termination_deletion_clears_cache(self):
"""
When a CircuitTermination is deleted, the parent Circuit's cache should be cleared.
"""
termination = CircuitTermination.objects.create(
circuit=self.circuits[0],
term_side='A',
termination=self.sites[0],
)
self.circuits[0].refresh_from_db()
self.assertEqual(self.circuits[0].termination_a, termination)
# Delete the termination
termination.delete()
self.circuits[0].refresh_from_db()
# Cache should be cleared (SET_NULL behavior)
self.assertIsNone(self.circuits[0].termination_a)

View File

@@ -1,23 +1,48 @@
from django.test import RequestFactory, TestCase, tag
from circuits.models import CircuitTermination
from circuits.tables import CircuitTerminationTable
from circuits.models import CircuitGroupAssignment, CircuitTermination
from circuits.tables import CircuitGroupAssignmentTable, CircuitTerminationTable
@tag('regression')
class CircuitTerminationTableTest(TestCase):
def test_every_orderable_field_does_not_throw_exception(self):
terminations = CircuitTermination.objects.all()
disallowed = {'actions', }
disallowed = {
'actions',
}
orderable_columns = [
column.name for column in CircuitTerminationTable(terminations).columns
column.name
for column in CircuitTerminationTable(terminations).columns
if column.orderable and column.name not in disallowed
]
fake_request = RequestFactory().get("/")
fake_request = RequestFactory().get('/')
for col in orderable_columns:
for dir in ('-', ''):
for direction in ('-', ''):
table = CircuitTerminationTable(terminations)
table.order_by = f'{dir}{col}'
table.order_by = f'{direction}{col}'
table.as_html(fake_request)
@tag('regression')
class CircuitGroupAssignmentTableTest(TestCase):
def test_every_orderable_field_does_not_throw_exception(self):
assignment = CircuitGroupAssignment.objects.all()
disallowed = {
'actions',
}
orderable_columns = [
column.name
for column in CircuitGroupAssignmentTable(assignment).columns
if column.orderable and column.name not in disallowed
]
fake_request = RequestFactory().get('/')
for col in orderable_columns:
for direction in ('-', ''):
table = CircuitGroupAssignmentTable(assignment)
table.order_by = f'{direction}{col}'
table.as_html(fake_request)

View File

View File

@@ -0,0 +1,139 @@
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _
from netbox.ui import actions, attrs, panels
from utilities.data import resolve_attr_path
class CircuitCircuitTerminationPanel(panels.ObjectPanel):
"""
A panel showing the CircuitTermination assigned to the object.
"""
template_name = 'circuits/panels/circuit_circuit_termination.html'
title = _('Termination')
def __init__(self, accessor=None, side=None, **kwargs):
super().__init__(**kwargs)
if accessor is not None:
self.accessor = accessor
if side is not None:
self.side = side
def get_context(self, context):
return {
**super().get_context(context),
'side': self.side,
'termination': resolve_attr_path(context, f'{self.accessor}.termination_{self.side.lower()}'),
}
class CircuitGroupAssignmentsPanel(panels.ObjectsTablePanel):
"""
A panel showing all Circuit Groups attached to the object.
"""
title = _('Group Assignments')
actions = [
actions.AddObject(
'circuits.CircuitGroupAssignment',
url_params={
'member_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
'member': lambda ctx: ctx['object'].pk,
'return_url': lambda ctx: ctx['object'].get_absolute_url(),
},
label=_('Assign Group'),
),
]
def __init__(self, **kwargs):
super().__init__(
'circuits.CircuitGroupAssignment',
filters={
'member_type_id': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
'member_id': lambda ctx: ctx['object'].pk,
},
**kwargs,
)
class CircuitGroupPanel(panels.OrganizationalObjectPanel):
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
class CircuitGroupAssignmentPanel(panels.ObjectAttributesPanel):
group = attrs.RelatedObjectAttr('group', linkify=True)
provider = attrs.RelatedObjectAttr('member.provider', linkify=True)
member = attrs.GenericForeignKeyAttr('member', linkify=True)
priority = attrs.ChoiceAttr('priority')
class CircuitPanel(panels.ObjectAttributesPanel):
provider = attrs.RelatedObjectAttr('provider', linkify=True)
provider_account = attrs.RelatedObjectAttr('provider_account', linkify=True)
cid = attrs.TextAttr('cid', label=_('Circuit ID'), style='font-monospace', copy_button=True)
type = attrs.RelatedObjectAttr('type', linkify=True)
status = attrs.ChoiceAttr('status')
distance = attrs.NumericAttr('distance', unit_accessor='get_distance_unit_display')
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
install_date = attrs.DateTimeAttr('install_date', spec='date')
termination_date = attrs.DateTimeAttr('termination_date', spec='date')
commit_rate = attrs.TemplatedAttr('commit_rate', template_name='circuits/circuit/attrs/commit_rate.html')
description = attrs.TextAttr('description')
class CircuitTypePanel(panels.OrganizationalObjectPanel):
color = attrs.ColorAttr('color')
class ProviderPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
asns = attrs.RelatedObjectListAttr('asns', linkify=True, label=_('ASNs'))
description = attrs.TextAttr('description')
class ProviderAccountPanel(panels.ObjectAttributesPanel):
provider = attrs.RelatedObjectAttr('provider', linkify=True)
account = attrs.TextAttr('account', style='font-monospace', copy_button=True)
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
class ProviderNetworkPanel(panels.ObjectAttributesPanel):
provider = attrs.RelatedObjectAttr('provider', linkify=True)
name = attrs.TextAttr('name')
service_id = attrs.TextAttr('service_id', label=_('Service ID'), style='font-monospace', copy_button=True)
description = attrs.TextAttr('description')
class VirtualCircuitTypePanel(panels.OrganizationalObjectPanel):
color = attrs.ColorAttr('color')
class VirtualCircuitPanel(panels.ObjectAttributesPanel):
provider = attrs.RelatedObjectAttr('provider', linkify=True)
provider_network = attrs.RelatedObjectAttr('provider_network', linkify=True)
provider_account = attrs.RelatedObjectAttr('provider_account', linkify=True)
cid = attrs.TextAttr('cid', label=_('Circuit ID'), style='font-monospace', copy_button=True)
type = attrs.RelatedObjectAttr('type', linkify=True)
status = attrs.ChoiceAttr('status')
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
description = attrs.TextAttr('description')
class VirtualCircuitTerminationPanel(panels.ObjectAttributesPanel):
provider = attrs.RelatedObjectAttr('virtual_circuit.provider', linkify=True)
provider_network = attrs.RelatedObjectAttr('virtual_circuit.provider_network', linkify=True)
provider_account = attrs.RelatedObjectAttr('virtual_circuit.provider_account', linkify=True)
virtual_circuit = attrs.RelatedObjectAttr('virtual_circuit', linkify=True)
role = attrs.ChoiceAttr('role')
class VirtualCircuitTerminationInterfacePanel(panels.ObjectAttributesPanel):
title = _('Interface')
device = attrs.RelatedObjectAttr('interface.device', linkify=True)
interface = attrs.RelatedObjectAttr('interface', linkify=True)
type = attrs.ChoiceAttr('interface.type')
description = attrs.TextAttr('interface.description')

View File

@@ -1,13 +1,23 @@
from django.utils.translation import gettext_lazy as _
from dcim.views import PathTraceView
from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
from ipam.models import ASN
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
from netbox.ui import actions, layout
from netbox.ui.panels import (
CommentsPanel,
ObjectsTablePanel,
Panel,
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
#
# Providers
@@ -29,6 +39,35 @@ class ProviderListView(generic.ObjectListView):
@register_model_view(Provider)
class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Provider.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ProviderPanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CustomFieldsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='circuits.ProviderAccount',
filters={'provider_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject(
'circuits.ProviderAccount', url_params={'provider': lambda ctx: ctx['object'].pk}
),
],
),
ObjectsTablePanel(
model='circuits.Circuit',
filters={'provider_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject('circuits.Circuit', url_params={'provider': lambda ctx: ctx['object'].pk}),
],
),
],
)
def get_extra_context(self, request, instance):
return {
@@ -44,7 +83,7 @@ class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
'provider_id',
),
),
),
),
}
@@ -108,6 +147,32 @@ class ProviderAccountListView(generic.ObjectListView):
@register_model_view(ProviderAccount)
class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ProviderAccount.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ProviderAccountPanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CommentsPanel(),
CustomFieldsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='circuits.Circuit',
filters={'provider_account_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject(
'circuits.Circuit',
url_params={
'provider': lambda ctx: ctx['object'].provider.pk,
'provider_account': lambda ctx: ctx['object'].pk,
},
),
],
),
],
)
def get_extra_context(self, request, instance):
return {
@@ -174,6 +239,32 @@ class ProviderNetworkListView(generic.ObjectListView):
@register_model_view(ProviderNetwork)
class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ProviderNetwork.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ProviderNetworkPanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CommentsPanel(),
CustomFieldsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='circuits.Circuit',
filters={'provider_network_id': lambda ctx: ctx['object'].pk},
),
ObjectsTablePanel(
model='circuits.VirtualCircuit',
filters={'provider_network_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject(
'circuits.VirtualCircuit', url_params={'provider_network': lambda ctx: ctx['object'].pk}
),
],
),
],
)
def get_extra_context(self, request, instance):
return {
@@ -251,6 +342,17 @@ class CircuitTypeListView(generic.ObjectListView):
@register_model_view(CircuitType)
class CircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = CircuitType.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.CircuitTypePanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CommentsPanel(),
CustomFieldsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
@@ -318,6 +420,20 @@ class CircuitListView(generic.ObjectListView):
@register_model_view(Circuit)
class CircuitView(generic.ObjectView):
queryset = Circuit.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.CircuitPanel(),
panels.CircuitGroupAssignmentsPanel(),
CustomFieldsPanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
panels.CircuitCircuitTerminationPanel(side='A'),
panels.CircuitCircuitTerminationPanel(side='Z'),
ImageAttachmentsPanel(),
],
)
@register_model_view(Circuit, 'add', detail=False)
@@ -390,6 +506,18 @@ class CircuitTerminationListView(generic.ObjectListView):
@register_model_view(CircuitTermination)
class CircuitTerminationView(generic.ObjectView):
queryset = CircuitTermination.objects.all()
layout = layout.SimpleLayout(
left_panels=[
Panel(
template_name='circuits/panels/circuit_termination.html',
title=_('Circuit Termination'),
)
],
right_panels=[
CustomFieldsPanel(),
TagsPanel(),
],
)
@register_model_view(CircuitTermination, 'add', detail=False)
@@ -446,6 +574,17 @@ class CircuitGroupListView(generic.ObjectListView):
@register_model_view(CircuitGroup)
class CircuitGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = CircuitGroup.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.CircuitGroupPanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CommentsPanel(),
CustomFieldsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
@@ -508,6 +647,15 @@ class CircuitGroupAssignmentListView(generic.ObjectListView):
@register_model_view(CircuitGroupAssignment)
class CircuitGroupAssignmentView(generic.ObjectView):
queryset = CircuitGroupAssignment.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.CircuitGroupAssignmentPanel(),
TagsPanel(),
],
right_panels=[
CustomFieldsPanel(),
],
)
@register_model_view(CircuitGroupAssignment, 'add', detail=False)
@@ -560,6 +708,17 @@ class VirtualCircuitTypeListView(generic.ObjectListView):
@register_model_view(VirtualCircuitType)
class VirtualCircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VirtualCircuitType.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.VirtualCircuitTypePanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CommentsPanel(),
CustomFieldsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
@@ -627,6 +786,30 @@ class VirtualCircuitListView(generic.ObjectListView):
@register_model_view(VirtualCircuit)
class VirtualCircuitView(generic.ObjectView):
queryset = VirtualCircuit.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.VirtualCircuitPanel(),
TagsPanel(),
],
right_panels=[
CustomFieldsPanel(),
CommentsPanel(),
panels.CircuitGroupAssignmentsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='circuits.VirtualCircuitTermination',
title=_('Terminations'),
filters={'virtual_circuit_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject(
'circuits.VirtualCircuitTermination',
url_params={'virtual_circuit': lambda ctx: ctx['object'].pk},
),
],
),
],
)
@register_model_view(VirtualCircuit, 'add', detail=False)
@@ -698,6 +881,16 @@ class VirtualCircuitTerminationListView(generic.ObjectListView):
@register_model_view(VirtualCircuitTermination)
class VirtualCircuitTerminationView(generic.ObjectView):
queryset = VirtualCircuitTermination.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.VirtualCircuitTerminationPanel(),
TagsPanel(),
CustomFieldsPanel(),
],
right_panels=[
panels.VirtualCircuitTerminationInterfacePanel(),
],
)
@register_model_view(VirtualCircuitTermination, 'edit')

View File

@@ -43,7 +43,7 @@ class DataSourceForm(PrimaryModelForm):
attrs={
'rows': 5,
'class': 'font-monospace',
'placeholder': '.cache\n*.txt'
'placeholder': '.cache\n*.txt\nsubdir/*'
}
),
}
@@ -165,9 +165,10 @@ class ConfigRevisionForm(forms.ModelForm, metaclass=ConfigFormMetaclass):
FieldSet('PAGINATE_COUNT', 'MAX_PAGE_SIZE', name=_('Pagination')),
FieldSet('CUSTOM_VALIDATORS', 'PROTECTION_RULES', name=_('Validation')),
FieldSet('DEFAULT_USER_PREFERENCES', name=_('User Preferences')),
FieldSet('CHANGELOG_RETENTION', 'CHANGELOG_RETAIN_CREATE_LAST_UPDATE', name=_('Change Log')),
FieldSet(
'MAINTENANCE_MODE', 'COPILOT_ENABLED', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION',
'MAPS_URL', name=_('Miscellaneous'),
'MAINTENANCE_MODE', 'COPILOT_ENABLED', 'GRAPHQL_ENABLED', 'JOB_RETENTION', 'MAPS_URL',
name=_('Miscellaneous'),
),
FieldSet('comment', name=_('Config Revision'))
)

View File

@@ -5,7 +5,7 @@ import strawberry
import strawberry_django
from django.contrib.contenttypes.models import ContentType as DjangoContentType
from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup
from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup, StrFilterLookup
from core import models
from netbox.graphql.filters import BaseModelFilter, PrimaryModelFilter
@@ -32,23 +32,23 @@ class DataFileFilter(BaseModelFilter):
strawberry_django.filter_field()
)
source_id: ID | None = strawberry_django.filter_field()
path: FilterLookup[str] | None = strawberry_django.filter_field()
path: StrFilterLookup[str] | None = strawberry_django.filter_field()
size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
hash: FilterLookup[str] | None = strawberry_django.filter_field()
hash: StrFilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.DataSource, lookups=True)
class DataSourceFilter(PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
type: FilterLookup[str] | None = strawberry_django.filter_field()
source_url: FilterLookup[str] | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
type: StrFilterLookup[str] | None = strawberry_django.filter_field()
source_url: StrFilterLookup[str] | None = strawberry_django.filter_field()
status: (
BaseFilterLookup[Annotated['DataSourceStatusEnum', strawberry.lazy('core.graphql.enums')]] | None
) = strawberry_django.filter_field()
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
ignore_rules: FilterLookup[str] | None = strawberry_django.filter_field()
ignore_rules: StrFilterLookup[str] | None = strawberry_django.filter_field()
parameters: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
@@ -62,8 +62,8 @@ class DataSourceFilter(PrimaryModelFilter):
class ObjectChangeFilter(BaseModelFilter):
time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
user_name: FilterLookup[str] | None = strawberry_django.filter_field()
request_id: FilterLookup[str] | None = strawberry_django.filter_field()
user_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
request_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
action: (
BaseFilterLookup[Annotated['ObjectChangeActionEnum', strawberry.lazy('core.graphql.enums')]] | None
) = strawberry_django.filter_field()
@@ -76,7 +76,7 @@ class ObjectChangeFilter(BaseModelFilter):
strawberry_django.filter_field()
)
related_object_id: ID | None = strawberry_django.filter_field()
object_repr: FilterLookup[str] | None = strawberry_django.filter_field()
object_repr: StrFilterLookup[str] | None = strawberry_django.filter_field()
prechange_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
@@ -87,5 +87,5 @@ class ObjectChangeFilter(BaseModelFilter):
@strawberry_django.filter_type(DjangoContentType, lookups=True)
class ContentTypeFilter(BaseModelFilter):
app_label: FilterLookup[str] | None = strawberry_django.filter_field()
model: FilterLookup[str] | None = strawberry_django.filter_field()
app_label: StrFilterLookup[str] | None = strawberry_django.filter_field()
model: StrFilterLookup[str] | None = strawberry_django.filter_field()

View File

@@ -5,6 +5,7 @@ from importlib import import_module
import requests
from django.conf import settings
from django.core.cache import cache
from django.db.models import Exists, OuterRef, Subquery
from django.utils import timezone
from packaging import version
@@ -14,7 +15,7 @@ from netbox.jobs import JobRunner, system_job
from netbox.search.backends import search_backend
from utilities.proxy import resolve_proxies
from .choices import DataSourceStatusChoices, JobIntervalChoices
from .choices import DataSourceStatusChoices, JobIntervalChoices, ObjectChangeActionChoices
from .models import DataSource
@@ -126,19 +127,51 @@ class SystemHousekeepingJob(JobRunner):
"""
Delete any ObjectChange records older than the configured changelog retention time (if any).
"""
self.logger.info("Pruning old changelog entries...")
self.logger.info('Pruning old changelog entries...')
config = Config()
if not config.CHANGELOG_RETENTION:
self.logger.info("No retention period specified; skipping.")
self.logger.info('No retention period specified; skipping.')
return
cutoff = timezone.now() - timedelta(days=config.CHANGELOG_RETENTION)
self.logger.debug(
f"Changelog retention period: {config.CHANGELOG_RETENTION} days ({cutoff:%Y-%m-%d %H:%M:%S})"
)
self.logger.debug(f'Changelog retention period: {config.CHANGELOG_RETENTION} days ({cutoff:%Y-%m-%d %H:%M:%S})')
count = ObjectChange.objects.filter(time__lt=cutoff).delete()[0]
self.logger.info(f"Deleted {count} expired changelog records")
expired_qs = ObjectChange.objects.filter(time__lt=cutoff)
# When enabled, retain each object's original create record and most recent update record while pruning expired
# changelog entries. This applies only to objects without a delete record.
if config.CHANGELOG_RETAIN_CREATE_LAST_UPDATE:
self.logger.debug('Retaining changelog create records and last update records (excluding deleted objects)')
deleted_exists = ObjectChange.objects.filter(
action=ObjectChangeActionChoices.ACTION_DELETE,
changed_object_type_id=OuterRef('changed_object_type_id'),
changed_object_id=OuterRef('changed_object_id'),
)
# Keep create records only where no delete exists for that object
create_pks_to_keep = (
ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_CREATE)
.annotate(has_delete=Exists(deleted_exists))
.filter(has_delete=False)
.values('pk')
)
# Keep the most recent update per object only where no delete exists for the object
latest_update_pks_to_keep = (
ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_UPDATE)
.annotate(has_delete=Exists(deleted_exists))
.filter(has_delete=False)
.order_by('changed_object_type_id', 'changed_object_id', '-time', '-pk')
.distinct('changed_object_type_id', 'changed_object_id')
.values('pk')
)
expired_qs = expired_qs.exclude(pk__in=Subquery(create_pks_to_keep))
expired_qs = expired_qs.exclude(pk__in=Subquery(latest_update_pks_to_keep))
count = expired_qs.delete()[0]
self.logger.info(f'Deleted {count} expired changelog records')
def delete_expired_jobs(self):
"""

View File

@@ -11,7 +11,7 @@ from mptt.models import MPTTModel
from core.choices import ObjectChangeActionChoices
from core.querysets import ObjectChangeQuerySet
from netbox.models.features import ChangeLoggingMixin, has_feature
from utilities.data import shallow_compare_dict
from utilities.data import deep_compare_dict
__all__ = (
'ObjectChange',
@@ -199,18 +199,18 @@ class ObjectChange(models.Model):
# Determine which attributes have changed
if self.action == ObjectChangeActionChoices.ACTION_CREATE:
changed_attrs = sorted(postchange_data.keys())
elif self.action == ObjectChangeActionChoices.ACTION_DELETE:
return {
'pre': {k: prechange_data.get(k) for k in changed_attrs},
'post': {k: postchange_data.get(k) for k in changed_attrs},
}
if self.action == ObjectChangeActionChoices.ACTION_DELETE:
changed_attrs = sorted(prechange_data.keys())
else:
# TODO: Support deep (recursive) comparison
changed_data = shallow_compare_dict(prechange_data, postchange_data)
changed_attrs = sorted(changed_data.keys())
return {
'pre': {k: prechange_data.get(k) for k in changed_attrs},
'post': {k: postchange_data.get(k) for k in changed_attrs},
}
diff_added, diff_removed = deep_compare_dict(prechange_data, postchange_data)
return {
'pre': {
k: prechange_data.get(k) for k in changed_attrs
},
'post': {
k: postchange_data.get(k) for k in changed_attrs
},
'pre': dict(sorted(diff_removed.items())),
'post': dict(sorted(diff_added.items())),
}

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

View File

@@ -1,4 +1,6 @@
import django_tables2 as tables
from django.utils.html import conditional_escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from core.constants import JOB_LOG_ENTRY_LEVELS
@@ -82,3 +84,9 @@ class JobLogEntryTable(BaseTable):
class Meta(BaseTable.Meta):
empty_text = _('No log entries')
fields = ('timestamp', 'level', 'message')
def render_message(self, record, value):
if record.get('level') == 'error' and '\n' in value:
value = conditional_escape(value)
return mark_safe(f'<pre class="p-0">{value}</pre>')
return value

View File

@@ -1,9 +1,16 @@
import logging
import uuid
from datetime import timedelta
from unittest.mock import patch
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from core.choices import ObjectChangeActionChoices
from core.jobs import SystemHousekeepingJob
from core.models import ObjectChange, ObjectType
from dcim.choices import InterfaceTypeChoices, ModuleStatusChoices, SiteStatusChoices
from dcim.models import (
@@ -694,3 +701,99 @@ class ChangeLogAPITest(APITestCase):
self.assertEqual(changes[3].changed_object_type, ContentType.objects.get_for_model(Module))
self.assertEqual(changes[3].changed_object_id, module.pk)
self.assertEqual(changes[3].action, ObjectChangeActionChoices.ACTION_DELETE)
class ChangelogPruneRetentionTest(TestCase):
"""Test suite for Changelog pruning retention settings."""
@staticmethod
def _make_oc(*, ct, obj_id, action, ts):
oc = ObjectChange.objects.create(
changed_object_type=ct,
changed_object_id=obj_id,
action=action,
user_name='test',
request_id=uuid.uuid4(),
object_repr=f'Object {obj_id}',
)
ObjectChange.objects.filter(pk=oc.pk).update(time=ts)
return oc.pk
@staticmethod
def _run_prune(*, retention_days, retain_create_last_update):
job = SystemHousekeepingJob.__new__(SystemHousekeepingJob)
job.logger = logging.getLogger('netbox.tests.changelog_prune')
with patch('core.jobs.Config') as MockConfig:
cfg = MockConfig.return_value
cfg.CHANGELOG_RETENTION = retention_days
cfg.CHANGELOG_RETAIN_CREATE_LAST_UPDATE = retain_create_last_update
job.prune_changelog()
def test_prune_retain_create_last_update_excludes_deleted_objects(self):
ct = ContentType.objects.get_for_model(Site)
retention_days = 90
now = timezone.now()
cutoff = now - timedelta(days=retention_days)
expired_old = cutoff - timedelta(days=10)
expired_newer = cutoff - timedelta(days=1)
not_expired = cutoff + timedelta(days=1)
# A) Not deleted: should keep CREATE + latest UPDATE, prune intermediate UPDATEs
a_create = self._make_oc(ct=ct, obj_id=1, action=ObjectChangeActionChoices.ACTION_CREATE, ts=expired_old)
a_update1 = self._make_oc(ct=ct, obj_id=1, action=ObjectChangeActionChoices.ACTION_UPDATE, ts=expired_old)
a_update2 = self._make_oc(ct=ct, obj_id=1, action=ObjectChangeActionChoices.ACTION_UPDATE, ts=expired_newer)
# B) Deleted (all expired): should keep NOTHING
b_create = self._make_oc(ct=ct, obj_id=2, action=ObjectChangeActionChoices.ACTION_CREATE, ts=expired_old)
b_update = self._make_oc(ct=ct, obj_id=2, action=ObjectChangeActionChoices.ACTION_UPDATE, ts=expired_newer)
b_delete = self._make_oc(ct=ct, obj_id=2, action=ObjectChangeActionChoices.ACTION_DELETE, ts=expired_newer)
# C) Deleted but delete is not expired: create/update expired should be pruned; delete remains
c_create = self._make_oc(ct=ct, obj_id=3, action=ObjectChangeActionChoices.ACTION_CREATE, ts=expired_old)
c_update = self._make_oc(ct=ct, obj_id=3, action=ObjectChangeActionChoices.ACTION_UPDATE, ts=expired_newer)
c_delete = self._make_oc(ct=ct, obj_id=3, action=ObjectChangeActionChoices.ACTION_DELETE, ts=not_expired)
self._run_prune(retention_days=retention_days, retain_create_last_update=True)
remaining = set(ObjectChange.objects.values_list('pk', flat=True))
# A) Not deleted -> create + latest update remain
self.assertIn(a_create, remaining)
self.assertIn(a_update2, remaining)
self.assertNotIn(a_update1, remaining)
# B) Deleted (all expired) -> nothing remains
self.assertNotIn(b_create, remaining)
self.assertNotIn(b_update, remaining)
self.assertNotIn(b_delete, remaining)
# C) Deleted, delete not expired -> delete remains, but create/update are pruned
self.assertNotIn(c_create, remaining)
self.assertNotIn(c_update, remaining)
self.assertIn(c_delete, remaining)
def test_prune_disabled_deletes_all_expired(self):
ct = ContentType.objects.get_for_model(Site)
retention_days = 90
now = timezone.now()
cutoff = now - timedelta(days=retention_days)
expired = cutoff - timedelta(days=1)
not_expired = cutoff + timedelta(days=1)
# expired create/update should be deleted when feature disabled
x_create = self._make_oc(ct=ct, obj_id=10, action=ObjectChangeActionChoices.ACTION_CREATE, ts=expired)
x_update = self._make_oc(ct=ct, obj_id=10, action=ObjectChangeActionChoices.ACTION_UPDATE, ts=expired)
# non-expired delete should remain regardless
y_delete = self._make_oc(ct=ct, obj_id=11, action=ObjectChangeActionChoices.ACTION_DELETE, ts=not_expired)
self._run_prune(retention_days=retention_days, retain_create_last_update=False)
remaining = set(ObjectChange.objects.values_list('pk', flat=True))
self.assertNotIn(x_create, remaining)
self.assertNotIn(x_update, remaining)
self.assertIn(y_delete, remaining)

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):

View File

91
netbox/core/ui/panels.py Normal file
View File

@@ -0,0 +1,91 @@
from django.utils.translation import gettext_lazy as _
from netbox.ui import attrs, panels
class DataSourcePanel(panels.ObjectAttributesPanel):
title = _('Data Source')
name = attrs.TextAttr('name')
type = attrs.ChoiceAttr('type')
enabled = attrs.BooleanAttr('enabled')
status = attrs.ChoiceAttr('status')
sync_interval = attrs.ChoiceAttr('sync_interval', label=_('Sync interval'))
last_synced = attrs.DateTimeAttr('last_synced', label=_('Last synced'))
description = attrs.TextAttr('description')
source_url = attrs.TemplatedAttr(
'source_url',
label=_('URL'),
template_name='core/datasource/attrs/source_url.html',
)
ignore_rules = attrs.TemplatedAttr(
'ignore_rules',
label=_('Ignore rules'),
template_name='core/datasource/attrs/ignore_rules.html',
)
class DataSourceBackendPanel(panels.ObjectPanel):
template_name = 'core/panels/datasource_backend.html'
title = _('Backend')
class DataFilePanel(panels.ObjectAttributesPanel):
title = _('Data File')
source = attrs.RelatedObjectAttr('source', linkify=True)
path = attrs.TextAttr('path', style='font-monospace', copy_button=True)
last_updated = attrs.DateTimeAttr('last_updated')
size = attrs.TemplatedAttr('size', template_name='core/datafile/attrs/size.html')
hash = attrs.TextAttr('hash', label=_('SHA256 hash'), style='font-monospace', copy_button=True)
class DataFileContentPanel(panels.ObjectPanel):
template_name = 'core/panels/datafile_content.html'
title = _('Content')
class JobPanel(panels.ObjectAttributesPanel):
title = _('Job')
object_type = attrs.TemplatedAttr(
'object_type',
label=_('Object type'),
template_name='core/job/attrs/object_type.html',
)
name = attrs.TextAttr('name')
status = attrs.ChoiceAttr('status')
error = attrs.TextAttr('error')
user = attrs.TextAttr('user', label=_('Created by'))
class JobSchedulingPanel(panels.ObjectAttributesPanel):
title = _('Scheduling')
created = attrs.DateTimeAttr('created')
scheduled = attrs.TemplatedAttr('scheduled', template_name='core/job/attrs/scheduled.html')
started = attrs.DateTimeAttr('started')
completed = attrs.DateTimeAttr('completed')
queue = attrs.TextAttr('queue_name', label=_('Queue'))
class ObjectChangePanel(panels.ObjectAttributesPanel):
title = _('Change')
time = attrs.DateTimeAttr('time')
user = attrs.TemplatedAttr(
'user_name',
label=_('User'),
template_name='core/objectchange/attrs/user.html',
)
action = attrs.ChoiceAttr('action')
changed_object_type = attrs.TextAttr(
'changed_object_type',
label=_('Object type'),
)
changed_object = attrs.TemplatedAttr(
'object_repr',
label=_('Object'),
template_name='core/objectchange/attrs/changed_object.html',
)
message = attrs.TextAttr('message')
request_id = attrs.TemplatedAttr(
'request_id',
label=_('Request ID'),
template_name='core/objectchange/attrs/request_id.html',
)

View File

@@ -23,14 +23,25 @@ from rq.worker import Worker
from rq.worker_registration import clean_worker_registry
from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job
from extras.ui.panels import CustomFieldsPanel, TagsPanel
from netbox.config import PARAMS, get_config
from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
from netbox.plugins.utils import get_installed_plugins
from netbox.ui import layout
from netbox.ui.panels import (
CommentsPanel,
ContextTablePanel,
JSONPanel,
ObjectsTablePanel,
PluginContentPanel,
RelatedObjectsPanel,
TemplatePanel,
)
from netbox.views import generic
from netbox.views.generic.base import BaseObjectView
from netbox.views.generic.mixins import TableMixin
from utilities.apps import get_installed_apps
from utilities.data import shallow_compare_dict
from utilities.data import deep_compare_dict
from utilities.forms import ConfirmationForm
from utilities.htmx import htmx_partial
from utilities.json import ConfigJSONEncoder
@@ -48,6 +59,7 @@ from .jobs import SyncDataSourceJob
from .models import *
from .plugins import get_catalog_plugins, get_local_plugins
from .tables import CatalogPluginTable, JobLogEntryTable, PluginVersionTable
from .ui import panels
#
# Data sources
@@ -67,6 +79,24 @@ class DataSourceListView(generic.ObjectListView):
@register_model_view(DataSource)
class DataSourceView(GetRelatedModelsMixin, generic.ObjectView):
queryset = DataSource.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.DataSourcePanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
panels.DataSourceBackendPanel(),
RelatedObjectsPanel(),
CustomFieldsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='core.DataFile',
filters={'source_id': lambda ctx: ctx['object'].pk},
),
],
)
def get_extra_context(self, request, instance):
return {
@@ -157,6 +187,14 @@ class DataFileListView(generic.ObjectListView):
class DataFileView(generic.ObjectView):
queryset = DataFile.objects.all()
actions = (DeleteObject,)
layout = layout.Layout(
layout.Row(
layout.Column(
panels.DataFilePanel(),
panels.DataFileContentPanel(),
),
),
)
@register_model_view(DataFile, 'delete')
@@ -188,6 +226,17 @@ class JobListView(generic.ObjectListView):
class JobView(generic.ObjectView):
queryset = Job.objects.all()
actions = (DeleteObject,)
layout = layout.SimpleLayout(
left_panels=[
panels.JobPanel(),
],
right_panels=[
panels.JobSchedulingPanel(),
],
bottom_panels=[
JSONPanel('data', title=_('Data')),
],
)
@register_model_view(Job, 'log')
@@ -200,6 +249,13 @@ class JobLogView(generic.ObjectView):
badge=lambda obj: len(obj.log_entries),
weight=500,
)
layout = layout.Layout(
layout.Row(
layout.Column(
ContextTablePanel('table', title=_('Log Entries')),
),
),
)
def get_extra_context(self, request, instance):
table = JobLogEntryTable(instance.log_entries)
@@ -241,6 +297,26 @@ class ObjectChangeListView(generic.ObjectListView):
@register_model_view(ObjectChange)
class ObjectChangeView(generic.ObjectView):
queryset = None
layout = layout.Layout(
layout.Row(
layout.Column(panels.ObjectChangePanel()),
layout.Column(TemplatePanel('core/panels/objectchange_difference.html')),
),
layout.Row(
layout.Column(TemplatePanel('core/panels/objectchange_prechange.html')),
layout.Column(TemplatePanel('core/panels/objectchange_postchange.html')),
),
layout.Row(
layout.Column(PluginContentPanel('left_page')),
layout.Column(PluginContentPanel('right_page')),
),
layout.Row(
layout.Column(
TemplatePanel('core/panels/objectchange_related.html'),
PluginContentPanel('full_width_page'),
),
),
)
def get_queryset(self, request):
return ObjectChange.objects.valid_models()
@@ -273,17 +349,14 @@ class ObjectChangeView(generic.ObjectView):
prechange_data = instance.prechange_data_clean
if prechange_data and instance.postchange_data:
diff_added = shallow_compare_dict(
prechange_data or dict(),
instance.postchange_data_clean or dict(),
diff_added, diff_removed = deep_compare_dict(
prechange_data,
instance.postchange_data_clean,
exclude=['last_updated'],
)
diff_removed = {
x: prechange_data.get(x) for x in diff_added
} if prechange_data else {}
else:
diff_added = None
diff_removed = None
diff_added = {}
diff_removed = {}
return {
'diff_added': diff_added,
@@ -312,6 +385,14 @@ class ConfigRevisionListView(generic.ObjectListView):
@register_model_view(ConfigRevision)
class ConfigRevisionView(generic.ObjectView):
queryset = ConfigRevision.objects.all()
layout = layout.Layout(
layout.Row(
layout.Column(
TemplatePanel('core/panels/configrevision_data.html'),
TemplatePanel('core/panels/configrevision_comment.html'),
),
),
)
def get_extra_context(self, request, instance):
"""

View File

@@ -3,7 +3,7 @@ from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from dcim.choices import *
from dcim.models import Cable, CablePath, CableTermination
from dcim.models import Cable, CableBundle, CablePath, CableTermination
from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.gfk_fields import GFKSerializerField
from netbox.api.serializers import (
@@ -16,6 +16,7 @@ from tenancy.api.serializers_.tenants import TenantSerializer
from utilities.api import get_serializer_for_model
__all__ = (
'CableBundleSerializer',
'CablePathSerializer',
'CableSerializer',
'CableTerminationSerializer',
@@ -24,6 +25,18 @@ __all__ = (
)
class CableBundleSerializer(PrimaryModelSerializer):
cable_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = CableBundle
fields = [
'id', 'url', 'display_url', 'display', 'name', 'description', 'owner', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'cable_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class CableSerializer(PrimaryModelSerializer):
a_terminations = GenericObjectSerializer(many=True, required=False)
b_terminations = GenericObjectSerializer(many=True, required=False)
@@ -31,12 +44,13 @@ class CableSerializer(PrimaryModelSerializer):
profile = ChoiceField(choices=CableProfileChoices, required=False)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False, allow_null=True)
bundle = CableBundleSerializer(nested=True, required=False, allow_null=True, default=None)
class Meta:
model = Cable
fields = [
'id', 'url', 'display_url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'profile',
'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
'tenant', 'bundle', 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'label', 'description')
@@ -84,6 +98,9 @@ class CablePathSerializer(serializers.ModelSerializer):
def get_path(self, obj):
ret = []
for nodes in obj.path_objects:
if not nodes:
# The path contains an invalid object
return []
serializer = get_serializer_for_model(nodes[0])
context = {'request': self.context['request']}
ret.append(serializer(nodes, nested=True, many=True, context=context).data)

View File

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

View File

@@ -6,8 +6,9 @@ from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from dcim.choices import *
from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS
from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS, MODULE_TOKEN
from dcim.models import Device, DeviceBay, MACAddress, Module, VirtualDeviceContext
from dcim.utils import get_module_bay_positions, resolve_module_placeholder
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
from ipam.api.serializers_.ip import IPAddressSerializer
from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
@@ -150,15 +151,132 @@ class ModuleSerializer(PrimaryModelSerializer):
module_bay = NestedModuleBaySerializer()
module_type = ModuleTypeSerializer(nested=True)
status = ChoiceField(choices=ModuleStatusChoices, required=False)
replicate_components = serializers.BooleanField(
required=False,
default=True,
write_only=True,
label=_('Replicate components'),
help_text=_('Automatically populate components associated with this module type (default: true)')
)
adopt_components = serializers.BooleanField(
required=False,
default=False,
write_only=True,
label=_('Adopt components'),
help_text=_('Adopt already existing components')
)
class Meta:
model = Module
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial',
'asset_tag', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'replicate_components', 'adopt_components',
]
brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description')
def validate(self, data):
# When used as a nested serializer (e.g. as the `module` field on device component
# serializers), `data` is already a resolved Module instance — skip our custom logic.
if self.nested:
return super().validate(data)
# Pop write-only transient fields before ValidatedModelSerializer tries to
# construct a Module instance for full_clean(); restore them afterwards.
replicate_components = data.pop('replicate_components', True)
adopt_components = data.pop('adopt_components', False)
data = super().validate(data)
# For updates these fields are not meaningful; omit them from validated_data so that
# ModelSerializer.update() does not set unexpected attributes on the instance.
if self.instance:
return data
# Always pass the flags to create() so it can set the correct private attributes.
data['replicate_components'] = replicate_components
data['adopt_components'] = adopt_components
# Skip conflict checks when no component operations are requested.
if not replicate_components and not adopt_components:
return data
device = data.get('device')
module_type = data.get('module_type')
module_bay = data.get('module_bay')
# Required-field validation fires separately; skip here if any are missing.
if not all([device, module_type, module_bay]):
return data
positions = get_module_bay_positions(module_bay)
for templates_attr, component_attr in [
('consoleporttemplates', 'consoleports'),
('consoleserverporttemplates', 'consoleserverports'),
('interfacetemplates', 'interfaces'),
('powerporttemplates', 'powerports'),
('poweroutlettemplates', 'poweroutlets'),
('rearporttemplates', 'rearports'),
('frontporttemplates', 'frontports'),
]:
installed_components = {
component.name: component
for component in getattr(device, component_attr).all()
}
for template in getattr(module_type, templates_attr).all():
resolved_name = template.name
if MODULE_TOKEN in template.name:
if not module_bay.position:
raise serializers.ValidationError(
_("Cannot install module with placeholder values in a module bay with no position defined.")
)
try:
resolved_name = resolve_module_placeholder(template.name, positions)
except ValueError as e:
raise serializers.ValidationError(str(e))
existing_item = installed_components.get(resolved_name)
if adopt_components and existing_item and existing_item.module:
raise serializers.ValidationError(
_("Cannot adopt {model} {name} as it already belongs to a module").format(
model=template.component_model.__name__,
name=resolved_name
)
)
if not adopt_components and resolved_name in installed_components:
raise serializers.ValidationError(
_("A {model} named {name} already exists").format(
model=template.component_model.__name__,
name=resolved_name
)
)
return data
def create(self, validated_data):
replicate_components = validated_data.pop('replicate_components', True)
adopt_components = validated_data.pop('adopt_components', False)
# Tags are handled after save; pop them here to pass to _save_tags()
tags = validated_data.pop('tags', None)
# _adopt_components and _disable_replication must be set on the instance before
# save() is called, so we cannot delegate to super().create() here.
instance = self.Meta.model(**validated_data)
if adopt_components:
instance._adopt_components = True
if not replicate_components:
instance._disable_replication = True
instance.save()
if tags is not None:
self._save_tags(instance, tags)
return instance
class MACAddressSerializer(PrimaryModelSerializer):
assigned_object_type = ContentTypeField(

View File

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

View File

@@ -3,7 +3,7 @@ from rest_framework import serializers
from dcim.choices import *
from dcim.constants import *
from dcim.models import Rack, RackReservation, RackRole, RackType
from dcim.models import Rack, RackGroup, RackReservation, RackRole, RackType
from netbox.api.fields import ChoiceField, RelatedObjectCountField
from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer
from netbox.choices import *
@@ -16,6 +16,7 @@ from .sites import LocationSerializer, SiteSerializer
__all__ = (
'RackElevationDetailFilterSerializer',
'RackGroupSerializer',
'RackReservationSerializer',
'RackRoleSerializer',
'RackSerializer',
@@ -23,6 +24,20 @@ __all__ = (
)
class RackGroupSerializer(OrganizationalModelSerializer):
# Related object counts
rack_count = RelatedObjectCountField('racks')
class Meta:
model = RackGroup
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'owner', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'rack_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count')
class RackRoleSerializer(OrganizationalModelSerializer):
# Related object counts
@@ -87,6 +102,11 @@ class RackSerializer(RackBaseSerializer):
allow_null=True,
default=None
)
group = RackGroupSerializer(
nested=True,
required=False,
allow_null=True
)
tenant = TenantSerializer(
nested=True,
required=False,
@@ -127,11 +147,11 @@ class RackSerializer(RackBaseSerializer):
class Meta:
model = Rack
fields = [
'id', 'url', 'display_url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status',
'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'weight',
'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit',
'mounting_depth', 'airflow', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created',
'last_updated', 'device_count', 'powerfeed_count',
'id', 'url', 'display_url', 'display', 'name', 'facility_id', 'site', 'location', 'group', 'tenant',
'status', 'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit',
'weight', 'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
'outer_unit', 'mounting_depth', 'airflow', 'description', 'owner', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'device_count', 'powerfeed_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')
@@ -153,11 +173,16 @@ class RackReservationSerializer(PrimaryModelSerializer):
allow_null=True,
)
unit_count = serializers.SerializerMethodField()
def get_unit_count(self, obj):
return len(obj.units)
class Meta:
model = RackReservation
fields = [
'id', 'url', 'display_url', 'display', 'rack', 'units', 'status', 'created', 'last_updated', 'user',
'tenant', 'description', 'owner', 'comments', 'tags', 'custom_fields',
'id', 'url', 'display_url', 'display', 'rack', 'units', 'unit_count', 'status', 'created', 'last_updated',
'user', 'tenant', 'description', 'owner', 'comments', 'tags', 'custom_fields',
]
brief_fields = ('id', 'url', 'display', 'status', 'user', 'description', 'units')

View File

@@ -12,6 +12,7 @@ router.register('sites', views.SiteViewSet)
# Racks
router.register('locations', views.LocationViewSet)
router.register('rack-groups', views.RackGroupViewSet)
router.register('rack-types', views.RackTypeViewSet)
router.register('rack-roles', views.RackRoleViewSet)
router.register('racks', views.RackViewSet)
@@ -63,6 +64,7 @@ router.register('mac-addresses', views.MACAddressViewSet)
# Cables
router.register('cables', views.CableViewSet)
router.register('cable-terminations', views.CableTerminationViewSet)
router.register('cable-bundles', views.CableBundleViewSet)
# Virtual chassis
router.register('virtual-chassis', views.VirtualChassisViewSet)

View File

@@ -12,13 +12,14 @@ from dcim import filtersets
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from dcim.models import *
from dcim.svg import CableTraceSVG
from extras.api.mixins import RenderConfigMixin
from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import StripCountAnnotationsPaginator
from netbox.api.viewsets import MPTTLockedMixin, NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
from utilities.api import get_serializer_for_model
from utilities.query import count_related
from utilities.query_functions import CollateAsChar
from virtualization.models import VirtualMachine
@@ -154,6 +155,17 @@ class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet):
filterset_class = filtersets.LocationFilterSet
#
# Rack groups
#
class RackGroupViewSet(NetBoxModelViewSet):
queryset = RackGroup.objects.all()
serializer_class = serializers.RackGroupSerializer
filterset_class = filtersets.RackGroupFilterSet
#
# Rack roles
#
@@ -398,8 +410,14 @@ class PlatformViewSet(MPTTLockedMixin, NetBoxModelViewSet):
# Devices/modules
#
class DeviceViewSet(SequentialBulkCreatesMixin, RenderConfigMixin, NetBoxModelViewSet):
class DeviceViewSet(
SequentialBulkCreatesMixin,
ConfigContextQuerySetMixin,
RenderConfigMixin,
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
@@ -568,6 +586,14 @@ class CableTerminationViewSet(NetBoxReadOnlyModelViewSet):
filterset_class = filtersets.CableTerminationFilterSet
class CableBundleViewSet(NetBoxModelViewSet):
queryset = CableBundle.objects.annotate(
cable_count=count_related(Cable, 'bundle')
)
serializer_class = serializers.CableBundleSerializer
filterset_class = filtersets.CableBundleFilterSet
#
# Virtual chassis
#

View File

@@ -1003,10 +1003,16 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_800GE_SR8 = '800gbase-sr8'
TYPE_800GE_VR8 = '800gbase-vr8'
# 1.6 Tbps Ethernet
TYPE_1TE_CR8 = '1.6tbase-cr8'
TYPE_1TE_DR8 = '1.6tbase-dr8'
TYPE_1TE_DR8_2 = '1.6tbase-dr8-2'
# Ethernet (modular)
TYPE_100ME_SFP = '100base-x-sfp'
TYPE_1GE_GBIC = '1000base-x-gbic'
TYPE_1GE_SFP = '1000base-x-sfp'
TYPE_2GE_SFP = '2.5gbase-x-sfp'
TYPE_10GE_SFP_PLUS = '10gbase-x-sfpp'
TYPE_10GE_XFP = '10gbase-x-xfp'
TYPE_10GE_XENPAK = '10gbase-x-xenpak'
@@ -1034,8 +1040,11 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_400GE_OSFP_RHS = '400gbase-x-osfp-rhs'
TYPE_400GE_CDFP = '400gbase-x-cdfp'
TYPE_400GE_CFP8 = '400gbase-x-cfp8'
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
TYPE_800GE_OSFP = '800gbase-x-osfp'
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd' # TODO: Rename to _QSFP_DD800
TYPE_800GE_OSFP = '800gbase-x-osfp' # TODO: Rename to _OSFP800
TYPE_1TE_OSFP1600 = '1.6tbase-x-osfp1600'
TYPE_1TE_OSFP1600_RHS = '1.6tbase-x-osfp1600-rhs'
TYPE_1TE_QSFP_DD1600 = '1.6tbase-x-qsfpdd1600'
# Backplane Ethernet
TYPE_1GE_KX = '1000base-kx'
@@ -1049,6 +1058,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_100GE_KP4 = '100gbase-kp4'
TYPE_100GE_KR2 = '100gbase-kr2'
TYPE_100GE_KR4 = '100gbase-kr4'
TYPE_1TE_KR8 = '1.6tbase-kr8'
# Wireless
TYPE_80211A = 'ieee802.11a'
@@ -1298,12 +1308,21 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_800GE_VR8, '800GBASE-VR8 (800GE)'),
)
),
(
_('1.6 Tbps Ethernet'),
(
(TYPE_1TE_CR8, '1.6TBASE-CR8 (1.6TE)'),
(TYPE_1TE_DR8, '1.6TBASE-DR8 (1.6TE)'),
(TYPE_1TE_DR8_2, '1.6TBASE-DR8-2 (1.6TE)'),
)
),
(
_('Pluggable transceivers'),
(
(TYPE_100ME_SFP, 'SFP (100ME)'),
(TYPE_1GE_GBIC, 'GBIC (1GE)'),
(TYPE_1GE_SFP, 'SFP (1GE)'),
(TYPE_2GE_SFP, 'SFP (2.5GE)'),
(TYPE_10GE_SFP_PLUS, 'SFP+ (10GE)'),
(TYPE_10GE_XENPAK, 'XENPAK (10GE)'),
(TYPE_10GE_XFP, 'XFP (10GE)'),
@@ -1333,6 +1352,9 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_400GE_OSFP_RHS, 'OSFP-RHS (400GE)'),
(TYPE_800GE_OSFP, 'OSFP (800GE)'),
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
(TYPE_1TE_OSFP1600, 'OSFP1600 (1.6TE)'),
(TYPE_1TE_OSFP1600_RHS, 'OSFP1600-RHS (1.6TE)'),
(TYPE_1TE_QSFP_DD1600, 'QSFP-DD1600 (1.6TE)'),
)
),
(
@@ -1349,6 +1371,7 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100GE_KP4, '100GBASE-KP4 (100GE)'),
(TYPE_100GE_KR2, '100GBASE-KR2 (100GE)'),
(TYPE_100GE_KR4, '100GBASE-KR4 (100GE)'),
(TYPE_1TE_KR8, '1.6TBASE-KR8 (1.6TE)'),
)
),
(
@@ -1495,9 +1518,12 @@ class InterfaceSpeedChoices(ChoiceSet):
(10000000, '10 Gbps'),
(25000000, '25 Gbps'),
(40000000, '40 Gbps'),
(50000000, '50 Gbps'),
(100000000, '100 Gbps'),
(200000000, '200 Gbps'),
(400000000, '400 Gbps'),
(800000000, '800 Gbps'),
(1600000000, '1.6 Tbps'),
]

View File

@@ -1,3 +1,5 @@
import re
from django.db.models import Q
from .choices import InterfaceTypeChoices
@@ -79,6 +81,7 @@ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
#
MODULE_TOKEN = '{module}'
VC_POSITION_RE = re.compile(r'\{vc_position(?::([^}]*))?\}')
MODULAR_COMPONENT_TEMPLATE_MODELS = Q(
app_label='dcim',

View File

@@ -1,6 +1,7 @@
import django_filters
import netaddr
from django.contrib.contenttypes.models import ContentType
from django.db.models import Func, IntegerField
from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
@@ -45,6 +46,7 @@ from .constants import *
from .models import *
__all__ = (
'CableBundleFilterSet',
'CableFilterSet',
'CableTerminationFilterSet',
'CabledObjectFilterSet',
@@ -85,6 +87,7 @@ __all__ = (
'PowerPortFilterSet',
'PowerPortTemplateFilterSet',
'RackFilterSet',
'RackGroupFilterSet',
'RackReservationFilterSet',
'RackRoleFilterSet',
'RackTypeFilterSet',
@@ -306,15 +309,20 @@ 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
@register_filterset
class RackGroupFilterSet(OrganizationalModelFilterSet):
class Meta:
model = RackGroup
fields = ('id', 'name', 'slug', 'description')
@register_filterset
class RackRoleFilterSet(OrganizationalModelFilterSet):
@@ -419,6 +427,18 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterS
to_field_name='slug',
label=_('Location (slug)'),
)
group_id = django_filters.ModelMultipleChoiceFilter(
queryset=RackGroup.objects.all(),
distinct=False,
label=_('Group (ID)'),
)
group = django_filters.ModelMultipleChoiceFilter(
field_name='group__slug',
queryset=RackGroup.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Group (slug)'),
)
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
field_name='rack_type__manufacturer',
queryset=Manufacturer.objects.all(),
@@ -553,6 +573,19 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
to_field_name='slug',
label=_('Location (slug)'),
)
group_id = django_filters.ModelMultipleChoiceFilter(
queryset=RackGroup.objects.all(),
field_name='rack__group',
distinct=False,
label=_('Group (ID)'),
)
group = django_filters.ModelMultipleChoiceFilter(
field_name='rack__group__slug',
queryset=RackGroup.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Group (slug)'),
)
status = django_filters.MultipleChoiceFilter(
choices=RackReservationStatusChoices,
distinct=False,
@@ -574,11 +607,30 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
field_name='units',
lookup_expr='contains'
)
unit_count_min = django_filters.NumberFilter(
field_name='unit_count',
lookup_expr='gte',
label=_('Minimum unit count'),
)
unit_count_max = django_filters.NumberFilter(
field_name='unit_count',
lookup_expr='lte',
label=_('Maximum unit count'),
)
class Meta:
model = RackReservation
fields = ('id', 'created', 'description')
def filter_queryset(self, queryset):
# Annotate unit_count here so unit_count_min/unit_count_max filters can reference it.
# When called from the list view the queryset is already annotated; Django silently
# overwrites a duplicate annotation with the same expression, so this is safe.
queryset = queryset.annotate(
unit_count=Func('units', function='CARDINALITY', output_field=IntegerField())
)
return super().filter_queryset(queryset)
def search(self, queryset, name, value):
if not value.strip():
return queryset
@@ -997,7 +1049,7 @@ class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
class Meta:
model = ModuleBayTemplate
fields = ('id', 'name', 'label', 'position', 'description')
fields = ('id', 'name', 'label', 'position', 'enabled', 'description')
@register_filterset
@@ -1005,7 +1057,7 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent
class Meta:
model = DeviceBayTemplate
fields = ('id', 'name', 'label', 'description')
fields = ('id', 'name', 'label', 'enabled', 'description')
@register_filterset
@@ -2362,7 +2414,7 @@ class ModuleBayFilterSet(ModularDeviceComponentFilterSet):
class Meta:
model = ModuleBay
fields = ('id', 'name', 'label', 'position', 'description')
fields = ('id', 'name', 'label', 'position', 'enabled', 'description')
@register_filterset
@@ -2382,7 +2434,7 @@ class DeviceBayFilterSet(DeviceComponentFilterSet):
class Meta:
model = DeviceBay
fields = ('id', 'name', 'label', 'description')
fields = ('id', 'name', 'label', 'enabled', 'description')
@register_filterset
@@ -2535,6 +2587,23 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet):
return queryset.filter(qs_filter).distinct()
@register_filterset
class CableBundleFilterSet(PrimaryModelFilterSet):
class Meta:
model = CableBundle
fields = ('id', 'name', 'description')
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)
@register_filterset
class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
termination_a_type = MultiValueContentTypeFilter(
@@ -2555,6 +2624,16 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
method='_unterminated',
label=_('Unterminated'),
)
bundle_id = django_filters.ModelMultipleChoiceFilter(
queryset=CableBundle.objects.all(),
label=_('Cable bundle (ID)'),
)
bundle = django_filters.ModelMultipleChoiceFilter(
field_name='bundle__name',
queryset=CableBundle.objects.all(),
to_field_name='name',
label=_('Cable bundle (name)'),
)
type = django_filters.MultipleChoiceFilter(
choices=CableTypeChoices,
distinct=False,

View File

@@ -3,9 +3,10 @@ from django.utils.translation import gettext_lazy as _
from dcim.models import *
from extras.models import Tag
from netbox.forms.mixins import CustomFieldsMixin
from netbox.forms.mixins import ChangelogMessageMixin, CustomFieldsMixin
from utilities.forms import form_from_model
from utilities.forms.fields import DynamicModelMultipleChoiceField, ExpandableNameField
from utilities.forms.mixins import BackgroundJobMixin
from .object_create import ComponentCreateForm
@@ -27,7 +28,7 @@ __all__ = (
# Device components
#
class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm):
class DeviceBulkAddComponentForm(BackgroundJobMixin, ChangelogMessageMixin, CustomFieldsMixin, ComponentCreateForm):
pk = forms.ModelMultipleChoiceField(
queryset=Device.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -108,10 +109,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 +123,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(

View File

@@ -29,6 +29,7 @@ from wireless.models import WirelessLAN, WirelessLANGroup
__all__ = (
'CableBulkEditForm',
'CableBundleBulkEditForm',
'ConsolePortBulkEditForm',
'ConsolePortTemplateBulkEditForm',
'ConsoleServerPortBulkEditForm',
@@ -61,6 +62,7 @@ __all__ = (
'PowerPortBulkEditForm',
'PowerPortTemplateBulkEditForm',
'RackBulkEditForm',
'RackGroupBulkEditForm',
'RackReservationBulkEditForm',
'RackRoleBulkEditForm',
'RackTypeBulkEditForm',
@@ -201,6 +203,14 @@ class LocationBulkEditForm(NestedGroupModelBulkEditForm):
nullable_fields = ('parent', 'tenant', 'facility', 'description', 'comments')
class RackGroupBulkEditForm(OrganizationalModelBulkEditForm):
model = RackGroup
fieldsets = (
FieldSet('description'),
)
nullable_fields = ('description', 'comments')
class RackRoleBulkEditForm(OrganizationalModelBulkEditForm):
color = ColorField(
label=_('Color'),
@@ -336,6 +346,11 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
'site_id': '$site'
}
)
group = DynamicModelChoiceField(
label=_('Group'),
queryset=RackGroup.objects.all(),
required=False
)
tenant = DynamicModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
@@ -435,14 +450,16 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
model = Rack
fieldsets = (
FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'rack_type', 'description', name=_('Rack')),
FieldSet(
'status', 'group', 'role', 'tenant', 'serial', 'asset_tag', 'rack_type', 'description', name=_('Rack')
),
FieldSet('region', 'site_group', 'site', 'location', name=_('Location')),
FieldSet('outer_width', 'outer_height', 'outer_depth', 'outer_unit', name=_('Outer Dimensions')),
FieldSet('form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'mounting_depth', name=_('Hardware')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
)
nullable_fields = (
'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_height', 'outer_depth',
'location', 'group', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_height', 'outer_depth',
'outer_unit', 'weight', 'max_weight', 'weight_unit', 'description', 'comments',
)
@@ -770,6 +787,24 @@ class ModuleBulkEditForm(PrimaryModelBulkEditForm):
nullable_fields = ('serial', 'description', 'comments')
class CableBundleBulkEditForm(PrimaryModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CableBundle.objects.all(),
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False,
)
model = CableBundle
fieldsets = (
FieldSet('description',),
)
nullable_fields = ('description', 'comments')
class CableBulkEditForm(PrimaryModelBulkEditForm):
type = forms.ChoiceField(
label=_('Type'),
@@ -794,6 +829,11 @@ class CableBulkEditForm(PrimaryModelBulkEditForm):
queryset=Tenant.objects.all(),
required=False
)
bundle = DynamicModelChoiceField(
label=_('Bundle'),
queryset=CableBundle.objects.all(),
required=False,
)
label = forms.CharField(
label=_('Label'),
max_length=100,
@@ -817,11 +857,11 @@ class CableBulkEditForm(PrimaryModelBulkEditForm):
model = Cable
fieldsets = (
FieldSet('type', 'status', 'profile', 'tenant', 'label', 'description'),
FieldSet('type', 'status', 'profile', 'tenant', 'bundle', 'label', 'description'),
FieldSet('color', 'length', 'length_unit', name=_('Attributes')),
)
nullable_fields = (
'type', 'status', 'profile', 'tenant', 'label', 'color', 'length', 'description', 'comments',
'type', 'status', 'profile', 'tenant', 'bundle', 'label', 'color', 'length', 'description', 'comments',
)
@@ -1205,6 +1245,11 @@ class ModuleBayTemplateBulkEditForm(ComponentTemplateBulkEditForm):
label=_('Description'),
required=False
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=BulkEditNullBooleanSelect,
)
nullable_fields = ('label', 'position', 'description')
@@ -1223,6 +1268,11 @@ class DeviceBayTemplateBulkEditForm(ComponentTemplateBulkEditForm):
label=_('Description'),
required=False
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=BulkEditNullBooleanSelect,
)
nullable_fields = ('label', 'description')
@@ -1647,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')

View File

@@ -34,6 +34,7 @@ from wireless.choices import WirelessRoleChoices
from .common import ModuleCommonForm
__all__ = (
'CableBundleImportForm',
'CableImportForm',
'ConsolePortImportForm',
'ConsoleServerPortImportForm',
@@ -57,6 +58,7 @@ __all__ = (
'PowerOutletImportForm',
'PowerPanelImportForm',
'PowerPortImportForm',
'RackGroupImportForm',
'RackImportForm',
'RackReservationImportForm',
'RackRoleImportForm',
@@ -187,6 +189,13 @@ class LocationImportForm(NestedGroupModelImportForm):
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
class RackGroupImportForm(OrganizationalModelImportForm):
class Meta:
model = RackGroup
fields = ('name', 'slug', 'description', 'owner', 'comments', 'tags')
class RackRoleImportForm(OrganizationalModelImportForm):
class Meta:
@@ -261,6 +270,13 @@ class RackImportForm(PrimaryModelImportForm):
to_field_name='name',
help_text=_('Name of assigned tenant')
)
group = CSVModelChoiceField(
label=_('Rack group'),
queryset=RackGroup.objects.all(),
required=False,
to_field_name='name',
help_text=_('Name of assigned group')
)
status = CSVChoiceField(
label=_('Status'),
choices=RackStatusChoices,
@@ -318,10 +334,10 @@ class RackImportForm(PrimaryModelImportForm):
class Meta:
model = Rack
fields = (
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'rack_type', 'form_factor', 'serial',
'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit',
'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description', 'owner', 'comments',
'tags',
'site', 'location', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'rack_type', 'form_factor',
'serial', 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description', 'owner',
'comments', 'tags',
)
def __init__(self, data=None, *args, **kwargs):
@@ -1138,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):
@@ -1160,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)
@@ -1188,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(
@@ -1386,6 +1414,7 @@ class MACAddressImportForm(PrimaryModelImportForm):
# Assign the MAC address as primary for its interface, if designated as such
if interface and self.cleaned_data['is_primary'] and self.instance.pk:
interface.snapshot()
interface.primary_mac_address = self.instance
interface.save()
@@ -1396,6 +1425,12 @@ class MACAddressImportForm(PrimaryModelImportForm):
# Cables
#
class CableBundleImportForm(PrimaryModelImportForm):
class Meta:
model = CableBundle
fields = ('name', 'description', 'owner', 'comments', 'tags')
class CableImportForm(PrimaryModelImportForm):
# Termination A
side_a_site = CSVModelChoiceField(
@@ -1473,6 +1508,13 @@ class CableImportForm(PrimaryModelImportForm):
to_field_name='name',
help_text=_('Assigned tenant')
)
bundle = CSVModelChoiceField(
label=_('Bundle'),
queryset=CableBundle.objects.all(),
required=False,
to_field_name='name',
help_text=_('Cable bundle name'),
)
length_unit = CSVChoiceField(
label=_('Length unit'),
choices=CableLengthUnitChoices,
@@ -1490,7 +1532,7 @@ class CableImportForm(PrimaryModelImportForm):
model = Cable
fields = [
'side_a_site', 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_site', 'side_b_device', 'side_b_type',
'side_b_name', 'type', 'status', 'profile', 'tenant', 'label', 'color', 'length', 'length_unit',
'side_b_name', 'type', 'status', 'profile', 'tenant', 'bundle', 'label', 'color', 'length', 'length_unit',
'description', 'owner', 'comments', 'tags',
]
@@ -1528,8 +1570,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)

View File

@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
from dcim.utils import get_module_bay_positions, resolve_module_placeholder
from utilities.forms import get_field_value
__all__ = (
@@ -70,18 +71,6 @@ class InterfaceCommonForm(forms.Form):
class ModuleCommonForm(forms.Form):
def _get_module_bay_tree(self, module_bay):
module_bays = []
while module_bay:
module_bays.append(module_bay)
if module_bay.module:
module_bay = module_bay.module.module_bay
else:
module_bay = None
module_bays.reverse()
return module_bays
def clean(self):
super().clean()
@@ -100,7 +89,7 @@ class ModuleCommonForm(forms.Form):
self.instance._disable_replication = True
return
module_bays = self._get_module_bay_tree(module_bay)
positions = get_module_bay_positions(module_bay)
for templates, component_attribute in [
("consoleporttemplates", "consoleports"),
@@ -119,25 +108,15 @@ class ModuleCommonForm(forms.Form):
# Get the templates for the module type.
for template in getattr(module_type, templates).all():
resolved_name = template.name
# Installing modules with placeholders require that the bay has a position value
if MODULE_TOKEN in template.name:
if not module_bay.position:
raise forms.ValidationError(
_("Cannot install module with placeholder values in a module bay with no position defined.")
)
if len(module_bays) != template.name.count(MODULE_TOKEN):
raise forms.ValidationError(
_(
"Cannot install module with placeholder values in a module bay tree {level} in tree "
"but {tokens} placeholders given."
).format(
level=len(module_bays), tokens=template.name.count(MODULE_TOKEN)
)
)
for module_bay in module_bays:
resolved_name = resolved_name.replace(MODULE_TOKEN, module_bay.position, 1)
try:
resolved_name = resolve_module_placeholder(template.name, positions)
except ValueError as e:
raise forms.ValidationError(str(e))
existing_item = installed_components.get(resolved_name)

View File

@@ -15,6 +15,10 @@ def get_cable_form(a_type, b_type):
def __new__(mcs, name, bases, attrs):
# NOTE: Cable.clone() mirrors the parent selector mapping below:
# termination_{end}_device / termination_{end}_powerpanel / termination_{end}_circuit
# This supports both the "Clone" and "Create & Add Another" workflows.
# If you change the mapping here, update Cable.clone() accordingly.
for cable_end, term_cls in (('a', a_type), ('b', b_type)):
# Device component

View File

@@ -27,6 +27,7 @@ from vpn.models import L2VPN
from wireless.choices import *
__all__ = (
'CableBundleFilterForm',
'CableFilterForm',
'ConsoleConnectionFilterForm',
'ConsolePortFilterForm',
@@ -64,6 +65,7 @@ __all__ = (
'PowerPortTemplateFilterForm',
'RackElevationFilterForm',
'RackFilterForm',
'RackGroupFilterForm',
'RackReservationFilterForm',
'RackRoleFilterForm',
'RackTypeFilterForm',
@@ -276,6 +278,15 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupM
tag = TagFilterField(model)
class RackGroupFilterForm(OrganizationalModelFilterSetForm):
model = RackGroup
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
tag = TagFilterField(model)
class RackRoleFilterForm(OrganizationalModelFilterSetForm):
model = RackRole
fieldsets = (
@@ -355,7 +366,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo
model = Rack
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'group_id', name=_('Location')),
FieldSet('status', 'role_id', 'manufacturer_id', 'rack_type_id', 'serial', 'asset_tag', name=_('Rack')),
FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Hardware')),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
@@ -392,6 +403,12 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo
},
label=_('Location')
)
group_id = DynamicModelMultipleChoiceField(
queryset=RackGroup.objects.all(),
required=False,
null_option='None',
label=_('Rack group')
)
status = forms.MultipleChoiceField(
label=_('Status'),
choices=RackStatusChoices,
@@ -435,7 +452,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo
class RackElevationFilterForm(RackFilterForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'id', name=_('Location')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'group_id', 'id', name=_('Location')),
FieldSet('status', 'role_id', name=_('Function')),
FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
@@ -458,8 +475,8 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = RackReservation
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('status', 'user_id', name=_('Reservation')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')),
FieldSet('status', 'user_id', 'unit_count_min', 'unit_count_max', name=_('Reservation')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'group_id', 'rack_id', name=_('Rack')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
@@ -491,10 +508,17 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
label=_('Location'),
null_option='None'
)
group_id = DynamicModelMultipleChoiceField(
queryset=RackGroup.objects.all(),
required=False,
null_option='None',
label=_('Rack group')
)
rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
required=False,
query_params={
'group_id': '$group_id',
'site_id': '$site_id',
'location_id': '$location_id',
},
@@ -510,6 +534,14 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
required=False,
label=_('User')
)
unit_count_min = forms.IntegerField(
required=False,
label=_("Minimum U's")
)
unit_count_max = forms.IntegerField(
required=False,
label=_("Maximum U's")
)
tag = TagFilterField(model)
@@ -1149,12 +1181,24 @@ class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
tag = TagFilterField(model)
class CableBundleFilterForm(PrimaryModelFilterSetForm):
model = CableBundle
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', name=_('Attributes')),
)
tag = TagFilterField(model)
class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = Cable
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
FieldSet('type', 'status', 'profile', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')),
FieldSet(
'type', 'status', 'profile', 'color', 'length', 'length_unit', 'unterminated', 'bundle_id',
name=_('Attributes'),
),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
@@ -1236,6 +1280,11 @@ class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
bundle_id = DynamicModelMultipleChoiceField(
queryset=CableBundle.objects.all(),
required=False,
label=_('Bundle'),
)
tag = TagFilterField(model)
@@ -1829,7 +1878,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',
@@ -1837,31 +1886,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',
@@ -1869,6 +1928,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)
@@ -1876,9 +1940,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):

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}."

View File

@@ -23,7 +23,7 @@ from utilities.forms.fields import (
NumericArrayField,
SlugField,
)
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields, TabbedGroups
from utilities.forms.widgets import (
APISelect,
ClearableFileInput,
@@ -39,6 +39,7 @@ from wireless.models import WirelessLAN, WirelessLANGroup
from .common import InterfaceCommonForm, ModuleCommonForm
__all__ = (
'CableBundleForm',
'CableForm',
'ConsolePortForm',
'ConsolePortTemplateForm',
@@ -74,6 +75,7 @@ __all__ = (
'PowerPortForm',
'PowerPortTemplateForm',
'RackForm',
'RackGroupForm',
'RackReservationForm',
'RackRoleForm',
'RackTypeForm',
@@ -142,6 +144,16 @@ class SiteForm(TenancyForm, PrimaryModelForm):
label=_('ASNs'),
required=False
)
add_asns = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
label=_('Add ASNs'),
required=False
)
remove_asns = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
label=_('Remove ASNs'),
required=False
)
slug = SlugField()
time_zone = TimeZoneFormField(
label=_('Time zone'),
@@ -151,7 +163,8 @@ class SiteForm(TenancyForm, PrimaryModelForm):
fieldsets = (
FieldSet(
'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags',
'name', 'slug', 'status', 'region', 'group', 'facility', M2MAddRemoveFields('asns'), 'time_zone',
'description', 'tags',
name=_('Site')
),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
@@ -161,7 +174,7 @@ class SiteForm(TenancyForm, PrimaryModelForm):
class Meta:
model = Site
fields = (
'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asns', 'time_zone',
'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'time_zone',
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'owner', 'comments', 'tags',
)
widgets = {
@@ -177,6 +190,21 @@ class SiteForm(TenancyForm, PrimaryModelForm):
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk and (count := self.instance.asns.count()) >= M2MAddRemoveFields.THRESHOLD:
# Add/remove mode for large M2M sets
self.fields.pop('asns')
self.fields['add_asns'].widget.add_query_param('site_id__n', self.instance.pk)
self.fields['remove_asns'].widget.add_query_param('site_id', self.instance.pk)
self.fields['remove_asns'].help_text = _("{count} ASNs currently assigned").format(count=count)
else:
# Simple mode for new objects or small M2M sets
self.fields.pop('add_asns')
self.fields.pop('remove_asns')
if self.instance.pk:
self.initial['asns'] = list(self.instance.asns.values_list('pk', flat=True))
class LocationForm(TenancyForm, NestedGroupModelForm):
site = DynamicModelChoiceField(
@@ -206,6 +234,18 @@ class LocationForm(TenancyForm, NestedGroupModelForm):
)
class RackGroupForm(OrganizationalModelForm):
fieldsets = (
FieldSet('name', 'slug', 'description', 'tags', name=_('Rack Group')),
)
class Meta:
model = RackGroup
fields = [
'name', 'slug', 'description', 'owner', 'comments', 'tags',
]
class RackRoleForm(OrganizationalModelForm):
fieldsets = (
FieldSet('name', 'slug', 'color', 'description', 'tags', name=_('Rack Role')),
@@ -263,6 +303,11 @@ class RackForm(TenancyForm, PrimaryModelForm):
'site_id': '$site'
}
)
group = DynamicModelChoiceField(
label=_('Rack Group'),
queryset=RackGroup.objects.all(),
required=False
)
role = DynamicModelChoiceField(
label=_('Role'),
queryset=RackRole.objects.all(),
@@ -278,7 +323,7 @@ class RackForm(TenancyForm, PrimaryModelForm):
fieldsets = (
FieldSet(
'site', 'location', 'name', 'status', 'role', 'rack_type', 'description', 'airflow', 'tags',
'site', 'location', 'group', 'name', 'status', 'role', 'rack_type', 'description', 'airflow', 'tags',
name=_('Rack')
),
FieldSet('facility_id', 'serial', 'asset_tag', name=_('Inventory Control')),
@@ -288,7 +333,7 @@ class RackForm(TenancyForm, PrimaryModelForm):
class Meta:
model = Rack
fields = [
'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
'site', 'location', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight',
'weight_unit', 'description', 'owner', 'comments', 'tags',
@@ -758,7 +803,7 @@ class ModuleForm(ModuleCommonForm, PrimaryModelForm):
'device_id': '$device',
},
context={
'disabled': 'installed_module',
'disabled': '_occupied',
},
)
module_type = DynamicModelChoiceField(
@@ -812,6 +857,17 @@ def get_termination_type_choices():
])
class CableBundleForm(PrimaryModelForm):
fieldsets = (
FieldSet('name', 'description', 'tags', name=_('Cable Bundle')),
)
class Meta:
model = CableBundle
fields = ['name', 'description', 'owner', 'comments', 'tags']
class CableForm(TenancyForm, PrimaryModelForm):
a_terminations_type = forms.ChoiceField(
choices=get_termination_type_choices,
@@ -825,12 +881,17 @@ class CableForm(TenancyForm, PrimaryModelForm):
widget=HTMXSelect(),
label=_('Type')
)
bundle = DynamicModelChoiceField(
queryset=CableBundle.objects.all(),
required=False,
label=_('Bundle'),
)
class Meta:
model = Cable
fields = [
'a_terminations_type', 'b_terminations_type', 'type', 'status', 'profile', 'tenant_group', 'tenant',
'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
'bundle', 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
]
@@ -1037,7 +1098,9 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
self.fields['name'].help_text = _(
"Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range are not "
"supported (example: <code>[ge,xe]-0/0/[0-9]</code>). The token <code>{module}</code>, if present, will be "
"automatically replaced with the position value when creating a new module."
"automatically replaced with the position value when creating a new module. "
"The token <code>{vc_position}</code> will be replaced with the device's Virtual Chassis position "
"(use <code>{vc_position:1}</code> to specify a fallback (default is 0))"
)
@@ -1198,26 +1261,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',
]
@@ -1663,25 +1726,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',
]

View File

@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Annotated
import strawberry
import strawberry_django
from strawberry import ID
from strawberry_django import BaseFilterLookup, FilterLookup
from strawberry_django import BaseFilterLookup, FilterLookup, StrFilterLookup
from core.graphql.filters import ContentTypeFilter
@@ -66,9 +66,9 @@ class ComponentModelFilterMixin:
)
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
device_id: ID | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
label: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
label: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
@dataclass
@@ -96,9 +96,9 @@ class ComponentTemplateFilterMixin:
strawberry_django.filter_field()
)
device_type_id: ID | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
label: FilterLookup[str] | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
label: StrFilterLookup[str] | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
@dataclass

View File

@@ -4,7 +4,7 @@ import strawberry
import strawberry_django
from django.db.models import Q
from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, ComparisonFilterLookup, FilterLookup
from strawberry_django import BaseFilterLookup, ComparisonFilterLookup, FilterLookup, StrFilterLookup
from dcim import models
from dcim.constants import *
@@ -57,6 +57,7 @@ if TYPE_CHECKING:
from .enums import *
__all__ = (
'CableBundleFilter',
'CableFilter',
'CableTerminationFilter',
'ConsolePortFilter',
@@ -93,6 +94,7 @@ __all__ = (
'PowerPortFilter',
'PowerPortTemplateFilter',
'RackFilter',
'RackGroupFilter',
'RackReservationFilter',
'RackRoleFilter',
'RackTypeFilter',
@@ -106,6 +108,11 @@ __all__ = (
)
@strawberry_django.filter_type(models.CableBundle, lookups=True)
class CableBundleFilter(PrimaryModelFilter):
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.Cable, lookups=True)
class CableFilter(TenancyFilterMixin, PrimaryModelFilter):
type: BaseFilterLookup[Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
@@ -114,7 +121,7 @@ class CableFilter(TenancyFilterMixin, PrimaryModelFilter):
status: BaseFilterLookup[Annotated['LinkStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
label: FilterLookup[str] | None = strawberry_django.filter_field()
label: StrFilterLookup[str] | None = strawberry_django.filter_field()
color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -141,6 +148,20 @@ class CableTerminationFilter(ChangeLoggedModelFilter):
)
termination_id: ID | None = strawberry_django.filter_field()
# Cached relations
_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field(
name='device'
)
_rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field(
name='rack'
)
_location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='location')
)
_site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field(
name='site'
)
@strawberry_django.filter_type(models.ConsolePort, lookups=True)
class ConsolePortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
@@ -196,9 +217,9 @@ class DeviceFilter(
platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
name: FilterLookup[str] | None = strawberry_django.filter_field()
serial: FilterLookup[str] | None = strawberry_django.filter_field()
asset_tag: FilterLookup[str] | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
site_id: ID | None = strawberry_django.filter_field()
location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
@@ -253,32 +274,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()
@@ -297,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()
)
@@ -305,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)
@@ -325,7 +347,7 @@ class InventoryItemTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedMode
strawberry_django.filter_field()
)
manufacturer_id: ID | None = strawberry_django.filter_field()
part_id: FilterLookup[str] | None = strawberry_django.filter_field()
part_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.DeviceRole, lookups=True)
@@ -342,13 +364,13 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
strawberry_django.filter_field()
)
manufacturer_id: ID | None = strawberry_django.filter_field()
model: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
model: StrFilterLookup[str] | None = strawberry_django.filter_field()
slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
default_platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
default_platform_id: ID | None = strawberry_django.filter_field()
part_number: FilterLookup[str] | None = strawberry_django.filter_field()
part_number: StrFilterLookup[str] | None = strawberry_django.filter_field()
instances: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -369,36 +391,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()
@@ -465,7 +487,7 @@ class PortTemplateMappingFilter(BaseModelFilter):
@strawberry_django.filter_type(models.MACAddress, lookups=True)
class MACAddressFilter(PrimaryModelFilter):
mac_address: FilterLookup[str] | None = strawberry_django.filter_field()
mac_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -511,7 +533,7 @@ class InterfaceFilter(
duplex: BaseFilterLookup[Annotated['InterfaceDuplexEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
wwn: FilterLookup[str] | None = strawberry_django.filter_field()
wwn: StrFilterLookup[str] | None = strawberry_django.filter_field()
parent: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -631,9 +653,9 @@ class InventoryItemFilter(ComponentModelFilterMixin, NetBoxModelFilter):
strawberry_django.filter_field()
)
manufacturer_id: ID | None = strawberry_django.filter_field()
part_id: FilterLookup[str] | None = strawberry_django.filter_field()
serial: FilterLookup[str] | None = strawberry_django.filter_field()
asset_tag: FilterLookup[str] | None = strawberry_django.filter_field()
part_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
discovered: FilterLookup[bool] | None = strawberry_django.filter_field()
@@ -651,7 +673,7 @@ class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilt
status: BaseFilterLookup[Annotated['LocationStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
facility: FilterLookup[str] | None = strawberry_django.filter_field()
facility: StrFilterLookup[str] | None = strawberry_django.filter_field()
prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -680,34 +702,34 @@ class ModuleFilter(ConfigContextFilterMixin, PrimaryModelFilter):
status: BaseFilterLookup[Annotated['ModuleStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
serial: FilterLookup[str] | None = strawberry_django.filter_field()
asset_tag: FilterLookup[str] | None = strawberry_django.filter_field()
console_ports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
asset_tag: StrFilterLookup[str] | 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()
@@ -720,17 +742,19 @@ class ModuleBayFilter(ModularComponentFilterMixin, NetBoxModelFilter):
strawberry_django.filter_field()
)
parent_id: ID | None = strawberry_django.filter_field()
position: FilterLookup[str] | 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: FilterLookup[str] | 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.ModuleTypeProfile, lookups=True)
class ModuleTypeProfileFilter(PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ModuleType, lookups=True)
@@ -743,44 +767,41 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
strawberry_django.filter_field()
)
profile_id: ID | None = strawberry_django.filter_field()
model: FilterLookup[str] | None = strawberry_django.filter_field()
part_number: FilterLookup[str] | None = strawberry_django.filter_field()
model: StrFilterLookup[str] | None = strawberry_django.filter_field()
part_number: StrFilterLookup[str] | None = strawberry_django.filter_field()
instances: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
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()
@@ -804,7 +825,7 @@ class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryM
power_panel_id: ID | None = strawberry_django.filter_field()
rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
rack_id: ID | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
status: BaseFilterLookup[Annotated['PowerFeedStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -875,7 +896,7 @@ class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryMo
location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
name: FilterLookup[str] | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.PowerPort, lookups=True)
@@ -913,8 +934,8 @@ class RackTypeFilter(ImageAttachmentFilterMixin, RackFilterMixin, WeightFilterMi
strawberry_django.filter_field()
)
manufacturer_id: ID | None = strawberry_django.filter_field()
model: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
model: StrFilterLookup[str] | None = strawberry_django.filter_field()
slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
racks: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
rack_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
@@ -935,8 +956,8 @@ class RackFilter(
strawberry_django.filter_field()
)
rack_type_id: ID | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
facility_id: FilterLookup[str] | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
facility_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
site_id: ID | None = strawberry_django.filter_field()
location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
@@ -945,13 +966,17 @@ class RackFilter(
location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
group: Annotated['RackGroupFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
group_id: ID | None = strawberry_django.filter_field()
status: BaseFilterLookup[Annotated['RackStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
role: Annotated['RackRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
role_id: ID | None = strawberry_django.filter_field()
serial: FilterLookup[str] | None = strawberry_django.filter_field()
asset_tag: FilterLookup[str] | None = strawberry_django.filter_field()
serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
airflow: BaseFilterLookup[Annotated['RackAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -960,6 +985,11 @@ class RackFilter(
)
@strawberry_django.filter_type(models.RackGroup, lookups=True)
class RackGroupFilter(OrganizationalModelFilter):
pass
@strawberry_django.filter_type(models.RackReservation, lookups=True)
class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter):
rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
@@ -967,9 +997,10 @@ class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter):
units: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
unit_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
user_id: ID | None = strawberry_django.filter_field()
description: FilterLookup[str] | None = strawberry_django.filter_field()
description: StrFilterLookup[str] | None = strawberry_django.filter_field()
status: BaseFilterLookup[Annotated['RackReservationStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -1020,8 +1051,8 @@ class RegionFilter(ContactFilterMixin, NestedGroupModelFilter):
@strawberry_django.filter_type(models.Site, lookups=True)
class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
status: BaseFilterLookup[Annotated['SiteStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@@ -1035,11 +1066,11 @@ class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMi
group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
facility: FilterLookup[str] | None = strawberry_django.filter_field()
facility: StrFilterLookup[str] | None = strawberry_django.filter_field()
asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
time_zone: FilterLookup[str] | None = strawberry_django.filter_field()
physical_address: FilterLookup[str] | None = strawberry_django.filter_field()
shipping_address: FilterLookup[str] | None = strawberry_django.filter_field()
time_zone: StrFilterLookup[str] | None = strawberry_django.filter_field()
physical_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
shipping_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
latitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
@@ -1068,8 +1099,8 @@ class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilter):
class VirtualChassisFilter(PrimaryModelFilter):
master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
master_id: ID | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
domain: FilterLookup[str] | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
domain: StrFilterLookup[str] | None = strawberry_django.filter_field()
members: (
Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
@@ -1080,7 +1111,7 @@ class VirtualChassisFilter(PrimaryModelFilter):
class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilter):
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
device_id: ID | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()
name: StrFilterLookup[str] | None = strawberry_django.filter_field()
status: (
BaseFilterLookup[Annotated['VirtualDeviceContextStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None
) = (
@@ -1097,7 +1128,7 @@ class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilter):
strawberry_django.filter_field()
)
primary_ip6_id: ID | None = strawberry_django.filter_field()
comments: FilterLookup[str] | None = strawberry_django.filter_field()
comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
interfaces: (
Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()

View File

@@ -9,6 +9,9 @@ class DCIMQuery:
cable: CableType = strawberry_django.field()
cable_list: list[CableType] = strawberry_django.field()
cable_bundle: CableBundleType = strawberry_django.field()
cable_bundle_list: list[CableBundleType] = strawberry_django.field()
console_port: ConsolePortType = strawberry_django.field()
console_port_list: list[ConsolePortType] = strawberry_django.field()
@@ -102,6 +105,9 @@ class DCIMQuery:
power_port_template: PowerPortTemplateType = strawberry_django.field()
power_port_template_list: list[PowerPortTemplateType] = strawberry_django.field()
rack_group: RackGroupType = strawberry_django.field()
rack_group_list: list[RackGroupType] = strawberry_django.field()
rack_type: RackTypeType = strawberry_django.field()
rack_type_list: list[RackTypeType] = strawberry_django.field()

View File

@@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Annotated
import strawberry
import strawberry_django
from django.db.models import Func, IntegerField
from core.graphql.mixins import ChangelogMixin
from dcim import models
@@ -39,6 +40,7 @@ if TYPE_CHECKING:
from wireless.graphql.types import WirelessLANType, WirelessLinkType
__all__ = (
'CableBundleType',
'CableType',
'ComponentType',
'ConsolePortTemplateType',
@@ -73,6 +75,7 @@ __all__ = (
'PowerPanelType',
'PowerPortTemplateType',
'PowerPortType',
'RackGroupType',
'RackReservationType',
'RackRoleType',
'RackType',
@@ -126,6 +129,16 @@ class ModularComponentTemplateType(ComponentTemplateType):
#
@strawberry_django.type(
models.CableBundle,
fields='__all__',
filters=CableBundleFilter,
pagination=True
)
class CableBundleType(PrimaryObjectType):
cables: list[Annotated['CableType', strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
models.CableTermination,
exclude=['termination_type', 'termination_id', '_device', '_rack', '_location', '_site'],
@@ -157,6 +170,7 @@ class CableTerminationType(NetBoxObjectType):
class CableType(PrimaryObjectType):
color: str
tenant: Annotated['TenantType', strawberry.lazy('tenancy.graphql.types')] | None
bundle: Annotated['CableBundleType', strawberry.lazy('dcim.graphql.types')] | None
terminations: list[CableTerminationType]
@@ -736,6 +750,17 @@ class PowerPortTemplateType(ModularComponentTemplateType):
poweroutlet_templates: list[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
models.RackGroup,
fields='__all__',
filters=RackGroupFilter,
pagination=True
)
class RackGroupType(OrganizationalObjectType):
racks: list[Annotated["RackType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
models.RackType,
fields='__all__',
@@ -756,6 +781,7 @@ class RackTypeType(ImageAttachmentsMixin, PrimaryObjectType):
class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, PrimaryObjectType):
site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]
location: Annotated["LocationType", strawberry.lazy('dcim.graphql.types')] | None
group: Annotated["RackGroupType", strawberry.lazy('dcim.graphql.types')] | None
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
role: Annotated["RackRoleType", strawberry.lazy('dcim.graphql.types')] | None
@@ -778,6 +804,17 @@ class RackReservationType(PrimaryObjectType):
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
user: Annotated["UserType", strawberry.lazy('users.graphql.types')]
@classmethod
def get_queryset(cls, queryset, info, **kwargs):
queryset = super().get_queryset(queryset, info, **kwargs)
return queryset.annotate(
unit_count=Func('units', function='CARDINALITY', output_field=IntegerField())
)
@strawberry.field
def unit_count(self) -> int:
return len(self.units)
@strawberry_django.type(
models.RackRole,

View File

@@ -22,17 +22,21 @@ def load_initial_data(apps, schema_editor):
'power_supply',
'expansion_card'
)
profile_objects = []
for name in initial_profiles:
file_path = DATA_FILES_PATH / f'{name}.json'
with file_path.open('r') as f:
data = json.load(f)
try:
ModuleTypeProfile.objects.using(db_alias).create(**data)
profile = ModuleTypeProfile(**data)
profile_objects.append(profile)
except Exception as e:
print(f"Error loading data from {file_path}")
raise e
ModuleTypeProfile.objects.using(db_alias).bulk_create(profile_objects)
class Migration(migrations.Migration):

View File

@@ -1,21 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0226_modulebay_rebuild_tree'),
]
operations = [
migrations.AddField(
model_name='device',
name='config_context_data',
field=models.JSONField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='module',
name='config_context_data',
field=models.JSONField(blank=True, editable=False, null=True),
),
]

View File

@@ -0,0 +1,57 @@
import django.db.models.deletion
import taggit.managers
from django.db import migrations, models
import netbox.models.deletion
import utilities.json
class Migration(migrations.Migration):
dependencies = [
('dcim', '0226_modulebay_rebuild_tree'),
('extras', '0134_owner'),
('users', '0015_owner'),
]
operations = [
migrations.CreateModel(
name='RackGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
(
'custom_field_data',
models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
(
'owner',
models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
),
),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'verbose_name': 'rack group',
'verbose_name_plural': 'rack groups',
'ordering': ('name',),
},
bases=(netbox.models.deletion.DeleteMixin, models.Model),
),
migrations.AddField(
model_name='rack',
name='group',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='racks',
to='dcim.rackgroup',
),
),
]

View File

@@ -0,0 +1,54 @@
import django.db.models.deletion
import taggit.managers
from django.db import migrations, models
import netbox.models.deletion
import utilities.json
class Migration(migrations.Migration):
dependencies = [
('dcim', '0227_rack_group'),
('extras', '0134_owner'),
('users', '0015_owner'),
]
operations = [
migrations.CreateModel(
name='CableBundle',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(
blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)
),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('name', models.CharField(max_length=100, unique=True)),
('owner', models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner')
),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'verbose_name': 'cable bundle',
'verbose_name_plural': 'cable bundles',
'ordering': ('name',),
},
bases=(netbox.models.deletion.DeleteMixin, models.Model),
),
migrations.AddField(
model_name='cable',
name='bundle',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='cables',
to='dcim.cablebundle',
verbose_name='bundle',
),
),
]

View File

@@ -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),
),
]

View File

@@ -0,0 +1,23 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0229_devicebay_modulebay_enabled'),
]
operations = [
migrations.AlterField(
model_name='interface',
name='rf_channel_frequency',
field=models.DecimalField(
blank=True,
decimal_places=3,
help_text='Populated by selected channel (if set)',
max_digits=8,
null=True,
verbose_name='channel frequency (MHz)',
),
),
]

View File

@@ -8,6 +8,7 @@ from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.dispatch import Signal
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from core.models import ObjectType
@@ -29,6 +30,7 @@ from .device_components import FrontPort, PathEndpoint, PortMapping, RearPort
__all__ = (
'Cable',
'CableBundle',
'CablePath',
'CableTermination',
)
@@ -38,6 +40,32 @@ logger = logging.getLogger(f'netbox.{__name__}')
trace_paths = Signal()
#
# Cable bundles
#
class CableBundle(PrimaryModel):
"""
A logical grouping of individual cables.
"""
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True,
)
class Meta:
ordering = ('name',)
verbose_name = _('cable bundle')
verbose_name_plural = _('cable bundles')
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('dcim:cablebundle', args=[self.pk])
#
# Cables
#
@@ -102,8 +130,16 @@ class Cable(PrimaryModel):
blank=True,
null=True
)
bundle = models.ForeignKey(
to='dcim.CableBundle',
on_delete=models.SET_NULL,
related_name='cables',
blank=True,
null=True,
verbose_name=_('bundle'),
)
clone_fields = ('tenant', 'type', 'profile')
clone_fields = ('tenant', 'type', 'profile', 'bundle')
class Meta:
ordering = ('pk',)
@@ -293,7 +329,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()
@@ -305,6 +340,50 @@ class Cable(PrimaryModel):
except UnsupportedCablePath as e:
raise AbortRequest(e)
def clone(self):
"""
Return attributes suitable for cloning this cable.
In addition to the fields defined in `clone_fields`, include the termination
type and parent selector fields used by dcim.forms.connections.get_cable_form().
"""
attrs = super().clone()
# Mirror dcim.forms.connections.get_cable_form() parent-field logic
for cable_end, terminations in (('a', self.a_terminations), ('b', self.b_terminations)):
if not terminations:
continue
term_cls = type(terminations[0])
term_label = term_cls._meta.label_lower
# Matches CableForm choices: "<app_label>.<model>"
attrs[f'{cable_end}_terminations_type'] = term_label
# Device component
if hasattr(term_cls, 'device'):
device_ids = sorted({t.device_id for t in terminations if t.device_id})
if device_ids:
attrs[f'termination_{cable_end}_device'] = device_ids
# PowerFeed
elif term_label == 'dcim.powerfeed':
powerpanel_ids = sorted({t.power_panel_id for t in terminations if t.power_panel_id})
if powerpanel_ids:
attrs[f'termination_{cable_end}_powerpanel'] = powerpanel_ids
# CircuitTermination
elif term_label == 'circuits.circuittermination':
circuit_ids = sorted({t.circuit_id for t in terminations if t.circuit_id})
if circuit_ids:
attrs[f'termination_{cable_end}_circuit'] = circuit_ids
# Never clone the actual terminations, as they are already occupied
attrs.pop('a_terminations', None)
attrs.pop('b_terminations', None)
return attrs
def serialize_object(self, exclude=None):
data = serialize_object(self, exclude=exclude or [])
@@ -359,6 +438,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):
@@ -768,9 +856,9 @@ class CablePath(models.Model):
path.append([
object_to_path_node(t) for t in terminations
])
# If not null, push cable position onto the stack
# If not null, push cable positions onto the stack
if isinstance(terminations[0], PathEndpoint) and terminations[0].cable_positions:
position_stack.append([terminations[0].cable_positions[0]])
position_stack.append(list(terminations[0].cable_positions))
# Step 2: Determine the attached links (Cable or WirelessLink), if any
links = list(dict.fromkeys(
@@ -811,10 +899,33 @@ class CablePath(models.Model):
# Profile-based tracing
if links[0].profile:
cable_profile = links[0].profile_class()
position = position_stack.pop()[0] if position_stack else None
term, position = cable_profile.get_peer_termination(terminations[0], position)
remote_terminations = [term]
position_stack.append([position])
positions = position_stack.pop() if position_stack else [None]
remote_terminations = []
new_positions = []
# Build (termination, position) pairs by matching stacked positions
# to each termination's cable_positions. This correctly handles
# multiple terminations on different connectors of the same cable.
remaining = list(positions)
term_position_pairs = []
for term in terminations:
if term.cable_positions:
for cp in term.cable_positions:
if cp in remaining:
term_position_pairs.append((term, cp))
remaining.remove(cp)
# Fallback for when positions don't match cable_positions
# (e.g., empty position stack yielding [None])
if not term_position_pairs:
term_position_pairs = [(terminations[0], pos) for pos in positions]
for term, pos in term_position_pairs:
peer, new_pos = cable_profile.get_peer_termination(term, pos)
if peer not in remote_terminations:
remote_terminations.append(peer)
new_positions.append(new_pos)
position_stack.append(new_positions)
# Legacy (positionless) behavior
else:

View File

@@ -9,6 +9,7 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models.base import PortMappingBase
from dcim.models.mixins import InterfaceValidationMixin
from dcim.utils import get_module_bay_positions, resolve_module_placeholder
from netbox.models import ChangeLoggedModel
from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager
@@ -165,41 +166,47 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
_("A component template must be associated with either a device type or a module type.")
)
def _get_module_tree(self, module):
modules = []
while module:
modules.append(module)
if module.module_bay:
module = module.module_bay.module
else:
module = None
@staticmethod
def _resolve_vc_position(value: str, device) -> str:
"""
Resolves {vc_position} and {vc_position:X} tokens.
modules.reverse()
return modules
If the device has a vc_position, replaces the token with that value.
Otherwise uses the explicit fallback X if given, else '0'.
"""
def replacer(match):
explicit_fallback = match.group(1)
if (
device is not None
and device.virtual_chassis is not None
and device.vc_position is not None
):
return str(device.vc_position)
return explicit_fallback if explicit_fallback is not None else '0'
def resolve_name(self, module):
if MODULE_TOKEN not in self.name:
return self.name
return VC_POSITION_RE.sub(replacer, value)
if module:
modules = self._get_module_tree(module)
name = self.name
for module in modules:
name = name.replace(MODULE_TOKEN, module.module_bay.position, 1)
return name
return self.name
def _resolve_all_placeholders(self, value, module=None, device=None):
has_module = MODULE_TOKEN in value
has_vc = VC_POSITION_RE.search(value) is not None
if not has_module and not has_vc:
return value
if has_module and module:
positions = get_module_bay_positions(module.module_bay)
value = resolve_module_placeholder(value, positions)
if has_vc:
resolved_device = (module.device if module else None) or device
value = self._resolve_vc_position(value, resolved_device)
return value
def resolve_label(self, module):
if MODULE_TOKEN not in self.label:
return self.label
def resolve_name(self, module=None, device=None):
return self._resolve_all_placeholders(self.name, module, device)
if module:
modules = self._get_module_tree(module)
label = self.label
for module in modules:
label = label.replace(MODULE_TOKEN, module.module_bay.position, 1)
return label
return self.label
def resolve_label(self, module=None, device=None):
return self._resolve_all_placeholders(self.label, module, device)
def resolve_position(self, module=None, device=None):
return self._resolve_all_placeholders(self.position, module, device)
class ConsolePortTemplate(ModularComponentTemplateModel):
@@ -222,8 +229,8 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
label=self.resolve_label(kwargs.get('module')),
name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
type=self.type,
**kwargs
)
@@ -257,8 +264,8 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
label=self.resolve_label(kwargs.get('module')),
name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
type=self.type,
**kwargs
)
@@ -307,8 +314,8 @@ class PowerPortTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
label=self.resolve_label(kwargs.get('module')),
name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
type=self.type,
maximum_draw=self.maximum_draw,
allocated_draw=self.allocated_draw,
@@ -395,13 +402,13 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs):
if self.power_port:
power_port_name = self.power_port.resolve_name(kwargs.get('module'))
power_port_name = self.power_port.resolve_name(kwargs.get('module'), kwargs.get('device'))
power_port = PowerPort.objects.get(name=power_port_name, **kwargs)
else:
power_port = None
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
label=self.resolve_label(kwargs.get('module')),
name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
type=self.type,
color=self.color,
power_port=power_port,
@@ -501,8 +508,8 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
label=self.resolve_label(kwargs.get('module')),
name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
type=self.type,
enabled=self.enabled,
mgmt_only=self.mgmt_only,
@@ -628,8 +635,8 @@ class FrontPortTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
label=self.resolve_label(kwargs.get('module')),
name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
type=self.type,
color=self.color,
positions=self.positions,
@@ -692,8 +699,8 @@ class RearPortTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
label=self.resolve_label(kwargs.get('module')),
name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
type=self.type,
color=self.color,
positions=self.positions,
@@ -722,6 +729,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
@@ -731,9 +742,10 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
label=self.resolve_label(kwargs.get('module')),
position=self.position,
name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
position=self.resolve_position(kwargs.get('module'), kwargs.get('device')),
enabled=self.enabled,
**kwargs
)
instantiate.do_not_call_in_templates = True
@@ -743,6 +755,7 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
'name': self.name,
'label': self.label,
'position': self.position,
'enabled': self.enabled,
'description': self.description,
}
@@ -751,6 +764,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 +779,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 +796,7 @@ class DeviceBayTemplate(ComponentTemplateModel):
return {
'name': self.name,
'label': self.label,
'enabled': self.enabled,
'description': self.description,
}

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