Compare commits

...

65 Commits

Author SHA1 Message Date
Jeremy Stretch
e73db97d46 Merge pull request #21079 from netbox-community/feature
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
Release v4.5.0
2026-01-06 16:12:06 -05:00
Jeremy Stretch
6f2ba5c75c Merge branch 'main' into feature
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
2026-01-06 13:05:07 -05:00
Jeremy Stretch
fa8a9ef9de Release v4.4.10
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-01-06 12:30:03 -05:00
Jeremy Stretch
6beb079b97 Revert "Fixed #20950: Add missing module and device properties in module-bay (#21005)"
This reverts commit 860db9590b.
2026-01-06 10:38:41 -05:00
bctiemann
bad688b8aa Merge pull request #21069 from netbox-community/21067-cable-profile-error
Fixes #21067: Force update of cable terminations when changing cable profile
2026-01-06 09:48:54 -05:00
github-actions
c8aad24a1b Update source translation strings
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-01-06 05:04:58 +00:00
bctiemann
42bd876604 Merge pull request #21072 from netbox-community/21071-exception-request-url
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
Closes #21071: Include the request method & URL when displaying a server error
2026-01-05 20:20:46 -05:00
bctiemann
f903442cb9 Merge pull request #21065 from netbox-community/21049-clean-stale-cf-data
Fixes #21049: Remove stale custom field data during object validation
2026-01-05 20:19:46 -05:00
Jason Novinger
5a64cb712d Fixes #21064: Ensures that extra choices preserve nested colons 2026-01-05 16:38:16 -05:00
Jason Novinger
4d90d559be Fix permission constraint example error 2026-01-05 16:33:21 -05:00
Jeremy Stretch
19de058f94 Closes #21071: Include the request method & URL when displaying a server error 2026-01-05 16:09:39 -05:00
Jeremy Stretch
d3e4c02807 Fixes #21067: Force update of cable terminations when changing cable profile 2026-01-05 15:14:04 -05:00
Jeremy Stretch
dc00e19c3c Fixes #21063: Check for duplicate choice values when validating a custom field choice set (#21066) 2026-01-05 13:10:04 -06:00
Jeremy Stretch
6ed6da49d9 Update test 2026-01-05 11:00:54 -05:00
Prince Kumar
7154d4ae2e Closes #20953: Show interfaces bridged to an interface in the UI (#21010)
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-01-05 09:40:38 -06:00
Jeremy Stretch
bc26529be8 Fixes #21049: Remove stale custom field data during object validation 2026-01-05 09:49:32 -05:00
github-actions
da64c564ae Update source translation strings
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
2026-01-01 05:07:03 +00:00
Jeremy Stretch
6199b3e039 FIxes #19506: Add filter forms for component templates (#21057)
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
Co-authored-by: Callum <callum@reja.au>
Co-authored-by: Callum <96725140+callumau@users.noreply.github.com>
2025-12-31 09:50:39 -06:00
Jeremy Stretch
ebada4bf72 Closes #21001: Annotate plugin filterset registration in v4.5 release notes (#21058)
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-31 09:42:47 -06:00
github-actions
2a391253a5 Update source translation strings
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-31 05:05:09 +00:00
Jason Novinger
914653d63e Fixes #21045: Allow saving Site with associated Prefix
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
This was a result of the fix for #20944 optimizing a query to only
include the `id` field with `.only(id)`. Since `Prefix.__init__()`
caches original values from other fields (`_prefix` and `_vrf_id`),
these cached values are `None` at init-time.

