Compare commits

..

101 Commits

Author SHA1 Message Date
Jeremy Stretch
539448683c Release v4.6.0-beta1 (#21910)
* Draft v4.6 release notes

* Revert django-tables2 upgrade

* Correct release notes

* Release v4.6.0-beta1

* Fix typo
2026-04-14 10:09:06 -04:00
Jeremy Stretch
e208a28137 Merge branch 'main' into feature 2026-04-14 08:48:22 -04:00
Jeremy Stretch
75e1b86613 Release v4.5.8 (#21903)
* Release v4.5.8
* Limit django-tables2 to <v2.9
2026-04-14 08:39:16 -04:00
github-actions
e12334c01b Update source translation strings 2026-04-14 05:39:35 +00:00
Jeremy Stretch
ea6552b239 Revert "Closes #21902: Upgrade django-tables2 to v3.0"
This reverts commit d57346d9f0.
2026-04-13 16:37:35 -04:00
bctiemann
36afe5541f Merge pull request #21904 from netbox-community/21902-upgrade-django-tables2
Closes #21902: Upgrade django-tables2 to v3.0
2026-04-13 16:23:01 -04:00
Jeremy Stretch
d57346d9f0 Closes #21902: Upgrade django-tables2 to v3.0 2026-04-13 14:17:05 -04:00
Jeremy Stretch
5aeb045fb5 Closes #21783: Fix support for bulk import of cables connected to power feeds (#21873) 2026-04-13 12:03:46 -05:00
Martin Hauser
6c12d8b402 Fixes #21869: Remove redundant ScriptModule class synchronization on save (#21899) 2026-04-13 10:53:00 -05:00
Jeremy Stretch
58275977bb Closes #21890: Deprecate the models registry key (#21892)
* Closes #21890: Deprecate the 'models' registry key

* Add deprecation note for 'models' key to development docs
2026-04-13 09:24:30 -04:00
Jeremy Stretch
5054566abb Fix migration indexes 2026-04-13 09:04:06 -04:00
Jason Novinger
28a11f6aad Fixes #21357: Add support for registering custom model actions (#21560)
* Add ModelAction and register_model_actions() API for custom permission actions

* Add ObjectTypeSplitMultiSelectWidget and RegisteredActionsWidget

* Integrate registered actions into ObjectPermissionForm

* Add JavaScript for registered actions show/hide

* Register custom actions for DataSource, Device, and VirtualMachine

* Add tests for ModelAction and register_model_actions

* Refine registered actions widget UI

- Use verbose labels (App | Model) for action group headers
- Simplify template layout with h5 headers instead of cards
- Consolidate Standard/Custom/Additional Actions into single Actions fieldset

* Hide custom actions field when no applicable models selected

The entire field row is now hidden when no selected object types
have registered custom actions, avoiding an empty "Custom actions"
label.

* Add documentation for custom model actions

- Add plugin development guide for registering custom actions
- Update admin permissions docs to mention custom actions UI
- Add docstrings to ModelAction and register_model_actions

* Add RESERVED_ACTIONS constant and fix dedup in registered actions

- Define RESERVED_ACTIONS in users/constants.py for the four built-in
  permission actions (view, add, change, delete)
- Replace hardcoded action lists in ObjectPermissionForm with the constant
- Fix duplicate action names in clean() when the same action is registered
  across multiple models (e.g. render_config for Device and VirtualMachine)
- Fix template substring matching bug in objectpermission.html detail view
  by passing RESERVED_ACTIONS through view context for proper list membership

* Fix shared action pre-selection and additional actions leakage on edit

* Prevent duplicate action registration in register_model_actions()

* Remove stale comment in RegisteredActionsWidget

* Rebuild frontend assets after rebase onto feature

* Refactor SplitMultiSelectWidget to use class attributes for widget classes

* Reject reserved action names in register_model_actions()

* Show all registered actions with enable/disable instead of show/hide

* Validate action name is not empty and clarify RESERVED_ACTIONS origin

* Adapt custom actions panel for declarative layout system

Convert the ObjectPermission detail view to use the new panel-based
layout from #21568. Add ObjectPermissionCustomActionsPanel that
cross-references assigned object types with the model_actions registry
to display which models each custom action applies to.

Also fix dark-mode visibility of disabled action checkboxes in the
permission form by overriding Bootstrap's disabled opacity.

* Flatten registered actions UI and declare via Meta.permissions

Implement two changes requested in review of #21560:

1. Use Meta.permissions for action declaration
   - Add Meta.permissions to DataSource, Device, and VirtualMachine
   - register_models() auto-registers actions from Meta.permissions
   - Remove explicit register_model_actions() calls from apps.py
   - Add get_action_model_map() utility to utilities/permissions.py

2. Flatten the ObjectPermission form UI
   - Show a single deduplicated list of action checkboxes (one per
     unique action name) instead of grouped-by-model checkboxes
   - RegisteredActionsWidget uses create_option() to inject model_keys
     and help_text; JS enables/disables based on selected object types
   - render_field.html bypasses outer wrapper for registeredactionswidget
     so widget emits rows with identical DOM structure to CRUD checkboxes
   - Unchecking a model now also unchecks unsupported action checkboxes

Fixes #21357

* Address review feedback on registered actions

- Sort model_keys in data-models attribute for deterministic output
- Rename registered_actions field label to 'Registered actions'
- Target object_types selected list via data-object-types-selected
  attribute instead of hardcoded DOM ID
- Reduce setTimeout delay to 0ms since moveOption() is synchronous

* Consolidate ObjectPermission detail view actions panel

Merge ObjectPermissionActionsPanel and ObjectPermissionCustomActionsPanel
into a single Actions panel that shows CRUD booleans and all registered
actions in one table, matching the form's consolidated layout.

Also fix data-object-types-selected attribute value (True -> 'true') and
update plugin docs to show Meta.permissions as the primary registration
approach.

* Address additional bot review feedback

- clean() collects all validation errors before raising instead of stopping at the first
- Fix stale admin docs (still referenced "Custom actions" and "grouped by model")

* Update netbox/netbox/registry.py

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

* Fix model_actions registry to use set operations

The registry was changed to defaultdict(set) but the registration
code still used list methods. Update .append() to .add() and fix
tests to use set-compatible access patterns.

* Rename permission migrations for clarity

* Move ModelAction validation into __post_init__

* Drop model name from permission descriptions

* Simplify ObjectPermission form and remove custom widgets

Replace the dynamic UI with standard BooleanField checkboxes for each
registered action. No custom widgets, no JavaScript, no template
changes.

- Remove RegisteredActionsWidget, ObjectTypeSplitMultiSelectWidget,
  and registeredActions.ts
- Use dynamic BooleanFields for registered actions (renders identically
  to CRUD checkboxes)
- Move action-resolution logic from panel to ObjectPermission model
- Remove object-type cross-validation from form clean()
- Remove unused get_action_model_map utility

* Remove register_model_actions from public API

Meta.permissions is the documented approach for plugins. The
register_model_actions function is now an internal implementation
detail.

* Sort registered actions and improve test coverage

Sort action names alphabetically for stable display order. Add tests
for cloning, empty registry, and models_csv output.

* Add help_text to registered action checkboxes

* Return model_keys as list from get_registered_actions()

Move string joining to the template so callers get native
list data instead of a pre-formatted CSV string.

* Improve detail view: human-friendly descriptions and additional actions

Return dicts from get_registered_actions() with help_text and verbose
model names. Add get_additional_actions() for manually-entered actions
that aren't CRUD or registered. Show both in the Actions panel.

* Renumber permission migrations after feature merge

Resolve migration conflicts with default_ordering_indexes migrations.
Renumber to 0023 (core), 0232 (dcim), 0056 (virtualization) and
update dependencies.

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2026-04-13 08:37:09 -04:00
Jeremy Stretch
cb5ade07f0 Closes #21887: Deprecate support for legacy view actions (#21889) 2026-04-11 00:55:27 +02:00
Jeremy Stretch
71d918636c Remove cancelled TODO 2026-04-10 17:10:11 -04:00
Jeremy Stretch
82cf60091a Closes #21884: Deprecate the DEFAULT_ACTION_PERMISSIONS constant 2026-04-10 17:08:35 -04:00
Jeremy Stretch
133ed53849 Closes #21881: Deprecate legacy Sentry configuration parameters (#21882) 2026-04-10 15:35:58 -05:00
Martin Hauser
ab94e3d40e feat(api): Include NAT IP fields in primary IP serializers
Add nat_inside and nat_outside fields to primary_ip, primary_ip4,
primary_ip6, and oob_ip on Device and VirtualMachine serializers.
Update prefetch logic to honor field-level constraints on nested
serializers and add test coverage for NAT field inclusion.

Fixes #19138
2026-04-10 15:07:53 -04:00
Jeremy Stretch
315fcdffb6 Merge branch 'main' into feature 2026-04-10 14:58:07 -04:00
Jeremy Stretch
7462e45c8e Closes #21865: Display debug toolbar if INTERNAL_IPS is empty (#21871) 2026-04-09 19:19:25 +02:00
bctiemann
cc03d509d1 Merge pull request #21842 from netbox-community/21455-sql-indexes-audit
Closes #21455: Add SQL indexes for default ordering
2026-04-07 13:00:17 -04:00
Jeremy Stretch
87bc20cdd5 Add default ordering index for ipam.VLANGroup 2026-04-07 12:04:42 -04:00
Jeremy Stretch
48e790c9f0 #21409: Disable CHANGELOG_RETAIN_CREATE_LAST_UPDATE by default (#21849) 2026-04-07 16:26:26 +02:00
bctiemann
25fb457331 Merge pull request #21846 from netbox-community/21780-add-changelog-message-support-to-bulk-creation-of-ip
Closes #21780: Add changelog message support for bulk creation of IP Addresses and Prefixes
2026-04-07 10:21:16 -04:00
Jeremy Stretch
06c90cb86a Closes #21847: Correct webhook documentation for deprecated keys (#21848) 2026-04-07 15:58:45 +02:00
Jeremy Stretch
bcc410d99f Closes #20924: Ready UI components for use by plugins (#21827)
* Misc cleanup

* Include permissions in TemplatedAttr context

* Introduce CircuitTerminationPanel to replace generic panel

* Replace all instantiations of Panel with TemplatePanel

* Misc cleanup for layouts

* Enable specifying column grid width

* Panel.render() should pass the request to render_to_string()

* CopyContent does not need to override render()

* Avoid setting mutable panel actions

* Catch exceptions raised when rendering embedded plugin content

* Handle panel title when object is not available

* Introduce should_render() method on Panel class

* Misc cleanup

* Pass the value returned by get_context() to should_render()

* Yet more cleanup

* Fix typos

* Clean up object attrs

* Replace candidate template panels with ObjectAttributesPanel subclasses

* Add tests for object attrs

* Remove beta warning

* PluginContentPanel should not call should_render()

* Clean up AddObject

* speed.html should reference value for port_speed

* Address PR feedback
2026-04-06 15:35:18 -04:00
Martin Hauser
d630afaf14 feat(ipam): Add changelog message support to bulk Prefix/IP creation
Extend bulk add forms for Prefix and IPAddress to support changelog
messages. Switch IPAddressBulkAddForm to PrimaryModelForm base, update
field ordering, consolidate template rendering, and add test coverage.

Fixes #21780
2026-04-06 20:15:02 +02:00
Jeremy Stretch
2b1f4ab51a Add migration files for indexes 2026-04-03 16:32:08 -04:00
Jeremy Stretch
84502e80d0 Add SQL indexes for default ordering on applicable models 2026-04-03 16:22:18 -04:00
bctiemann
02f9ca8f01 Merge pull request #21816 from netbox-community/21770-embedded-table-columns
Closes #21770: Enable including/excluding columns on ObjectsTablePanel
2026-04-03 13:04:27 -04:00
Martin Hauser
5ad4e95207 Closes #21720: Improve validation of URLs containing HTTP basic authentication (#21822)
Fixes #21720
2026-04-02 11:42:06 -05: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
6c08941542 Tweak behavior of include_columns 2026-04-01 14:58:41 -04:00
Jeremy Stretch
be1a29d7ee Misc cleanup 2026-04-01 14:46:53 -04:00
Jeremy Stretch
f06f8f3f1d Exclude assigned object columns from IP addresses table on interface views 2026-04-01 14:25:31 -04:00
Jeremy Stretch
a45ec6620a Protect exempt columns from exclusion 2026-04-01 14:17:57 -04:00
Jeremy Stretch
bd35afe320 Apply column hiding before prefetching 2026-04-01 14:14:13 -04:00
Jeremy Stretch
364868a207 Implement exclude_columns on embedded tables 2026-04-01 13:46:59 -04:00
Jeremy Stretch
d4569df305 Closes #21770: Enable including/excluding columns on ObjectsTablePanel 2026-04-01 13:32:42 -04: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
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
Jeremy Stretch
e5b9e5a279 Closes #19025: Add schema validation for JSON custom fields (#21746) 2026-03-31 12:41:49 -05: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
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
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
Arthur
9bc66ee0bf cleanup 2026-03-26 15:00:52 -07: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
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
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
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
bctiemann
2a78c05984 Closes #19034: Add calculated RackReservation.unit_count, with min/max filtering (#21665) 2026-03-25 08:50:53 -05: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
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
É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
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
Arthur
45b53ee036 #14329 Improve diffs for custom_fields 2026-03-17 09:03:57 -07:00
Arthur
992630d670 #14329 Improve diffs for custom_fields 2026-03-17 08:44:18 -07:00
Arthur
c8cd5fd6cd #14329 Improve diffs for custom_fields 2026-03-16 17:14:26 -07:00
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
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
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
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
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
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
bctiemann
719effb548 Fixes: #20123 - Add replicate_components and adopt_components write_only fields to ModuleSerializer (#21600) 2026-03-09 11:11:40 -07:00
Jeremy Stretch
6659bb3abe Closes #21363: Implement cursor-based pagination for the REST API (#21594) 2026-03-06 17:13:08 -08: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
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
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
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
Jeremy Stretch
694e3765dd Use weak ETags 2026-03-04 10:04:30 -05:00
Jeremy Stretch
303199dc8f Closes #21356: Implement ETag support for REST API 2026-03-04 09:57:59 -05:00
bctiemann
6eafffb497 Closes: #21304 - Add stronger deprecation warning on use of housekeeping management command (#21483)
* 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
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
326 changed files with 26220 additions and 10877 deletions

View File

@@ -15,7 +15,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v4.5.7
placeholder: v4.5.8
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.7
placeholder: v4.5.8
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.7
placeholder: v4.5.8
validations:
required: true
- type: dropdown

View File

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

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

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
@@ -55,7 +57,8 @@ django-storages
# Abstraction models for rendering and paginating HTML tables
# https://github.com/jieter/django-tables2/blob/master/CHANGELOG.md
django-tables2
# See #21902 for upgrading to django-tables2 v2.9+
django-tables2<2.9
# User-defined tags for objects
# https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst
@@ -68,7 +71,7 @@ django-timezone-field
# A REST API framework for Django projects
# https://www.django-rest-framework.org/community/release-notes/
# TODO: Re-evaluate the monkey-patch of get_unique_validators() before upgrading
djangorestframework==3.16.1
djangorestframework==3.17.1
# Sane and flexible OpenAPI 3 schema generation for Django REST framework.
# https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst
@@ -98,10 +101,6 @@ jsonschema
# https://python-markdown.github.io/changelog/
Markdown
# MkDocs
# https://github.com/mkdocs/mkdocs/releases
mkdocs<2.0
# MkDocs Material theme (for documentation build)
# https://squidfunk.github.io/mkdocs-material/changelog/
mkdocs-material
@@ -175,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

File diff suppressed because it is too large Load Diff

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

@@ -20,7 +20,9 @@ There are four core actions that can be permitted for each type of object within
* **Change** - Modify an existing object
* **Delete** - Delete an existing object
In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `run` permission for scripts allows a user to execute custom scripts. These can be specified when granting a permission in the "additional actions" field.
In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `sync` action for data sources allows a user to synchronize data from a remote source, and the `render_config` action for devices and virtual machines allows rendering configuration templates.
Some models have registered actions that appear as checkboxes in the "Actions" section when creating or editing a permission. These are shown in a flat list alongside the built-in CRUD actions. Additional actions (such as those not yet registered by a plugin, or for backwards compatibility) can be entered manually in the "Additional actions" field.
!!! note
Internally, all actions granted by a permission (both built-in and custom) are stored as strings in an array field named `actions`.

View File

@@ -4,9 +4,9 @@
Default: `False`
This setting enables debugging. Debugging should be enabled only during development or troubleshooting. Note that only
clients which access NetBox from a recognized [internal IP address](./system.md#internal_ips) will see debugging tools in the user
interface.
This setting enables debugging and displays a debugging toolbar in the user interface. Debugging should be enabled only during development or troubleshooting.
Note that the debugging toolbar will be displayed only for requests originating from [internal IP addresses](./system.md#internal_ips), if defined. If no internal IPs are defined, the toolbar will be displayed for all requests.
!!! warning
Never enable debugging on a production system, as it can expose sensitive data to unauthenticated users and impose a

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,23 @@ This data enables the project maintainers to estimate how many NetBox deployment
---
## CHANGELOG_RETAIN_CREATE_LAST_UPDATE
!!! tip "Dynamic Configuration Parameter"
Default: `False`
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`.
---
## CHANGELOG_RETENTION
!!! tip "Dynamic Configuration Parameter"

View File

@@ -105,6 +105,13 @@ A list of IP addresses recognized as internal to the system, used to control the
example, the debugging toolbar will be viewable only when a client is accessing NetBox from one of the listed IP
addresses (and [`DEBUG`](./development.md#debug) is `True`).
!!! info "New in NetBox v4.6"
Setting this parameter to an empty list will enable the toolbar for all requests provided debugging is enabled:
```python
INTERNAL_IPS = []
```
---
## ISOLATED_DEPLOYMENT

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

@@ -32,6 +32,9 @@ Core model features are listed in the [features matrix](./models.md#features-mat
### `models`
!!! warning "Deprecated"
Usage of this key has been deprecated and will be removed in NetBox v4.7. Use `ObjectType.objects.public()` to find registered models.
This key lists all models which have been registered in NetBox which are not designated for private use. (Setting `_netbox_private` to True on a model excludes it from this list.) As with individual features under `model_features`, models are organized by app label.
### `plugins`

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

@@ -26,10 +26,20 @@ The following data is available as context for Jinja2 templates:
* `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.
* `request` - Data about the triggering request (if available).
* `request.id` - The UUID associated with the request
* `request.method` - The HTTP method (e.g. `GET` or `POST`)
* `request.path` - The URL path (ex: `/dcim/sites/123/edit/`)
* `request.user` - The name of the authenticated user who made the request (if available)
* `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.
* ⚠️ `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
* ⚠️ `username` - The name of the user account associated with the change.
!!! warning "Deprecation of legacy keys"
The `request_id` and `username` keys 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.
### Default Request Body
@@ -56,6 +66,12 @@ If no body template is specified, the request body will be populated with a JSON
"region": null,
...
},
"request": {
"id": "17af32f0-852a-46ca-a7d4-33ecd0c13de6",
"method": "POST",
"path": "/dcim/sites/add/",
"user": "jstretch"
},
"snapshots": {
"prechange": null,
"postchange": {

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

@@ -81,10 +81,13 @@ The following context variables are available to the text and link templates.
| Variable | Description |
|---------------|------------------------------------------------------|
| `event` | The event type (`created`, `updated`, or `deleted`) |
| `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

@@ -0,0 +1,24 @@
# Custom Model Actions
Plugins can register custom permission actions for their models. These actions appear as checkboxes in the ObjectPermission form, making it easy for administrators to grant or restrict access to plugin-specific functionality without manually entering action names.
For example, a plugin might define a "sync" action for a model that syncs data from an external source, or a "bypass" action that allows users to bypass certain restrictions.
## Registering Model Actions
The preferred way to register custom actions is via Django's `Meta.permissions` on the model class. NetBox will automatically register these as model actions when the app is loaded:
```python
from netbox.models import NetBoxModel
class MyModel(NetBoxModel):
# ...
class Meta:
permissions = [
('sync', 'Synchronize data from external source'),
('export', 'Export data to external system'),
]
```
Once registered, these actions appear as checkboxes in a flat list when creating or editing an ObjectPermission.

View File

@@ -1,12 +1,9 @@
# UI Components
!!! note "New in NetBox v4.5"
All UI components described here were introduced in NetBox v4.5. Be sure to set the minimum NetBox version to 4.5.0 for your plugin before incorporating any of these resources.
!!! note "New in NetBox v4.6"
All UI components described here were introduced in NetBox v4.6. Be sure to set the minimum NetBox version to 4.6.0 for your plugin before incorporating any of these resources.
!!! danger "Beta Feature"
UI components are considered a beta feature, and are still under active development. Please be aware that the API for resources on this page is subject to change in future releases.
To simply the process of designing your plugin's user interface, and to encourage a consistent look and feel throughout the entire application, NetBox provides a set of components that enable programmatic UI design. These make it possible to declare complex page layouts with little or no custom HTML.
To simplify the process of designing your plugin's user interface, and to encourage a consistent look and feel throughout the entire application, NetBox provides a set of components that enable programmatic UI design. These make it possible to declare complex page layouts with little or no custom HTML.
## Page Layout
@@ -75,9 +72,12 @@ class RecentChangesPanel(Panel):
**super().get_context(context),
'changes': get_changes()[:10],
}
def should_render(self, context):
return len(context['changes']) > 0
```
NetBox also includes a set of panels suite for specific uses, such as display object details or embedding a table of related objects. These are listed below.
NetBox also includes a set of panels suited for specific uses, such as displaying object details or embedding a table of related objects. These are listed below.
::: netbox.ui.panels.Panel
@@ -85,26 +85,6 @@ NetBox also includes a set of panels suite for specific uses, such as display ob
::: netbox.ui.panels.ObjectAttributesPanel
#### Object Attributes
The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes.
| Class | Description |
|--------------------------------------|--------------------------------------------------|
| `netbox.ui.attrs.AddressAttr` | A physical or mailing address. |
| `netbox.ui.attrs.BooleanAttr` | A boolean value |
| `netbox.ui.attrs.ColorAttr` | A color expressed in RGB |
| `netbox.ui.attrs.ChoiceAttr` | A selection from a set of choices |
| `netbox.ui.attrs.GPSCoordinatesAttr` | GPS coordinates (latitude and longitude) |
| `netbox.ui.attrs.ImageAttr` | An attached image (displays the image) |
| `netbox.ui.attrs.NestedObjectAttr` | A related nested object |
| `netbox.ui.attrs.NumericAttr` | An integer or float value |
| `netbox.ui.attrs.RelatedObjectAttr` | A related object |
| `netbox.ui.attrs.TemplatedAttr` | Renders an attribute using a custom template |
| `netbox.ui.attrs.TextAttr` | A string (text) value |
| `netbox.ui.attrs.TimezoneAttr` | A timezone with annotated offset |
| `netbox.ui.attrs.UtilizationAttr` | A numeric value expressed as a utilization graph |
::: netbox.ui.panels.OrganizationalObjectPanel
::: netbox.ui.panels.NestedGroupObjectPanel
@@ -119,9 +99,13 @@ The following classes are available to represent object attributes within an Obj
::: netbox.ui.panels.TemplatePanel
::: netbox.ui.panels.TextCodePanel
::: netbox.ui.panels.ContextTablePanel
::: netbox.ui.panels.PluginContentPanel
## Panel Actions
### Panel Actions
Each panel may have actions associated with it. These render as links or buttons within the panel header, opposite the panel's title. For example, a common use case is to include an "Add" action on a panel which displays a list of objects. Below is an example of this.
@@ -146,3 +130,60 @@ panels.ObjectsTablePanel(
::: netbox.ui.actions.AddObject
::: netbox.ui.actions.CopyContent
## Object Attributes
The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes.
| Class | Description |
|------------------------------------------|--------------------------------------------------|
| `netbox.ui.attrs.AddressAttr` | A physical or mailing address. |
| `netbox.ui.attrs.BooleanAttr` | A boolean value |
| `netbox.ui.attrs.ChoiceAttr` | A selection from a set of choices |
| `netbox.ui.attrs.ColorAttr` | A color expressed in RGB |
| `netbox.ui.attrs.DateTimeAttr` | A date or datetime value |
| `netbox.ui.attrs.GenericForeignKeyAttr` | A related object via a generic foreign key |
| `netbox.ui.attrs.GPSCoordinatesAttr` | GPS coordinates (latitude and longitude) |
| `netbox.ui.attrs.ImageAttr` | An attached image (displays the image) |
| `netbox.ui.attrs.NestedObjectAttr` | A related nested object (includes ancestors) |
| `netbox.ui.attrs.NumericAttr` | An integer or float value |
| `netbox.ui.attrs.RelatedObjectAttr` | A related object |
| `netbox.ui.attrs.RelatedObjectListAttr` | A list of related objects |
| `netbox.ui.attrs.TemplatedAttr` | Renders an attribute using a custom template |
| `netbox.ui.attrs.TextAttr` | A string (text) value |
| `netbox.ui.attrs.TimezoneAttr` | A timezone with annotated offset |
| `netbox.ui.attrs.UtilizationAttr` | A numeric value expressed as a utilization graph |
::: netbox.ui.attrs.ObjectAttribute
::: netbox.ui.attrs.AddressAttr
::: netbox.ui.attrs.BooleanAttr
::: netbox.ui.attrs.ChoiceAttr
::: netbox.ui.attrs.ColorAttr
::: netbox.ui.attrs.DateTimeAttr
::: netbox.ui.attrs.GenericForeignKeyAttr
::: netbox.ui.attrs.GPSCoordinatesAttr
::: netbox.ui.attrs.ImageAttr
::: netbox.ui.attrs.NestedObjectAttr
::: netbox.ui.attrs.NumericAttr
::: netbox.ui.attrs.RelatedObjectAttr
::: netbox.ui.attrs.RelatedObjectListAttr
::: netbox.ui.attrs.TemplatedAttr
::: netbox.ui.attrs.TextAttr
::: netbox.ui.attrs.TimezoneAttr
::: netbox.ui.attrs.UtilizationAttr

View File

@@ -36,6 +36,7 @@ The resulting webhook payload will look like the following:
"url": "/api/dcim/sites/2/",
...
},
"request": {...},
"snapshots": {...},
"context": {
"foo": 123
@@ -43,6 +44,11 @@ The resulting webhook payload will look like the following:
}
```
!!! warning "Deprecation of legacy keys"
The `request_id` and `username` keys 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 "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

@@ -10,6 +10,14 @@ Minor releases are published in April, August, and December of each calendar yea
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
#### [Version 4.6](./version-4.6.md) (May 2026)
* Virtual Machine Types ([#5795](https://github.com/netbox-community/netbox/issues/5795))
* Cable Bundles ([#20151](https://github.com/netbox-community/netbox/issues/20151))
* Rack Groups ([#20961](https://github.com/netbox-community/netbox/issues/20961))
* ETag Support for REST API ([#21356](https://github.com/netbox-community/netbox/issues/21356))
* Cursor-based Pagination for REST API ([#21363](https://github.com/netbox-community/netbox/issues/21363))
#### [Version 4.5](./version-4.5.md) (January 2026)
* Lookup Modifiers in Filter Forms ([#7604](https://github.com/netbox-community/netbox/issues/7604))

View File

@@ -1,5 +1,27 @@
# NetBox v4.5
## v4.5.8 (2026-04-14)
### Enhancements
* [#21430](https://github.com/netbox-community/netbox/issues/21430) - Display the device role's color in the device view
* [#21795](https://github.com/netbox-community/netbox/issues/21795) - Update `humanize_speed` template filter to support decimal Gbps/Tbps values
### Bug Fixes
* [#21529](https://github.com/netbox-community/netbox/issues/21529) - Exclude non-existent custom fields from object changelog data returned via the REST API
* [#21542](https://github.com/netbox-community/netbox/issues/21542) - Expand interface speed field to 64-bit integer to prevent overflow for LAG interfaces exceeding ~2.1 Tbps
* [#21704](https://github.com/netbox-community/netbox/issues/21704) - Fix missing port mappings in device type YAML export
* [#21783](https://github.com/netbox-community/netbox/issues/21783) - Fix support for bulk import of cables connected to power feeds
* [#21801](https://github.com/netbox-community/netbox/issues/21801) - Prevent duplicate filename collision when uploading files using S3 storage
* [#21814](https://github.com/netbox-community/netbox/issues/21814) - Fix custom script "last run" time to reflect job start time rather than creation time
* [#21835](https://github.com/netbox-community/netbox/issues/21835) - Correct help text for color selection form fields
* [#21841](https://github.com/netbox-community/netbox/issues/21841) - Restore visibility of the edit button for script modules to non-superusers
* [#21845](https://github.com/netbox-community/netbox/issues/21845) - Fix CSV export of connection columns rendering template whitespace instead of a formatted value
* [#21869](https://github.com/netbox-community/netbox/issues/21869) - Remove redundant `ScriptModule` class synchronization triggered on save
---
## v4.5.7 (2026-04-03)
### Enhancements

View File

@@ -0,0 +1,123 @@
# NetBox v4.6
## v4.6.0-beta1 (2026-04-14)
### New Features
#### Virtual Machine Types ([#5795](https://github.com/netbox-community/netbox/issues/5795))
A new VirtualMachineType model has been introduced to enable categorization of virtual machines by instance type, analogous to how DeviceType categorizes physical hardware. VM types can be defined once and reused across many virtual machines.
#### Cable Bundles ([#20151](https://github.com/netbox-community/netbox/issues/20151))
A new CableBundle model allows individual cables to be grouped together to represent physical cable runs that are managed as a unit; e.g. a bundle of 48 CAT6 cables between two patch panels. (Please note that this feature is _not_ suitable for modeling individual fiber strands within a single cable.)
#### Rack Groups ([#20961](https://github.com/netbox-community/netbox/issues/20961))
A flat RackGroup model has been reintroduced to provide a lightweight secondary axis of rack organization (e.g. by row or aisle) that is independent of the location hierarchy. Racks carry an optional foreign key to a RackGroup, and RackGroup can also serve as a scope for VLANGroup assignments.
#### ETag Support for REST API ([#21356](https://github.com/netbox-community/netbox/issues/21356))
The REST API now returns an `ETag` header on responses for individual objects, derived from the object's last-updated timestamp. Clients can supply an `If-Match` header on PUT/PATCH requests to guard against conflicting concurrent updates; if the object has been modified since the ETag was issued, the server returns a 412 (Precondition Failed) response.
#### Cursor-based Pagination for REST API ([#21363](https://github.com/netbox-community/netbox/issues/21363))
A new `start` query parameter has been introduced as an efficient alternative to the existing `offset` parameter for paginating large result sets. Rather than scanning the table up to a relative offset, the `start` parameter filters for objects with a primary key equal to or greater than the given value, enabling constant-time pagination regardless of result set size.
### Enhancements
* [#12024](https://github.com/netbox-community/netbox/issues/12024) - Permit virtual machines to be assigned to devices without a cluster
* [#14329](https://github.com/netbox-community/netbox/issues/14329) - Improve diff highlighting for custom field data in change logs
* [#15513](https://github.com/netbox-community/netbox/issues/15513) - Add bulk creation support for IP prefixes
* [#17654](https://github.com/netbox-community/netbox/issues/17654) - Support role assignment for ASNs
* [#19025](https://github.com/netbox-community/netbox/issues/19025) - Support optional schema validation for JSON custom fields
* [#19034](https://github.com/netbox-community/netbox/issues/19034) - Annotate total reserved unit count on rack reservations
* [#19138](https://github.com/netbox-community/netbox/issues/19138) - Include NAT addresses for primary & out-of-band IP addresses in REST API
* [#19796](https://github.com/netbox-community/netbox/issues/19796) - Support `{module}` position inheritance for nested module bays
* [#19953](https://github.com/netbox-community/netbox/issues/19953) - Enable debugging support for ConfigTemplate rendering
* [#20123](https://github.com/netbox-community/netbox/issues/20123) - Introduce options to control adoption/replication of device components via REST API (replicates UI behavior)
* [#20152](https://github.com/netbox-community/netbox/issues/20152) - Support for marking module and device bays as disabled
* [#20162](https://github.com/netbox-community/netbox/issues/20162) - Provide an option to execute as a background job when adding components to devices in bulk
* [#20163](https://github.com/netbox-community/netbox/issues/20163) - Add changelog message support for bulk device component creation
* [#20698](https://github.com/netbox-community/netbox/issues/20698) - Add read-only `total_vlan_ids` attribute on VLAN group representation in REST & GraphQL APIs
* [#20916](https://github.com/netbox-community/netbox/issues/20916) - Include stack trace for unhandled exceptions in job logs
* [#21157](https://github.com/netbox-community/netbox/issues/21157) - Include all public model classes in export template context
* [#21409](https://github.com/netbox-community/netbox/issues/21409) - Introduce `CHANGELOG_RETAIN_CREATE_LAST_UPDATE` configuration parameter to retain creation & most recent update record in change log for each object
* [#21575](https://github.com/netbox-community/netbox/issues/21575) - Introduce `{vc_position}` template variable for device component template name/label
* [#21662](https://github.com/netbox-community/netbox/issues/21662) - Increase `rf_channel_frequency` precision to 3 decimal places
* [#21702](https://github.com/netbox-community/netbox/issues/21702) - Include a serialized representation of the HTTP request in each webhook
* [#21720](https://github.com/netbox-community/netbox/issues/21720) - Align HTTP basic auth regex of `EnhancedURLValidator` with Django's `URLValidator`
* [#21770](https://github.com/netbox-community/netbox/issues/21770) - Enable specifying columns to include/exclude on embedded tables
* [#21771](https://github.com/netbox-community/netbox/issues/21771) - Add support for partial tag assignment (`add_tags`) and removal (`remove_tags`) via REST API
* [#21780](https://github.com/netbox-community/netbox/issues/21780) - Add changelog message support to bulk creation of IP addresses
* [#21865](https://github.com/netbox-community/netbox/issues/21865) - Allow setting empty `INTERNAL_IPS` to enable debug toolbar for all clients
### Performance
* [#21455](https://github.com/netbox-community/netbox/issues/21455) - Ensure PostgreSQL indexes exist to support the default ordering of each model
### Plugins
* [#20924](https://github.com/netbox-community/netbox/issues/20924) - Introduce support for declarative layouts and reusable UI components
* [#21357](https://github.com/netbox-community/netbox/issues/21357) - Provide an API for plugins to register custom model actions (for permission assignment)
### Deprecations
* [#21284](https://github.com/netbox-community/netbox/issues/21284) - Deprecate the `username` and `request_id` fields in event data
* [#21304](https://github.com/netbox-community/netbox/issues/21304) - Deprecate the `housekeeping` management command
* [#21331](https://github.com/netbox-community/netbox/issues/21331) - Deprecate NetBox's custom `querystring` template tag
* [#21881](https://github.com/netbox-community/netbox/issues/21881) - Deprecate legacy Sentry configuration parameters
* [#21884](https://github.com/netbox-community/netbox/issues/21884) - Deprecate the obsolete `DEFAULT_ACTION_PERMISSIONS` mapping
* [#21887](https://github.com/netbox-community/netbox/issues/21887) - Deprecate support for legacy view actions
* [#21890](https://github.com/netbox-community/netbox/issues/21890) - Deprecate `models` key in application registry
### Other Changes
* [#20984](https://github.com/netbox-community/netbox/issues/20984) - Upgrade to Django 6.0
* [#21635](https://github.com/netbox-community/netbox/issues/21635) - Migrate documentation site from mkdocs to Zensical
### REST API Changes
* New features:
* `ETag` response header and `If-Match` request header support for all individual object endpoints
* `start` query parameter for cursor-based pagination on all list endpoints
* `add_tags` and `remove_tags` write-only fields on all taggable model serializers
* New endpoints:
* `GET/POST /api/dcim/cable-bundles/`
* `GET/PUT/PATCH/DELETE /api/dcim/cable-bundles/<id>/`
* `GET/POST /api/dcim/rack-groups/`
* `GET/PUT/PATCH/DELETE /api/dcim/rack-groups/<id>/`
* `GET/POST /api/virtualization/virtual-machine-types/`
* `GET/PUT/PATCH/DELETE /api/virtualization/virtual-machine-types/<id>/`
* `dcim.Cable`
* Add optional foreign key field `bundle`
* `dcim.Device`
* The `primary_ip`, `primary_ip4`, `primary_ip6`, and `oob_ip` nested representations now include `nat_inside` and `nat_outside`
* `dcim.DeviceBay`
* Add boolean field `enabled`
* Add read-only boolean field `_occupied`
* `dcim.DeviceBayTemplate`
* Add boolean field `enabled`
* `dcim.Module`
* Add write-only boolean fields `replicate_components` and `adopt_components`
* `dcim.ModuleBay`
* Add boolean field `enabled`
* Add read-only boolean field `_occupied`
* `dcim.ModuleBayTemplate`
* Add boolean field `enabled`
* `dcim.Rack`
* Add optional foreign key field `group`
* `dcim.RackReservation`
* Add read-only integer field `unit_count`
* `extras.CustomField`
* Add JSON field `validation_schema`
* `ipam.ASN`
* Add optional foreign key field `role`
* `ipam.Role`
* Annotate count of assigned ASNs (`asn_count`)
* `ipam.VLANGroup`
* Add read-only field `total_vlan_ids`
* `virtualization.VirtualMachine`
* Add optional foreign key field `virtual_machine_type`
* The `primary_ip`, `primary_ip4`, and `primary_ip6` nested representations now include `nat_inside` and `nat_outside`
* The `cluster` field is now optional (nullable)

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/
@@ -151,6 +152,7 @@ nav:
- Filters & Filter Sets: 'plugins/development/filtersets.md'
- Search: 'plugins/development/search.md'
- Event Types: 'plugins/development/event-types.md'
- Permissions: 'plugins/development/permissions.md'
- Data Backends: 'plugins/development/data-backends.md'
- Webhooks: 'plugins/development/webhooks.md'
- User Interface: 'plugins/development/user-interface.md'
@@ -189,6 +191,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 +224,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 +289,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'
@@ -322,6 +327,7 @@ nav:
- git Cheat Sheet: 'development/git-cheat-sheet.md'
- Release Notes:
- Summary: 'release-notes/index.md'
- Version 4.6: 'release-notes/version-4.6.md'
- Version 4.5: 'release-notes/version-4.5.md'
- Version 4.4: 'release-notes/version-4.4.md'
- Version 4.3: 'release-notes/version-4.3.md'

View File

@@ -0,0 +1,35 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0056_gfk_indexes'),
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0231_interface_rf_channel_frequency_precision'),
('extras', '0136_customfield_validation_schema'),
('tenancy', '0023_add_mptt_tree_indexes'),
('users', '0015_owner'),
]
operations = [
migrations.AddIndex(
model_name='circuit',
index=models.Index(fields=['provider', 'provider_account', 'cid'], name='circuits_ci_provide_a0c42c_idx'),
),
migrations.AddIndex(
model_name='circuitgroupassignment',
index=models.Index(
fields=['group', 'member_type', 'member_id', 'priority', 'id'], name='circuits_ci_group_i_2f8327_idx'
),
),
migrations.AddIndex(
model_name='virtualcircuit',
index=models.Index(
fields=['provider_network', 'provider_account', 'cid'], name='circuits_vi_provide_989efa_idx'
),
),
migrations.AddIndex(
model_name='virtualcircuittermination',
index=models.Index(fields=['virtual_circuit', 'role', 'id'], name='circuits_vi_virtual_4b5c0c_idx'),
),
]

View File

@@ -144,6 +144,9 @@ class Circuit(ContactsMixin, ImageAttachmentsMixin, DistanceMixin, PrimaryModel)
name='%(app_label)s_%(class)s_unique_provideraccount_cid'
),
)
indexes = (
models.Index(fields=('provider', 'provider_account', 'cid')), # Default ordering
)
verbose_name = _('circuit')
verbose_name_plural = _('circuits')
@@ -221,6 +224,9 @@ class CircuitGroupAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin,
name='%(app_label)s_%(class)s_unique_member_group'
),
)
indexes = (
models.Index(fields=('group', 'member_type', 'member_id', 'priority', 'id')), # Default ordering
)
verbose_name = _('Circuit group assignment')
verbose_name_plural = _('Circuit group assignments')

View File

@@ -97,6 +97,9 @@ class VirtualCircuit(ContactsMixin, PrimaryModel):
name='%(app_label)s_%(class)s_unique_provideraccount_cid'
),
)
indexes = (
models.Index(fields=('provider_network', 'provider_account', 'cid')), # Default ordering
)
verbose_name = _('virtual circuit')
verbose_name_plural = _('virtual circuits')
@@ -150,6 +153,9 @@ class VirtualCircuitTermination(
class Meta:
ordering = ['virtual_circuit', 'role', 'pk']
indexes = (
models.Index(fields=('virtual_circuit', 'role', 'id')), # Default ordering
)
verbose_name = _('virtual circuit termination')
verbose_name_plural = _('virtual circuit terminations')

View File

@@ -13,13 +13,9 @@ class CircuitCircuitTerminationPanel(panels.ObjectPanel):
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 __init__(self, side, accessor=None, **kwargs):
super().__init__(accessor=accessor, **kwargs)
self.side = side
def get_context(self, context):
return {
@@ -58,6 +54,26 @@ class CircuitGroupAssignmentsPanel(panels.ObjectsTablePanel):
)
class CircuitTerminationPanel(panels.ObjectAttributesPanel):
title = _('Circuit Termination')
circuit = attrs.RelatedObjectAttr('circuit', linkify=True)
provider = attrs.RelatedObjectAttr('circuit.provider', linkify=True)
termination = attrs.GenericForeignKeyAttr('termination', linkify=True, label=_('Termination point'))
connection = attrs.TemplatedAttr(
'pk',
template_name='circuits/circuit_termination/attrs/connection.html',
label=_('Connection'),
)
speed = attrs.TemplatedAttr(
'port_speed',
template_name='circuits/circuit_termination/attrs/speed.html',
label=_('Speed'),
)
xconnect_id = attrs.TextAttr('xconnect_id', label=_('Cross-Connect'), style='font-monospace')
pp_info = attrs.TextAttr('pp_info', label=_('Patch Panel/Port'))
description = attrs.TextAttr('description')
class CircuitGroupPanel(panels.OrganizationalObjectPanel):
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')

View File

@@ -8,7 +8,6 @@ from netbox.ui import actions, layout
from netbox.ui.panels import (
CommentsPanel,
ObjectsTablePanel,
Panel,
RelatedObjectsPanel,
)
from netbox.views import generic
@@ -53,6 +52,7 @@ class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
ObjectsTablePanel(
model='circuits.ProviderAccount',
filters={'provider_id': lambda ctx: ctx['object'].pk},
exclude_columns=['provider'],
actions=[
actions.AddObject(
'circuits.ProviderAccount', url_params={'provider': lambda ctx: ctx['object'].pk}
@@ -62,6 +62,7 @@ class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
ObjectsTablePanel(
model='circuits.Circuit',
filters={'provider_id': lambda ctx: ctx['object'].pk},
exclude_columns=['provider'],
actions=[
actions.AddObject('circuits.Circuit', url_params={'provider': lambda ctx: ctx['object'].pk}),
],
@@ -161,6 +162,7 @@ class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView):
ObjectsTablePanel(
model='circuits.Circuit',
filters={'provider_account_id': lambda ctx: ctx['object'].pk},
exclude_columns=['provider_account'],
actions=[
actions.AddObject(
'circuits.Circuit',
@@ -257,6 +259,7 @@ class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
ObjectsTablePanel(
model='circuits.VirtualCircuit',
filters={'provider_network_id': lambda ctx: ctx['object'].pk},
exclude_columns=['provider_network'],
actions=[
actions.AddObject(
'circuits.VirtualCircuit', url_params={'provider_network': lambda ctx: ctx['object'].pk}
@@ -508,10 +511,7 @@ class CircuitTerminationView(generic.ObjectView):
queryset = CircuitTermination.objects.all()
layout = layout.SimpleLayout(
left_panels=[
Panel(
template_name='circuits/panels/circuit_termination.html',
title=_('Circuit Termination'),
)
panels.CircuitTerminationPanel(),
],
right_panels=[
CustomFieldsPanel(),
@@ -801,6 +801,7 @@ class VirtualCircuitView(generic.ObjectView):
model='circuits.VirtualCircuitTermination',
title=_('Terminations'),
filters={'virtual_circuit_id': lambda ctx: ctx['object'].pk},
exclude_columns=['virtual_circuit'],
actions=[
actions.AddObject(
'circuits.VirtualCircuitTermination',

View File

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

@@ -0,0 +1,21 @@
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('core', '0021_job_queue_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddIndex(
model_name='configrevision',
index=models.Index(fields=['-created'], name='core_config_created_ef9552_idx'),
),
migrations.AddIndex(
model_name='job',
index=models.Index(fields=['-created'], name='core_job_created_efa7cb_idx'),
),
]

View File

@@ -0,0 +1,15 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0022_default_ordering_indexes'),
]
operations = [
migrations.AlterModelOptions(
name='datasource',
options={'ordering': ('name',), 'permissions': [('sync', 'Synchronize data from remote source')]},
),
]

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

@@ -37,6 +37,9 @@ class ConfigRevision(models.Model):
class Meta:
ordering = ['-created']
indexes = (
models.Index(fields=('-created',)), # Default ordering
)
verbose_name = _('config revision')
verbose_name_plural = _('config revisions')
constraints = [

View File

@@ -87,6 +87,9 @@ class DataSource(JobsMixin, PrimaryModel):
ordering = ('name',)
verbose_name = _('data source')
verbose_name_plural = _('data sources')
permissions = [
('sync', 'Synchronize data from remote source'),
]
def __str__(self):
return f'{self.name}'

View File

@@ -133,6 +133,7 @@ class Job(models.Model):
class Meta:
ordering = ['-created']
indexes = (
models.Index(fields=('-created',)), # Default ordering
models.Index(fields=('object_type', 'object_id')),
)
verbose_name = _('job')

View File

@@ -51,7 +51,6 @@ class ObjectTypeManager(models.Manager):
"""
return self.get(app_label=app_label, model=model)
# TODO: Remove in NetBox v4.5
def get_for_id(self, id):
"""
Retrieve an ObjectType by its primary key (numeric ID).

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

@@ -18,7 +18,7 @@ class JobTableTest(TableTestCases.StandardTableTestCase):
class ObjectChangeTableTest(TableTestCases.StandardTableTestCase):
table = ObjectChangeTable
queryset_sources = [
('ObjectChangeListView', ObjectChange.objects.valid_models()),
('ObjectChangeListView', ObjectChange.objects.all()),
]

View File

@@ -41,7 +41,7 @@ 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
@@ -94,6 +94,7 @@ class DataSourceView(GetRelatedModelsMixin, generic.ObjectView):
ObjectsTablePanel(
model='core.DataFile',
filters={'source_id': lambda ctx: ctx['object'].pk},
exclude_columns=['source'],
),
],
)
@@ -192,8 +193,14 @@ class DataFileView(generic.ObjectView):
layout.Column(
panels.DataFilePanel(),
panels.DataFileContentPanel(),
PluginContentPanel('left_page'),
),
),
layout.Row(
layout.Column(
PluginContentPanel('full_width_page'),
)
),
)
@@ -349,17 +356,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,

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

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

@@ -58,10 +58,30 @@ class DeviceSerializer(PrimaryModelSerializer):
)
status = ChoiceField(choices=DeviceStatusChoices, required=False)
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True)
primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True)
primary_ip6 = IPAddressSerializer(nested=True, required=False, allow_null=True)
oob_ip = IPAddressSerializer(nested=True, required=False, allow_null=True)
primary_ip = IPAddressSerializer(
nested=True,
read_only=True,
allow_null=True,
fields=[*IPAddressSerializer.Meta.brief_fields, 'nat_inside', 'nat_outside'],
)
primary_ip4 = IPAddressSerializer(
nested=True,
required=False,
allow_null=True,
fields=[*IPAddressSerializer.Meta.brief_fields, 'nat_inside', 'nat_outside'],
)
primary_ip6 = IPAddressSerializer(
nested=True,
required=False,
allow_null=True,
fields=[*IPAddressSerializer.Meta.brief_fields, 'nat_inside', 'nat_outside'],
)
oob_ip = IPAddressSerializer(
nested=True,
required=False,
allow_null=True,
fields=[*IPAddressSerializer.Meta.brief_fields, 'nat_inside', 'nat_outside'],
)
parent_device = serializers.SerializerMethodField()
cluster = ClusterSerializer(nested=True, required=False, allow_null=True)
virtual_chassis = VirtualChassisSerializer(nested=True, required=False, allow_null=True, default=None)
@@ -151,48 +171,80 @@ 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)
if self.nested:
# 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
# Skip validation for existing modules (updates)
if self.instance is not None:
# 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
module_bay = data.get('module_bay')
module_type = data.get('module_type')
device = data.get('device')
module_type = data.get('module_type')
module_bay = data.get('module_bay')
if not all((module_bay, module_type, device)):
# 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, component_attribute in [
("consoleporttemplates", "consoleports"),
("consoleserverporttemplates", "consoleserverports"),
("interfacetemplates", "interfaces"),
("powerporttemplates", "powerports"),
("poweroutlettemplates", "poweroutlets"),
("rearporttemplates", "rearports"),
("frontporttemplates", "frontports"),
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_attribute).all()
component.name: component
for component in getattr(device, component_attr).all()
}
for template in getattr(module_type, templates).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:
@@ -204,7 +256,17 @@ class ModuleSerializer(PrimaryModelSerializer):
except ValueError as e:
raise serializers.ValidationError(str(e))
if resolved_name in installed_components:
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__,
@@ -214,6 +276,27 @@ class ModuleSerializer(PrimaryModelSerializer):
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

@@ -1,9 +1,11 @@
from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
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 +18,7 @@ from .sites import LocationSerializer, SiteSerializer
__all__ = (
'RackElevationDetailFilterSerializer',
'RackGroupSerializer',
'RackReservationSerializer',
'RackRoleSerializer',
'RackSerializer',
@@ -23,6 +26,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 +104,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 +149,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 +175,17 @@ class RackReservationSerializer(PrimaryModelSerializer):
allow_null=True,
)
unit_count = serializers.SerializerMethodField()
@extend_schema_field(OpenApiTypes.INT32)
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

@@ -19,6 +19,7 @@ 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
#
@@ -574,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

@@ -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
@@ -46,6 +47,7 @@ from .constants import *
from .models import *
__all__ = (
'CableBundleFilterSet',
'CableFilterSet',
'CableTerminationFilterSet',
'CabledObjectFilterSet',
@@ -86,6 +88,7 @@ __all__ = (
'PowerPortFilterSet',
'PowerPortTemplateFilterSet',
'RackFilterSet',
'RackGroupFilterSet',
'RackReservationFilterSet',
'RackRoleFilterSet',
'RackTypeFilterSet',
@@ -313,6 +316,14 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupMode
return queryset
@register_filterset
class RackGroupFilterSet(OrganizationalModelFilterSet):
class Meta:
model = RackGroup
fields = ('id', 'name', 'slug', 'description')
@register_filterset
class RackRoleFilterSet(OrganizationalModelFilterSet):
@@ -417,6 +428,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(),
@@ -551,6 +574,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,
@@ -572,11 +608,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
@@ -995,7 +1050,7 @@ class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
class Meta:
model = ModuleBayTemplate
fields = ('id', 'name', 'label', 'position', 'description')
fields = ('id', 'name', 'label', 'position', 'enabled', 'description')
@register_filterset
@@ -1003,7 +1058,7 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent
class Meta:
model = DeviceBayTemplate
fields = ('id', 'name', 'label', 'description')
fields = ('id', 'name', 'label', 'enabled', 'description')
@register_filterset
@@ -2360,7 +2415,7 @@ class ModuleBayFilterSet(ModularDeviceComponentFilterSet):
class Meta:
model = ModuleBay
fields = ('id', 'name', 'label', 'position', 'description')
fields = ('id', 'name', 'label', 'position', 'enabled', 'description')
@register_filterset
@@ -2380,7 +2435,7 @@ class DeviceBayFilterSet(DeviceComponentFilterSet):
class Meta:
model = DeviceBay
fields = ('id', 'name', 'label', 'description')
fields = ('id', 'name', 'label', 'enabled', 'description')
@register_filterset
@@ -2533,6 +2588,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(
@@ -2553,6 +2625,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

@@ -35,6 +35,7 @@ from wireless.models import WirelessLAN, WirelessLANGroup
__all__ = (
'CableBulkEditForm',
'CableBundleBulkEditForm',
'ConsolePortBulkEditForm',
'ConsolePortTemplateBulkEditForm',
'ConsoleServerPortBulkEditForm',
@@ -67,6 +68,7 @@ __all__ = (
'PowerPortBulkEditForm',
'PowerPortTemplateBulkEditForm',
'RackBulkEditForm',
'RackGroupBulkEditForm',
'RackReservationBulkEditForm',
'RackRoleBulkEditForm',
'RackTypeBulkEditForm',
@@ -207,6 +209,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'),
@@ -342,6 +352,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(),
@@ -441,14 +456,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',
)
@@ -776,6 +793,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'),
@@ -800,6 +835,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,
@@ -823,11 +863,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',
)
@@ -1211,6 +1251,11 @@ class ModuleBayTemplateBulkEditForm(ComponentTemplateBulkEditForm):
label=_('Description'),
required=False
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=BulkEditNullBooleanSelect,
)
nullable_fields = ('label', 'position', 'description')
@@ -1229,6 +1274,11 @@ class DeviceBayTemplateBulkEditForm(ComponentTemplateBulkEditForm):
label=_('Description'),
required=False
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=BulkEditNullBooleanSelect,
)
nullable_fields = ('label', 'description')
@@ -1653,23 +1703,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(
@@ -1397,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(
@@ -1409,8 +1443,16 @@ class CableImportForm(PrimaryModelImportForm):
side_a_device = CSVModelChoiceField(
label=_('Side A device'),
queryset=Device.objects.all(),
required=False,
to_field_name='name',
help_text=_('Device name')
help_text=_('Device name (for device component terminations)')
)
side_a_power_panel = CSVModelChoiceField(
label=_('Side A power panel'),
queryset=PowerPanel.objects.all(),
required=False,
to_field_name='name',
help_text=_('Power panel name (for power feed terminations)')
)
side_a_type = CSVContentTypeField(
label=_('Side A type'),
@@ -1434,8 +1476,16 @@ class CableImportForm(PrimaryModelImportForm):
side_b_device = CSVModelChoiceField(
label=_('Side B device'),
queryset=Device.objects.all(),
required=False,
to_field_name='name',
help_text=_('Device name')
help_text=_('Device name (for device component terminations)')
)
side_b_power_panel = CSVModelChoiceField(
label=_('Side B power panel'),
queryset=PowerPanel.objects.all(),
required=False,
to_field_name='name',
help_text=_('Power panel name (for power feed terminations)')
)
side_b_type = CSVContentTypeField(
label=_('Side B type'),
@@ -1474,6 +1524,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,8 +1547,9 @@ class CableImportForm(PrimaryModelImportForm):
class Meta:
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_a_site', 'side_a_device', 'side_a_power_panel', 'side_a_type', 'side_a_name',
'side_b_site', 'side_b_device', 'side_b_power_panel', 'side_b_type', 'side_b_name',
'type', 'status', 'profile', 'tenant', 'bundle', 'label', 'color', 'length', 'length_unit',
'description', 'owner', 'comments', 'tags',
]
@@ -1501,16 +1559,22 @@ class CableImportForm(PrimaryModelImportForm):
if data:
# Limit choices for side_a_device to the assigned side_a_site
if side_a_site := data.get('side_a_site'):
side_a_device_params = {f'site__{self.fields["side_a_site"].to_field_name}': side_a_site}
side_a_parent_params = {f'site__{self.fields['side_a_site'].to_field_name}': side_a_site}
self.fields['side_a_device'].queryset = self.fields['side_a_device'].queryset.filter(
**side_a_device_params
**side_a_parent_params
)
self.fields['side_a_power_panel'].queryset = self.fields['side_a_power_panel'].queryset.filter(
**side_a_parent_params
)
# Limit choices for side_b_device to the assigned side_b_site
if side_b_site := data.get('side_b_site'):
side_b_device_params = {f'site__{self.fields["side_b_site"].to_field_name}': side_b_site}
side_b_parent_params = {f'site__{self.fields['side_b_site'].to_field_name}': side_b_site}
self.fields['side_b_device'].queryset = self.fields['side_b_device'].queryset.filter(
**side_b_device_params
**side_b_parent_params
)
self.fields['side_b_power_panel'].queryset = self.fields['side_b_power_panel'].queryset.filter(
**side_b_parent_params
)
def _clean_side(self, side):
@@ -1522,33 +1586,57 @@ class CableImportForm(PrimaryModelImportForm):
assert side in 'ab', f"Invalid side designation: {side}"
device = self.cleaned_data.get(f'side_{side}_device')
power_panel = self.cleaned_data.get(f'side_{side}_power_panel')
content_type = self.cleaned_data.get(f'side_{side}_type')
name = self.cleaned_data.get(f'side_{side}_name')
if not device or not content_type or not name:
if not content_type or not name:
return None
model = content_type.model_class()
try:
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)
if termination_object.cable is not None and termination_object.cable != self.instance:
# PowerFeed terminations reference a PowerPanel, not a Device
if content_type.model == 'powerfeed':
if not power_panel:
return None
try:
termination_object = model.objects.get(power_panel=power_panel, name=name)
if termination_object.cable is not None and termination_object.cable != self.instance:
raise forms.ValidationError(
_("Side {side_upper}: {power_panel} {termination_object} is already connected").format(
side_upper=side.upper(), power_panel=power_panel, termination_object=termination_object
)
)
except ObjectDoesNotExist:
raise forms.ValidationError(
_("Side {side_upper}: {device} {termination_object} is already connected").format(
side_upper=side.upper(), device=device, termination_object=termination_object
_("{side_upper} side termination not found: {power_panel} {name}").format(
side_upper=side.upper(), power_panel=power_panel, name=name
)
)
except ObjectDoesNotExist:
raise forms.ValidationError(
_("{side_upper} side termination not found: {device} {name}").format(
side_upper=side.upper(), device=device, name=name
else:
if not device:
return None
try:
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)
if termination_object.cable is not None and termination_object.cable != self.instance:
raise forms.ValidationError(
_("Side {side_upper}: {device} {termination_object} is already connected").format(
side_upper=side.upper(), device=device, termination_object=termination_object
)
)
except ObjectDoesNotExist:
raise forms.ValidationError(
_("{side_upper} side termination not found: {device} {name}").format(
side_upper=side.upper(), device=device, name=name
)
)
)
setattr(self.instance, f'{side}_terminations', [termination_object])
return termination_object

View File

@@ -113,7 +113,6 @@ class ModuleCommonForm(forms.Form):
raise forms.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:

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

@@ -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',
@@ -232,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')),
@@ -289,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(),
@@ -304,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')),
@@ -314,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',
@@ -784,7 +803,7 @@ class ModuleForm(ModuleCommonForm, PrimaryModelForm):
'device_id': '$device',
},
context={
'disabled': 'installed_module',
'disabled': '_occupied',
},
)
module_type = DynamicModelChoiceField(
@@ -838,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,
@@ -851,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',
]
@@ -1063,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))"
)
@@ -1224,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',
]
@@ -1689,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

@@ -63,6 +63,7 @@ if TYPE_CHECKING:
from .enums import *
__all__ = (
'CableBundleFilter',
'CableFilter',
'CableTerminationFilter',
'ConsolePortFilter',
@@ -99,6 +100,7 @@ __all__ = (
'PowerPortFilter',
'PowerPortTemplateFilter',
'RackFilter',
'RackGroupFilter',
'RackReservationFilter',
'RackRoleFilter',
'RackTypeFilter',
@@ -112,6 +114,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 = (
@@ -317,6 +324,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()
)
@@ -325,7 +333,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)
@@ -741,11 +749,13 @@ class ModuleBayFilter(ModularComponentFilterMixin, NetBoxModelFilter):
)
parent_id: ID | None = strawberry_django.filter_field()
position: StrFilterLookup[str] | None = strawberry_django.filter_field()
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True)
class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
position: StrFilterLookup[str] | None = strawberry_django.filter_field()
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True)
@@ -962,6 +972,10 @@ 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()
)
@@ -977,6 +991,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()
@@ -984,6 +1003,7 @@ 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: StrFilterLookup[str] | 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]
@@ -737,6 +751,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__',
@@ -757,6 +782,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
@@ -779,6 +805,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

@@ -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', '0227_alter_interface_speed_bigint'),
('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', '0228_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', '0229_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', '0230_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

@@ -0,0 +1,78 @@
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0231_interface_rf_channel_frequency_precision'),
('extras', '0136_customfield_validation_schema'),
('ipam', '0088_rename_vlangroup_total_vlan_ids'),
('tenancy', '0023_add_mptt_tree_indexes'),
('users', '0015_owner'),
('virtualization', '0054_virtualmachinetype'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddIndex(
model_name='consoleporttemplate',
index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_consol_device__101ed5_idx'),
),
migrations.AddIndex(
model_name='consoleserverporttemplate',
index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_consol_device__a901e6_idx'),
),
migrations.AddIndex(
model_name='device',
index=models.Index(fields=['name', 'id'], name='dcim_device_name_c27913_idx'),
),
migrations.AddIndex(
model_name='frontporttemplate',
index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_frontp_device__ec2ffb_idx'),
),
migrations.AddIndex(
model_name='interfacetemplate',
index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_interf_device__601012_idx'),
),
migrations.AddIndex(
model_name='macaddress',
index=models.Index(fields=['mac_address', 'id'], name='dcim_macadd_mac_add_f2662a_idx'),
),
migrations.AddIndex(
model_name='modulebaytemplate',
index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_module_device__9eabad_idx'),
),
migrations.AddIndex(
model_name='moduletype',
index=models.Index(fields=['profile', 'manufacturer', 'model'], name='dcim_module_profile_868277_idx'),
),
migrations.AddIndex(
model_name='poweroutlettemplate',
index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_powero_device__b83a8f_idx'),
),
migrations.AddIndex(
model_name='powerporttemplate',
index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_powerp_device__6c25da_idx'),
),
migrations.AddIndex(
model_name='rack',
index=models.Index(fields=['site', 'location', 'name', 'id'], name='dcim_rack_site_id_715040_idx'),
),
migrations.AddIndex(
model_name='rackreservation',
index=models.Index(fields=['created', 'id'], name='dcim_rackre_created_84f02e_idx'),
),
migrations.AddIndex(
model_name='rearporttemplate',
index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_rearpo_device__27f194_idx'),
),
migrations.AddIndex(
model_name='virtualchassis',
index=models.Index(fields=['name'], name='dcim_virtua_name_2dc5cd_idx'),
),
migrations.AddIndex(
model_name='virtualdevicecontext',
index=models.Index(fields=['name'], name='dcim_virtua_name_079d4d_idx'),
),
]

View File

@@ -0,0 +1,15 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0232_default_ordering_indexes'),
]
operations = [
migrations.AlterModelOptions(
name='device',
options={'ordering': ('name', 'pk'), 'permissions': [('render_config', 'Render configuration')]},
),
]

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

View File

@@ -144,6 +144,9 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
name='%(app_label)s_%(class)s_unique_module_type_name'
),
)
indexes = (
models.Index(fields=('device_type', 'module_type', 'name')), # Default ordering
)
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
@@ -166,15 +169,47 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
_("A component template must be associated with either a device type or a module type.")
)
def resolve_name(self, module):
if MODULE_TOKEN not in self.name or not module:
return self.name
return resolve_module_placeholder(self.name, get_module_bay_positions(module.module_bay))
@staticmethod
def _resolve_vc_position(value: str, device) -> str:
"""
Resolves {vc_position} and {vc_position:X} tokens.
def resolve_label(self, module):
if MODULE_TOKEN not in self.label or not module:
return self.label
return resolve_module_placeholder(self.label, get_module_bay_positions(module.module_bay))
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'
return VC_POSITION_RE.sub(replacer, value)
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_name(self, module=None, device=None):
return self._resolve_all_placeholders(self.name, module, device)
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):
@@ -197,8 +232,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
)
@@ -232,8 +267,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
)
@@ -282,8 +317,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,
@@ -370,13 +405,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,
@@ -476,8 +511,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,
@@ -611,8 +646,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,
@@ -675,8 +710,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,
@@ -705,6 +740,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
@@ -712,16 +751,12 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
verbose_name = _('module bay template')
verbose_name_plural = _('module bay templates')
def resolve_position(self, module):
if MODULE_TOKEN not in self.position or not module:
return self.position
return resolve_module_placeholder(self.position, get_module_bay_positions(module.module_bay))
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
label=self.resolve_label(kwargs.get('module')),
position=self.resolve_position(kwargs.get('module')),
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
@@ -731,6 +766,7 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
'name': self.name,
'label': self.label,
'position': self.position,
'enabled': self.enabled,
'description': self.description,
}
@@ -739,6 +775,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):
@@ -749,7 +790,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
@@ -765,6 +807,7 @@ class DeviceBayTemplate(ComponentTemplateModel):
return {
'name': self.name,
'label': self.label,
'enabled': self.enabled,
'description': self.description,
}

View File

@@ -839,8 +839,8 @@ class Interface(
verbose_name=_('wireless channel')
)
rf_channel_frequency = models.DecimalField(
max_digits=7,
decimal_places=2,
max_digits=8,
decimal_places=3,
blank=True,
null=True,
verbose_name=_('channel frequency (MHz)'),
@@ -1289,10 +1289,14 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
blank=True,
help_text=_('Identifier to reference when renaming installed components')
)
enabled = models.BooleanField(
verbose_name=_('enabled'),
default=True,
)
objects = TreeManager()
clone_fields = ('device',)
clone_fields = ('device', 'enabled')
class Meta(ModularComponentModel.Meta):
# Empty tuple triggers Django migration detection for MPTT indexes
@@ -1331,6 +1335,13 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
self.parent = None
super().save(*args, **kwargs)
@property
def _occupied(self):
"""
Indicates whether the module bay is occupied by a module.
"""
return bool(not self.enabled or hasattr(self, 'installed_module'))
class DeviceBay(ComponentModel, TrackingModelMixin):
"""
@@ -1343,8 +1354,12 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
blank=True,
null=True
)
enabled = models.BooleanField(
verbose_name=_('enabled'),
default=True,
)
clone_fields = ('device',)
clone_fields = ('device', 'enabled')
class Meta(ComponentModel.Meta):
verbose_name = _('device bay')
@@ -1359,6 +1374,16 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
device_type=self.device.device_type
))
# Prevent installing a device into a disabled bay
if self.installed_device and not self.enabled:
current_installed_device_id = (
DeviceBay.objects.filter(pk=self.pk).values_list('installed_device_id', flat=True).first()
)
if self.pk is None or current_installed_device_id != self.installed_device_id:
raise ValidationError({
'installed_device': _("Cannot install a device in a disabled device bay.")
})
# Cannot install a device into itself, obviously
if self.installed_device and getattr(self, 'device', None) == self.installed_device:
raise ValidationError(_("Cannot install a device into itself."))
@@ -1373,6 +1398,13 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
).format(bay=current_bay)
})
@property
def _occupied(self):
"""
Indicates whether the device bay is occupied by a child device.
"""
return bool(not self.enabled or self.installed_device_id)
#
# Inventory items

View File

@@ -26,6 +26,7 @@ from netbox.config import ConfigItem
from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
from netbox.models.mixins import WeightMixin
from utilities.exceptions import AbortRequest
from utilities.fields import ColorField, CounterCacheField
from utilities.prefetch import get_prefetchable_fields
from utilities.tracking import TrackingModelMixin
@@ -745,6 +746,9 @@ class Device(
class Meta:
ordering = ('name', 'pk') # Name may be null
indexes = (
models.Index(fields=('name', 'id')), # Default ordering
)
constraints = (
models.UniqueConstraint(
Lower('name'), 'site', 'tenant',
@@ -767,6 +771,9 @@ class Device(
)
verbose_name = _('device')
verbose_name_plural = _('devices')
permissions = [
('render_config', 'Render configuration'),
]
def __str__(self):
if self.label and self.asset_tag:
@@ -957,6 +964,20 @@ class Device(
).format(virtual_chassis=self.vc_master_for)
})
def _check_duplicate_component_names(self, components):
"""
Check for duplicate component names after resolving {vc_position} placeholders.
Raises AbortRequest if duplicates are found.
"""
names = [c.name for c in components]
duplicates = {n for n in names if names.count(n) > 1}
if duplicates:
raise AbortRequest(
_("Component name conflict after resolving {{vc_position}}: {names}").format(
names=', '.join(duplicates)
)
)
def _instantiate_components(self, queryset, bulk_create=True):
"""
Instantiate components for the device from the specified component templates.
@@ -971,6 +992,10 @@ class Device(
components = [obj.instantiate(device=self) for obj in queryset]
if not components:
return
# Check for duplicate names after resolution {vc_position}
self._check_duplicate_component_names(components)
# Set default values for any applicable custom fields
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
for component in components:
@@ -995,8 +1020,14 @@ class Device(
update_fields=None
)
else:
for obj in queryset:
component = obj.instantiate(device=self)
components = [obj.instantiate(device=self) for obj in queryset]
if not components:
return
# Check for duplicate names after resolution {vc_position}
self._check_duplicate_component_names(components)
for component in components:
# Set default values for any applicable custom fields
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
component.custom_field_data = cf_defaults
@@ -1168,6 +1199,9 @@ class VirtualChassis(PrimaryModel):
class Meta:
ordering = ['name']
indexes = (
models.Index(fields=('name',)), # Default ordering
)
verbose_name = _('virtual chassis')
verbose_name_plural = _('virtual chassis')
@@ -1274,6 +1308,9 @@ class VirtualDeviceContext(PrimaryModel):
name='%(app_label)s_%(class)s_device_name'
),
)
indexes = (
models.Index(fields=('name',)), # Default ordering
)
verbose_name = _('virtual device context')
verbose_name_plural = _('virtual device contexts')
@@ -1340,6 +1377,7 @@ class MACAddress(PrimaryModel):
class Meta:
ordering = ('mac_address', 'pk')
indexes = (
models.Index(fields=('mac_address', 'id')), # Default ordering
models.Index(fields=('assigned_object_type', 'assigned_object_id')),
)
verbose_name = _('MAC address')

View File

@@ -113,6 +113,9 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
name='%(app_label)s_%(class)s_unique_manufacturer_model'
),
)
indexes = (
models.Index(fields=('profile', 'manufacturer', 'model')), # Default ordering
)
verbose_name = _('module type')
verbose_name_plural = _('module types')
@@ -266,6 +269,14 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
)
)
# Prevent module from being installed in a disabled bay
if hasattr(self, 'module_bay') and self.module_bay and not self.module_bay.enabled:
current_module_bay_id = Module.objects.filter(pk=self.pk).values_list('module_bay_id', flat=True).first()
if self.pk is None or current_module_bay_id != self.module_bay_id:
raise ValidationError({
'module_bay': _("Cannot install a module in a disabled module bay.")
})
# Check for recursion
module = self
module_bays = []

View File

@@ -29,14 +29,30 @@ from .power import PowerFeed
__all__ = (
'Rack',
'RackGroup',
'RackReservation',
'RackRole',
'RackType',
)
#
# Rack Organization
#
class RackGroup(OrganizationalModel):
"""
Racks can be grouped by physical placement within a Location.
"""
class Meta:
ordering = ('name',)
verbose_name = _('rack group')
verbose_name_plural = _('rack groups')
#
# Rack Types
# Rack Base
#
class RackBase(WeightMixin, PrimaryModel):
@@ -123,6 +139,10 @@ class RackBase(WeightMixin, PrimaryModel):
abstract = True
#
# Rack Types
#
class RackType(ImageAttachmentsMixin, RackBase):
"""
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
@@ -290,6 +310,14 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
blank=True,
null=True
)
group = models.ForeignKey(
to='dcim.RackGroup',
on_delete=models.PROTECT,
related_name='racks',
blank=True,
null=True,
help_text=_('physical grouping')
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
@@ -362,6 +390,9 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
name='%(app_label)s_%(class)s_unique_location_facility_id'
),
)
indexes = (
models.Index(fields=('site', 'location', 'name', 'id')), # Default ordering
)
verbose_name = _('rack')
verbose_name_plural = _('racks')
@@ -710,6 +741,9 @@ class RackReservation(PrimaryModel):
class Meta:
ordering = ['created', 'pk']
indexes = (
models.Index(fields=('created', 'id')), # Default ordering
)
verbose_name = _('rack reservation')
verbose_name_plural = _('rack reservations')

View File

@@ -3,6 +3,17 @@ from netbox.search import SearchIndex, register_search
from . import models
@register_search
class CableBundleIndex(SearchIndex):
model = models.CableBundle
fields = (
('name', 100),
('description', 500),
('comments', 5000),
)
display_attrs = ('description',)
@register_search
class CableIndex(SearchIndex):
model = models.Cable
@@ -315,6 +326,18 @@ class RackReservationIndex(SearchIndex):
display_attrs = ('rack', 'tenant', 'user', 'description')
@register_search
class RackGroupIndex(SearchIndex):
model = models.RackGroup
fields = (
('name', 100),
('slug', 110),
('description', 500),
('comments', 5000),
)
display_attrs = ('description',)
@register_search
class RackRoleIndex(SearchIndex):
model = models.RackRole

View File

@@ -4,13 +4,14 @@ from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django_tables2.utils import Accessor
from dcim.models import Cable
from dcim.models import Cable, CableBundle
from netbox.tables import PrimaryModelTable, columns
from tenancy.tables import TenancyColumnsMixin
from .template_code import CABLE_LENGTH
__all__ = (
'CableBundleTable',
'CableTable',
)
@@ -119,6 +120,10 @@ class CableTable(TenancyColumnsMixin, PrimaryModelTable):
verbose_name=_('Color Name'),
orderable=False
)
bundle = tables.Column(
verbose_name=_('Bundle'),
linkify=True,
)
tags = columns.TagColumn(
url_name='dcim:cable_list'
)
@@ -128,8 +133,30 @@ class CableTable(TenancyColumnsMixin, PrimaryModelTable):
fields = (
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b',
'location_a', 'location_b', 'site_a', 'site_b', 'status', 'profile', 'type', 'tenant', 'tenant_group',
'color', 'color_name', 'length', 'description', 'comments', 'tags', 'created', 'last_updated',
'color', 'color_name', 'bundle', 'length', 'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',
)
class CableBundleTable(PrimaryModelTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True,
)
cable_count = tables.Column(
verbose_name=_('Cables'),
)
tags = columns.TagColumn(
url_name='dcim:cablebundle_list'
)
class Meta(PrimaryModelTable.Meta):
model = CableBundle
fields = (
'pk', 'id', 'name', 'cable_count', 'description', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'id', 'name', 'cable_count', 'description',
)

View File

@@ -908,6 +908,9 @@ class DeviceBayTable(DeviceComponentTable):
'args': [Accessor('device_id')],
}
)
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
status = tables.TemplateColumn(
verbose_name=_('Status'),
template_code=DEVICEBAY_STATUS,
@@ -945,12 +948,12 @@ class DeviceBayTable(DeviceComponentTable):
class Meta(DeviceComponentTable.Meta):
model = models.DeviceBay
fields = (
'pk', 'id', 'name', 'device', 'label', 'status', 'description', 'installed_device', 'installed_role',
'installed_device_type', 'installed_description', 'installed_serial', 'installed_asset_tag', 'tags',
'created', 'last_updated',
'pk', 'id', 'name', 'device', 'label', 'enabled', 'status', 'description', 'installed_device',
'installed_role', 'installed_device_type', 'installed_description', 'installed_serial',
'installed_asset_tag', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'status', 'installed_device', 'description')
class DeviceDeviceBayTable(DeviceBayTable):
@@ -960,6 +963,9 @@ class DeviceDeviceBayTable(DeviceBayTable):
'"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
attrs={'td': {'class': 'text-nowrap'}}
)
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
actions = columns.ActionsColumn(
extra_buttons=DEVICEBAY_BUTTONS
)
@@ -967,9 +973,9 @@ class DeviceDeviceBayTable(DeviceBayTable):
class Meta(DeviceComponentTable.Meta):
model = models.DeviceBay
fields = (
'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions',
'pk', 'id', 'name', 'label', 'enabled', 'status', 'installed_device', 'description', 'tags', 'actions',
)
default_columns = ('pk', 'name', 'label', 'status', 'installed_device', 'description')
default_columns = ('pk', 'name', 'label', 'enabled', 'status', 'installed_device', 'description')
class ModuleBayTable(ModularDeviceComponentTable):
@@ -980,6 +986,9 @@ class ModuleBayTable(ModularDeviceComponentTable):
'args': [Accessor('device_id')],
}
)
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
parent = tables.Column(
linkify=True,
verbose_name=_('Parent'),
@@ -1008,11 +1017,11 @@ class ModuleBayTable(ModularDeviceComponentTable):
class Meta(ModularDeviceComponentTable.Meta):
model = models.ModuleBay
fields = (
'pk', 'id', 'name', 'device', 'parent', 'label', 'position', 'installed_module', 'module_status',
'pk', 'id', 'name', 'device', 'enabled', 'parent', 'label', 'position', 'installed_module', 'module_status',
'module_serial', 'module_asset_tag', 'description', 'tags',
)
default_columns = (
'pk', 'name', 'device', 'parent', 'label', 'installed_module', 'module_status', 'description',
'pk', 'name', 'device', 'enabled', 'parent', 'label', 'installed_module', 'module_status', 'description',
)
def render_parent_bay(self, value):
@@ -1027,6 +1036,9 @@ class DeviceModuleBayTable(ModuleBayTable):
verbose_name=_('Name'),
linkify=True,
)
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
actions = columns.ActionsColumn(
extra_buttons=MODULEBAY_BUTTONS
)
@@ -1034,10 +1046,10 @@ class DeviceModuleBayTable(ModuleBayTable):
class Meta(ModuleBayTable.Meta):
model = models.ModuleBay
fields = (
'pk', 'id', 'parent', 'name', 'label', 'position', 'installed_module', 'module_status', 'module_serial',
'module_asset_tag', 'description', 'tags', 'actions',
'pk', 'id', 'parent', 'name', 'label', 'enabled', 'position', 'installed_module', 'module_status',
'module_serial', 'module_asset_tag', 'description', 'tags', 'actions',
)
default_columns = ('pk', 'name', 'label', 'installed_module', 'module_status', 'description')
default_columns = ('pk', 'name', 'label', 'enabled', 'installed_module', 'module_status', 'description')
class InventoryItemTable(DeviceComponentTable):

View File

@@ -289,24 +289,30 @@ class RearPortTemplateTable(ComponentTemplateTable):
class ModuleBayTemplateTable(ComponentTemplateTable):
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
actions = columns.ActionsColumn(
actions=('edit', 'delete')
)
class Meta(ComponentTemplateTable.Meta):
model = models.ModuleBayTemplate
fields = ('pk', 'name', 'label', 'position', 'description', 'actions')
fields = ('pk', 'name', 'label', 'position', 'enabled', 'description', 'actions')
empty_text = "None"
class DeviceBayTemplateTable(ComponentTemplateTable):
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
actions = columns.ActionsColumn(
actions=('edit', 'delete')
)
class Meta(ComponentTemplateTable.Meta):
model = models.DeviceBayTemplate
fields = ('pk', 'name', 'label', 'description', 'actions')
fields = ('pk', 'name', 'label', 'enabled', 'description', 'actions')
empty_text = "None"

View File

@@ -2,13 +2,14 @@ import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from django_tables2.utils import Accessor
from dcim.models import Rack, RackReservation, RackRole, RackType
from dcim.models import Rack, RackGroup, RackReservation, RackRole, RackType
from netbox.tables import OrganizationalModelTable, PrimaryModelTable, columns
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from .template_code import OUTER_UNIT, WEIGHT
__all__ = (
'RackGroupTable',
'RackReservationTable',
'RackRoleTable',
'RackTable',
@@ -16,6 +17,29 @@ __all__ = (
)
class RackGroupTable(OrganizationalModelTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True,
)
rack_count = columns.LinkedCountColumn(
viewname='dcim:rack_list',
url_params={'group_id': 'pk'},
verbose_name=_('Racks'),
)
tags = columns.TagColumn(
url_name='dcim:rackgroup_list',
)
class Meta(OrganizationalModelTable.Meta):
model = RackGroup
fields = (
'pk', 'id', 'name', 'rack_count', 'description', 'slug', 'comments', 'tags', 'actions', 'created',
'last_updated',
)
default_columns = ('pk', 'name', 'rack_count', 'description')
class RackRoleTable(OrganizationalModelTable):
name = tables.Column(
verbose_name=_('Name'),
@@ -111,6 +135,10 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
verbose_name=_('Site'),
linkify=True
)
group = tables.Column(
verbose_name=_('Group'),
linkify=True,
)
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
)
@@ -172,15 +200,15 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
class Meta(PrimaryModelTable.Meta):
model = Rack
fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role',
'pk', 'id', 'name', 'site', 'location', 'group', 'status', 'facility_id', 'tenant', 'tenant_group', 'role',
'rack_type', 'serial', 'asset_tag', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'comments',
'device_count', 'get_utilization', 'get_power_utilization', 'description', 'contacts',
'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'rack_type', 'u_height',
'device_count', 'get_utilization',
'pk', 'name', 'site', 'location', 'group', 'status', 'facility_id', 'tenant', 'role', 'rack_type',
'u_height', 'device_count', 'get_utilization',
)
@@ -200,6 +228,11 @@ class RackReservationTable(TenancyColumnsMixin, PrimaryModelTable):
accessor=Accessor('rack__location'),
linkify=True
)
group = tables.Column(
verbose_name=_('Group'),
accessor=Accessor('rack__group'),
linkify=True
)
rack = tables.Column(
verbose_name=_('Rack'),
linkify=True
@@ -208,6 +241,9 @@ class RackReservationTable(TenancyColumnsMixin, PrimaryModelTable):
orderable=False,
verbose_name=_('Units')
)
unit_count = tables.Column(
verbose_name=_("Total U's")
)
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
)
@@ -218,7 +254,9 @@ class RackReservationTable(TenancyColumnsMixin, PrimaryModelTable):
class Meta(PrimaryModelTable.Meta):
model = RackReservation
fields = (
'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'status', 'user', 'tenant',
'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated',
'pk', 'id', 'reservation', 'site', 'location', 'group', 'rack', 'unit_list', 'unit_count', 'status',
'user', 'tenant', 'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'reservation', 'site', 'rack', 'unit_list', 'unit_count', 'status', 'user', 'description',
)
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'status', 'user', 'description')

View File

@@ -565,7 +565,7 @@ DEVICEBAY_BUTTONS = """
<a href="{% url 'dcim:devicebay_depopulate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-server-minus" aria-hidden="true" title="Remove device"></i>
</a>
{% else %}
{% elif record.enabled %}
<a href="{% url 'dcim:devicebay_populate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-success btn-sm">
<i class="mdi mdi-server-plus" aria-hidden="true" title="Install device"></i>
</a>
@@ -579,7 +579,7 @@ MODULEBAY_BUTTONS = """
<a href="{% url 'dcim:module_delete' pk=record.installed_module.pk %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-server-minus" aria-hidden="true" title="Remove module"></i>
</a>
{% else %}
{% elif record.enabled %}
<a href="{% url 'dcim:module_add' %}?device={{ record.device_id }}&module_bay={{ record.pk }}&manufacturer={{ object.device_type.manufacturer_id }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm">
<i class="mdi mdi-server-plus" aria-hidden="true" title="Install module"></i>
</a>

View File

@@ -5,17 +5,24 @@ from django.urls import reverse
from django.utils.translation import gettext as _
from rest_framework import status
from core.models import ObjectType
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from extras.models import ConfigTemplate
from extras.models import ConfigTemplate, Tag
from ipam.choices import VLANQinQRoleChoices
from ipam.models import ASN, RIR, VLAN, VRF
from netbox.api.serializers import GenericObjectSerializer
from tenancy.models import Tenant
from users.constants import TOKEN_PREFIX
from users.models import Token, User
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_logging
from users.models import ObjectPermission, Token, User
from utilities.testing import (
APITestCase,
APIViewTestCases,
create_test_device,
create_test_nat_ip_pair,
disable_logging,
)
from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices
from wireless.models import WirelessLAN
@@ -195,6 +202,222 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
},
]
def test_add_tags(self):
"""
Add tags to an existing object via the add_tags field.
"""
site = Site.objects.first()
tags = Tag.objects.bulk_create((
Tag(name='Alpha', slug='alpha'),
Tag(name='Bravo', slug='bravo'),
Tag(name='Charlie', slug='charlie'),
))
site.tags.set([tags[0], tags[1]])
# Grant change permission
obj_perm = ObjectPermission(name='Test permission', actions=['change'])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
url = self._get_detail_url(site)
data = {
'add_tags': [{'name': 'Charlie'}],
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
# Verify all three tags are now assigned
tag_names = sorted(site.tags.values_list('name', flat=True))
self.assertEqual(tag_names, ['Alpha', 'Bravo', 'Charlie'])
# Verify add_tags and remove_tags are not in the response
self.assertNotIn('add_tags', response.data)
self.assertNotIn('remove_tags', response.data)
self.assertIn('tags', response.data)
def test_remove_tags(self):
"""
Remove tags from an existing object via the remove_tags field.
"""
site = Site.objects.first()
tags = Tag.objects.bulk_create((
Tag(name='Alpha', slug='alpha'),
Tag(name='Bravo', slug='bravo'),
Tag(name='Charlie', slug='charlie'),
))
site.tags.set(tags)
# Grant change permission
obj_perm = ObjectPermission(name='Test permission', actions=['change'])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
url = self._get_detail_url(site)
data = {
'remove_tags': [{'name': 'Charlie'}],
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
# Verify only Alpha and Bravo remain
tag_names = sorted(site.tags.values_list('name', flat=True))
self.assertEqual(tag_names, ['Alpha', 'Bravo'])
def test_remove_tags_not_assigned(self):
"""
Removing a tag that is not assigned should not raise an error.
"""
site = Site.objects.first()
tags = Tag.objects.bulk_create((
Tag(name='Alpha', slug='alpha'),
Tag(name='Bravo', slug='bravo'),
Tag(name='Charlie', slug='charlie'),
))
site.tags.set([tags[0], tags[1]])
# Grant change permission
obj_perm = ObjectPermission(name='Test permission', actions=['change'])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
url = self._get_detail_url(site)
data = {
'remove_tags': [{'name': 'Charlie'}],
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
# Tags should be unchanged
tag_names = sorted(site.tags.values_list('name', flat=True))
self.assertEqual(tag_names, ['Alpha', 'Bravo'])
def test_add_and_remove_tags(self):
"""
Add and remove tags in the same request.
"""
site = Site.objects.first()
tags = Tag.objects.bulk_create((
Tag(name='Alpha', slug='alpha'),
Tag(name='Bravo', slug='bravo'),
Tag(name='Charlie', slug='charlie'),
))
site.tags.set([tags[0], tags[1]])
# Grant change permission
obj_perm = ObjectPermission(name='Test permission', actions=['change'])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
url = self._get_detail_url(site)
data = {
'add_tags': [{'name': 'Charlie'}],
'remove_tags': [{'name': 'Alpha'}],
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
# Verify Bravo and Charlie remain
tag_names = sorted(site.tags.values_list('name', flat=True))
self.assertEqual(tag_names, ['Bravo', 'Charlie'])
def test_tags_with_add_tags_error(self):
"""
Specifying tags together with add_tags or remove_tags should raise a validation error.
"""
site = Site.objects.first()
Tag.objects.bulk_create((
Tag(name='Alpha', slug='alpha'),
Tag(name='Bravo', slug='bravo'),
))
# Grant change permission
obj_perm = ObjectPermission(name='Test permission', actions=['change'])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
url = self._get_detail_url(site)
data = {
'tags': [{'name': 'Alpha'}],
'add_tags': [{'name': 'Bravo'}],
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
def test_create_with_add_tags(self):
"""
Create a new object using add_tags.
"""
Tag.objects.bulk_create((
Tag(name='Alpha', slug='alpha'),
Tag(name='Bravo', slug='bravo'),
))
obj_perm = ObjectPermission(name='Test permission', actions=['add'])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
data = {
'name': 'Site 10',
'slug': 'site-10',
'add_tags': [{'name': 'Alpha'}, {'name': 'Bravo'}],
}
response = self.client.post(self._get_list_url(), data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
site = Site.objects.get(pk=response.data['id'])
tag_names = sorted(site.tags.values_list('name', flat=True))
self.assertEqual(tag_names, ['Alpha', 'Bravo'])
def test_create_with_remove_tags_error(self):
"""
Using remove_tags when creating a new object should raise a validation error.
"""
Tag.objects.bulk_create((
Tag(name='Alpha', slug='alpha'),
))
obj_perm = ObjectPermission(name='Test permission', actions=['add'])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
data = {
'name': 'Site 10',
'slug': 'site-10',
'remove_tags': [{'name': 'Alpha'}],
}
response = self.client.post(self._get_list_url(), data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
def test_add_and_remove_same_tag_error(self):
"""
Including the same tag in both add_tags and remove_tags should raise a validation error.
"""
site = Site.objects.first()
Tag.objects.bulk_create((
Tag(name='Alpha', slug='alpha'),
Tag(name='Bravo', slug='bravo'),
))
obj_perm = ObjectPermission(name='Test permission', actions=['change'])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
url = self._get_detail_url(site)
data = {
'add_tags': [{'name': 'Alpha'}, {'name': 'Bravo'}],
'remove_tags': [{'name': 'Alpha'}],
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
class LocationTest(APIViewTestCases.APIViewTestCase):
model = Location
@@ -280,6 +503,38 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
]
class RackGroupTest(APIViewTestCases.APIViewTestCase):
model = RackGroup
brief_fields = ['description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
create_data = [
{
'name': 'Rack Group 4',
'slug': 'rack-group-4',
},
{
'name': 'Rack Group 5',
'slug': 'rack-group-5',
},
{
'name': 'Rack Group 6',
'slug': 'rack-group-6',
},
]
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
rack_groups = (
RackGroup(name='Rack Group 1', slug='rack-group-1'),
RackGroup(name='Rack Group 2', slug='rack-group-2'),
RackGroup(name='Rack Group 3', slug='rack-group-3'),
)
RackGroup.objects.bulk_create(rack_groups)
class RackRoleTest(APIViewTestCases.APIViewTestCase):
model = RackRole
brief_fields = ['description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
@@ -397,6 +652,12 @@ class RackTest(APIViewTestCases.APIViewTestCase):
Location.objects.create(site=sites[1], name='Location 2', slug='location-2'),
)
rack_groups = (
RackGroup(name='Rack Group 1', slug='rack-group-1'),
RackGroup(name='Rack Group 2', slug='rack-group-2'),
)
RackGroup.objects.bulk_create(rack_groups)
rack_roles = (
RackRole(name='Rack Role 1', slug='rack-role-1', color='ff0000'),
RackRole(name='Rack Role 2', slug='rack-role-2', color='00ff00'),
@@ -404,9 +665,9 @@ class RackTest(APIViewTestCases.APIViewTestCase):
RackRole.objects.bulk_create(rack_roles)
racks = (
Rack(site=sites[0], location=locations[0], role=rack_roles[0], name='Rack 1'),
Rack(site=sites[0], location=locations[0], role=rack_roles[0], name='Rack 2'),
Rack(site=sites[0], location=locations[0], role=rack_roles[0], name='Rack 3'),
Rack(site=sites[0], location=locations[0], group=rack_groups[0], role=rack_roles[0], name='Rack 1'),
Rack(site=sites[0], location=locations[0], group=rack_groups[0], role=rack_roles[0], name='Rack 2'),
Rack(site=sites[0], location=locations[0], group=rack_groups[0], role=rack_roles[0], name='Rack 3'),
)
Rack.objects.bulk_create(racks)
@@ -415,18 +676,21 @@ class RackTest(APIViewTestCases.APIViewTestCase):
'name': 'Test Rack 4',
'site': sites[1].pk,
'location': locations[1].pk,
'group': rack_groups[1].pk,
'role': rack_roles[1].pk,
},
{
'name': 'Test Rack 5',
'site': sites[1].pk,
'location': locations[1].pk,
'group': rack_groups[1].pk,
'role': rack_roles[1].pk,
},
{
'name': 'Test Rack 6',
'site': sites[1].pk,
'location': locations[1].pk,
'group': rack_groups[1].pk,
'role': rack_roles[1].pk,
},
]
@@ -529,6 +793,15 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
},
]
def test_unit_count(self):
"""unit_count should reflect the number of units in the reservation."""
url = reverse('dcim-api:rackreservation-list')
self.add_permissions('dcim.view_rackreservation')
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, 200)
for result in response.data['results']:
self.assertEqual(result['unit_count'], len(result['units']))
class ManufacturerTest(APIViewTestCases.APIViewTestCase):
model = Manufacturer
@@ -1185,7 +1458,7 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
model = ModuleBayTemplate
brief_fields = ['description', 'display', 'id', 'name', 'url']
brief_fields = ['description', 'display', 'enabled', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1202,9 +1475,9 @@ class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
)
module_bay_templates = (
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 1'),
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 2'),
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 3'),
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 1', enabled=True),
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 2', enabled=False),
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 3', enabled=True),
)
ModuleBayTemplate.objects.bulk_create(module_bay_templates)
@@ -1212,6 +1485,7 @@ class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
{
'device_type': devicetype.pk,
'name': 'Module Bay Template 4',
'enabled': False,
},
{
'device_type': devicetype.pk,
@@ -1226,7 +1500,7 @@ class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
model = DeviceBayTemplate
brief_fields = ['description', 'display', 'id', 'name', 'url']
brief_fields = ['description', 'display', 'enabled', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -1243,9 +1517,9 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
)
device_bay_templates = (
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 1'),
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 2'),
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 3'),
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 1', enabled=True),
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 2', enabled=False),
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 3', enabled=True),
)
DeviceBayTemplate.objects.bulk_create(device_bay_templates)
@@ -1253,6 +1527,7 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
{
'device_type': devicetype.pk,
'name': 'Device Bay Template 4',
'enabled': False,
},
{
'device_type': devicetype.pk,
@@ -1633,6 +1908,81 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
self.assertHttpStatus(response, status.HTTP_200_OK)
def test_list_object_includes_nat_inside_on_primary_ip(self):
device = create_test_device('natted-device')
interface = Interface.objects.create(device=device, name='eth0', type='other')
real_ip, nat_ip = create_test_nat_ip_pair(
real_address='10.0.0.10/32',
nat_address='198.51.100.10/32',
inside_interface=interface,
)
device.primary_ip4 = nat_ip
device.save()
self.add_permissions('dcim.view_device', 'ipam.view_ipaddress')
response = self.client.get(f'{self._get_list_url()}?id={device.pk}', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
result = response.data['results'][0]
for field in ('primary_ip', 'primary_ip4'):
self.assertEqual(result[field]['address'], str(nat_ip.address))
self.assertEqual(result[field]['nat_inside']['address'], str(real_ip.address))
self.assertEqual(result[field]['nat_outside'], [])
def test_get_object_includes_nat_outside_on_primary_ip(self):
device = create_test_device('real-ip-device')
interface = Interface.objects.create(device=device, name='eth0', type='other')
real_ip, nat_ip = create_test_nat_ip_pair(
real_address='10.0.0.11/32',
nat_address='198.51.100.11/32',
inside_interface=interface,
)
device.primary_ip4 = real_ip
device.save()
self.add_permissions('dcim.view_device', 'ipam.view_ipaddress')
response = self.client.get(
f'{self._get_detail_url(device)}?exclude=config_context',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_200_OK)
for field in ('primary_ip', 'primary_ip4'):
self.assertEqual(response.data[field]['address'], str(real_ip.address))
self.assertIsNone(response.data[field]['nat_inside'])
self.assertCountEqual(
[ip['address'] for ip in response.data[field]['nat_outside']],
[str(nat_ip.address)],
)
def test_get_object_includes_nat_on_oob_ip(self):
device = create_test_device('oob-nat-device')
interface = Interface.objects.create(device=device, name='oob0', type='other')
real_ip, nat_ip = create_test_nat_ip_pair(
real_address='10.0.0.12/32',
nat_address='198.51.100.12/32',
inside_interface=interface,
)
device.oob_ip = nat_ip
device.save()
self.add_permissions('dcim.view_device', 'ipam.view_ipaddress')
response = self.client.get(
f'{self._get_detail_url(device)}?exclude=config_context',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['oob_ip']['address'], str(nat_ip.address))
self.assertEqual(response.data['oob_ip']['nat_inside']['address'], str(real_ip.address))
self.assertEqual(response.data['oob_ip']['nat_outside'], [])
class ModuleTest(APIViewTestCases.APIViewTestCase):
model = Module
@@ -1699,6 +2049,238 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
},
]
def test_replicate_components(self):
"""
Installing a module with replicate_components=True (the default) should create
components from the module type's templates on the parent device.
"""
self.add_permissions('dcim.add_module')
manufacturer = Manufacturer.objects.get(name='Generic')
device = create_test_device('Device for Replication Test')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Replication Test Module Type')
InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
module_bay = ModuleBay.objects.create(device=device, name='Replication Bay')
url = reverse('dcim-api:module-list')
data = {
'device': device.pk,
'module_bay': module_bay.pk,
'module_type': module_type.pk,
'replicate_components': True,
}
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertTrue(device.interfaces.filter(name='eth0').exists())
def test_no_replicate_components(self):
"""
Installing a module with replicate_components=False should NOT create components
from the module type's templates.
"""
self.add_permissions('dcim.add_module')
manufacturer = Manufacturer.objects.get(name='Generic')
device = create_test_device('Device for No Replication Test')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='No Replication Test Module Type')
InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
module_bay = ModuleBay.objects.create(device=device, name='No Replication Bay')
url = reverse('dcim-api:module-list')
data = {
'device': device.pk,
'module_bay': module_bay.pk,
'module_type': module_type.pk,
'replicate_components': False,
}
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertFalse(device.interfaces.filter(name='eth0').exists())
def test_adopt_components(self):
"""
Installing a module with adopt_components=True should assign existing unattached
device components to the new module.
"""
self.add_permissions('dcim.add_module')
manufacturer = Manufacturer.objects.get(name='Generic')
device = create_test_device('Device for Adopt Test')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Adopt Test Module Type')
InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
module_bay = ModuleBay.objects.create(device=device, name='Adopt Bay')
existing_iface = Interface.objects.create(device=device, name='eth0', type='1000base-t')
url = reverse('dcim-api:module-list')
data = {
'device': device.pk,
'module_bay': module_bay.pk,
'module_type': module_type.pk,
'adopt_components': True,
'replicate_components': False,
}
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
existing_iface.refresh_from_db()
self.assertIsNotNone(existing_iface.module)
def test_replicate_components_conflict(self):
"""
Installing a module with replicate_components=True when a component with the same name
already exists should return a validation error.
"""
self.add_permissions('dcim.add_module')
manufacturer = Manufacturer.objects.get(name='Generic')
device = create_test_device('Device for Conflict Test')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Conflict Test Module Type')
InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
module_bay = ModuleBay.objects.create(device=device, name='Conflict Bay')
Interface.objects.create(device=device, name='eth0', type='1000base-t')
url = reverse('dcim-api:module-list')
data = {
'device': device.pk,
'module_bay': module_bay.pk,
'module_type': module_type.pk,
'replicate_components': True,
}
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
def test_adopt_components_already_owned(self):
"""
Installing a module with adopt_components=True when an existing component already
belongs to another module should return a validation error.
"""
self.add_permissions('dcim.add_module')
manufacturer = Manufacturer.objects.get(name='Generic')
device = create_test_device('Device for Adopt Owned Test')
owner_module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Owner Module Type')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Adopt Owned Test Module Type')
InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
owner_bay = ModuleBay.objects.create(device=device, name='Owner Bay')
target_bay = ModuleBay.objects.create(device=device, name='Adopt Owned Bay')
# Install a module that owns the interface
owner_module = Module.objects.create(device=device, module_bay=owner_bay, module_type=owner_module_type)
Interface.objects.create(device=device, name='eth0', type='1000base-t', module=owner_module)
url = reverse('dcim-api:module-list')
data = {
'device': device.pk,
'module_bay': target_bay.pk,
'module_type': module_type.pk,
'adopt_components': True,
}
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
def test_patch_ignores_replicate_and_adopt(self):
"""
PATCH requests that include replicate_components or adopt_components should not
trigger component replication or adoption (these fields are create-only).
"""
self.add_permissions('dcim.change_module')
manufacturer = Manufacturer.objects.get(name='Generic')
device = create_test_device('Device for PATCH Test')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='PATCH Test Module Type')
InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
module_bay = ModuleBay.objects.create(device=device, name='PATCH Bay')
# Create the module without replication so we can verify PATCH doesn't trigger it
module = Module(device=device, module_bay=module_bay, module_type=module_type)
module._disable_replication = True
module.save()
url = reverse('dcim-api:module-detail', kwargs={'pk': module.pk})
data = {
'replicate_components': True,
'adopt_components': True,
'serial': 'PATCHED',
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['serial'], 'PATCHED')
# No interfaces should have been created by the PATCH
self.assertFalse(device.interfaces.exists())
def test_adopt_and_replicate_components(self):
"""
Installing a module with both adopt_components=True and replicate_components=True
should adopt existing unowned components and create new components for templates
that have no matching existing component.
"""
self.add_permissions('dcim.add_module')
manufacturer = Manufacturer.objects.get(name='Generic')
device = create_test_device('Device for Adopt+Replicate Test')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Adopt+Replicate Test Module Type')
InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
InterfaceTemplate.objects.create(module_type=module_type, name='eth1', type='1000base-t')
module_bay = ModuleBay.objects.create(device=device, name='Adopt+Replicate Bay')
# eth0 already exists (unowned); eth1 does not
existing_iface = Interface.objects.create(device=device, name='eth0', type='1000base-t')
url = reverse('dcim-api:module-list')
data = {
'device': device.pk,
'module_bay': module_bay.pk,
'module_type': module_type.pk,
'adopt_components': True,
'replicate_components': True,
}
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
# eth0 should have been adopted (now owned by the new module)
existing_iface.refresh_from_db()
self.assertIsNotNone(existing_iface.module)
# eth1 should have been created
self.assertTrue(device.interfaces.filter(name='eth1').exists())
def test_module_token_no_position(self):
"""
Installing a module whose type has a template with a MODULE_TOKEN placeholder into a
module bay with no position defined should return a validation error.
"""
self.add_permissions('dcim.add_module')
manufacturer = Manufacturer.objects.get(name='Generic')
device = create_test_device('Device for Token No-Position Test')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token No-Position Module Type')
# Template name contains the MODULE_TOKEN placeholder
InterfaceTemplate.objects.create(
module_type=module_type, name=f'{MODULE_TOKEN}-eth0', type='1000base-t'
)
# Module bay has no position
module_bay = ModuleBay.objects.create(device=device, name='No-Position Bay')
url = reverse('dcim-api:module-list')
data = {
'device': device.pk,
'module_bay': module_bay.pk,
'module_type': module_type.pk,
}
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
def test_module_token_depth_mismatch(self):
"""
Installing a module whose template name has more MODULE_TOKEN placeholders than the
depth of the module bay tree should return a validation error.
"""
self.add_permissions('dcim.add_module')
manufacturer = Manufacturer.objects.get(name='Generic')
device = create_test_device('Device for Token Depth Mismatch Test')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token Depth Mismatch Module Type')
# Template name has two placeholders but the bay is at depth 1
InterfaceTemplate.objects.create(
module_type=module_type, name=f'{MODULE_TOKEN}-{MODULE_TOKEN}-eth0', type='1000base-t'
)
module_bay = ModuleBay.objects.create(device=device, name='Depth 1 Bay', position='1')
url = reverse('dcim-api:module-list')
data = {
'device': device.pk,
'module_bay': module_bay.pk,
'module_type': module_type.pk,
}
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = ConsolePort
@@ -2321,7 +2903,7 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
class ModuleBayTest(APIViewTestCases.APIViewTestCase):
model = ModuleBay
brief_fields = ['description', 'display', 'id', 'installed_module', 'name', 'url']
brief_fields = ['_occupied', 'description', 'display', 'enabled', 'id', 'installed_module', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -2337,9 +2919,9 @@ class ModuleBayTest(APIViewTestCases.APIViewTestCase):
device = Device.objects.create(device_type=device_type, role=role, name='Device 1', site=site)
module_bays = (
ModuleBay(device=device, name='Device Bay 1'),
ModuleBay(device=device, name='Device Bay 2'),
ModuleBay(device=device, name='Device Bay 3'),
ModuleBay(device=device, name='Device Bay 1', enabled=True),
ModuleBay(device=device, name='Device Bay 2', enabled=False),
ModuleBay(device=device, name='Device Bay 3', enabled=True),
)
for module_bay in module_bays:
module_bay.save()
@@ -2348,6 +2930,7 @@ class ModuleBayTest(APIViewTestCases.APIViewTestCase):
{
'device': device.pk,
'name': 'Device Bay 4',
'enabled': False,
},
{
'device': device.pk,
@@ -2362,7 +2945,7 @@ class ModuleBayTest(APIViewTestCases.APIViewTestCase):
class DeviceBayTest(APIViewTestCases.APIViewTestCase):
model = DeviceBay
brief_fields = ['description', 'device', 'display', 'id', 'name', 'url']
brief_fields = ['_occupied', 'description', 'device', 'display', 'enabled', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@@ -2399,9 +2982,9 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase):
Device.objects.bulk_create(devices)
device_bays = (
DeviceBay(device=devices[0], name='Device Bay 1'),
DeviceBay(device=devices[0], name='Device Bay 2'),
DeviceBay(device=devices[0], name='Device Bay 3'),
DeviceBay(device=devices[0], name='Device Bay 1', enabled=True),
DeviceBay(device=devices[0], name='Device Bay 2', enabled=False),
DeviceBay(device=devices[0], name='Device Bay 3', enabled=True),
)
DeviceBay.objects.bulk_create(device_bays)
@@ -2526,6 +3109,60 @@ class InventoryItemRoleTest(APIViewTestCases.APIViewTestCase):
InventoryItemRole.objects.bulk_create(roles)
class CableBundleTest(APIViewTestCases.APIViewTestCase):
model = CableBundle
brief_fields = ['description', 'display', 'id', 'name', 'url']
create_data = [
{'name': 'Cable Bundle 4'},
{'name': 'Cable Bundle 5'},
{'name': 'Cable Bundle 6'},
]
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
cable_bundles = (
CableBundle(name='Cable Bundle 1'),
CableBundle(name='Cable Bundle 2'),
CableBundle(name='Cable Bundle 3'),
)
CableBundle.objects.bulk_create(cable_bundles)
def test_cable_count(self):
"""cable_count annotation is returned correctly in the API response."""
self.add_permissions('dcim.view_cablebundle')
bundle = CableBundle.objects.first()
site = Site.objects.create(name='CB Test Site', slug='cb-test-site')
manufacturer = Manufacturer.objects.create(name='CB Manufacturer', slug='cb-manufacturer')
device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='CB Device Type', slug='cb-device-type'
)
role = DeviceRole.objects.create(name='CB Role', slug='cb-role', color='ff0000')
devices = (
Device(device_type=device_type, role=role, name='CB Device 1', site=site),
Device(device_type=device_type, role=role, name='CB Device 2', site=site),
)
Device.objects.bulk_create(devices)
interfaces = (
Interface(device=devices[0], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[0], name='eth1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[1], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[1], name='eth1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
)
Interface.objects.bulk_create(interfaces)
for a, b in [(interfaces[0], interfaces[2]), (interfaces[1], interfaces[3])]:
cable = Cable(a_terminations=[a], b_terminations=[b], bundle=bundle)
cable.save()
url = reverse('dcim-api:cablebundle-detail', kwargs={'pk': bundle.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['cable_count'], 2)
class CableTest(APIViewTestCases.APIViewTestCase):
model = Cable
brief_fields = ['description', 'display', 'id', 'label', 'url']

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