This might not normally be a problem, but the sequence of events in
the bug report also end up causing the `handle_prefix_saved` handler
to run, which uses an ORM lookup, (either `net_contained_or_equal`
original`net_contained`) that does not support a query argument of
`None`.
2025-12-30 12:26:48 -05:00
Martin Hauser
3813aad8b1 Fixes #20320: Ensure related interface options availibility in bulk edit (#21006) 2025-12-30 10:17:14 -06:00
Jeremy Stretch
ea5371040e Fixes #20817: Re-enable sync button when disabling scheduled syncing for a data source (#21055) 2025-12-30 10:05:08 -06:00
Unknown
6c824cc48f Fixes #20044: Elevations stuck in light mode (#21037)
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
Co-authored-by: UnknownTy <meaphunter+git@hotmail.com>
Co-authored-by: Jason Novinger <jnovinger@gmail.com>
2025-12-29 16:27:03 -06:00
Jeremy Stretch
c78b8401dc Fixes #21020: Fix object filtering for image attachments panel (#21030)
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-29 15:19:24 -06:00
Jeremy Stretch
f510e40428 Closes #21047: Add compatibility matrix to plugin setup instructions (#21048) 2025-12-29 11:39:51 -06:00
Prince Kumar
860db9590b Fixed #20950: Add missing module and device properties in module-bay (#21005)
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
2025-12-23 13:34:06 -06:00
Jeremy Stretch
7c63d001b1 Release v4.4.9 2025-12-23 12:02:30 -05:00
Jeremy Stretch
93119f52c3 Fixes #21032: Avoid subquery in RestrictedQuerySet where unnecessary 2025-12-23 10:15:06 -05:00
github-actions
ee2aa35cba Update source translation strings
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-23 05:04:20 +00:00
bctiemann
edf35e35be Merge pull request #21028 from netbox-community/fix/device-api-missing-owner-field
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Fix missing owner field in DeviceWithConfigContextSerializer
2025-12-22 14:28:58 -05:00
bctiemann
7896a48075 Merge pull request #21029 from netbox-community/21011-configrevision-save
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
Fixes #21011: Avoid updating database when loading active ConfigRevision
2025-12-22 14:19:19 -05:00
bctiemann
eb87c3f304 Merge pull request #21000 from netbox-community/20011-misleading-error-message
Fixes #20011: Provide accurate error for bulk import duplicate IDs
2025-12-22 14:12:36 -05:00
Jeremy Stretch
062a871521 Add missing owner field to device & VM component serializers
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
2025-12-22 13:52:39 -05:00
Vincent Simonin
3acbb0a08c Fix on delete cascade entity order (#20949)
* Fix on delete cascade entity order

Since [#20708](https://github.com/netbox-community/netbox/pull/20708)
relation with a on delete RESTRICT are not deleted in the proper order.
Then the error `violate not-null constraint` occurs and breaks the
delete cascade feature.

* Revert unrelated and simplify changes
2025-12-22 13:19:02 -05:00
Jeremy Stretch
f67cc47def Fixes #21011: Avoid updating database when loading active ConfigRevision 2025-12-22 11:00:04 -05:00
Martin Hauser
f7219e0672 Closes #20309: Add ASDOT notation support for ASN ranges (#21004)
* feat(ipam): Add ASDOT notation support for ASN ranges

Introduces ASDOT notation for ASN Ranges to improve readability of large
AS numbers. Adds `start_asdot` and `end_asdot` properties, columns, and
display logic for ASN ranges in the UI.

Fixes #20309

* Wrap "ASDOT" with parentheses in column header

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2025-12-22 10:06:08 -05:00
Prince Kumar
e5a975176d Fixed #20944: Ensure cached scope fields stay consistent when Region, Site, or Location changes (#20986) 2025-12-22 09:48:43 -05:00
Mark Coleman
07d8157ccd Fix missing owner field in DeviceWithConfigContextSerializer
Fixes: https://github.com/netbox-community/netbox/issues/21022
2025-12-20 11:02:36 +01:00
github-actions
83ee4fb593 Update source translation strings
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
2025-12-20 05:02:02 +00:00
bctiemann
db8271c904 Fixes #20114: Preserve parent bay during device bulk import when tags are present (#21019)
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
2025-12-19 17:05:32 -06:00
Jeremy Stretch
712c743bcb Closes #20954: Add indexes for GFKs (#21015)
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-18 14:49:00 -08:00
Jeremy Stretch
2eb42d4907 Fixes #20997: Enable creating permissions for the Owner model (#21009)
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-18 09:19:40 -08:00
github-actions
5a24f99c9d Update source translation strings
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
2025-12-18 05:03:18 +00:00
Jeremy Stretch
9318c91405 Closes #20720: Add support for Latvian translations (#21003) 2025-12-17 15:20:04 -06:00
Martin Hauser
5c6aaf2388 Closes #20900: Allow multiple choices in CustomField select filter fields (#20992) 2025-12-17 14:32:46 -06:00
Jason Novinger
265f375595 Fixes #20876: Allow editing IPAddress in IPRange marked populated 2025-12-17 13:03:45 -05:00
bctiemann
a28269b73a Closes: #20930 - Add an ASNSiteSerializer to allow serialization of Site in ASNSerializer (#20991) 2025-12-17 09:18:51 -08:00
Jason Novinger
d95fa8dbb2 Fixes #20011: UI Error msg for duplicate IDs in bulk import 2025-12-17 09:21:17 -06:00
bctiemann
2699149016 Merge pull request #20963 from pheus/20491-normalize-arrayfield-values-to-inclusive-pairs-for-api-tests
Fixes #20491: Normalize numeric range array fields for API test comparisons
2025-12-16 15:40:44 -05:00
vo42
f371004809 Fixes #20969: Fix FrontPortTemplateFilterSet rear_port_id queryset. (#20987)
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
2025-12-16 11:23:18 -08:00
github-actions
ad29402b87 Update source translation strings
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
2025-12-13 05:02:00 +00:00
Jason Novinger
598f8d034d Fixes #20912: Clear ModuleBay parent when module assignment removed (#20974)
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
2025-12-12 13:31:59 -08:00
Arthur Hanson
ec13a79907 Fixes #20875: Fix updating of denormalized fields for component models (#20956) 2025-12-12 13:29:34 -06:00
github-actions
21f4036782 Update source translation strings
Some checks are pending
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
2025-12-12 05:03:16 +00:00
bctiemann
ce3738572c Merge pull request #20967 from netbox-community/20966-remove-stick-scroll
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
Fixes #20966: Fix broken optgroup stickiness in ObjectType multiselect
2025-12-11 19:44:16 -05:00
bctiemann
cbb979934e Merge pull request #20958 from netbox-community/17976-manufacturer-devicetype_count
Fixes #17976: Remove devicetype_count from nested manufacturer to correct OpenAPI schema
2025-12-11 19:42:26 -05:00
bctiemann
642d83a4c6 Merge pull request #20937 from netbox-community/20560-bulk-import-prefix
Fixes #20560: Fix VLAN disambiguation in prefix bulk import
2025-12-11 19:40:59 -05:00
Jason Novinger
a06c12c6b8 Fixes #20966: Fix broken optgroup stickiness in ObjectType multiselect 2025-12-11 08:59:16 -06:00
Martin Hauser
60fce84c96 feat(ipam): Normalize numeric ranges in API output
Adds logic to handle numeric range fields in API responses by
converting them into inclusive `[low, high]` pairs for consistent
behavior. Updates test cases with `vid_ranges` fields to reflect the
changes.

Closes #20491
2025-12-10 21:11:23 +01:00
Jeremy Stretch
59afa0b41d Fix test 2025-12-10 09:01:11 -05:00
Jeremy Stretch
14b246cb8a Fixes #17976: Remove devicetype_count from nested manufacturer to correct OpenAPI schema 2025-12-10 08:23:48 -05:00
github-actions
f0507d00bf Update source translation strings
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
2025-12-10 05:02:48 +00:00
Arthur Hanson
77b389f105 Fixes #20873: fix webhooks with image fields (#20955) 2025-12-09 22:06:11 -06:00
Jason Novinger
9ae53fc232 Fixes #20560: Fix VLAN disambiguation in prefix bulk import 2025-12-05 16:39:28 -06:00
104 changed files with 29855 additions and 6718 deletions

View File

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

View File

@@ -5,7 +5,7 @@
<a href="https://github.com/netbox-community/netbox/blob/main/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a>
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-16-blue" alt="Languages supported" /></a>
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/actions/workflows/ci.yml/badge.svg" alt="CI status" /></a>
<p>
<strong><a href="https://netboxlabs.com/community/">NetBox Community</a></strong> |

File diff suppressed because it is too large Load Diff

View File

@@ -88,7 +88,7 @@ While permissions are typically assigned to specific groups and/or users, it is
### Viewing Objects
Object-based permissions work by filtering the database query generated by a user's request to restrict the set of objects returned. When a request is received, NetBox first determines whether the user is authenticated and has been granted to perform the requested action. For example, if the requested URL is `/dcim/devices/`, NetBox will check for the `dcim.view_device` permission. If the user has not been assigned this permission (either directly or via a group assignment), NetBox will return a 403 (forbidden) HTTP response.
Object-based permissions work by filtering the database query generated by a user's request to restrict the set of objects returned. When a request is received, NetBox first determines whether the user is authenticated and has been granted permission to perform the requested action. For example, if the requested URL is `/dcim/devices/`, NetBox will check for the `dcim.view_device` permission. If the user has not been assigned this permission (either directly or via a group assignment), NetBox will return a 403 (forbidden) HTTP response.
If the permission _has_ been granted, NetBox will compile any specified constraints for the model and action. For example, suppose two permissions have been assigned to the user granting view access to the device model, with the following constraints:
@@ -102,9 +102,9 @@ If the permission _has_ been granted, NetBox will compile any specified constrai
This grants the user access to view any device that is assigned to a site named NYC1 or NYC2, **or** which has a status of "offline" and has no tenant assigned. These constraints are equivalent to the following ORM query:
```no-highlight
Site.objects.filter(
Device.objects.filter(
Q(site__name__in=['NYC1', 'NYC2']),
Q(status='active', tenant__isnull=True)
Q(status='offline', tenant__isnull=True)
)
```

View File

@@ -74,7 +74,7 @@ The plugin source directory contains all the actual Python code and other resour
The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below:
```python
```python title="__init__.py"
from netbox.plugins import PluginConfig
class FooBarConfig(PluginConfig):
@@ -151,7 +151,7 @@ Any additional apps must be installed within the same Python environment as NetB
An example `pyproject.toml` is below:
```
```toml title="pyproject.toml"
# See PEP 518 for the spec of this file
# https://www.python.org/dev/peps/pep-0518/
@@ -179,11 +179,24 @@ classifiers=[
]
requires-python = ">=3.12.0"
```
Many of these are self-explanatory, but for more information, see the [pyproject.toml documentation](https://packaging.python.org/en/latest/specifications/pyproject-toml/).
## Compatibility Matrix
Consider adding a file named `COMPATIBILITY.md` to your plugin project root (alongside `pyproject.toml`). This file should contain a table listing the minimum and maximum supported versions of NetBox (`min_version` and `max_version`) for each release. This serves as a handy reference for users who are upgrading from a previous version of your plugin. An example is shown below:
```markdown title="COMPATIBILITY.md"
# Compatibility Matrix
| Release | Minimum NetBox Version | Maximum NetBox Version |
|---------|------------------------|------------------------|
| 0.2.0 | 4.4.0 | 4.5.x |
| 0.1.1 | 4.3.0 | 4.4.x |
| 0.1.0 | 4.3.0 | 4.4.x |
```
## Create a Virtual Environment
It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) for the development of your plugin, as opposed to using system-wide packages. This will afford you complete control over the installed versions of all dependencies and avoid conflict with system packages. This environment can live wherever you'd like;however, it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.)

View File

@@ -1,5 +1,51 @@
# NetBox v4.4
## v4.4.10 (2026-01-06)
### Enhancements
* [#20953](https://github.com/netbox-community/netbox/issues/20953) - Show reverse bridge relationships on interface detail pages
* [#21071](https://github.com/netbox-community/netbox/issues/21071) - Include request method & URL when displaying server errors
### Bug Fixes
* [#19506](https://github.com/netbox-community/netbox/issues/19506) - Add filter forms for component templates to ensure object selector support
* [#20044](https://github.com/netbox-community/netbox/issues/20044) - Fix dark mode support for rack elevations
* [#20320](https://github.com/netbox-community/netbox/issues/20320) - Restore support for selecting related interfaces when bulk editing device interfaces
* [#20817](https://github.com/netbox-community/netbox/issues/20817) - Re-enable sync button when disabling scheduled syncing for a data source
* [#21045](https://github.com/netbox-community/netbox/issues/21045) - Fix `ValueError` exception when saving a site with an assigned prefix
* [#21049](https://github.com/netbox-community/netbox/issues/21049) - Ignore stale custom field data when validating an object
* [#21063](https://github.com/netbox-community/netbox/issues/21063) - Check for duplicate choice values when validating a custom field choice set
* [#21064](https://github.com/netbox-community/netbox/issues/21064) - Ensures that extra choices in custom field choice sets preserve escaped colons
---
## v4.4.9 (2025-12-23)
### Enhancements
* [#20309](https://github.com/netbox-community/netbox/issues/20309) - Support ASDOT notation for ASN ranges
* [#20720](https://github.com/netbox-community/netbox/issues/20720) - Add Latvian translations
* [#20900](https://github.com/netbox-community/netbox/issues/20900) - Allow filtering custom choice fields by multiple values in the UI
### Bug Fixes
* [#17976](https://github.com/netbox-community/netbox/issues/17976) - Remove `devicetype_count` from nested manufacturer to correct OpenAPI schema
* [#20011](https://github.com/netbox-community/netbox/issues/20011) - Provide a clear message when encountering duplicate object IDs during bulk import
* [#20114](https://github.com/netbox-community/netbox/issues/20114) - Preserve `parent_bay` during device bulk import when tags are present
* [#20491](https://github.com/netbox-community/netbox/issues/20491) - Improve handling of numeric ranges in tests
* [#20873](https://github.com/netbox-community/netbox/issues/20873) - Fix `AttributeError` exception triggered by event rules associated with an object that supports file attachments
* [#20875](https://github.com/netbox-community/netbox/issues/20875) - Ensure that parent object relations are cached (for filtering) on device/module components during instantiation
* [#20876](https://github.com/netbox-community/netbox/issues/20876) - Allow editing an IP address that resides within a range marked as populated
* [#20912](https://github.com/netbox-community/netbox/issues/20912) - Fix inconsistent clearing of `module` field on ModuleBay
* [#20944](https://github.com/netbox-community/netbox/issues/20944) - Ensure cached scope is updated on child objects when a parent region/site/location is changed
* [#20948](https://github.com/netbox-community/netbox/issues/20948) - Handle the deletion of related objects with `on_delete=RESTRICT` the same as `CASCADE`
* [#20969](https://github.com/netbox-community/netbox/issues/20969) - Fix querying of front port templates by `rear_port_id`
* [#21011](https://github.com/netbox-community/netbox/issues/21011) - Avoid writing to the database when loading active ConfigRevision
* [#21032](https://github.com/netbox-community/netbox/issues/21032) - Avoid SQL subquery in RestrictedQuerySet where unnecessary
---
## v4.4.8 (2025-12-09)
### Enhancements

View File

@@ -22,7 +22,7 @@
#### Lookup Modifiers in Filter Forms ([#7604](https://github.com/netbox-community/netbox/issues/7604))
Most object list filters within the UI have been extended to include optional lookup modifiers to support more complex queries. For instance, filters for numeric values now include a dropdown where a user can select "less than," "greater than," or "not" in addition to the default equivalency match. The specific modifiers available depend on the type of each filter.
Most object list filters within the UI have been extended to include optional lookup modifiers to support more complex queries. For instance, filters for numeric values now include a dropdown where a user can select "less than," "greater than," or "not" in addition to the default equivalency match. The specific modifiers available depend on the type of each filter. Plugins can register their own filtersets using the `register_filterset()` decorator to enable this new functionality.
(Note that this feature does not introduce any new filters. Rather, it makes available in the UI filters which already exist.)

View File

@@ -0,0 +1,17 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0055_add_comments_to_organizationalmodel'),
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0224_add_comments_to_organizationalmodel'),
('extras', '0134_owner'),
]
operations = [
migrations.AddIndex(
model_name='circuittermination',
index=models.Index(fields=['termination_type', 'termination_id'], name='circuits_ci_termina_505dda_idx'),
),
]

View File

@@ -335,6 +335,9 @@ class CircuitTermination(
name='%(app_label)s_%(class)s_unique_circuit_term_side'
),
)
indexes = (
models.Index(fields=('termination_type', 'termination_id')),
)
verbose_name = _('circuit termination')
verbose_name_plural = _('circuit terminations')

View File

@@ -63,16 +63,20 @@ class ConfigRevision(models.Model):
return reverse('core:config') # Default config view
return reverse('core:configrevision', args=[self.pk])
def activate(self):
def activate(self, update_db=True):
"""
Cache the configuration data.
Parameters:
update_db: Mark the ConfigRevision as active in the database (default: True)
"""
cache.set('config', self.data, None)
cache.set('config_version', self.pk, None)
# Set all instances of ConfigRevision to false and set this instance to true
ConfigRevision.objects.all().update(active=False)
ConfigRevision.objects.filter(pk=self.pk).update(active=True)
if update_db:
# Set all instances of ConfigRevision to false and set this instance to true
ConfigRevision.objects.all().update(active=False)
ConfigRevision.objects.filter(pk=self.pk).update(active=True)
activate.alters_data = True

View File

@@ -131,6 +131,19 @@ class DataSource(JobsMixin, PrimaryModel):
'source_url': "URLs for local sources must start with file:// (or specify no scheme)"
})
def save(self, *args, **kwargs):
# If recurring sync is disabled for an existing DataSource, clear any pending sync jobs for it and reset its
# "queued" status
if not self._state.adding and not self.sync_interval:
self.jobs.filter(status=JobStatusChoices.STATUS_PENDING).delete()
if self.status == DataSourceStatusChoices.QUEUED and self.last_synced:
self.status = DataSourceStatusChoices.COMPLETED
elif self.status == DataSourceStatusChoices.QUEUED:
self.status = DataSourceStatusChoices.NEW
super().save(*args, **kwargs)
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)

View File

@@ -3,7 +3,7 @@ from threading import local
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db.models import CASCADE
from django.db.models import CASCADE, RESTRICT
from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
from django.db.models.signals import m2m_changed, post_migrate, post_save, pre_delete
from django.dispatch import receiver, Signal
@@ -221,7 +221,7 @@ def handle_deleted_object(sender, instance, **kwargs):
obj.snapshot() # Ensure the change record includes the "before" state
if type(relation) is ManyToManyRel:
getattr(obj, related_field_name).remove(instance)
elif type(relation) is ManyToOneRel and relation.null and relation.on_delete is not CASCADE:
elif type(relation) is ManyToOneRel and relation.null and relation.on_delete not in (CASCADE, RESTRICT):
setattr(obj, related_field_name, None)
obj.save()

View File

@@ -1,5 +1,5 @@
from django.utils.translation import gettext as _
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
from rest_framework import serializers
from dcim.choices import *
@@ -14,6 +14,7 @@ from ipam.models import VLAN
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.gfk_fields import GFKSerializerField
from netbox.api.serializers import NetBoxModelSerializer
from users.api.serializers_.mixins import OwnerMixin
from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
from wireless.api.serializers_.nested import NestedWirelessLinkSerializer
from wireless.api.serializers_.wirelesslans import WirelessLANSerializer
@@ -40,7 +41,12 @@ __all__ = (
)
class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
class ConsoleServerPortSerializer(
OwnerMixin,
NetBoxModelSerializer,
CabledObjectSerializer,
ConnectedEndpointsSerializer
):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@@ -64,13 +70,18 @@ class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer,
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
class ConsolePortSerializer(
OwnerMixin,
NetBoxModelSerializer,
CabledObjectSerializer,
ConnectedEndpointsSerializer
):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@@ -94,13 +105,18 @@ class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
class PowerPortSerializer(
OwnerMixin,
NetBoxModelSerializer,
CabledObjectSerializer,
ConnectedEndpointsSerializer
):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@@ -120,13 +136,18 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw',
'allocated_draw', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags',
'custom_fields', 'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
class PowerOutletSerializer(
OwnerMixin,
NetBoxModelSerializer,
CabledObjectSerializer,
ConnectedEndpointsSerializer
):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@@ -159,12 +180,17 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'status', 'color',
'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
class InterfaceSerializer(
OwnerMixin,
NetBoxModelSerializer,
CabledObjectSerializer,
ConnectedEndpointsSerializer
):
device = DeviceSerializer(nested=True)
vdcs = SerializedPKRelatedField(
queryset=VirtualDeviceContext.objects.all(),
@@ -182,6 +208,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
type = ChoiceField(choices=InterfaceTypeChoices)
parent = NestedInterfaceSerializer(required=False, allow_null=True)
bridge = NestedInterfaceSerializer(required=False, allow_null=True)
bridge_interfaces = NestedInterfaceSerializer(many=True, read_only=True)
lag = NestedInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True)
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True)
@@ -221,13 +248,13 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
model = Interface
fields = [
'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled',
'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'primary_mac_address', 'mac_addresses', 'speed', 'duplex',
'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers',
'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
'parent', 'bridge', 'bridge_interfaces', 'lag', 'mtu', 'mac_address', 'primary_mac_address',
'mac_addresses', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end',
'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags',
'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
@@ -307,7 +334,7 @@ class RearPortMappingSerializer(serializers.ModelSerializer):
fields = ('position', 'front_port', 'front_port_position')
class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
class RearPortSerializer(OwnerMixin, NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@@ -327,7 +354,7 @@ class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, PortSeri
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions',
'front_ports', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
@@ -345,7 +372,7 @@ class FrontPortMappingSerializer(serializers.ModelSerializer):
fields = ('position', 'rear_port', 'rear_port_position')
class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
class FrontPortSerializer(OwnerMixin, NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@@ -365,12 +392,12 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, PortSer
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions',
'rear_ports', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class ModuleBaySerializer(NetBoxModelSerializer):
class ModuleBaySerializer(OwnerMixin, NetBoxModelSerializer):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@@ -390,12 +417,12 @@ class ModuleBaySerializer(NetBoxModelSerializer):
model = ModuleBay
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'installed_module', 'label', 'position',
'description', 'tags', 'custom_fields', 'created', 'last_updated',
'description', 'owner', 'tags', 'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')
class DeviceBaySerializer(NetBoxModelSerializer):
class DeviceBaySerializer(OwnerMixin, NetBoxModelSerializer):
device = DeviceSerializer(nested=True)
installed_device = DeviceSerializer(nested=True, required=False, allow_null=True)
@@ -403,12 +430,12 @@ class DeviceBaySerializer(NetBoxModelSerializer):
model = DeviceBay
fields = [
'id', 'url', 'display_url', 'display', 'device', 'name', 'label', 'description', 'installed_device',
'tags', 'custom_fields', 'created', 'last_updated',
'owner', 'tags', 'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
class InventoryItemSerializer(NetBoxModelSerializer):
class InventoryItemSerializer(OwnerMixin, NetBoxModelSerializer):
device = DeviceSerializer(nested=True)
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
role = InventoryItemRoleSerializer(nested=True, required=False, allow_null=True)
@@ -427,6 +454,6 @@ class InventoryItemSerializer(NetBoxModelSerializer):
fields = [
'id', 'url', 'display_url', 'display', 'device', 'parent', 'name', 'label', 'status', 'role',
'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', 'component_type',
'component_id', 'component', 'tags', 'custom_fields', 'created', 'last_updated', '_depth',
'component_id', 'component', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_depth',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', '_depth')

View File

@@ -111,7 +111,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
'id', 'url', 'display_url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'config_context',
'vc_position', 'vc_priority', 'description', 'owner', 'comments', 'config_template', 'config_context',
'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count',
'console_server_port_count', 'power_port_count', 'power_outlet_count', 'interface_count',
'front_port_count', 'rear_port_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count',

View File

@@ -22,4 +22,4 @@ class ManufacturerSerializer(OrganizationalModelSerializer):
'custom_fields', 'created', 'last_updated', 'devicetype_count', 'moduletype_count', 'inventoryitem_count',
'platform_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'devicetype_count')
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')

View File

@@ -905,7 +905,7 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
)
rear_port_id = django_filters.ModelMultipleChoiceFilter(
field_name='mappings__rear_port',
queryset=RearPort.objects.all(),
queryset=RearPortTemplate.objects.all(),
to_field_name='rear_port',
label=_('Rear port (ID)'),
)

View File

@@ -27,35 +27,45 @@ __all__ = (
'CableFilterForm',
'ConsoleConnectionFilterForm',
'ConsolePortFilterForm',
'ConsolePortTemplateFilterForm',
'ConsoleServerPortFilterForm',
'ConsoleServerPortTemplateFilterForm',
'DeviceBayFilterForm',
'DeviceBayTemplateFilterForm',
'DeviceFilterForm',
'DeviceRoleFilterForm',
'DeviceTypeFilterForm',
'FrontPortFilterForm',
'FrontPortTemplateFilterForm',
'InterfaceConnectionFilterForm',
'InterfaceFilterForm',
'InterfaceTemplateFilterForm',
'InventoryItemFilterForm',
'InventoryItemTemplateFilterForm',
'InventoryItemRoleFilterForm',
'LocationFilterForm',
'MACAddressFilterForm',
'ManufacturerFilterForm',
'ModuleFilterForm',
'ModuleBayFilterForm',
'ModuleBayTemplateFilterForm',
'ModuleTypeFilterForm',
'ModuleTypeProfileFilterForm',
'PlatformFilterForm',
'PowerConnectionFilterForm',
'PowerFeedFilterForm',
'PowerOutletFilterForm',
'PowerOutletTemplateFilterForm',
'PowerPanelFilterForm',
'PowerPortFilterForm',
'PowerPortTemplateFilterForm',
'RackFilterForm',
'RackElevationFilterForm',
'RackReservationFilterForm',
'RackRoleFilterForm',
'RackTypeFilterForm',
'RearPortFilterForm',
'RearPortTemplateFilterForm',
'RegionFilterForm',
'SiteFilterForm',
'SiteGroupFilterForm',
@@ -1333,6 +1343,23 @@ class PowerFeedFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
# Device components
#
class DeviceComponentTemplateFilterForm(NetBoxModelFilterSetForm):
device_type_id = DynamicModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
required=False,
label=_('Device type'),
)
class ModularDeviceComponentTemplateFilterForm(DeviceComponentTemplateFilterForm):
module_type_id = DynamicModelMultipleChoiceField(
queryset=ModuleType.objects.all(),
required=False,
query_params={'manufacturer_id': '$manufacturer_id'},
label=_('Module Type'),
)
class CabledFilterForm(forms.Form):
cabled = forms.NullBooleanField(
label=_('Cabled'),
@@ -1385,6 +1412,20 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
tag = TagFilterField(model)
class ConsolePortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
model = ConsolePortTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', name=_('Attributes')),
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=ConsolePortTypeChoices,
required=False
)
class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = ConsoleServerPort
fieldsets = (
@@ -1410,6 +1451,20 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
tag = TagFilterField(model)
class ConsoleServerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
model = ConsoleServerPortTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', name=_('Attributes')),
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=ConsolePortTypeChoices,
required=False
)
class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerPort
fieldsets = (
@@ -1430,6 +1485,20 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
tag = TagFilterField(model)
class PowerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
model = PowerPortTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', name=_('Attributes')),
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=PowerPortTypeChoices,
required=False
)
class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerOutlet
fieldsets = (
@@ -1459,6 +1528,20 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
)
class PowerOutletTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
model = PowerOutletTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', name=_('Attributes')),
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=PowerOutletTypeChoices,
required=False
)
class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = Interface
fieldsets = (
@@ -1586,6 +1669,51 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
tag = TagFilterField(model)
class InterfaceTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
model = InterfaceTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'enabled', 'mgmt_only', name=_('Attributes')),
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('rf_role', name=_('Wireless')),
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=InterfaceTypeChoices,
required=False
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
mgmt_only = forms.NullBooleanField(
label=_('Management only'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
poe_mode = forms.MultipleChoiceField(
choices=InterfacePoEModeChoices,
required=False,
label=_('PoE mode')
)
poe_type = forms.MultipleChoiceField(
choices=InterfacePoETypeChoices,
required=False,
label=_('PoE type')
)
rf_role = forms.MultipleChoiceField(
choices=WirelessRoleChoices,
required=False,
label=_('Wireless role')
)
class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
@@ -1610,6 +1738,24 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
tag = TagFilterField(model)
class FrontPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
model = FrontPortTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=PortTypeChoices,
required=False
)
color = ColorField(
label=_('Color'),
required=False
)
class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
model = RearPort
fieldsets = (
@@ -1634,6 +1780,24 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
tag = TagFilterField(model)
class RearPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
model = RearPortTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=PortTypeChoices,
required=False
)
color = ColorField(
label=_('Color'),
required=False
)
class ModuleBayFilterForm(DeviceComponentFilterForm):
model = ModuleBay
fieldsets = (
@@ -1652,6 +1816,19 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
)
class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
model = ModuleBayTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'position', name=_('Attributes')),
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
)
position = forms.CharField(
label=_('Position'),
required=False,
)
class DeviceBayFilterForm(DeviceComponentFilterForm):
model = DeviceBay
fieldsets = (
@@ -1666,6 +1843,15 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model)
class DeviceBayTemplateFilterForm(DeviceComponentTemplateFilterForm):
model = DeviceBayTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', name=_('Attributes')),
FieldSet('device_type_id', name=_('Device')),
)
class InventoryItemFilterForm(DeviceComponentFilterForm):
model = InventoryItem
fieldsets = (
@@ -1713,6 +1899,25 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model)
class InventoryItemTemplateFilterForm(DeviceComponentTemplateFilterForm):
model = InventoryItemTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'role_id', 'manufacturer_id', name=_('Attributes')),
FieldSet('device_type_id', name=_('Device')),
)
role_id = DynamicModelMultipleChoiceField(
queryset=InventoryItemRole.objects.all(),
required=False,
label=_('Role')
)
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
label=_('Manufacturer')
)
#
# Device component roles
#

View File

@@ -0,0 +1,19 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0224_add_comments_to_organizationalmodel'),
('extras', '0134_owner'),
('users', '0015_owner'),
]
operations = [
migrations.AddIndex(
model_name='macaddress',
index=models.Index(
fields=['assigned_object_type', 'assigned_object_id'], name='dcim_macadd_assigne_54115d_idx'
),
),
]

View File

@@ -115,8 +115,9 @@ class Cable(PrimaryModel):
# A copy of the PK to be used by __str__ in case the object is deleted
self._pk = self.__dict__.get('id')
# Cache the original status so we can check later if it's been changed
# Cache the original profile & status so we can check later whether either has been changed
self._orig_status = self.__dict__.get('status')
self._orig_profile = self.__dict__.get('profile')
self._terminations_modified = False
@@ -290,7 +291,10 @@ class Cable(PrimaryModel):
# Update the private PK used in __str__()
self._pk = self.pk
if self._terminations_modified:
if self._orig_profile != self.profile:
print(f'profile changed from {self._orig_profile} to {self.profile}')
self.update_terminations(force=True)
elif self._terminations_modified:
self.update_terminations()
super().save(*args, force_update=True, using=using, update_fields=update_fields)
@@ -344,24 +348,28 @@ class Cable(PrimaryModel):
return a_terminations, b_terminations
def update_terminations(self):
def update_terminations(self, force=False):
"""
Create/delete CableTerminations for this Cable to reflect its current state.
Args:
force: Force the recreation of all CableTerminations, even if no changes have been made. Needed e.g. when
altering a Cable's assigned profile.
"""
a_terminations, b_terminations = self.get_terminations()
# Delete any stale CableTerminations
for termination, ct in a_terminations.items():
if termination.pk and termination not in self.a_terminations:
if force or (termination.pk and termination not in self.a_terminations):
ct.delete()
for termination, ct in b_terminations.items():
if termination.pk and termination not in self.b_terminations:
if force or (termination.pk and termination not in self.b_terminations):
ct.delete()
# Save any new CableTerminations
profile = self.profile_class() if self.profile else None
for i, termination in enumerate(self.a_terminations, start=1):
if not termination.pk or termination not in a_terminations:
if force or not termination.pk or termination not in a_terminations:
connector = positions = None
if profile:
connector = i
@@ -374,7 +382,7 @@ class Cable(PrimaryModel):
termination=termination
).save()
for i, termination in enumerate(self.b_terminations, start=1):
if not termination.pk or termination not in b_terminations:
if force or not termination.pk or termination not in b_terminations:
connector = positions = None
if profile:
connector = i
@@ -790,7 +798,8 @@ class CablePath(models.Model):
# Profile-based tracing
if links[0].profile:
cable_profile = links[0].profile_class()
term, position = cable_profile.get_peer_termination(terminations[0], position_stack.pop()[0])
position = position_stack.pop()[0] if position_stack else None
term, position = cable_profile.get_peer_termination(terminations[0], position)
remote_terminations = [term]
position_stack.append([position])

View File

@@ -1292,6 +1292,8 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
def save(self, *args, **kwargs):
if self.module:
self.parent = self.module.module_bay
else:
self.parent = None
super().save(*args, **kwargs)

View File

@@ -959,6 +959,11 @@ class Device(
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
for component in components:
component.custom_field_data = cf_defaults
# Set denormalized references
for component in components:
component._site = self.site
component._location = self.location
component._rack = self.rack
components = model.objects.bulk_create(components)
# Prefetch related objects to minimize queries needed during post_save
prefetch_fields = get_prefetchable_fields(model)
@@ -1318,7 +1323,10 @@ class MACAddress(PrimaryModel):
)
class Meta:
ordering = ('mac_address', 'pk',)
ordering = ('mac_address', 'pk')
indexes = (
models.Index(fields=('assigned_object_type', 'assigned_object_id')),
)
verbose_name = _('MAC address')
verbose_name_plural = _('MAC addresses')

View File

@@ -321,6 +321,12 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
for component in create_instances:
component.custom_field_data = cf_defaults
# Set denormalized references
for component in create_instances:
component._site = self.device.site
component._location = self.device.location
component._rack = self.device.rack
if component_model is not ModuleBay:
component_model.objects.bulk_create(create_instances)
# Emit the post_save signal for each newly created object

View File

@@ -1,15 +1,17 @@
import logging
from django.db.models import Q
from django.db.models.signals import post_save, post_delete
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from dcim.choices import CableEndChoices, LinkStatusChoices
from virtualization.models import VMInterface
from ipam.models import Prefix
from virtualization.models import Cluster, VMInterface
from wireless.models import WirelessLAN
from .models import (
Cable, CablePath, CableTermination, ConsolePort, ConsoleServerPort, Device, DeviceBay, FrontPort, Interface,
InventoryItem, ModuleBay, PathEndpoint, PortMapping, PowerOutlet, PowerPanel, PowerPort, Rack, RearPort, Location,
VirtualChassis,
InventoryItem, Location, ModuleBay, PathEndpoint, PortMapping, PowerOutlet, PowerPanel, PowerPort, Rack, RearPort,
Site, VirtualChassis,
)
from .models.cables import trace_paths
from .utils import create_cablepaths, rebuild_paths
@@ -45,6 +47,9 @@ def handle_location_site_change(instance, created, **kwargs):
Device.objects.filter(location__in=locations).update(site=instance.site)
PowerPanel.objects.filter(location__in=locations).update(site=instance.site)
CableTermination.objects.filter(_location__in=locations).update(_site=instance.site)
# Update component models for devices in these locations
for model in COMPONENT_MODELS:
model.objects.filter(device__location__in=locations).update(_site=instance.site)
@receiver(post_save, sender=Rack)
@@ -54,6 +59,12 @@ def handle_rack_site_change(instance, created, **kwargs):
"""
if not created:
Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location)
# Update component models for devices in this rack
for model in COMPONENT_MODELS:
model.objects.filter(device__rack=instance).update(
_site=instance.site,
_location=instance.location,
)
@receiver(post_save, sender=Device)
@@ -172,3 +183,40 @@ def update_mac_address_interface(instance, created, raw, **kwargs):
if created and not raw and instance.primary_mac_address:
instance.primary_mac_address.assigned_object = instance
instance.primary_mac_address.save()
@receiver(post_save, sender=Location)
@receiver(post_save, sender=Site)
def sync_cached_scope_fields(instance, created, **kwargs):
"""
Rebuild cached scope fields for all CachedScopeMixin-based models
affected by a change in a Region, SiteGroup, Site, or Location.
This method is safe to run for objects created in the past and does
not rely on incremental updates. Cached fields are recomputed from
authoritative relationships.
"""
if created:
return
if isinstance(instance, Location):
filters = {'_location': instance}
elif isinstance(instance, Site):
filters = {'_site': instance}
else:
return
# These models are explicitly listed because they all subclass CachedScopeMixin
# and therefore require their cached scope fields to be recomputed.
for model in (Prefix, Cluster, WirelessLAN):
qs = model.objects.filter(**filters)
for obj in qs:
# Recompute cache using the same logic as save()
obj.cache_related_objects()
obj.save(update_fields=[
'_location',
'_site',
'_site_group',
'_region',
])

View File

@@ -532,7 +532,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
class ManufacturerTest(APIViewTestCases.APIViewTestCase):
model = Manufacturer
brief_fields = ['description', 'devicetype_count', 'display', 'id', 'name', 'slug', 'url']
brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
create_data = [
{
'name': 'Manufacturer 4',

View File

@@ -6,6 +6,7 @@ from core.models import ObjectType
from dcim.choices import *
from dcim.models import *
from extras.models import CustomField
from ipam.models import Prefix
from netbox.choices import WeightUnitChoices
from tenancy.models import Tenant
from utilities.data import drange
@@ -848,6 +849,32 @@ class ModuleBayTestCase(TestCase):
nested_bay = module.modulebays.get(name='SFP A-21')
self.assertEqual(nested_bay.label, 'A-21')
@tag('regression') # #20912
def test_module_bay_parent_cleared_when_module_removed(self):
"""Test that the parent field is properly cleared when a module bay's module assignment is removed"""
device = Device.objects.first()
manufacturer = Manufacturer.objects.first()
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Test Module Type')
bay1 = ModuleBay.objects.create(device=device, name='Test Bay 1')
bay2 = ModuleBay.objects.create(device=device, name='Test Bay 2')
# Install a module in bay1
module1 = Module.objects.create(device=device, module_bay=bay1, module_type=module_type)
# Assign bay2 to module1 and verify parent is now set to bay1 (module1's bay)
bay2.module = module1
bay2.save()
bay2.refresh_from_db()
self.assertEqual(bay2.parent, bay1)
self.assertEqual(bay2.module, module1)
# Clear the module assignment (return bay2 to device level) Verify parent is cleared
bay2.module = None
bay2.save()
bay2.refresh_from_db()
self.assertIsNone(bay2.parent)
self.assertIsNone(bay2.module)
class CableTestCase(TestCase):
@@ -1179,3 +1206,14 @@ class VirtualChassisTestCase(TestCase):
device2.vc_position = 1
with self.assertRaises(ValidationError):
device2.full_clean()
class SiteSignalTestCase(TestCase):
@tag('regression')
def test_edit_site_with_prefix_no_vrf(self):
site = Site.objects.create(name='Test Site', slug='test-site')
Prefix.objects.create(prefix='192.0.2.0/24', scope=site, vrf=None)
# Regression test for #21045: should not raise ValueError
site.save()

View File

@@ -2362,6 +2362,32 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
self.remove_permissions('dcim.view_device')
self.assertHttpStatus(self.client.get(url), 403)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_bulk_import_duplicate_ids_error_message(self):
device = Device.objects.first()
csv_data = (
"id,role",
f"{device.pk},Device Role 1",
f"{device.pk},Device Role 2",
)
self.add_permissions('dcim.add_device', 'dcim.change_device')
response = self.client.post(
self._get_url('bulk_import'),
{
'data': '\n'.join(csv_data),
'format': ImportFormatChoices.CSV,
'csv_delimiter': CSVDelimiterChoices.AUTO,
},
follow=True
)
self.assertEqual(response.status_code, 200)
self.assertIn(
f'Duplicate objects found: Device with ID(s) {device.pk} appears multiple times',
response.content.decode('utf-8')
)
class ModuleTestCase(
# Module does not support bulk renaming (no name field) or

View File

@@ -16,7 +16,7 @@ from circuits.models import Circuit, CircuitTermination
from dcim.ui import panels
from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from netbox.object_actions import *
from netbox.ui import actions, layout
@@ -2714,11 +2714,12 @@ class DeviceBulkImportView(generic.BulkImportView):
model_form = forms.DeviceImportForm
def save_object(self, object_form, request):
parent_bay = getattr(object_form.instance, 'parent_bay', None)
obj = object_form.save()
# For child devices, save the reverse relation to the parent device bay
if getattr(obj, 'parent_bay', None):
device_bay = obj.parent_bay
if parent_bay:
device_bay = parent_bay
device_bay.installed_device = obj
device_bay.save()
@@ -3159,6 +3160,7 @@ class InterfaceView(generic.ObjectView):
return {
'vdc_table': vdc_table,
'bridge_interfaces': bridge_interfaces,
'bridge_interfaces_table': bridge_interfaces_table,
'child_interfaces_table': child_interfaces_table,
'vlan_table': vlan_table,

View File

@@ -119,7 +119,9 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
if snapshots:
params["snapshots"] = snapshots
if request:
params["request"] = copy_safe_request(request)
# Exclude FILES - webhooks don't need uploaded files,
# which can cause pickle errors with Pillow.
params["request"] = copy_safe_request(request, include_files=False)
# Enqueue the task
rq_queue.enqueue(

View File

@@ -189,22 +189,22 @@ class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelFor
# if standardize these, we can simplify this code
# Convert extra_choices Array Field from model to CharField for form
if 'extra_choices' in self.initial and self.initial['extra_choices']:
extra_choices = self.initial['extra_choices']
if extra_choices := self.initial.get('extra_choices', None):
if isinstance(extra_choices, str):
extra_choices = [extra_choices]
choices = ""
choices = []
for choice in extra_choices:
# Setup choices in Add Another use case
if isinstance(choice, str):
choice_str = ":".join(choice.replace("'", "").replace(" ", "")[1:-1].split(","))
choices += choice_str + "\n"
choices.append(choice_str)
# Setup choices in Edit use case
elif isinstance(choice, list):
choice_str = ":".join(choice)
choices += choice_str + "\n"
value = choice[0].replace(':', '\\:')
label = choice[1].replace(':', '\\:')
choices.append(f'{value}:{label}')
self.initial['extra_choices'] = choices
self.initial['extra_choices'] = '\n'.join(choices)
def clean_extra_choices(self):
data = []

View File

@@ -450,7 +450,14 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
return model.objects.filter(pk__in=value)
return value
def to_form_field(self, set_initial=True, enforce_required=True, enforce_visibility=True, for_csv_import=False):
def to_form_field(
self,
set_initial=True,
enforce_required=True,
enforce_visibility=True,
for_csv_import=False,
for_filterset_form=False,
):
"""
Return a form field suitable for setting a CustomField's value for an object.
@@ -458,6 +465,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
enforce_visibility: Honor the value of CustomField.ui_visible. Set to False for filtering.
for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
for_filterset_form: Return a form field suitable for use in a FilterSet form.
"""
initial = self.default if set_initial else None
required = self.required if enforce_required else False
@@ -520,7 +528,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
field_class = CSVMultipleChoiceField
field = field_class(choices=choices, required=required, initial=initial)
else:
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
if self.type == CustomFieldTypeChoices.TYPE_SELECT and not for_filterset_form:
field_class = DynamicChoiceField
widget_class = APISelect
else:
@@ -871,6 +879,16 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, OwnerMixin, Chang
if not self.base_choices and not self.extra_choices:
raise ValidationError(_("Must define base or extra choices."))
# Check for duplicate values in extra_choices
choice_values = [c[0] for c in self.extra_choices] if self.extra_choices else []
if len(set(choice_values)) != len(choice_values):
# At least one duplicate value is present. Find the first one and raise an error.
_seen = []
for value in choice_values:
if value in _seen:
raise ValidationError(_("Duplicate value '{value}' found in extra choices.").format(value=value))
_seen.append(value)
# Check whether any choices have been removed. If so, check whether any of the removed
# choices are still set in custom field data for any object.
original_choices = set([

View File

@@ -1506,19 +1506,18 @@ class CustomFieldModelTest(TestCase):
def test_invalid_data(self):
"""
Setting custom field data for a non-applicable (or non-existent) CustomField should raise a ValidationError.
Any invalid or stale custom field data should be removed from the instance.
"""
site = Site(name='Test Site', slug='test-site')
# Set custom field data
site.custom_field_data['foo'] = 'abc'
site.custom_field_data['bar'] = 'def'
with self.assertRaises(ValidationError):
site.clean()
del site.custom_field_data['bar']
site.clean()
self.assertIn('foo', site.custom_field_data)
self.assertNotIn('bar', site.custom_field_data)
def test_missing_required_field(self):
"""
Check that a ValidationError is raised if any required custom fields are not present.

View File

@@ -5,6 +5,7 @@ from dcim.forms import SiteForm
from dcim.models import Site
from extras.choices import CustomFieldTypeChoices
from extras.forms import SavedFilterForm
from extras.forms.model_forms import CustomFieldChoiceSetForm
from extras.models import CustomField, CustomFieldChoiceSet
@@ -90,6 +91,31 @@ class CustomFieldModelFormTest(TestCase):
self.assertIsNone(instance.custom_field_data[field_type])
class CustomFieldChoiceSetFormTest(TestCase):
def test_escaped_colons_preserved_on_edit(self):
choice_set = CustomFieldChoiceSet.objects.create(
name='Test Choice Set',
extra_choices=[['foo:bar', 'label'], ['value', 'label:with:colons']]
)
form = CustomFieldChoiceSetForm(instance=choice_set)
initial_choices = form.initial['extra_choices']
# colons are re-escaped
self.assertEqual(initial_choices, 'foo\\:bar:label\nvalue:label\\:with\\:colons')
form = CustomFieldChoiceSetForm(
{'name': choice_set.name, 'extra_choices': initial_choices},
instance=choice_set
)
self.assertTrue(form.is_valid())
updated = form.save()
# cleaned extra choices are correct, which does actually mean a list of tuples
self.assertEqual(updated.extra_choices, [('foo:bar', 'label'), ('value', 'label:with:colons')])
class SavedFilterFormTest(TestCase):
def test_basic_submit(self):

View File

@@ -51,7 +51,14 @@ class ImageAttachmentsPanel(panels.ObjectsTablePanel):
]
def __init__(self, **kwargs):
super().__init__('extras.imageattachment', **kwargs)
super().__init__(
'extras.imageattachment',
filters={
'object_type_id': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
'object_id': lambda ctx: ctx['object'].pk,
},
**kwargs,
)
class TagsPanel(panels.ObjectPanel):

View File

@@ -1,13 +1,15 @@
from rest_framework import serializers
from dcim.models import Site
from ipam.models import ASN, ASNRange, RIR
from netbox.api.fields import RelatedObjectCountField
from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField
from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer
__all__ = (
'ASNRangeSerializer',
'ASNSerializer',
'ASNSiteSerializer',
'AvailableASNSerializer',
'RIRSerializer',
)
@@ -41,9 +43,27 @@ class ASNRangeSerializer(OrganizationalModelSerializer):
brief_fields = ('id', 'url', 'display', 'name', 'description')
class ASNSiteSerializer(PrimaryModelSerializer):
"""
This serializer is meant for inclusion in ASNSerializer and is only used
to avoid a circular import of SiteSerializer.
"""
class Meta:
model = Site
fields = ('id', 'url', 'display', 'name', 'description', 'slug')
brief_fields = ('id', 'url', 'display', 'name', 'description', 'slug')
class ASNSerializer(PrimaryModelSerializer):
rir = RIRSerializer(nested=True, required=False, allow_null=True)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
sites = SerializedPKRelatedField(
queryset=Site.objects.all(),
serializer=ASNSiteSerializer,
nested=True,
required=False,
many=True
)
# Related object counts
site_count = RelatedObjectCountField('sites')
@@ -53,7 +73,7 @@ class ASNSerializer(PrimaryModelSerializer):
model = ASN
fields = [
'id', 'url', 'display_url', 'display', 'asn', 'rir', 'tenant', 'description', 'owner', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'site_count', 'provider_count',
'custom_fields', 'created', 'last_updated', 'site_count', 'provider_count', 'sites',
]
brief_fields = ('id', 'url', 'display', 'asn', 'description')

View File

@@ -16,6 +16,7 @@ __all__ = (
# BGP ASN bounds
BGP_ASN_MIN = 1
BGP_ASN_MAX = 2**32 - 1
BGP_ASN_ASDOT_BASE = 2**16
class BaseIPField(models.Field):
@@ -126,3 +127,16 @@ class ASNField(models.BigIntegerField):
}
defaults.update(**kwargs)
return super().formfield(**defaults)
@staticmethod
def to_asdot(value) -> str:
"""
Return ASDOT notation for AS numbers greater than 16 bits.
"""
if value is None:
return ''
if value >= BGP_ASN_ASDOT_BASE:
hi, lo = divmod(value, BGP_ASN_ASDOT_BASE)
return f'{hi}.{lo}'
return str(value)

View File

@@ -229,10 +229,6 @@ class PrefixImportForm(ScopedImportForm, PrimaryModelImportForm):
query |= Q(**{
f"site__{self.fields['vlan_site'].to_field_name}": vlan_site
})
# Don't Forget to include VLANs without a site in the filter
query |= Q(**{
f"site__{self.fields['vlan_site'].to_field_name}__isnull": True
})
if vlan_group:
query &= Q(**{

View File

@@ -0,0 +1,19 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0225_gfk_indexes'),
('extras', '0134_owner'),
('ipam', '0085_add_comments_to_organizationalmodel'),
('tenancy', '0022_add_comments_to_organizationalmodel'),
('users', '0015_owner'),
]
operations = [
migrations.AddIndex(
model_name='prefix',
index=models.Index(fields=['scope_type', 'scope_id'], name='ipam_prefix_scope_t_fe84a6_idx'),
),
]

View File

@@ -55,13 +55,6 @@ class ASNRange(OrganizationalModel):
def __str__(self):
return f'{self.name} ({self.range_as_string()})'
@property
def range(self):
return range(self.start, self.end + 1)
def range_as_string(self):
return f'{self.start}-{self.end}'
def clean(self):
super().clean()
@@ -72,7 +65,45 @@ class ASNRange(OrganizationalModel):
)
)
@property
def range(self):
"""
Return a range of integers representing the ASN range.
"""
return range(self.start, self.end + 1)
@property
def start_asdot(self):
"""
Return ASDOT notation for AS numbers greater than 16 bits.
"""
return ASNField.to_asdot(self.start)
@property
def end_asdot(self):
"""
Return ASDOT notation for AS numbers greater than 16 bits.
"""
return ASNField.to_asdot(self.end)
def range_as_string(self):
"""
Return a string representation of the ASN range.
"""
return f'{self.start}-{self.end}'
def range_as_string_with_asdot(self):
"""
Return a string representation of the ASN range, including ASDOT notation.
"""
if self.end >= 65536:
return f'{self.range_as_string()} ({self.start_asdot}-{self.end_asdot})'
return self.range_as_string()
def get_child_asns(self):
"""
Return all child ASNs (ASNs within the range).
"""
return ASN.objects.filter(
asn__gte=self.start,
asn__lte=self.end
@@ -131,20 +162,20 @@ class ASN(ContactsMixin, PrimaryModel):
"""
Return ASDOT notation for AS numbers greater than 16 bits.
"""
if self.asn > 65535:
return f'{self.asn // 65536}.{self.asn % 65536}'
return self.asn
return ASNField.to_asdot(self.asn)
@property
def asn_with_asdot(self):
"""
Return both plain and ASDOT notation, where applicable.
"""
if self.asn > 65535:
return f'{self.asn} ({self.asn // 65536}.{self.asn % 65536})'
else:
return self.asn
if self.asn >= 65536:
return f'{self.asn} ({self.asn_asdot})'
return str(self.asn)
@property
def prefixed_name(self):
"""
Return the ASN with ASDOT notation prefixed with "AS".
"""
return f'AS{self.asn_with_asdot}'

View File

@@ -282,13 +282,10 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
ordering = (F('vrf').asc(nulls_first=True), 'prefix', 'pk') # (vrf, prefix) may be non-unique
verbose_name = _('prefix')
verbose_name_plural = _('prefixes')
indexes = [
GistIndex(
fields=['prefix'],
name='ipam_prefix_gist_idx',
opclasses=['inet_ops'],
),
]
indexes = (
models.Index(fields=('scope_type', 'scope_id')),
GistIndex(fields=['prefix'], name='ipam_prefix_gist_idx', opclasses=['inet_ops']),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -910,13 +907,13 @@ class IPAddress(ContactsMixin, PrimaryModel):
})
# Disallow the creation of IPAddresses within an IPRange with mark_populated=True
parent_range = IPRange.objects.filter(
parent_range_qs = IPRange.objects.filter(
start_address__lte=self.address,
end_address__gte=self.address,
vrf=self.vrf,
mark_populated=True
).first()
if parent_range:
)
if not self.pk and (parent_range := parent_range_qs.first()):
raise ValidationError({
'address': _(
"Cannot create IP address {ip} inside range {range}."

View File

@@ -20,6 +20,16 @@ class ASNRangeTable(TenancyColumnsMixin, OrganizationalModelTable):
verbose_name=_('RIR'),
linkify=True
)
start_asdot = tables.Column(
accessor=tables.A('start_asdot'),
order_by=tables.A('start'),
verbose_name=_('Start (ASDOT)')
)
end_asdot = tables.Column(
accessor=tables.A('end_asdot'),
order_by=tables.A('end'),
verbose_name=_('End (ASDOT)')
)
tags = columns.TagColumn(
url_name='ipam:asnrange_list'
)
@@ -30,8 +40,8 @@ class ASNRangeTable(TenancyColumnsMixin, OrganizationalModelTable):
class Meta(OrganizationalModelTable.Meta):
model = ASNRange
fields = (
'pk', 'name', 'slug', 'rir', 'start', 'end', 'asn_count', 'tenant', 'tenant_group', 'description',
'comments', 'tags', 'created', 'last_updated', 'actions',
'pk', 'name', 'slug', 'rir', 'start', 'start_asdot', 'end', 'end_asdot', 'asn_count', 'tenant',
'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'rir', 'start', 'end', 'tenant', 'asn_count', 'description')

View File

@@ -1071,14 +1071,17 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase):
{
'name': 'VLAN Group 4',
'slug': 'vlan-group-4',
'vid_ranges': [[1, 4094]]
},
{
'name': 'VLAN Group 5',
'slug': 'vlan-group-5',
'vid_ranges': [[1, 4094]]
},
{
'name': 'VLAN Group 6',
'slug': 'vlan-group-6',
'vid_ranges': [[1, 4094]]
},
]
bulk_update_data = {

View File

@@ -564,6 +564,82 @@ vlan: 102
self.assertEqual(prefix.vlan.vid, 102)
self.assertEqual(prefix.scope, site)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_import_with_vlan_site_multiple_vlans_same_vid(self):
"""
Test import when multiple VLANs exist with the same vid but different sites.
Ref: #20560
"""
site1 = Site.objects.get(name='Site 1')
site2 = Site.objects.get(name='Site 2')
# Create VLANs with the same vid but different sites
vlan1 = VLAN.objects.create(vid=1, name='VLAN1-Site1', site=site1)
VLAN.objects.create(vid=1, name='VLAN1-Site2', site=site2) # Create ambiguity
# Import prefix with vlan_site specified
IMPORT_DATA = f"""
prefix: 10.11.0.0/22
status: active
scope_type: dcim.site
scope_id: {site1.pk}
vlan_site: {site1.name}
vlan: 1
description: LOC02-MGMT
"""
# Add all required permissions to the test user
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
form_data = {
'data': IMPORT_DATA,
'format': 'yaml'
}
response = self.client.post(reverse('ipam:prefix_bulk_import'), data=form_data, follow=True)
self.assertHttpStatus(response, 200)
# Verify the prefix was created with the correct VLAN
prefix = Prefix.objects.get(prefix='10.11.0.0/22')
self.assertEqual(prefix.vlan, vlan1)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_import_with_vlan_site_and_global_vlan(self):
"""
Test import when a global VLAN (no site) and site-specific VLAN exist with same vid.
When vlan_site is specified, should prefer the site-specific VLAN.
Ref: #20560
"""
site1 = Site.objects.get(name='Site 1')
# Create a global VLAN (no site) and a site-specific VLAN with the same vid
VLAN.objects.create(vid=10, name='VLAN10-Global', site=None) # Create ambiguity
vlan_site = VLAN.objects.create(vid=10, name='VLAN10-Site1', site=site1)
# Import prefix with vlan_site specified
IMPORT_DATA = f"""
prefix: 10.12.0.0/22
status: active
scope_type: dcim.site
scope_id: {site1.pk}
vlan_site: {site1.name}
vlan: 10
description: Test Site-Specific VLAN
"""
# Add all required permissions to the test user
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
form_data = {
'data': IMPORT_DATA,
'format': 'yaml'
}
response = self.client.post(reverse('ipam:prefix_bulk_import'), data=form_data, follow=True)
self.assertHttpStatus(response, 200)
# Verify the prefix was created with the site-specific VLAN (not the global one)
prefix = Prefix.objects.get(prefix='10.12.0.0/22')
self.assertEqual(prefix.vlan, vlan_site)
class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = IPRange

View File

@@ -80,22 +80,21 @@ class Config:
try:
# Enforce the creation date as the ordering parameter
revision = ConfigRevision.objects.get(active=True)
logger.debug(f"Loaded active configuration revision #{revision.pk}")
logger.debug(f"Loaded active configuration revision (#{revision.pk})")
except (ConfigRevision.DoesNotExist, ConfigRevision.MultipleObjectsReturned):
logger.debug("No active configuration revision found - falling back to most recent")
revision = ConfigRevision.objects.order_by('-created').first()
if revision is None:
logger.debug("No previous configuration found in database; proceeding with default values")
logger.debug("No configuration found in database; proceeding with default values")
return
logger.debug(f"Using fallback configuration revision #{revision.pk}")
logger.debug(f"No active configuration revision found; falling back to most recent (#{revision.pk})")
except DatabaseError:
# The database may not be available yet (e.g. when running a management command)
logger.warning("Skipping config initialization (database unavailable)")
return
revision.activate()
logger.debug("Filled cache with data from latest ConfigRevision")
revision.activate(update_db=False)
self._populate_from_cache()
logger.debug("Filled cache with data from latest ConfigRevision")
class ConfigItem:

View File

@@ -42,7 +42,9 @@ class NetBoxModelFilterSetForm(FilterModifierMixin, CustomFieldsMixin, SavedFilt
)
def _get_form_field(self, customfield):
return customfield.to_form_field(set_initial=False, enforce_required=False, enforce_visibility=False)
return customfield.to_form_field(
set_initial=False, enforce_required=False, enforce_visibility=False, for_filterset_form=True
)
class OwnerFilterMixin(forms.Form):

View File

@@ -288,12 +288,13 @@ class CustomFieldsMixin(models.Model):
cf.name: cf for cf in CustomField.objects.get_for_model(self)
}
# Remove any stale custom field data
self.custom_field_data = {
k: v for k, v in self.custom_field_data.items() if k in custom_fields.keys()
}
# Validate all field values
for field_name, value in self.custom_field_data.items():
if field_name not in custom_fields:
raise ValidationError(_("Unknown field name '{name}' in custom field data.").format(
name=field_name
))
try:
custom_fields[field_name].validate(value)
except ValidationError as e:

View File

@@ -1,3 +1,4 @@
from django.db.models import ForeignKey
from django.template import loader
from django.urls.exceptions import NoReverseMatch
from django.utils.translation import gettext_lazy as _
@@ -175,6 +176,21 @@ class BulkEdit(ObjectAction):
permissions_required = {'change'}
template_name = 'buttons/bulk_edit.html'
@classmethod
def get_context(cls, context, model):
url_params = super().get_url_params(context)
# If this is a child object, pass the parent's PK as a URL parameter
if parent := context.get('object'):
for field in model._meta.get_fields():
if isinstance(field, ForeignKey) and field.remote_field.model == parent.__class__:
url_params[field.name] = parent.pk
break
return {
'url_params': url_params,
}
class BulkRename(ObjectAction):
"""

View File

@@ -833,6 +833,7 @@ LANGUAGES = (
('fr', _('French')),
('it', _('Italian')),
('ja', _('Japanese')),
('lv', _('Latvian')),
('nl', _('Dutch')),
('pl', _('Polish')),
('pt', _('Portuguese')),

View File

@@ -1,11 +1,10 @@
from unittest import skipIf
from django.conf import settings
from django.test import TestCase
from django.test import RequestFactory, TestCase
from dcim.models import Device
from netbox.object_actions import AddObject, BulkImport
from netbox.tests.dummy_plugin.models import DummyNetBoxModel
from dcim.models import Device, DeviceType, Manufacturer
from netbox.object_actions import AddObject, BulkEdit, BulkImport
class ObjectActionTest(TestCase):
@@ -20,9 +19,11 @@ class ObjectActionTest(TestCase):
url = BulkImport.get_url(obj)
self.assertEqual(url, '/dcim/devices/import/')
@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, 'dummy_plugin not in settings.PLUGINS')
def test_get_url_plugin_model(self):
"""Test URL generation for plugin models includes plugins: namespace"""
from netbox.tests.dummy_plugin.models import DummyNetBoxModel
obj = DummyNetBoxModel()
url = AddObject.get_url(obj)
@@ -30,3 +31,29 @@ class ObjectActionTest(TestCase):
url = BulkImport.get_url(obj)
self.assertEqual(url, '/plugins/dummy-plugin/netboxmodel/import/')
def test_bulk_edit_get_context_child_object(self):
"""
Test that the parent object's PK is included in the context for child objects.
Ensure that BulkEdit.get_context() correctly identifies and
includes the parent object's PK when rendering a child object's
action button.
"""
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
# Mock context containing the parent object (DeviceType)
request = RequestFactory().get('/')
context = {
'request': request,
'object': device_type,
}
# Get context for the child model (Device)
action_context = BulkEdit.get_context(context, Device)
# Verify that 'device_type' (the FK field name) is present in
# url_params with the parent's PK
self.assertIn('url_params', action_context)
self.assertEqual(action_context['url_params'].get('device_type'), device_type.pk)

View File

@@ -52,6 +52,7 @@ def handler_500(request, template_name=ERROR_500_TEMPLATE_NAME):
type_, error = sys.exc_info()[:2]
return HttpResponseServerError(template.render({
'request': request,
'error': error,
'exception': str(type_),
'netbox_version': settings.RELEASE.full_version,

View File

@@ -1,5 +1,6 @@
import logging
import re
from collections import Counter
from copy import deepcopy
from django.contrib import messages
@@ -33,6 +34,7 @@ from utilities.jobs import is_background_request, process_request_as_job
from utilities.permissions import get_permission_for_model
from utilities.query import reapply_model_ordering
from utilities.request import safe_for_redirect
from utilities.string import title
from utilities.tables import get_table_configs
from utilities.views import GetReturnURLMixin, get_action_url
from .base import BaseMultiObjectView
@@ -443,6 +445,18 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
# Prefetch objects to be updated, if any
prefetch_ids = [int(record['id']) for record in records if record.get('id')]
# check for duplicate IDs
duplicate_pks = [pk for pk, count in Counter(prefetch_ids).items() if count > 1]
if duplicate_pks:
error_msg = _(
"Duplicate objects found: {model} with ID(s) {ids} appears multiple times"
).format(
model=title(self.queryset.model._meta.verbose_name),
ids=', '.join(str(pk) for pk in sorted(duplicate_pks))
)
raise ValidationError(error_msg)
prefetched_objects = {
obj.pk: obj
for obj in self.queryset.model.objects.filter(id__in=prefetch_ids)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -28,10 +28,10 @@
"bootstrap": "5.3.8",
"clipboard": "2.0.11",
"flatpickr": "4.6.13",
"gridstack": "12.4.1",
"gridstack": "12.4.2",
"htmx.org": "2.0.8",
"query-string": "9.3.1",
"sass": "1.97.0",
"sass": "1.97.2",
"tom-select": "2.4.3",
"typeface-inter": "3.18.1",
"typeface-roboto-mono": "1.1.13"

View File

@@ -28,13 +28,27 @@ function updateElements(targetMode: ColorMode): void {
}
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
const svg = elevation.contentDocument?.querySelector('svg') ?? null;
if (svg !== null) {
const svg = elevation.firstElementChild ?? null;
if (svg !== null && svg.nodeName == 'svg') {
svg.setAttribute(`data-bs-theme`, targetMode);
}
}
}
/**
* Set the color mode to light of elevations after an htmx call.
* Pulls current color mode from document
*
* @param event htmx listener event details. See: https://htmx.org/events/#htmx:afterSwap
*/
function updateElevations(evt: CustomEvent, ): void {
const swappedElement = evt.detail.elt
if (swappedElement.nodeName == 'svg') {
const currentMode = localStorage.getItem(COLOR_MODE_KEY);
swappedElement.setAttribute('data-bs-theme', currentMode)
}
}
/**
* Call all functions necessary to update the color mode across the UI.
*
@@ -115,6 +129,7 @@ function initColorModeToggle(): void {
*/
export function initColorMode(): void {
window.addEventListener('load', defaultColorMode);
window.addEventListener('htmx:afterSwap', updateElevations as EventListener); // Uses a custom event from HTMX
for (const func of [initColorModeToggle]) {
func();
}

View File

@@ -36,7 +36,6 @@ form.object-edit {
// Make optgroup labels sticky when scrolling through select elements
select[multiple] {
optgroup {
position: sticky;
top: 0;
background-color: var(--bs-body-bg);
font-style: normal;

View File

@@ -2304,10 +2304,10 @@ graphql@16.12.0:
resolved "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz"
integrity sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==
gridstack@12.4.1:
version "12.4.1"
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-12.4.1.tgz#4a44511e5da33016e731f00bee279bed550d4ab9"
integrity sha512-dYBNVEDw2zwnz0bCDouHk8rMclrMoMn4r6rtNyyWSeYsV3RF8QV2KFRTj4c86T2FsZPr3iQv+/LD/ae29FcpHQ==
gridstack@12.4.2:
version "12.4.2"
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-12.4.2.tgz#188de180b6cda77e48b1414aac1d778a38f48f04"
integrity sha512-aXbJrQpi3LwpYXYOr4UriPM5uc/dPcjK01SdOE5PDpx2vi8tnLhU7yBg/1i4T59UhNkG/RBfabdFUObuN+gMnw==
has-bigints@^1.0.1, has-bigints@^1.0.2:
version "1.0.2"
@@ -3251,10 +3251,10 @@ safe-regex-test@^1.1.0:
es-errors "^1.3.0"
is-regex "^1.2.1"
sass@1.97.0:
version "1.97.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.0.tgz#8ed65df5e2f73012d5ef0e98837ff63550657ab2"
integrity sha512-KR0igP1z4avUJetEuIeOdDlwaUDvkH8wSx7FdSjyYBS3dpyX3TzHfAMO0G1Q4/3cdjcmi3r7idh+KCmKqS+KeQ==
sass@1.97.2:
version "1.97.2"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.2.tgz#e515a319092fd2c3b015228e3094b40198bff0da"
integrity sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==
dependencies:
chokidar "^4.0.0"
immutable "^5.0.2"

View File

@@ -1,4 +1,3 @@
version: "4.5.0"
edition: "Community"
published: "2025-12-16"
designation: "beta1"
published: "2026-01-06"

View File

@@ -35,6 +35,12 @@
{% trans "Plugins" %}: {% for plugin, version in plugins.items %}
{{ plugin }}: {{ version }}{% empty %}{% trans "None installed" %}{% endfor %}
</pre>
<p>
{% trans "The request which yielded the above error is shown below:" %}
</p>
<p>
<code>{{ request.method }} {{ request.build_absolute_uri }}</code>
</p>
<p>
{% trans "If further assistance is required, please post to the" %} <a href="https://github.com/netbox-community/netbox/discussions">{% trans "NetBox discussion forum" %}</a> {% trans "on GitHub" %}.
</p>

View File

@@ -112,6 +112,19 @@
<th scope="row">{% trans "Bridge" %}</th>
<td>{{ object.bridge|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Bridged Interfaces" %}</th>
<td>
{% if bridge_interfaces %}
{% for interface in bridge_interfaces %}
{{ interface|linkify }}
{% if not forloop.last %}<br />{% endif %}
{% endfor %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "LAG" %}</th>
<td>{{ object.lag|linkify|placeholder }}</td>
@@ -435,13 +448,11 @@
</div>
</div>
{% endif %}
{% if object.is_bridge %}
<div class="row mb-3">
<div class="col col-md-12">
{% include 'inc/panel_table.html' with table=bridge_interfaces_table heading="Bridge Interfaces" %}
</div>
<div class="row mb-3">
<div class="col col-md-12">
{% include 'inc/panel_table.html' with table=bridge_interfaces_table heading="Bridged Interfaces" %}
</div>
{% endif %}
</div>
<div class="row mb-3">
<div class="col col-md-12">
{% include 'inc/panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}

View File

@@ -23,7 +23,7 @@
</tr>
<tr>
<th scope="row">{% trans "Range" %}</th>
<td>{{ object.range_as_string }}</td>
<td>{{ object.range_as_string_with_asdot }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ from django.db.models import Q
OBJECTPERMISSION_OBJECT_TYPES = Q(
~Q(app_label__in=['account', 'admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users']) |
Q(app_label='users', model__in=['objectpermission', 'token', 'group', 'user'])
Q(app_label='users', model__in=['objectpermission', 'token', 'group', 'user', 'owner'])
)
CONSTRAINT_TOKEN_USER = '$user'

View File

@@ -50,21 +50,21 @@ class RestrictedQuerySet(QuerySet):
# Bypass restriction for superusers and exempt views
if user and user.is_superuser or permission_is_exempt(permission_required):
qs = self
return self
# User is anonymous or has not been granted the requisite permission
elif user is None or not user.is_authenticated or permission_required not in user.get_all_permissions():
qs = self.none()
if user is None or not user.is_authenticated or permission_required not in user.get_all_permissions():
return self.none()
# Filter the queryset to include only objects with allowed attributes
else:
tokens = {
CONSTRAINT_TOKEN_USER: user,
}
attrs = qs_filter_from_constraints(user._object_perm_cache[permission_required], tokens)
constraints = user._object_perm_cache[permission_required]
tokens = {
CONSTRAINT_TOKEN_USER: user,
}
if attrs := qs_filter_from_constraints(constraints, tokens):
# #8715: Avoid duplicates when JOIN on many-to-many fields without using DISTINCT.
# DISTINCT acts globally on the entire request, which may not be desirable.
allowed_objects = self.model.objects.filter(attrs)
qs = self.filter(pk__in=allowed_objects)
return self.filter(pk__in=allowed_objects)
return qs
return self

View File

@@ -35,27 +35,34 @@ class NetBoxFakeRequest:
# Utility functions
#
def copy_safe_request(request):
def copy_safe_request(request, include_files=True):
"""
Copy selected attributes from a request object into a new fake request object. This is needed in places where
thread safe pickling of the useful request data is needed.
Args:
request: The original request object
include_files: Whether to include request.FILES.
"""
meta = {
k: request.META[k]
for k in HTTP_REQUEST_META_SAFE_COPY
if k in request.META and isinstance(request.META[k], str)
}
return NetBoxFakeRequest({
data = {
'META': meta,
'COOKIES': request.COOKIES,
'POST': request.POST,
'GET': request.GET,
'FILES': request.FILES,
'user': request.user,
'method': request.method,
'path': request.path,
'id': getattr(request, 'id', None), # UUID assigned by middleware
})
}
if include_files:
data['FILES'] = request.FILES
return NetBoxFakeRequest(data)
def get_client_ip(request, additional_headers=()):

View File

@@ -151,8 +151,8 @@ class ModelTestCase(TestCase):
elif value and type(field) is GenericForeignKey:
model_dict[key] = value.pk
# Handle API output
elif api:
# Replace ContentType numeric IDs with <app_label>.<model>
if type(getattr(instance, key)) in (ContentType, ObjectType):
object_type = ObjectType.objects.get(pk=value)
@@ -162,9 +162,13 @@ class ModelTestCase(TestCase):
elif type(value) is IPNetwork:
model_dict[key] = str(value)
else:
field = instance._meta.get_field(key)
# Normalize arrays of numeric ranges (e.g. VLAN IDs or port ranges).
# DB uses canonical half-open [lo, hi) via NumericRange; API uses inclusive [lo, hi].
# Convert to inclusive pairs for stable API comparisons.
elif type(field) is ArrayField and issubclass(type(field.base_field), RangeField):
model_dict[key] = [[r.lower, r.upper - 1] for r in value]
else:
# Convert ArrayFields to CSV strings
if type(field) is ArrayField:
if getattr(field.base_field, 'choices', None):

View File

@@ -15,6 +15,7 @@ from ipam.models import VLAN
from netbox.api.fields import ChoiceField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer, PrimaryModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer
from users.api.serializers_.mixins import OwnerMixin
from virtualization.choices import *
from virtualization.models import VirtualDisk, VirtualMachine, VMInterface
from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
@@ -65,8 +66,8 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
fields = [
'id', 'url', 'display_url', 'display', 'name', 'status', 'start_on_boot', 'site', 'cluster', 'device',
'serial', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory',
'disk', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields',
'config_context', 'created', 'last_updated', 'interface_count', 'virtual_disk_count',
'disk', 'description', 'owner', 'comments', 'config_template', 'local_context_data', 'tags',
'custom_fields', 'config_context', 'created', 'last_updated', 'interface_count', 'virtual_disk_count',
]
@extend_schema_field(serializers.JSONField(allow_null=True))
@@ -78,7 +79,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
# VM interfaces
#
class VMInterfaceSerializer(NetBoxModelSerializer):
class VMInterfaceSerializer(OwnerMixin, NetBoxModelSerializer):
virtual_machine = VirtualMachineSerializer(nested=True)
parent = NestedVMInterfaceSerializer(required=False, allow_null=True)
bridge = NestedVMInterfaceSerializer(required=False, allow_null=True)
@@ -107,7 +108,7 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
fields = [
'id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu',
'mac_address', 'primary_mac_address', 'mac_addresses', 'description', 'mode', 'untagged_vlan',
'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'l2vpn_termination', 'tags',
'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'l2vpn_termination', 'owner', 'tags',
'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups',
]
brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description')
@@ -147,13 +148,13 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
# Virtual Disk
#
class VirtualDiskSerializer(NetBoxModelSerializer):
class VirtualDiskSerializer(OwnerMixin, NetBoxModelSerializer):
virtual_machine = VirtualMachineSerializer(nested=True)
class Meta:
model = VirtualDisk
fields = [
'id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'description', 'size', 'tags',
'id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'description', 'size', 'owner', 'tags',
'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description', 'size')

View File

@@ -0,0 +1,19 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0225_gfk_indexes'),
('extras', '0134_owner'),
('tenancy', '0022_add_comments_to_organizationalmodel'),
('users', '0015_owner'),
('virtualization', '0051_add_comments_to_organizationalmodel'),
]
operations = [
migrations.AddIndex(
model_name='cluster',
index=models.Index(fields=['scope_type', 'scope_id'], name='virtualizat_scope_t_fb3b6e_idx'),
),
]

View File

@@ -107,6 +107,9 @@ class Cluster(ContactsMixin, CachedScopeMixin, PrimaryModel):
name='%(app_label)s_%(class)s_unique__site_name'
),
)
indexes = (
models.Index(fields=('scope_type', 'scope_id')),
)
verbose_name = _('cluster')
verbose_name_plural = _('clusters')

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