Compare commits

...

78 Commits

Author SHA1 Message Date
bctiemann
de1c5120dd Merge pull request #21346 from netbox-community/release-v4.5.2
CI / build (20.x, 3.13) (push) Failing after 14s
CI / build (20.x, 3.12) (push) Failing after 16s
CI / build (20.x, 3.14) (push) Failing after 18s
CodeQL / Analyze (actions) (push) Failing after 32s
CodeQL / Analyze (javascript-typescript) (push) Failing after 25s
CodeQL / Analyze (python) (push) Failing after 25s
Release v4.5.2
2026-02-03 08:42:21 -05:00
Jeremy Stretch
87d2e02c85 Release v4.5.2 2026-02-03 08:09:14 -05:00
github-actions
cbbc4f74b8 Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 30s
CodeQL / Analyze (javascript-typescript) (push) Failing after 28s
CodeQL / Analyze (python) (push) Failing after 27s
2026-02-03 05:22:13 +00:00
Martin Hauser
be5bd74d4e feat(ipam): Add parent object fields for Services
CI / build (20.x, 3.12) (push) Failing after 13s
CI / build (20.x, 3.13) (push) Failing after 9s
CI / build (20.x, 3.14) (push) Failing after 9s
CodeQL / Analyze (actions) (push) Failing after 31s
CodeQL / Analyze (python) (push) Failing after 36s
CodeQL / Analyze (javascript-typescript) (push) Failing after 40s
Include `parent_object_type` and `parent_object_id` in `clone_fields`
for services. This improves cloning behavior for models using parent
object references, ensuring more accurate data duplication.

Fixes #21168
2026-02-02 16:05:09 -05:00
Jason Novinger
cf12bb5bf5 Fixes #20902: Avoid conflict when Git URL contains embedded username (#21252)
CI / build (20.x, 3.12) (push) Failing after 14s
CI / build (20.x, 3.13) (push) Failing after 17s
CI / build (20.x, 3.14) (push) Failing after 15s
CodeQL / Analyze (actions) (push) Failing after 37s
CodeQL / Analyze (javascript-typescript) (push) Failing after 38s
CodeQL / Analyze (python) (push) Failing after 36s
2026-02-02 11:16:32 -08:00
Jeremy Stretch
c060eef1d8 Closes #21300: Cache model-specific custom field lookups for the duration of a request (#21334) 2026-02-02 10:58:12 -08:00
bctiemann
96f0debe6e Merge pull request #21328 from netbox-community/21327-ContentTypeField-caching
Closes #21327: Leverage `get_by_natural_key()` to resolve ContentTypes
2026-02-02 13:46:04 -05:00
Martin Hauser
b26c7f34cd feat(models): Handle GFK attributes in CloningMixin
Extend the CloningMixin to inject GenericForeignKey (GFK) attributes
when both content type and ID fields are present. Improves support for
models using GFK fields during cloning operations.

Fixes #21201
2026-02-02 13:02:32 -05:00
bctiemann
d6428c6aa4 Merge pull request #21314 from marsteel/21233-UI-Add-horizontal-padding-to-Release-info-section
Fixes #21233: UI Add horizontal padding to Release info section in Navigation menu
2026-02-02 11:17:30 -05:00
github-actions
e3eca98897 Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 31s
CodeQL / Analyze (javascript-typescript) (push) Failing after 46s
CodeQL / Analyze (python) (push) Failing after 49s
2026-01-31 05:14:50 +00:00
Jeremy Stretch
cdc735fe41 Closes #21302: Avoid redundant uniqueness checks in REST API serializers
CI / build (20.x, 3.12) (push) Failing after 13s
CI / build (20.x, 3.13) (push) Failing after 13s
CI / build (20.x, 3.14) (push) Failing after 24s
CodeQL / Analyze (actions) (push) Failing after 33s
CodeQL / Analyze (javascript-typescript) (push) Failing after 45s
CodeQL / Analyze (python) (push) Failing after 40s
2026-01-30 19:36:42 -05:00
Jeremy Stretch
aa4a9da955 Closes #21303: Cache serialized post-change data on object (#21325)
* Closes #21303: Cache serialized post-change data on object

* Set to_objectchange.alters_data

* Restructure logic for determining post-change snapshot
2026-01-30 14:49:12 -05:00
Jeremy Stretch
5c6fc2fb6f Closes #21110: Support for cursor-based pagination in GraphQL API (#21322) 2026-01-30 11:45:35 -08:00
Jeremy Stretch
ad29cb2d66 Closes #21263: Prefetch related objects after creating/updating objects via REST API (#21329)
* Closes #21263: Prefetch related objects after creating/updating objects via REST API

* Add comment re: ordering by PK
2026-01-30 14:13:05 -05:00
Aditya Sharma
bec5ecf6a9 Closes #21209: Accept case-insensitive model names in configuration (#21275)
CI / build (20.x, 3.12) (push) Failing after 9s
CI / build (20.x, 3.13) (push) Failing after 8s
CI / build (20.x, 3.14) (push) Failing after 8s
CodeQL / Analyze (actions) (push) Failing after 25s
CodeQL / Analyze (javascript-typescript) (push) Failing after 35s
CodeQL / Analyze (python) (push) Failing after 37s
NetBox now accepts case-insensitive model identifiers in configuration, allowing
both lowercase (e.g. "dcim.site") and PascalCase (e.g. "dcim.Site") for
DEFAULT_DASHBOARD, CUSTOM_VALIDATORS, and PROTECTION_RULES.
This makes model name handling consistent with FIELD_CHOICES.

- Add a shared case-insensitive config lookup helper (get_config_value_ci())
- Use the helper in extras/signals.py and core/signals.py
- Update FIELD_CHOICES ChoiceSetMeta to support case-insensitive replace/extend
  (only compute extend choices if no replacement is defined)
- Add unit tests for get_config_value_ci()
- Add integration tests for case-insensitive FIELD_CHOICES replacement/extension
- Update documentation examples to use PascalCase consistently
2026-01-30 13:48:38 +01:00
github-actions
c98f55dbd2 Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 32s
CodeQL / Analyze (javascript-typescript) (push) Failing after 50s
CodeQL / Analyze (python) (push) Failing after 52s
2026-01-30 05:18:59 +00:00
Jeremy Stretch
dfe20532a1 Closes #21327: Leverage get_by_natural_key() to resolve ContentTypes 2026-01-29 19:46:22 -05:00
Martin Hauser
359179fd4a fix(dcim): Add port mapping creation for module install (#21308)
CI / build (20.x, 3.12) (push) Failing after 17s
CI / build (20.x, 3.13) (push) Failing after 19s
CI / build (20.x, 3.14) (push) Failing after 16s
CodeQL / Analyze (actions) (push) Failing after 6m50s
CodeQL / Analyze (javascript-typescript) (push) Failing after 7m4s
CodeQL / Analyze (python) (push) Failing after 7m2s
2026-01-29 14:37:57 -08:00
Arthur Hanson
c44e8606f7 21129 Store queue_name in Job so correctly deleted in RQ (#21309)
* Add queue name to Job

* Add queue name to serializer, filterset, detail view

* fix job queue delete

* fix job queue delete

* review feedback
2026-01-29 15:29:33 -05:00
github-actions
8e620ef325 Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 18s
CodeQL / Analyze (javascript-typescript) (push) Failing after 17s
CodeQL / Analyze (python) (push) Failing after 18s
2026-01-29 05:17:01 +00:00
Jeremy Stretch
1526e437f1 Closes #21244: Introduce ability to omit specific fields from REST API responses (#21312)
CI / build (20.x, 3.12) (push) Failing after 14s
CI / build (20.x, 3.13) (push) Failing after 10s
CI / build (20.x, 3.14) (push) Failing after 11s
CodeQL / Analyze (actions) (push) Failing after 21s
CodeQL / Analyze (javascript-typescript) (push) Failing after 17s
CodeQL / Analyze (python) (push) Failing after 17s
Introduce support for omitting specific serializer fields via an
`omit` parameter, acting as the inverse of `fields`.
Wire it through the API viewset and queryset optimization helpers
so omitted fields don’t trigger unnecessary annotations/prefetches,
and document the new behavior.
2026-01-28 22:06:46 +01:00
Martin Hauser
0b507eb207 fix(ipam): Include scope params in Prefix creation links
CI / build (20.x, 3.12) (push) Failing after 11s
CI / build (20.x, 3.13) (push) Failing after 11s
CI / build (20.x, 3.14) (push) Failing after 12s
CodeQL / Analyze (actions) (push) Failing after 7m12s
CodeQL / Analyze (javascript-typescript) (push) Failing after 18s
CodeQL / Analyze (python) (push) Failing after 28s
Update prefix creation URLs to pass `scope_type` and `scope` (replacing
the legacy `site` query parameter) for both the Child Prefixes
"Add Prefix" button and in-table available-prefix links.
Scope parameters are only rendered when a scope is defined, so
unscoped prefixes remain unchanged.

Fixes #21262
2026-01-28 15:19:44 -05:00
Elliott Balsley
5a36e79215 Fixes #20977: Apply defaults for missing script variables (#21295)
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
Ensure script variables fall back to their defined defaults when a value is not
submitted (e.g. via "Run again" or other minimal POSTs).

- Populate omitted script variables with their initial/default values before
  validation and job enqueueing
- Treat falsy defaults (e.g. False/0) as valid defaults
- Add a test asserting defaults are included in enqueued job data
- Remove the redundant default from ScriptValidationErrorTest
2026-01-28 15:35:33 +01:00
Martin Hauser
2a0f26623b Fixes #21254: Fix release check failure when stale latest_release cache can't be unpickled (#21282)
* fix(misc): Handle cache unpickling failure in release check

Guard `cache.get('latest_release')` during release checks to prevent a
500 when stale cached data can't be unpickled after dependency upgrades.
On failure, log at debug level and delete the affected cache key.

Fixes #21254

* Correct comment

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2026-01-28 09:28:20 -05:00
MA Gang
43ae52089f Add padding to release info div
Add padding to release info div in layout.html
2026-01-28 14:29:38 +01:00
github-actions
1a603981b2 Update source translation strings
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-01-28 05:07:33 +00:00
Aditya Sharma
245495b2fe Closes #21228: Add image attachments support to RackType model (#21276)
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
2026-01-27 09:36:11 -08:00
bctiemann
8d3eb69055 Merge pull request #21264 from netbox-community/19869-provide-information-about-lag-targets-in-lag-members-section
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
Fixes #19869: Display peer connections for LAG member interfaces
2026-01-27 10:23:14 -05:00
bctiemann
7e3b60f194 Merge pull request #21299 from netbox-community/20172-ability-to-query-for-cabled-interfaces-via-graphql
Closes #20172: Add `cabled` filter for DCIM interfaces in GraphQL
2026-01-27 10:13:27 -05:00
bctiemann
5338c842b8 Merge pull request #21289 from llamafilm/20052-loglevel
Fixes #20052: improve logging for faulty scripts
2026-01-27 10:10:17 -05:00
bctiemann
9186b0edaa Merge pull request #21281 from netbox-community/21176-remove-iprange-checkboxes
Fixes #21176: Remove checkboxes from IP ranges in mixed-type tables
2026-01-27 10:08:37 -05:00
bctiemann
d883be9e56 Merge pull request #21246 from adionit7/21150-docs-config-menu-path
Fixes #21150: Correct Dynamic Configuration menu path in documentation
2026-01-27 08:43:52 -05:00
bctiemann
6fc7fa6c64 Merge pull request #21220 from netbox-community/15801-vlan-overview-device-interfaces-list-with-connection-link
Closes #15801: Add link peer and connection columns to `VLANDeviceTable`
2026-01-27 08:35:33 -05:00
Martin Hauser
3a33df0e43 feat(forms): Add Owner Group support to Filter Forms
Introduces support for `owner_group` in various filter forms, improving
ownership granularity.
Updates DynamicModel fields to handle relationships
between `owner_group` and `owner` effectively.

Fixes #21081
2026-01-27 08:34:42 -05:00
github-actions
433f46746e Update source translation strings
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-01-27 05:07:09 +00:00
Jeremy Stretch
8f5f91fcfe Closes #21259: Cache ObjectType results for the duration of a request (#21287)
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
2026-01-26 15:07:13 -08:00
Martin Hauser
1a2175127e Fixes #21202: Avoid clearing scope on clone (#21265) 2026-01-26 16:14:36 -06:00
Martin Hauser
e859807d1d docs(guides): Update Ubuntu reference to 24.04
Update the installation and administration guides to reference
Ubuntu 24.04 instead of 22.04 where applicable, and refresh examples
to match NetBox v4.5.

This includes updates to Python version requirements, NetBox shell
commands, Redis configuration, and sample outputs to align with current
compatibility and best practices.

Fixes #21297
2026-01-26 15:43:59 -05:00
Jeremy Stretch
a8c997ff29 Closes #21260: Defer object serialization for events pipeline (#21286) 2026-01-26 14:35:00 -06:00
adionit7
4a28ab98f4 Fixes #21115: Include attribute_data in ModuleType YAML export
- Added airflow and attribute_data fields to ModuleType.to_yaml() method
- Ensures custom JSON properties from module type profiles are properly exported
- Maintains consistency with import functionality in ModuleTypeImportForm
2026-01-26 15:01:21 -05:00
Martin Hauser
3636d55017 fix(nav): Show Authentication admin menu items based on object perms (#21283)
Replace hardcoded menu entries for Users, Groups, API Tokens, and
Permissions with `get_model_item()`. This drops the `staff_only` gate
and relies on the standard model permission checks, restoring visibility
of these Admin menu items for non-superusers with the relevant object
permissions.

Fixes #21242
2026-01-26 11:34:46 -08:00
Aditya Sharma
aa69e96818 Fixes #21173: Fix plugin menu registration order timing issue (#21248)
* Fixes #21173: Fix plugin menu registration order timing issue

- Converted static MENUS list to dynamic get_menus() function
- Ensures plugin menus are built at request time after all plugins complete ready()
- Fixes issue where only first few plugin menus appeared in navigation sidebar
- Updated navigation template tag to call get_menus() dynamically

* Fix ruff linting errors

- Add missing blank line before get_menus() function definition
- Remove trailing whitespace

* Add @cache decorator to get_menus() for performance optimization

Per reviewer feedback, the menu list is now cached since it doesn't change
without a Django restart. This eliminates redundant list building on each request.

---------

Co-authored-by: adionit7 <adionit7@users.noreply.github.com>
2026-01-26 10:34:57 -08:00
Martin Hauser
1745d2ae93 feat(dcim): Add filter for cabled objects in GraphQL
Introduces a `cabled` filter to the GraphQL API for DCIM. Allows
filtering objects based on whether they are connected to a cable,
improving query customization.

Fixes #20172
2026-01-26 15:39:56 +01:00
Elliott Balsley
e097a848dc display error in UI 2026-01-24 19:04:14 -08:00
Elliott Balsley
595be6dcd4 log the error with error level instead of debug 2026-01-24 19:04:06 -08:00
github-actions
a9e50238eb Update source translation strings
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-24 05:03:22 +00:00
Arthur Hanson
a9a300197a Clear Rack Face when clear Rack (#21182)
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.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
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
* #20383 clear rack face if no rack on edit

* #20383 clear rack face if no rack on edit

* review changes

* review changes
2026-01-23 12:26:27 -05:00
Jeremy Stretch
3dcca73ecc Fixes #21249: Avoid unneeded user query when no event rules are present (#21250) 2026-01-23 09:44:54 -06:00
Jason Novinger
cedbeb7b19 Fixes #21176: Remove checkboxes from IP ranges in mixed-type tables
When IP addresses and IP ranges are displayed together in a prefix's
  IP Addresses tab, only IP addresses should be selectable for bulk
  operations since the bulk delete form doesn't support mixed object types.

  - Override render_pk() in AnnotatedIPAddressTable to conditionally render
    checkboxes only for the table's primary model type (IPAddress)
  - Add warning comment to add_requested_prefixes() about fake Prefix objects
  - Add regression test to verify IPAddress has checkboxes but IPRange does not
2026-01-23 09:36:15 -06:00
Martin Hauser
a45b6b170d feat(dcim): Show peer connections for LAG members
Add `InterfaceLAGMemberTable` for the LAG Members panel on
LAG interface detail views. The table includes the parent device,
member interface/type, and a peer column which renders
connected endpoints (including the peer LAG when present).

Fixes #19869
2026-01-22 20:41:40 +01:00
bctiemann
4b4c542dce Add truncate_middle filter for middle-ellipsis on long filenames (#21253)
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
2026-01-22 09:40:48 -08:00
github-actions
077d9b1129 Update source translation strings
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-01-22 05:07:49 +00:00
Aditya Sharma
e81ccb9be6 Fixes #21214: Clean up AutoSyncRecord when detaching from DataSource (#21219)
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.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
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: adionit7 <adionit7@users.noreply.github.com>
2026-01-21 16:38:27 -06:00
Jeremy Stretch
bc83d04c8f Introduce performance issue template (#21247) 2026-01-21 16:34:01 -06:00
adionit7
42ecf3cac0 Fixes #21150: Correct Dynamic Configuration menu path in documentation
- Updated menu path from 'Admin > Extras > Configuration Revisions'
  to 'Admin > System > Configuration History'
- Reflects actual location in NetBox admin interface
2026-01-21 22:53:29 +05:30
Matthew Papaleo
339ad455e4 Support for max_length and max_depth standardised for prefix_list, aggreate/prefixes and prefix/prefixes
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
2026-01-21 10:02:06 -05:00
Martin Hauser
af8e53d8fb feat(ipam): Add connection/link peer to VLANDeviceTable
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
The VLAN Device Interfaces table now includes `connection` and
`link_peer` columns, using the existing interface templates to render
peer/connection context consistently.

Fixes #15801
2026-01-21 13:04:39 +01:00
github-actions
f24376cfab Update source translation strings
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-01-21 05:07:22 +00:00
Jeremy Stretch
47d4ae29c1 Release v4.5.1
CodeQL / Analyze (python) (push) Has been cancelled
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
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-20 14:44:04 -05:00
bctiemann
8fce672682 Merge pull request #21238 from netbox-community/21160-follow-up-null-option
Fixes #21160: Handle "null" choice selection in widgets
2026-01-20 13:39:54 -05:00
Antoine Keranflec'h
f776b97415 fixes #21139 support api filter for core (#21192) 2026-01-20 09:10:27 -08:00
Aditya Sharma
3cc1f30287 Fixes #21213: Make Tag weight field required in forms (#21218)
The weight field was explicitly declared with required=False in TagForm
and TagImportForm, allowing empty submissions that would crash with a
database IntegrityError since the column is NOT NULL.

By removing the explicit field override, Django now auto-generates the
form field from the model, which has default=1000 and is required.

Co-authored-by: adionit7 <adionit7@users.noreply.github.com>
2026-01-20 08:50:31 -08:00
Martin Hauser
6d166aa10d feat(utilities): Handle "null" choice selection in widgets
Enhances widget handling by preserving "null" choice values in both
individual and mixed-object selections. Updates tests to validate UI
rendering and ensure compatibility with null sentinel values.
2026-01-20 17:29:48 +01:00
Aditya Sharma
040a2ae9a9 Enable specifying mask length when creating IP addresses via available-ips endpoint (#21193)
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
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
* Enable specifying mask length when creating IP addresses via available-ips endpoint

Fixes #21144

Allow clients to specify an arbitrary mask length when creating IP addresses
from a parent prefix or range using the 'next available' REST API endpoint.

Changes:
- Updated AvailableIPAddressesView to use PrefixLengthSerializer as write_serializer_class
- Enhanced PrefixLengthSerializer to support both 'prefix' and 'parent' context keys
- Added validation to ensure requested prefix_length >= parent mask_length
- Updated prep_object_data to use requested prefix_length if provided, otherwise fall back to parent mask_length for backwards compatibility
- Updated API schema documentation to reflect PrefixLengthSerializer usage

This enables use cases like creating loopback IP addresses with /32 mask length
from a parent prefix with a shorter mask length.

* Refine available-ips prefix length handling

Keep PrefixLengthSerializer strict for available-prefixes and introduce
AvailableIPRequestSerializer for the available-ips endpoint, where
prefix_length is optional and validated against the parent prefix/range.

* Revert PrefixLengthSerializer to original strict state

PrefixLengthSerializer should remain required and strict for the
available-prefixes endpoint. The optional prefix_length functionality
for available-ips is handled by AvailableIPRequestSerializer.

* Add API test; misc cleanup

---------

Co-authored-by: adionit7 <adionit7@users.noreply.github.com>
Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2026-01-20 11:20:02 -05:00
Martin Hauser
39f11f28fb fix(core): Cache table existence for ObjectType checks
Introduces a cached `_table_exists` flag to avoid repeated database
introspection queries for `core_objecttype`.
Improves performance during ObjectType lookups and reduces
redundant query overhead.

Fixes #21231
2026-01-20 11:15:14 -05:00
Jeremy Stretch
62b9025a9e Fixes #21181: Handle AuthenticationFailed exception on /media endpoint (#21224) 2026-01-20 08:07:18 -08:00
Jeremy Stretch
21091f22e6 Closes #21234: Add #20966 to the changelog for v4.4.9 (#21236) 2026-01-20 09:22:03 -06:00
github-actions
3efa23cf8f Update source translation strings
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-01-20 05:07:49 +00:00
bctiemann
0f62137957 Merge pull request #21199 from netbox-community/21178-change-rack-dimensions-display-to-be-more-consistent
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.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
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 #21178: Use localized “millimeters” for rack mounting depth (follow-up)
2026-01-19 14:14:24 -05:00
Jeremy Stretch
78c56c2cb8 Update only the primary/OOB IP fields when saving the parent object
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
2026-01-19 11:57:35 -05:00
Martin Hauser
7858ccb712 feat(extras): Add AVIF support for image attachments
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
Extends allowed image file formats to include AVIF for better modern
format support. Introduces a constants mapping for image formats to
centralize file type definitions. Updates form widgets and utilities
to leverage the new constants, enabling more flexible and consistent
image handling.

Fixes #21039
2026-01-19 09:56:06 -05:00
Martin Hauser
6b7b38ee0a fix(users): Refactor object permission query logic
Simplifies the `OBJECTPERMISSION_OBJECT_TYPES` definition by adjusting
query filters and introducing new conditions for specific app labels
and models.

Fixes #21051
2026-01-19 09:30:36 -05:00
matthew-242
c8f17e06a2 Add support to filter on cached relations _location, _region, _site and _site_group to ScopedFilterMixin (#21162) 2026-01-19 09:09:03 -05:00
Jeremy Stretch
edace6aff4 Fixes #21166: Fix support for filtering on unsigned 32-bit integer values in GraphQL API (#21186)
* Fixes #21166: Fix support for filtering on unsigned 32-bit integer values in GraphQL API

* tunnel_id should also use BigIntegerLookup
2026-01-19 08:54:39 -05:00
github-actions
586bc132b6 Update source translation strings
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-17 05:02:55 +00:00
Arthur Hanson
52a2b934a0 Fixes #21160: Fix performance issue rendering FilterSet forms w/ large choicesets (#21200)
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.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
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-16 16:34:12 -06:00
Jeremy Stretch
5dd5d65d74 Initial testing for #21203 2026-01-16 17:33:03 -05:00
Martin Hauser
3d1f18d6dd fix(dcim): Localize mounting depth format string
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
Replaces the fixed format string for `mounting_depth` with a localized
version using `gettext_lazy`. This ensures proper translation of the
unit label for internationalization purposes.

Fixes #21178
2026-01-16 19:53:49 +01:00
175 changed files with 87861 additions and 79065 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v4.4.10
placeholder: v4.5.2
validations:
required: true
- type: dropdown
+1 -1
View File
@@ -27,7 +27,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v4.4.10
placeholder: v4.5.2
validations:
required: true
- type: dropdown
+43
View File
@@ -0,0 +1,43 @@
---
name: 🏁 Performance
type: Performance
description: An opportunity to improve application performance
labels: ["netbox", "type: performance", "status: needs triage"]
body:
- type: input
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v4.5.2
validations:
required: true
- type: dropdown
attributes:
label: Python Version
description: What version of Python are you currently running?
options:
- "3.12"
- "3.13"
- "3.14"
validations:
required: true
- type: checkboxes
attributes:
label: Area(s) of Concern
description: Which application interface(s) are affected?
options:
- label: User Interface
- label: REST API
- label: GraphQL API
- label: Python ORM
- label: Other
validations:
required: true
- type: textarea
attributes:
label: Details
description: >
Describe in detail the operations being performed and the indications of a performance issue.
Include any relevant testing parameters, benchmarks, and expected results.
validations:
required: true
+1 -1
View File
@@ -85,7 +85,7 @@ drf-spectacular-sidecar
feedparser
# WSGI HTTP server
# https://docs.gunicorn.org/en/latest/news.html
# https://gunicorn.org/news/
gunicorn
# Platform-agnostic template rendering engine
+4870 -3090
View File
File diff suppressed because it is too large Load Diff
+24 -12
View File
@@ -3,29 +3,41 @@
NetBox includes a Python management shell within which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
```
./manage.py nbshell
cd /opt/netbox
source /opt/netbox/venv/bin/activate
python3 netbox/manage.py nbshell
```
This will launch a lightly customized version of [the built-in Django shell](https://docs.djangoproject.com/en/stable/ref/django-admin/#shell) with all relevant NetBox models pre-loaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.)
This will launch a lightly customized version of [the built-in Django shell](https://docs.djangoproject.com/en/stable/ref/django-admin/#shell) with all relevant NetBox models preloaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.)
```
$ ./manage.py nbshell
(venv) $ python3 netbox/manage.py nbshell
### NetBox interactive shell (localhost)
### Python 3.7.10 | Django 3.2.5 | NetBox 3.0
### lsmodels() will show available models. Use help(<model>) for more info.
### Python v3.12.3 | Django v5.2.10 | NetBox Community v4.5.1
### lsapps() & lsmodels() will show available models. Use help(<model>) for more info.
```
The function `lsmodels()` will print a list of all available NetBox models:
```
>>> lsmodels()
DCIM:
ConsolePort
ConsolePortTemplate
ConsoleServerPort
ConsoleServerPortTemplate
Device
...
DCIM:
dcim.Cable
dcim.CableTermination
dcim.ConsolePort
dcim.ConsolePortTemplate
dcim.ConsoleServerPort
dcim.ConsoleServerPortTemplate
dcim.Device
...
```
To exit the NetBox shell, type `exit()` or press `Ctrl+D`.
```
>>> exit()
(venv) $
```
!!! warning
@@ -114,7 +126,7 @@ Reverse relationships can be traversed as well. For example, the following will
>>> Device.objects.filter(interfaces__name="em0")
```
Character fields can be filtered against partial matches using the `contains` or `icontains` field lookup (the later of which is case-insensitive).
Character fields can be filtered against partial matches using the `contains` or `icontains` field lookup (the latter of which is case-insensitive).
```
>>> Device.objects.filter(name__icontains="testdevice")
+12 -3
View File
@@ -8,7 +8,7 @@ This is a mapping of models to [custom validators](../customization/custom-valid
```python
CUSTOM_VALIDATORS = {
"dcim.site": [
"dcim.Site": [
{
"name": {
"min_length": 5,
@@ -17,12 +17,15 @@ CUSTOM_VALIDATORS = {
},
"my_plugin.validators.Validator1"
],
"dcim.device": [
"dcim.Device": [
"my_plugin.validators.Validator1"
]
}
```
!!! info "Case-Insensitive Model Names"
Model identifiers are case-insensitive. Both `dcim.site` and `dcim.Site` are valid and equivalent.
---
## FIELD_CHOICES
@@ -53,6 +56,9 @@ FIELD_CHOICES = {
}
```
!!! info "Case-Insensitive Field Identifiers"
Field identifiers are case-insensitive. Both `dcim.Site.status` and `dcim.site.status` are valid and equivalent.
The following model fields support configurable choices:
* `circuits.Circuit.status`
@@ -98,7 +104,7 @@ This is a mapping of models to [custom validators](../customization/custom-valid
```python
PROTECTION_RULES = {
"dcim.site": [
"dcim.Site": [
{
"status": {
"eq": "decommissioning"
@@ -108,3 +114,6 @@ PROTECTION_RULES = {
]
}
```
!!! info "Case-Insensitive Model Names"
Model identifiers are case-insensitive. Both `dcim.site` and `dcim.Site` are valid and equivalent.
+1 -1
View File
@@ -15,7 +15,7 @@ Some configuration parameters may alternatively be defined either in `configurat
## Dynamic Configuration Parameters
Some configuration parameters are primarily controlled via NetBox's admin interface (under Admin > Extras > Configuration Revisions). These are noted where applicable in the documentation. These settings may also be overridden in `configuration.py` to prevent them from being modified via the UI. A complete list of supported parameters is provided below:
Some configuration parameters are primarily controlled via NetBox's admin interface (under Admin > System > Configuration History). These are noted where applicable in the documentation. These settings may also be overridden in `configuration.py` to prevent them from being modified via the UI. A complete list of supported parameters is provided below:
* [`ALLOWED_URL_SCHEMES`](./security.md#allowed_url_schemes)
* [`BANNER_BOTTOM`](./miscellaneous.md#banner_bottom)
+1 -1
View File
@@ -144,7 +144,7 @@ Then, compile these portable (`.po`) files for use in the application:
* Update the version number and published date in `netbox/release.yaml`. Add or remove the designation (e.g. `beta1`) if applicable.
* Copy the version number from `release.yaml` to `pyproject.toml` in the project root.
* Update the example version numbers in the feature request and bug report templates under `.github/ISSUE_TEMPLATES/`.
* Update the example version numbers in the feature request, bug report, and performance templates under `.github/ISSUE_TEMPLATES/`.
* Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release.
!!! tip
+4 -4
View File
@@ -51,14 +51,14 @@ You can verify that authentication works by executing the `psql` command and pas
```no-highlight
$ psql --username netbox --password --host localhost netbox
Password for user netbox:
psql (12.5 (Ubuntu 12.5-0ubuntu0.20.04.1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Password:
psql (16.11 (Ubuntu 16.11-0ubuntu0.24.04.1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
Type "help" for help.
netbox=> \conninfo
You are connected to database "netbox" as user "netbox" on host "localhost" (address "127.0.0.1") at port "5432".
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
netbox=> \q
```
+14 -14
View File
@@ -36,7 +36,7 @@ sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox
```
!!! note
It is recommended to install NetBox in a directory named for its version number. For example, NetBox v3.0.0 would be installed into `/opt/netbox-3.0.0`, and a symlink from `/opt/netbox/` would point to this location. (You can verify this configuration with the command `ls -l /opt | grep netbox`.) This allows for future releases to be installed in parallel without interrupting the current installation. When changing to the new release, only the symlink needs to be updated.
It is recommended to install NetBox in a directory named for its version number. For example, NetBox v4.0.0 would be installed into `/opt/netbox-4.0.0`, and a symlink from `/opt/netbox/` would point to this location. (You can verify this configuration with the command `ls -l /opt | grep netbox`.) This allows for future releases to be installed in parallel without interrupting the current installation. When changing to the new release, only the symlink needs to be updated.
### Option B: Clone the Git Repository
@@ -63,12 +63,12 @@ This command should generate output similar to the following:
```
Cloning into '.'...
remote: Enumerating objects: 996, done.
remote: Counting objects: 100% (996/996), done.
remote: Compressing objects: 100% (935/935), done.
remote: Total 996 (delta 148), reused 386 (delta 34), pack-reused 0
Receiving objects: 100% (996/996), 4.26 MiB | 9.81 MiB/s, done.
Resolving deltas: 100% (148/148), done.
remote: Enumerating objects: 148317, done.
remote: Counting objects: 100% (183/183), done.
remote: Compressing objects: 100% (115/115), done.
remote: Total 148317 (delta 127), reused 68 (delta 68), pack-reused 148134 (from 3)
Receiving objects: 100% (148317/148317), 165.12 MiB | 28.71 MiB/s, done.
Resolving deltas: 100% (116428/116428), done.
```
Finally, check out the tag for the desired release. You can find these on our [releases page](https://github.com/netbox-community/netbox/releases). Replace `vX.Y.Z` with your selected release tag below.
@@ -102,7 +102,8 @@ sudo cp configuration_example.py configuration.py
Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations:
* `ALLOWED_HOSTS`
* `DATABASES` (or `DATABASE`)
* `API_TOKEN_PEPPERS`
* `DATABASES`
* `REDIS`
* `SECRET_KEY`
@@ -158,7 +159,7 @@ DATABASES = {
### REDIS
Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-parameters.md#redis) for more detail on individual parameters.
Redis is an in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-parameters.md#redis) for more detail on individual parameters.
Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique numeric database ID.
@@ -252,7 +253,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
sudo /opt/netbox/upgrade.sh
```
Note that **Python 3.12 or later is required** for NetBox v4.5 and later releases. If the default Python installation on your server is set to a lesser version, pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.)
Note that **Python 3.12 or later is required** for NetBox v4.5 and later releases. If the default Python installation on your server is set to a lesser version, pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.)
```no-highlight
sudo PYTHON=/usr/bin/python3.12 /opt/netbox/upgrade.sh
@@ -295,13 +296,12 @@ python3 manage.py runserver 0.0.0.0:8000 --insecure
If successful, you should see output similar to the following:
```no-highlight
Watching for file changes with StatReloader
Performing system checks...
System check identified no issues (0 silenced).
August 30, 2021 - 18:02:23
Django version 3.2.6, using settings 'netbox.settings'
Starting development server at http://127.0.0.1:8000/
January 26, 2026 - 17:00:00
Django version 5.2.10, using settings 'netbox.settings'
Starting development server at http://0.0.0.0:8000/
Quit the server with CONTROL-C.
```
+14 -8
View File
@@ -43,16 +43,22 @@ You should see output similar to the following:
```no-highlight
● netbox.service - NetBox WSGI Service
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
Active: active (running) since Mon 2021-08-30 04:02:36 UTC; 14h ago
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; preset: enabled)
Active: active (running) since Mon 2026-01-26 11:00:00 CST; 7s ago
Docs: https://docs.netbox.dev/
Main PID: 1140492 (gunicorn)
Tasks: 19 (limit: 4683)
Memory: 666.2M
Main PID: 7283 (gunicorn)
Tasks: 6 (limit: 4545)
Memory: 556.1M (peak: 556.3M)
CPU: 3.387s
CGroup: /system.slice/netbox.service
├─1140492 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
├─1140513 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
├─1140514 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
├─7283 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
├─7285 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
├─7286 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
├─7287 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
├─7288 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
└─7289 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
Jan 26 11:00:00 netbox systemd[1]: Started netbox.service - NetBox WSGI Service.
...
```
+1 -1
View File
@@ -3,7 +3,7 @@
This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](https://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible.
!!! info
For the sake of brevity, only Ubuntu 20.04 instructions are provided here. These tasks are not unique to NetBox and should carry over to other distributions with minimal changes. Please consult your distribution's documentation for assistance if needed.
For the sake of brevity, only Ubuntu 24.04 instructions are provided here. These tasks are not unique to NetBox and should carry over to other distributions with minimal changes. Please consult your distribution's documentation for assistance if needed.
## Obtain an SSL Certificate
+2 -2
View File
@@ -12,12 +12,12 @@
</div>
The installation instructions provided here have been tested to work on Ubuntu 22.04. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
The installation instructions provided here have been tested to work on Ubuntu 24.04. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
The following sections detail how to set up a new instance of NetBox:
1. [PostgreSQL database](1-postgresql.md)
1. [Redis](2-redis.md)
2. [Redis](2-redis.md)
3. [NetBox components](3-netbox.md)
4. [Gunicorn](4a-gunicorn.md) or [uWSGI](4b-uwsgi.md)
5. [HTTP server](5-http-server.md)
+4 -4
View File
@@ -65,7 +65,7 @@ Download and extract the latest version:
```no-highlight
# Set $NEWVER to the NetBox version being installed
NEWVER=3.5.0
NEWVER=4.5.0
wget https://github.com/netbox-community/netbox/archive/v$NEWVER.tar.gz
sudo tar -xzf v$NEWVER.tar.gz -C /opt
sudo ln -sfn /opt/netbox-$NEWVER/ /opt/netbox
@@ -75,7 +75,7 @@ Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if pres
```no-highlight
# Set $OLDVER to the NetBox version currently installed
OLDVER=3.4.9
OLDVER=4.4.10
sudo cp /opt/netbox-$OLDVER/local_requirements.txt /opt/netbox/
sudo cp /opt/netbox-$OLDVER/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/
sudo cp /opt/netbox-$OLDVER/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/
@@ -116,7 +116,7 @@ Check out the desired release by specifying its tag. For example:
```
cd /opt/netbox && \
sudo git fetch --tags && \
sudo git checkout v4.2.7
sudo git checkout v4.5.0
```
## 4. Run the Upgrade Script
@@ -128,7 +128,7 @@ sudo ./upgrade.sh
```
!!! warning
If the default version of Python is not at least 3.10, you'll need to pass the path to a supported Python version as an environment variable when calling the upgrade script. For example:
If the default version of Python is not **at least 3.12**, you'll need to pass the path to a supported Python version as an environment variable when calling the upgrade script. For example:
```no-highlight
sudo PYTHON=/usr/bin/python3.12 ./upgrade.sh
+51 -7
View File
@@ -133,23 +133,67 @@ The field "class_type" is an easy way to distinguish what type of object it is w
## Pagination
Queries can be paginated by specifying pagination in the query and supplying an offset and optionaly a limit in the query. If no limit is given, a default of 100 is used. Queries are not paginated unless requested in the query. An example paginated query is shown below:
The GraphQL API supports two types of pagination. Offset-based pagination operates using an offset relative to the first record in a set, specified by the `offset` parameter. For example, the response to a request specifying an offset of 100 will contain the 101st and later matching records. Offset-based pagination feels very natural, but its performance can suffer when dealing with large data sets due to the overhead involved in calculating the relative offset.
The alternative approach is cursor-based pagination, which operates using absolute (rather than relative) primary key values. (These are the numeric IDs assigned to each object in the database.) When using cursor-based pagination, the response will contain records with a primary key greater than or equal to the specified start value, up to the maximum number of results. This strategy requires keeping track of the last seen primary key from each response when paginating through data, but is extremely performant. The cursor is specified by passing the starting object ID via the `start` parameter.
To ensure consistent ordering, objects will always be ordered by their primary keys when cursor-based pagination is used.
!!! note "Cursor-based pagination was introduced in NetBox v4.5.2."
Both pagination strategies support passing an optional `limit` parameter. In both approaches, this specifies the maximum number of objects to include in the response. If no limit is specified, a default value of 100 is used.
### Offset Pagination
The first page will have an `offset` of zero, or the `offset` parameter will be omitted:
```
query {
device_list(pagination: { offset: 0, limit: 20 }) {
device_list(pagination: {offset: 0, limit: 20}) {
id
}
}
```
The second page will have an offset equal to the size of the first page. If the number of records is less than the specified limit, there are no more records to process. For example, if a request specifies a `limit` of 20 but returns only 13 records, we can conclude that this is the final page of records.
```
query {
device_list(pagination: {offset: 20, limit: 20}) {
id
}
}
```
### Cursor Pagination
Set the `start` value to zero to fetch the first page. Note that if the `start` parameter is omitted, offset-based pagination will be used by default.
```
query {
device_list(pagination: {start: 0, limit: 20}) {
id
}
}
```
To determine the `start` value for the next page, add 1 to the primary key (`id`) of the last record in the previous page.
For example, if the ID of the last record in the previous response was 123, we would specify a `start` value of 124:
```
query {
device_list(pagination: {start: 124, limit: 20}) {
id
}
}
```
This will return up to 20 records with an ID greater than or equal to 124.
## Authentication
NetBox's GraphQL API uses the same API authentication tokens as its REST API. Authentication tokens are included with requests by attaching an `Authorization` HTTP header in the following form:
```
Authorization: Token $TOKEN
```
NetBox's GraphQL API uses the same API authentication tokens as its REST API. See the [REST API authentication](./rest-api.md#authentication) documentation for further detail.
## Disabling the GraphQL API
+46 -4
View File
@@ -215,9 +215,51 @@ http://netbox/api/ipam/ip-addresses/ \
If we wanted to assign this IP address to a virtual machine interface instead, we would have set `assigned_object_type` to `virtualization.vminterface` and updated the object ID appropriately.
### Brief Format
### Specifying Fields
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. As an example, the default (complete) format of a prefix looks like this:
A REST API response will include all available fields for the object type by default. If you wish to return only a subset of the available fields, you can append `?fields=` to the URL followed by a comma-separated list of field names. For example, the following request will return only the `id`, `name`, `status`, and `region` fields for each site in the response.
```
GET /api/dcim/sites/?fields=id,name,status,region
```
```json
{
"id": 1,
"name": "DM-NYC",
"status": {
"value": "active",
"label": "Active"
},
"region": {
"id": 43,
"url": "http://netbox:8000/api/dcim/regions/43/",
"display": "New York",
"name": "New York",
"slug": "us-ny",
"description": "",
"site_count": 0,
"_depth": 2
}
}
```
Similarly, you can opt to omit only specific fields by passing the `omit` parameter:
```
GET /api/dcim/sites/?omit=circuit_count,device_count,virtualmachine_count
```
!!! note "The `omit` parameter was introduced in NetBox v4.5.2."
Strategic use of the `fields` and `omit` parameters can drastically improve REST API performance, as the exclusion of fields which reference related objects reduces the number and complexity of underlying database queries needed to generate the response.
!!! note
The `fields` and `omit` parameters should be considered mutually exclusive. If both are passed, `fields` takes precedence.
#### Brief Format
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. It's also more convenient than listing out individual fields via the `fields` or `omit` parameters. As an example, the default (complete) format of a prefix looks like this:
```no-highlight
GET /api/ipam/prefixes/13980/
@@ -270,10 +312,10 @@ GET /api/ipam/prefixes/13980/
}
```
The brief format is much more terse:
The brief format includes only a few fields:
```no-highlight
GET /api/ipam/prefixes/13980/?brief=1
GET /api/ipam/prefixes/13980/?brief=true
```
```json
+1
View File
@@ -40,6 +40,7 @@
* [#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`
* [#20966](https://github.com/netbox-community/netbox/issues/20966) - Fix UI rendering issue when scrolling list of object types in permissions form
* [#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
+86 -1
View File
@@ -1,4 +1,89 @@
## v4.5.0 (FUTURE)
# NetBox v4.5
## v4.5.2 (2026-02-03)
### Enhancements
* [#15801](https://github.com/netbox-community/netbox/issues/15801) - Add link peer and connection columns to the VLAN device interfaces table
* [#19221](https://github.com/netbox-community/netbox/issues/19221) - Truncate long image attachment filenames in the UI
* [#19869](https://github.com/netbox-community/netbox/issues/19869) - Display peer connections for LAG member interfaces
* [#20052](https://github.com/netbox-community/netbox/issues/20052) - Increase logging level of error message when a custom script fails to load
* [#20172](https://github.com/netbox-community/netbox/issues/20172) - Add `cabled` filter for interfaces in GraphQL API
* [#21081](https://github.com/netbox-community/netbox/issues/21081) - Add owner group table columns & filters across all supported object list views
* [#21088](https://github.com/netbox-community/netbox/issues/21088) - Add max depth and max length dropdowns for child prefix views
* [#21110](https://github.com/netbox-community/netbox/issues/21110) - Support cursor-based pagination in GraphQL API
* [#21201](https://github.com/netbox-community/netbox/issues/21201) - Pre-populate GenericForeignKey form fields when cloning
* [#21209](https://github.com/netbox-community/netbox/issues/21209) - Ignore case sensitivity for configuration parameters which specify an app label and model name
* [#21228](https://github.com/netbox-community/netbox/issues/21228) - Support image attachments for rack types
* [#21244](https://github.com/netbox-community/netbox/issues/21244) - Enable omitting specific fields from REST API responses with `?omit=` parameter
### Performance Improvements
* [#21249](https://github.com/netbox-community/netbox/issues/21249) - Avoid extraneous user query when no event rules are present
* [#21259](https://github.com/netbox-community/netbox/issues/21259) - Cache ObjectType lookups for the duration of a request
* [#21260](https://github.com/netbox-community/netbox/issues/21260) - Defer object serialization for events pipeline processing
* [#21263](https://github.com/netbox-community/netbox/issues/21263) - Prefetch related objects after creating/updating objects via REST API
* [#21300](https://github.com/netbox-community/netbox/issues/21300) - Cache custom field lookups for the duration of a request
* [#21302](https://github.com/netbox-community/netbox/issues/21302) - Avoid redundant uniqueness checks in ValidatedModelSerializer
* [#21303](https://github.com/netbox-community/netbox/issues/21303) - Cache post-change snapshot on each instance after serialization
* [#21327](https://github.com/netbox-community/netbox/issues/21327) - Always leverage `get_by_natural_key()` to resolve ContentTypes
### Bug Fixes
* [#20212](https://github.com/netbox-community/netbox/issues/20212) - Fix support for image attachment thumbnails when using S3 storage
* [#20383](https://github.com/netbox-community/netbox/issues/20383) - When editing a device, clearing the assigned unit should also clear the rack face selection
* [#20902](https://github.com/netbox-community/netbox/issues/20902) - Avoid `SyncError` exception when Git URL contains an embedded username
* [#20977](https://github.com/netbox-community/netbox/issues/20977) - "Run again" button should respect script variable defaults
* [#21115](https://github.com/netbox-community/netbox/issues/21115) - Include `attribute_data` in ModuleType YAML export
* [#21129](https://github.com/netbox-community/netbox/issues/21129) - Store queue name on the Job model to ensure deletion of associated RQ task when a non-default queue is used
* [#21168](https://github.com/netbox-community/netbox/issues/21168) - Fix Application Service cloning to preserve parent object
* [#21173](https://github.com/netbox-community/netbox/issues/21173) - Ensure all plugin menu items are registered regardless of initialization order
* [#21176](https://github.com/netbox-community/netbox/issues/21176) - Remove checkboxes from IP ranges in mixed-type tables
* [#21202](https://github.com/netbox-community/netbox/issues/21202) - Fix scoped form cloning clearing the `scope` field when `scope_type` changes
* [#21214](https://github.com/netbox-community/netbox/issues/21214) - Clean up AutoSyncRecord when detaching from DataSource
* [#21242](https://github.com/netbox-community/netbox/issues/21242) - Navigation menu items for authentication should not require `staff_only` permission
* [#21254](https://github.com/netbox-community/netbox/issues/21254) - Fix `AttributeError` exception when checking for latest release
* [#21262](https://github.com/netbox-community/netbox/issues/21262) - Assigned scope should be replicated when cloning a prefix
* [#21269](https://github.com/netbox-community/netbox/issues/21269) - Fix replication of front/rear port assignments from the module type when installing a module
---
## v4.5.1 (2026-01-20)
### Enhancements
* [#21018](https://github.com/netbox-community/netbox/issues/21018) - Enable filtering prefixes by location/site/site group/region directly via GraphQL API
* [#21142](https://github.com/netbox-community/netbox/issues/21142) - Enable filtering device components by site/location/rack directly via GraphQL API
* [#21144](https://github.com/netbox-community/netbox/issues/21144) - Enable specifying a prefix length for IP addresses when utilizing the `/api/ipam/prefixes/<id>/available-ips/` REST API endpoint
* [#21165](https://github.com/netbox-community/netbox/issues/21165) - VLAN selector should default to group (instead of site)
* [#21178](https://github.com/netbox-community/netbox/issues/21178) - Improve consistency of rack measurements in UI
### Bug Fixes
* [#19901](https://github.com/netbox-community/netbox/issues/19901) - Fix `RelatedObjectDoesNotExist` exception when importing modules into unnamed devices
* [#20239](https://github.com/netbox-community/netbox/issues/20239) - Prevent shared mutable state in PluginMenuItem & PluginMenuButton
* [#20933](https://github.com/netbox-community/netbox/issues/20933) - Fix writable `data_file` assignment for ConfigContext and ConfigContextProfile via the REST API
* [#21039](https://github.com/netbox-community/netbox/issues/21039) - Fix support for AVIF image uploads
* [#21050](https://github.com/netbox-community/netbox/issues/21050) - Clear device OOB IP assignments when reassigning IP addresses
* [#21051](https://github.com/netbox-community/netbox/issues/21051) - Remove irrelevant object types from permissions form
* [#21097](https://github.com/netbox-community/netbox/issues/21097) - Fix comparison lookups for ID filters in GraphQL API
* [#21102](https://github.com/netbox-community/netbox/issues/21102) - Fix GraphiQL explorer UI
* [#21117](https://github.com/netbox-community/netbox/issues/21117) - Avoid `ValueError` exception when `API_TOKEN_PEPPERS` is not defined
* [#21118](https://github.com/netbox-community/netbox/issues/21118) - Address performance issue when saving sites with many assigned objects
* [#21124](https://github.com/netbox-community/netbox/issues/21124) - Fix front/rear port mapping for module types
* [#21134](https://github.com/netbox-community/netbox/issues/21134) - Fix bulk renaming for module types
* [#21139](https://github.com/netbox-community/netbox/issues/21139) - Support `fields` parameter for job, object change, and object type REST API endpoints
* [#21140](https://github.com/netbox-community/netbox/issues/21140) - Restore translation for object attribute labels on several UI views
* [#21160](https://github.com/netbox-community/netbox/issues/21160) - Fix performance issue loading UI views caused by unintended `APISelect` choices resolution
* [#21166](https://github.com/netbox-community/netbox/issues/21166) - Fix support for 32-bit ASN filtering in GraphQL API
* [#21175](https://github.com/netbox-community/netbox/issues/21175) - Fix pending migrations warning when `DEFAULT_LANGUAGE` is set
* [#21181](https://github.com/netbox-community/netbox/issues/21181) - Handle `AuthenticationFailed` exception when using an invalid API token to fetch media files
* [#21213](https://github.com/netbox-community/netbox/issues/21213) - Tag weight field should be marked as required in UI forms
* [#21231](https://github.com/netbox-community/netbox/issues/21231) - Presence of object types table should be checked only during migration (performance improvement)
---
## v4.5.0 (2026-01-06)
### Breaking Changes
+16 -8
View File
@@ -34,9 +34,10 @@ __all__ = (
class ProviderFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
model = Provider
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet('asn_id', name=_('ASN')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
region_id = DynamicModelMultipleChoiceField(
@@ -69,8 +70,9 @@ class ProviderFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
class ProviderAccountFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
model = ProviderAccount
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'account', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
provider_id = DynamicModelMultipleChoiceField(
@@ -88,8 +90,9 @@ class ProviderAccountFilterForm(ContactModelFilterForm, PrimaryModelFilterSetFor
class ProviderNetworkFilterForm(PrimaryModelFilterSetForm):
model = ProviderNetwork
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'service_id', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
@@ -107,8 +110,9 @@ class ProviderNetworkFilterForm(PrimaryModelFilterSetForm):
class CircuitTypeFilterForm(OrganizationalModelFilterSetForm):
model = CircuitType
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('color', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
tag = TagFilterField(model)
@@ -121,7 +125,7 @@ class CircuitTypeFilterForm(OrganizationalModelFilterSetForm):
class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
model = Circuit
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
FieldSet(
'type_id', 'status', 'install_date', 'termination_date', 'commit_rate', 'distance', 'distance_unit',
@@ -129,6 +133,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelF
),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'provider_id', 'provider_network_id')
@@ -274,8 +279,9 @@ class CircuitTerminationFilterForm(NetBoxModelFilterSetForm):
class CircuitGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
model = CircuitGroup
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
tag = TagFilterField(model)
@@ -312,8 +318,9 @@ class CircuitGroupAssignmentFilterForm(NetBoxModelFilterSetForm):
class VirtualCircuitTypeFilterForm(OrganizationalModelFilterSetForm):
model = VirtualCircuitType
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('color', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
tag = TagFilterField(model)
@@ -326,10 +333,11 @@ class VirtualCircuitTypeFilterForm(OrganizationalModelFilterSetForm):
class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
model = VirtualCircuit
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
FieldSet('type_id', 'status', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id')
provider_id = DynamicModelMultipleChoiceField(
+2 -1
View File
@@ -31,7 +31,8 @@ class JobSerializer(BaseModelSerializer):
model = Job
fields = [
'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'object', 'name', 'status', 'created',
'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'log_entries',
'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'queue_name',
'log_entries',
]
brief_fields = ('url', 'created', 'completed', 'user', 'status')
+15 -5
View File
@@ -11,7 +11,6 @@ from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from rest_framework.routers import APIRootView
from rest_framework.viewsets import ReadOnlyModelViewSet
from rq.job import Job as RQ_Job
from rq.worker import Worker
@@ -64,7 +63,7 @@ class DataFileViewSet(NetBoxReadOnlyModelViewSet):
filterset_class = filtersets.DataFileFilterSet
class JobViewSet(ReadOnlyModelViewSet):
class JobViewSet(NetBoxReadOnlyModelViewSet):
"""
Retrieve a list of job results
"""
@@ -73,19 +72,20 @@ class JobViewSet(ReadOnlyModelViewSet):
filterset_class = filtersets.JobFilterSet
class ObjectChangeViewSet(ReadOnlyModelViewSet):
class ObjectChangeViewSet(NetBoxReadOnlyModelViewSet):
"""
Retrieve a list of recent changes.
"""
metadata_class = ContentTypeMetadata
queryset = ObjectChange.objects.all()
serializer_class = serializers.ObjectChangeSerializer
filterset_class = filtersets.ObjectChangeFilterSet
def get_queryset(self):
return ObjectChange.objects.valid_models()
return super().get_queryset().valid_models()
class ObjectTypeViewSet(ReadOnlyModelViewSet):
class ObjectTypeViewSet(NetBoxReadOnlyModelViewSet):
"""
Read-only list of ObjectTypes.
"""
@@ -94,6 +94,16 @@ class ObjectTypeViewSet(ReadOnlyModelViewSet):
serializer_class = serializers.ObjectTypeSerializer
filterset_class = filtersets.ObjectTypeFilterSet
def initial(self, request, *args, **kwargs):
"""
Override initial() to skip the restrict() call since ObjectType (a ContentType proxy)
doesn't use RestrictedQuerySet and is publicly accessible metadata.
"""
# Call GenericViewSet.initial() directly, skipping BaseViewSet.initial()
# which would try to call restrict() on the queryset
from rest_framework.viewsets import GenericViewSet
GenericViewSet.initial(self, request, *args, **kwargs)
class BaseRQViewSet(viewsets.ViewSet):
"""
+16 -1
View File
@@ -21,11 +21,24 @@ __all__ = (
'GitBackend',
'LocalBackend',
'S3Backend',
'url_has_embedded_credentials',
)
logger = logging.getLogger('netbox.data_backends')
def url_has_embedded_credentials(url):
"""
Check if a URL contains embedded credentials (username in the URL).
URLs like 'https://user@bitbucket.org/...' have embedded credentials.
This is used to avoid passing explicit credentials to dulwich when the
URL already contains them, which would cause authentication conflicts.
"""
parsed = urlparse(url)
return bool(parsed.username)
@register_data_backend()
class LocalBackend(DataBackend):
name = 'local'
@@ -102,7 +115,9 @@ class GitBackend(DataBackend):
clone_args['pool_manager'] = ProxyPoolManager(self.socks_proxy)
if self.url_scheme in ('http', 'https'):
if self.params.get('username'):
# Only pass explicit credentials if URL doesn't already contain embedded username
# to avoid credential conflicts (see #20902)
if not url_has_embedded_credentials(self.url) and self.params.get('username'):
clone_args.update(
{
"username": self.params.get('username'),
+5 -1
View File
@@ -129,10 +129,14 @@ class JobFilterSet(BaseFilterSet):
choices=JobStatusChoices,
null_value=None
)
queue_name = django_filters.CharFilter()
class Meta:
model = Job
fields = ('id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id')
fields = (
'id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id',
'queue_name',
)
def search(self, queryset, name, value):
if not value.strip():
+7 -2
View File
@@ -26,8 +26,9 @@ __all__ = (
class DataSourceFilterForm(PrimaryModelFilterSetForm):
model = DataSource
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('type', 'status', 'enabled', 'sync_interval', name=_('Data Source')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
@@ -71,7 +72,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
model = Job
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('object_type_id', 'status', name=_('Attributes')),
FieldSet('object_type_id', 'status', 'queue_name', name=_('Attributes')),
FieldSet(
'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation')
@@ -87,6 +88,10 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
choices=JobStatusChoices,
required=False
)
queue_name = forms.CharField(
label=_('Queue'),
required=False
)
created__after = forms.DateTimeField(
label=_('Created after'),
required=False,
@@ -0,0 +1,18 @@
# Generated by Django 5.2.9 on 2026-01-27 00:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0020_owner'),
]
operations = [
migrations.AddField(
model_name='job',
name='queue_name',
field=models.CharField(blank=True, max_length=100),
),
]
+14 -3
View File
@@ -112,6 +112,12 @@ class Job(models.Model):
verbose_name=_('job ID'),
unique=True
)
queue_name = models.CharField(
verbose_name=_('queue name'),
max_length=100,
blank=True,
help_text=_('Name of the queue in which this job was enqueued')
)
log_entries = ArrayField(
verbose_name=_('log entries'),
base_field=models.JSONField(
@@ -179,11 +185,15 @@ class Job(models.Model):
return f"{int(minutes)} minutes, {seconds:.2f} seconds"
def delete(self, *args, **kwargs):
# Use the stored queue name, or fall back to get_queue_for_model for legacy jobs
rq_queue_name = self.queue_name or get_queue_for_model(self.object_type.model if self.object_type else None)
rq_job_id = str(self.job_id)
super().delete(*args, **kwargs)
rq_queue_name = get_queue_for_model(self.object_type.model if self.object_type else None)
# Cancel the RQ job using the stored queue name
queue = django_rq.get_queue(rq_queue_name)
job = queue.fetch_job(str(self.job_id))
job = queue.fetch_job(rq_job_id)
if job:
try:
@@ -288,7 +298,8 @@ class Job(models.Model):
scheduled=schedule_at,
interval=interval,
user=user,
job_id=uuid.uuid4()
job_id=uuid.uuid4(),
queue_name=rq_queue_name
)
job.full_clean()
job.save()
+21 -4
View File
@@ -9,6 +9,7 @@ from django.db import connection, models
from django.db.models import Q
from django.utils.translation import gettext as _
from netbox.context import query_cache
from netbox.plugins import PluginConfig
from netbox.registry import registry
from utilities.string import title
@@ -35,6 +36,10 @@ class ObjectTypeQuerySet(models.QuerySet):
class ObjectTypeManager(models.Manager):
# TODO: Remove this in NetBox v5.0
# Cache the result of introspection to avoid repeated queries.
_table_exists = False
def get_queryset(self):
return ObjectTypeQuerySet(self.model, using=self._db)
@@ -66,13 +71,21 @@ class ObjectTypeManager(models.Manager):
"""
from netbox.models.features import get_model_features, model_is_public
# Check the request cache before hitting the database
cache = query_cache.get()
if cache is not None:
if ot := cache['object_types'].get((model._meta.model, for_concrete_model)):
return ot
# TODO: Remove this in NetBox v5.0
# If the ObjectType table has not yet been provisioned (e.g. because we're in a pre-v4.4 migration),
# fall back to ContentType.
if 'core_objecttype' not in connection.introspection.table_names():
ct = ContentType.objects.get_for_model(model, for_concrete_model=for_concrete_model)
ct.features = get_model_features(ct.model_class())
return ct
if not ObjectTypeManager._table_exists:
if 'core_objecttype' not in connection.introspection.table_names():
ct = ContentType.objects.get_for_model(model, for_concrete_model=for_concrete_model)
ct.features = get_model_features(ct.model_class())
return ct
ObjectTypeManager._table_exists = True
if not inspect.isclass(model):
model = model.__class__
@@ -90,6 +103,10 @@ class ObjectTypeManager(models.Manager):
features=get_model_features(model),
)[0]
# Populate the request cache to avoid redundant lookups
if cache is not None:
cache['object_types'][(model._meta.model, for_concrete_model)] = ot
return ot
def get_for_models(self, *models, for_concrete_models=True):
+2 -1
View File
@@ -18,6 +18,7 @@ from extras.events import enqueue_event
from extras.models import Tag
from extras.utils import run_validators
from netbox.config import get_config
from utilities.data import get_config_value_ci
from netbox.context import current_request, events_queue
from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public
from utilities.exceptions import AbortRequest
@@ -168,7 +169,7 @@ def handle_deleted_object(sender, instance, **kwargs):
# to queueing any events for the object being deleted, in case a validation error is
# raised, causing the deletion to fail.
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config().PROTECTION_RULES.get(model_name, [])
validators = get_config_value_ci(get_config().PROTECTION_RULES, model_name, default=[])
try:
run_validators(instance, validators)
except ValidationError as e:
+4 -1
View File
@@ -42,6 +42,9 @@ class JobTable(NetBoxTable):
completed = columns.DateTimeColumn(
verbose_name=_('Completed'),
)
queue_name = tables.Column(
verbose_name=_('Queue'),
)
log_entries = tables.Column(
verbose_name=_('Log Entries'),
)
@@ -53,7 +56,7 @@ class JobTable(NetBoxTable):
model = Job
fields = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
'completed', 'user', 'error', 'job_id',
'completed', 'user', 'queue_name', 'log_entries', 'error', 'job_id',
)
default_columns = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
+116
View File
@@ -0,0 +1,116 @@
from unittest import skipIf
from unittest.mock import patch
from django.test import TestCase
from core.data_backends import url_has_embedded_credentials
try:
import dulwich # noqa: F401
DULWICH_AVAILABLE = True
except ImportError:
DULWICH_AVAILABLE = False
class URLEmbeddedCredentialsTests(TestCase):
def test_url_with_embedded_username(self):
self.assertTrue(url_has_embedded_credentials('https://myuser@bitbucket.org/workspace/repo.git'))
def test_url_without_embedded_username(self):
self.assertFalse(url_has_embedded_credentials('https://bitbucket.org/workspace/repo.git'))
def test_url_with_username_and_password(self):
self.assertTrue(url_has_embedded_credentials('https://user:pass@bitbucket.org/workspace/repo.git'))
def test_various_providers_with_embedded_username(self):
urls = [
'https://user@bitbucket.org/workspace/repo.git',
'https://user@github.com/owner/repo.git',
'https://deploy-key@gitlab.com/group/project.git',
'http://user@internal-git.example.com/repo.git',
]
for url in urls:
with self.subTest(url=url):
self.assertTrue(url_has_embedded_credentials(url))
def test_various_providers_without_embedded_username(self):
"""Various Git providers without embedded usernames."""
urls = [
'https://bitbucket.org/workspace/repo.git',
'https://github.com/owner/repo.git',
'https://gitlab.com/group/project.git',
'http://internal-git.example.com/repo.git',
]
for url in urls:
with self.subTest(url=url):
self.assertFalse(url_has_embedded_credentials(url))
def test_ssh_url(self):
# git@host:path format doesn't parse as having a username in the traditional sense
self.assertFalse(url_has_embedded_credentials('git@github.com:owner/repo.git'))
def test_file_url(self):
self.assertFalse(url_has_embedded_credentials('file:///path/to/repo'))
@skipIf(not DULWICH_AVAILABLE, "dulwich is not installed")
class GitBackendCredentialIntegrationTests(TestCase):
"""
Integration tests that verify GitBackend correctly applies credential logic.
These tests require dulwich to be installed and verify the full integration
of the credential handling in GitBackend.fetch().
"""
def _get_clone_kwargs(self, url, **params):
from core.data_backends import GitBackend
backend = GitBackend(url=url, **params)
with patch('dulwich.porcelain.clone') as mock_clone, \
patch('dulwich.porcelain.NoneStream'):
try:
with backend.fetch():
pass
except Exception:
pass
if mock_clone.called:
return mock_clone.call_args.kwargs
return {}
def test_url_with_embedded_username_skips_explicit_credentials(self):
kwargs = self._get_clone_kwargs(
url='https://myuser@bitbucket.org/workspace/repo.git',
username='myuser',
password='my-api-key'
)
self.assertEqual(kwargs.get('username'), None)
self.assertEqual(kwargs.get('password'), None)
def test_url_without_embedded_username_passes_explicit_credentials(self):
kwargs = self._get_clone_kwargs(
url='https://bitbucket.org/workspace/repo.git',
username='myuser',
password='my-api-key'
)
self.assertEqual(kwargs.get('username'), 'myuser')
self.assertEqual(kwargs.get('password'), 'my-api-key')
def test_url_with_embedded_username_no_explicit_credentials(self):
kwargs = self._get_clone_kwargs(
url='https://myuser@bitbucket.org/workspace/repo.git'
)
self.assertEqual(kwargs.get('username'), None)
self.assertEqual(kwargs.get('password'), None)
def test_public_repo_no_credentials(self):
kwargs = self._get_clone_kwargs(
url='https://github.com/public/repo.git'
)
self.assertEqual(kwargs.get('username'), None)
self.assertEqual(kwargs.get('password'), None)
+38 -1
View File
@@ -1,8 +1,10 @@
from unittest.mock import patch, MagicMock
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.test import TestCase
from core.models import DataSource, ObjectType
from core.models import DataSource, Job, ObjectType
from core.choices import ObjectChangeActionChoices
from dcim.models import Site, Location, Device
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
@@ -200,3 +202,38 @@ class ObjectTypeTest(TestCase):
bookmarks_ots = ObjectType.objects.with_feature('bookmarks')
self.assertIn(ObjectType.objects.get_by_natural_key('dcim', 'site'), bookmarks_ots)
self.assertNotIn(ObjectType.objects.get_by_natural_key('dcim', 'cabletermination'), bookmarks_ots)
class JobTest(TestCase):
@patch('core.models.jobs.django_rq.get_queue')
def test_delete_cancels_job_from_correct_queue(self, mock_get_queue):
"""
Test that when a job is deleted, it's canceled from the correct queue.
"""
mock_queue = MagicMock()
mock_rq_job = MagicMock()
mock_queue.fetch_job.return_value = mock_rq_job
mock_get_queue.return_value = mock_queue
def dummy_func(**kwargs):
pass
# Enqueue a job with a custom queue name
custom_queue = 'my_custom_queue'
job = Job.enqueue(
func=dummy_func,
name='Test Job',
queue_name=custom_queue
)
# Reset mock to clear enqueue call
mock_get_queue.reset_mock()
# Delete the job
job.delete()
# Verify the correct queue was used for cancellation
mock_get_queue.assert_called_with(custom_queue)
mock_queue.fetch_job.assert_called_with(str(job.job_id))
mock_rq_job.cancel.assert_called_once()
+79 -49
View File
@@ -12,11 +12,12 @@ from netbox.forms import (
NestedGroupModelFilterSetForm, NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm,
PrimaryModelFilterSetForm,
)
from netbox.forms.mixins import OwnerFilterMixin
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from tenancy.models import Tenant
from users.models import Owner, User
from users.models import User
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import NumberWithOptions
from virtualization.models import Cluster, ClusterGroup, VirtualMachine
@@ -70,11 +71,11 @@ __all__ = (
'SiteFilterForm',
'SiteGroupFilterForm',
'VirtualChassisFilterForm',
'VirtualDeviceContextFilterForm'
'VirtualDeviceContextFilterForm',
)
class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
class DeviceComponentFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
name = forms.CharField(
label=_('Name'),
required=False
@@ -157,18 +158,14 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('Device Status'),
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class RegionFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
model = Region
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('parent_id', name=_('Region')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
)
parent_id = DynamicModelMultipleChoiceField(
@@ -182,8 +179,9 @@ class RegionFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
class SiteGroupFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
model = SiteGroup
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('parent_id', name=_('Site Group')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
)
parent_id = DynamicModelMultipleChoiceField(
@@ -197,9 +195,10 @@ class SiteGroupFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm)
class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
model = Site
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('status', 'region_id', 'group_id', 'asn_id', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
selector_fields = ('filter_id', 'q', 'region_id', 'group_id')
@@ -229,9 +228,10 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilt
class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupModelFilterSetForm):
model = Location
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', 'parent_id', 'status', 'facility', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
region_id = DynamicModelMultipleChoiceField(
@@ -277,7 +277,8 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupM
class RackRoleFilterForm(OrganizationalModelFilterSetForm):
model = RackRole
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
tag = TagFilterField(model)
@@ -328,10 +329,11 @@ class RackBaseFilterForm(PrimaryModelFilterSetForm):
class RackTypeFilterForm(RackBaseFilterForm):
model = RackType
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', 'rack_count', name=_('Rack Type')),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
selector_fields = ('filter_id', 'q', 'manufacturer_id')
manufacturer_id = DynamicModelMultipleChoiceField(
@@ -350,13 +352,14 @@ class RackTypeFilterForm(RackBaseFilterForm):
class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterForm):
model = Rack
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('status', 'role_id', 'manufacturer_id', 'rack_type_id', 'serial', 'asset_tag', name=_('Rack')),
FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Hardware')),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id')
@@ -433,9 +436,10 @@ class RackElevationFilterForm(RackFilterForm):
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'id', name=_('Location')),
FieldSet('status', 'role_id', name=_('Function')),
FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
@@ -451,10 +455,11 @@ class RackElevationFilterForm(RackFilterForm):
class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = RackReservation
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('status', 'user_id', name=_('Reservation')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -509,7 +514,8 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class ManufacturerFilterForm(ContactModelFilterForm, OrganizationalModelFilterSetForm):
model = Manufacturer
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
)
tag = TagFilterField(model)
@@ -518,7 +524,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, OrganizationalModelFilterSe
class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
model = DeviceType
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet(
'manufacturer_id', 'default_platform_id', 'part_number', 'device_count',
'subdevice_role', 'airflow', name=_('Hardware')
@@ -529,6 +535,7 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', name=_('Components')
),
FieldSet('weight', 'weight_unit', name=_('Weight')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
selector_fields = ('filter_id', 'q', 'manufacturer_id')
manufacturer_id = DynamicModelMultipleChoiceField(
@@ -652,7 +659,8 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
model = ModuleTypeProfile
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
selector_fields = ('filter_id', 'q')
tag = TagFilterField(model)
@@ -661,7 +669,7 @@ class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
model = ModuleType
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet(
'profile_id', 'manufacturer_id', 'part_number', 'module_count',
'airflow', name=_('Hardware')
@@ -671,6 +679,7 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
'pass_through_ports', name=_('Components')
),
FieldSet('weight', 'weight_unit', name=_('Weight')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
selector_fields = ('filter_id', 'q', 'manufacturer_id')
profile_id = DynamicModelMultipleChoiceField(
@@ -754,8 +763,9 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
class DeviceRoleFilterForm(NestedGroupModelFilterSetForm):
model = DeviceRole
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('parent_id', 'config_template_id', name=_('Device Role'))
FieldSet('q', 'filter_id', 'tag'),
FieldSet('parent_id', 'config_template_id', name=_('Device Role')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
config_template_id = DynamicModelMultipleChoiceField(
queryset=ConfigTemplate.objects.all(),
@@ -773,8 +783,9 @@ class DeviceRoleFilterForm(NestedGroupModelFilterSetForm):
class PlatformFilterForm(NestedGroupModelFilterSetForm):
model = Platform
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('manufacturer_id', 'parent_id', 'config_template_id', name=_('Platform'))
FieldSet('q', 'filter_id', 'tag'),
FieldSet('manufacturer_id', 'parent_id', 'config_template_id', name=_('Platform')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
selector_fields = ('filter_id', 'q', 'manufacturer_id')
parent_id = DynamicModelMultipleChoiceField(
@@ -803,11 +814,12 @@ class DeviceFilterForm(
):
model = Device
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address', name=_('Operation')),
FieldSet('manufacturer_id', 'device_type_id', 'platform_id', name=_('Hardware')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
FieldSet(
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
@@ -996,9 +1008,10 @@ class DeviceFilterForm(
class VirtualDeviceContextFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = VirtualDeviceContext
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('device', 'status', 'has_primary_ip', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
device = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
@@ -1023,9 +1036,10 @@ class VirtualDeviceContextFilterForm(TenancyFilterForm, PrimaryModelFilterSetFor
class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = Module
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
FieldSet('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag', name=_('Hardware')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
@@ -1106,9 +1120,10 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, PrimaryM
class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = VirtualChassis
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -1135,10 +1150,11 @@ class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = Cable
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
FieldSet('type', 'status', 'profile', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -1224,8 +1240,9 @@ class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class PowerPanelFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
model = PowerPanel
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
selector_fields = ('filter_id', 'q', 'site_id', 'location_id')
@@ -1263,10 +1280,11 @@ class PowerPanelFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
class PowerFeedFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = PowerFeed
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id', name=_('Location')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -1390,7 +1408,7 @@ class PathEndpointFilterForm(CabledFilterForm):
class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = ConsolePort
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
@@ -1398,6 +1416,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
@@ -1429,7 +1448,7 @@ class ConsolePortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = ConsoleServerPort
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
@@ -1437,6 +1456,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
@@ -1468,7 +1488,7 @@ class ConsoleServerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterFo
class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerPort
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
@@ -1476,6 +1496,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
@@ -1502,7 +1523,7 @@ class PowerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerOutlet
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
@@ -1510,6 +1531,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
@@ -1545,7 +1567,7 @@ class PowerOutletTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = Interface
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')),
FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
@@ -1558,6 +1580,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
selector_fields = ('filter_id', 'q', 'device_id')
vdc_id = DynamicModelMultipleChoiceField(
@@ -1716,7 +1739,7 @@ class InterfaceTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
@@ -1724,6 +1747,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
name=_('Device')
),
FieldSet('cabled', 'occupied', name=_('Cable')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
model = FrontPort
type = forms.MultipleChoiceField(
@@ -1759,7 +1783,7 @@ class FrontPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
model = RearPort
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
@@ -1767,6 +1791,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
name=_('Device')
),
FieldSet('cabled', 'occupied', name=_('Cable')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
@@ -1801,13 +1826,14 @@ class RearPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class ModuleBayFilterForm(DeviceComponentFilterForm):
model = ModuleBay
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'position', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
tag = TagFilterField(model)
position = forms.CharField(
@@ -1832,13 +1858,14 @@ class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class DeviceBayFilterForm(DeviceComponentFilterForm):
model = DeviceBay
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
tag = TagFilterField(model)
@@ -1855,7 +1882,7 @@ class DeviceBayTemplateFilterForm(DeviceComponentTemplateFilterForm):
class InventoryItemFilterForm(DeviceComponentFilterForm):
model = InventoryItem
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet(
'name', 'label', 'status', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered',
name=_('Attributes')
@@ -1865,6 +1892,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
role_id = DynamicModelMultipleChoiceField(
queryset=InventoryItemRole.objects.all(),
@@ -1925,7 +1953,8 @@ class InventoryItemTemplateFilterForm(DeviceComponentTemplateFilterForm):
class InventoryItemRoleFilterForm(OrganizationalModelFilterSetForm):
model = InventoryItemRole
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
tag = TagFilterField(model)
@@ -1937,9 +1966,10 @@ class InventoryItemRoleFilterForm(OrganizationalModelFilterSetForm):
class MACAddressFilterForm(PrimaryModelFilterSetForm):
model = MACAddress
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('mac_address', name=_('Attributes')),
FieldSet('device_id', 'virtual_machine_id', 'assigned', 'primary', name=_('Assignments')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
selector_fields = ('filter_id', 'q', 'device_id', 'virtual_machine_id')
mac_address = forms.CharField(
+1 -1
View File
@@ -75,7 +75,7 @@ class ScopedForm(forms.Form):
except ObjectDoesNotExist:
pass
if self.instance and scope_type_id != self.instance.scope_type_id:
if self.instance and self.instance.pk and scope_type_id != self.instance.scope_type_id:
self.initial['scope'] = None
else:
+11 -1
View File
@@ -20,7 +20,9 @@ from utilities.forms.fields import (
DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField,
)
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
from utilities.forms.widgets import (
APISelect, ClearableFileInput, ClearableSelect, HTMXSelect, NumberWithOptions, SelectWithPK,
)
from utilities.jsonschema import JSONSchemaProperty
from virtualization.models import Cluster, VMInterface
from wireless.models import WirelessLAN, WirelessLANGroup
@@ -592,6 +594,14 @@ class DeviceForm(TenancyForm, PrimaryModelForm):
},
)
)
face = forms.ChoiceField(
label=_('Face'),
choices=add_blank_choice(DeviceFaceChoices),
required=False,
widget=ClearableSelect(
requires_fields=['rack']
)
)
device_type = DynamicModelChoiceField(
label=_('Device type'),
queryset=DeviceType.objects.all(),
+2 -1
View File
@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.models import *
from netbox.forms import NetBoxModelForm
from netbox.forms.mixins import OwnerMixin
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
from utilities.forms.rendering import FieldSet, TabbedGroups
from utilities.forms.widgets import APISelect
@@ -271,7 +272,7 @@ class InventoryItemCreateForm(ComponentCreateForm, model_forms.InventoryItemForm
# Virtual chassis
#
class VirtualChassisCreateForm(NetBoxModelForm):
class VirtualChassisCreateForm(OwnerMixin, NetBoxModelForm):
region = DynamicModelChoiceField(
label=_('Region'),
queryset=Region.objects.all(),
+15
View File
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
from netbox.graphql.filter_lookups import IntegerLookup
from extras.graphql.filters import ConfigTemplateFilter
from ipam.graphql.filters import VLANFilter, VLANTranslationPolicyFilter
from dcim.graphql.filters import LocationFilter, RegionFilter, SiteFilter, SiteGroupFilter
from .filters import *
__all__ = (
@@ -35,6 +36,20 @@ class ScopedFilterMixin:
)
scope_id: ID | None = strawberry_django.filter_field()
# Cached relations
_location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='location')
)
_region: Annotated['RegionFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='region')
)
_site_group: Annotated['SiteGroupFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='site_group')
)
_site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='site')
)
@dataclass
class ComponentModelFilterMixin:
+5 -1
View File
@@ -550,6 +550,10 @@ class InterfaceFilter(
strawberry_django.filter_field()
)
@strawberry_django.filter_field
def cabled(self, value: bool, prefix: str):
return Q(**{f'{prefix}cable__isnull': (not value)})
@strawberry_django.filter_field
def connected(self, queryset, value: bool, prefix: str):
if value is True:
@@ -889,7 +893,7 @@ class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
@strawberry_django.filter_type(models.RackType, lookups=True)
class RackTypeFilter(RackFilterMixin, WeightFilterMixin, PrimaryModelFilter):
class RackTypeFilter(ImageAttachmentFilterMixin, RackFilterMixin, WeightFilterMixin, PrimaryModelFilter):
form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
+1 -1
View File
@@ -734,7 +734,7 @@ class PowerPortTemplateType(ModularComponentTemplateType):
filters=RackTypeFilter,
pagination=True
)
class RackTypeType(PrimaryObjectType):
class RackTypeType(ImageAttachmentsMixin, PrimaryObjectType):
rack_count: BigInt
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
+5 -1
View File
@@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
from jsonschema.exceptions import ValidationError as JSONValidationError
from dcim.choices import *
from dcim.utils import update_interface_bridges
from dcim.utils import create_port_mappings, update_interface_bridges
from extras.models import ConfigContextModel, CustomField
from netbox.models import PrimaryModel
from netbox.models.features import ImageAttachmentsMixin
@@ -155,6 +155,8 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
'description': self.description,
'weight': float(self.weight) if self.weight is not None else None,
'weight_unit': self.weight_unit,
'airflow': self.airflow,
'attribute_data': self.attribute_data,
'comments': self.comments,
}
@@ -359,5 +361,7 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
update_fields=update_fields
)
# Replicate any front/rear port mappings from the ModuleType
create_port_mappings(self.device, self.module_type, self)
# Interface bridges have to be set after interface instantiation
update_interface_bridges(self.device, self.module_type.interfacetemplates, self)
+1 -1
View File
@@ -122,7 +122,7 @@ class RackBase(WeightMixin, PrimaryModel):
abstract = True
class RackType(RackBase):
class RackType(ImageAttachmentsMixin, RackBase):
"""
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
Each Rack is assigned to a Site and (optionally) a Location.
+28
View File
@@ -27,6 +27,7 @@ __all__ = (
'DeviceTable',
'FrontPortTable',
'InterfaceTable',
'InterfaceLAGMemberTable',
'InventoryItemRoleTable',
'InventoryItemTable',
'MACAddressTable',
@@ -689,6 +690,33 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
class InterfaceLAGMemberTable(PathEndpointTable, NetBoxTable):
parent = tables.Column(
verbose_name=_('Parent'),
accessor=Accessor('device'),
linkify=True,
)
name = tables.Column(
verbose_name=_('Name'),
linkify=True,
order_by=('_name',),
)
connection = columns.TemplateColumn(
accessor='connected_endpoints',
template_code=INTERFACE_LAG_MEMBERS_LINKTERMINATION,
verbose_name=_('Peer'),
orderable=False,
)
tags = columns.TagColumn(
url_name='dcim:interface_list'
)
class Meta(NetBoxTable.Meta):
model = models.Interface
fields = ('pk', 'parent', 'name', 'type', 'connection')
default_columns = ('pk', 'parent', 'name', 'type', 'connection')
class DeviceInterfaceTable(InterfaceTable):
name = tables.TemplateColumn(
verbose_name=_('Name'),
+18
View File
@@ -24,6 +24,24 @@ INTERFACE_LINKTERMINATION = """
{% else %}""" + LINKTERMINATION + """{% endif %}
"""
INTERFACE_LAG_MEMBERS_LINKTERMINATION = """
{% for termination in value %}
{% if termination.parent_object %}
<a href="{{ termination.parent_object.get_absolute_url }}">{{ termination.parent_object }}</a>
<i class="mdi mdi-chevron-right"></i>
{% endif %}
<a href="{{ termination.get_absolute_url }}">{{ termination }}</a>
{% if termination.lag %}
<i class="mdi mdi-chevron-right"></i>
<a href="{{ termination.lag.get_absolute_url }}">{{ termination.lag }}</a>
<span class="text-muted">(LAG)</span>
{% endif %}
{% if not forloop.last %}<br />{% endif %}
{% empty %}
{{ ''|placeholder }}
{% endfor %}
"""
CABLE_LENGTH = """
{% load helpers %}
{% if record.length %}{{ record.length|floatformat:"-2" }} {{ record.length_unit }}{% endif %}
+136
View File
@@ -875,6 +875,142 @@ class ModuleBayTestCase(TestCase):
self.assertIsNone(bay2.parent)
self.assertIsNone(bay2.module)
def test_module_installation_creates_port_mappings(self):
"""
Test that installing a module with front/rear port templates correctly
creates PortMapping instances for the device.
"""
device = Device.objects.first()
manufacturer = Manufacturer.objects.first()
module_bay = ModuleBay.objects.create(device=device, name='Test Bay PortMapping 1')
# Create a module type with a rear port template
module_type_with_mappings = ModuleType.objects.create(
manufacturer=manufacturer,
model='Module Type With Mappings',
)
# Create a rear port template with 12 positions (splice)
rear_port_template = RearPortTemplate.objects.create(
module_type=module_type_with_mappings,
name='Rear Port 1',
type=PortTypeChoices.TYPE_SPLICE,
positions=12,
)
# Create 12 front port templates mapped to the rear port
front_port_templates = []
for i in range(1, 13):
front_port_template = FrontPortTemplate.objects.create(
module_type=module_type_with_mappings,
name=f'port {i}',
type=PortTypeChoices.TYPE_LC,
positions=1,
)
front_port_templates.append(front_port_template)
# Create port template mapping
PortTemplateMapping.objects.create(
device_type=None,
module_type=module_type_with_mappings,
front_port=front_port_template,
front_port_position=1,
rear_port=rear_port_template,
rear_port_position=i,
)
# Install the module
module = Module.objects.create(
device=device,
module_bay=module_bay,
module_type=module_type_with_mappings,
status=ModuleStatusChoices.STATUS_ACTIVE,
)
# Verify that front ports were created
front_ports = FrontPort.objects.filter(device=device, module=module)
self.assertEqual(front_ports.count(), 12)
# Verify that the rear port was created
rear_ports = RearPort.objects.filter(device=device, module=module)
self.assertEqual(rear_ports.count(), 1)
rear_port = rear_ports.first()
self.assertEqual(rear_port.positions, 12)
# Verify that port mappings were created
port_mappings = PortMapping.objects.filter(front_port__module=module)
self.assertEqual(port_mappings.count(), 12)
# Verify each mapping is correct
for i, front_port_template in enumerate(front_port_templates, start=1):
front_port = FrontPort.objects.get(
device=device,
name=front_port_template.name,
module=module,
)
# Check that a mapping exists for this front port
mapping = PortMapping.objects.get(
device=device,
front_port=front_port,
front_port_position=1,
)
self.assertEqual(mapping.rear_port, rear_port)
self.assertEqual(mapping.front_port_position, 1)
self.assertEqual(mapping.rear_port_position, i)
def test_module_installation_without_mappings(self):
"""
Test that installing a module without port template mappings
doesn't create any PortMapping instances.
"""
device = Device.objects.first()
manufacturer = Manufacturer.objects.first()
module_bay = ModuleBay.objects.create(device=device, name='Test Bay PortMapping 2')
# Create a module type without any port template mappings
module_type_no_mappings = ModuleType.objects.create(
manufacturer=manufacturer,
model='Module Type Without Mappings',
)
# Create a rear port template
RearPortTemplate.objects.create(
module_type=module_type_no_mappings,
name='Rear Port 1',
type=PortTypeChoices.TYPE_SPLICE,
positions=12,
)
# Create front port templates but DO NOT create PortTemplateMapping rows
for i in range(1, 13):
FrontPortTemplate.objects.create(
module_type=module_type_no_mappings,
name=f'port {i}',
type=PortTypeChoices.TYPE_LC,
positions=1,
)
# Install the module
module = Module.objects.create(
device=device,
module_bay=module_bay,
module_type=module_type_no_mappings,
status=ModuleStatusChoices.STATUS_ACTIVE,
)
# Verify no port mappings were created for this module
port_mappings = PortMapping.objects.filter(
device=device,
front_port__module=module,
front_port_position=1,
)
self.assertEqual(port_mappings.count(), 0)
self.assertEqual(FrontPort.objects.filter(module=module).count(), 12)
self.assertEqual(RearPort.objects.filter(module=module).count(), 1)
self.assertEqual(PortMapping.objects.filter(front_port__module=module).count(), 0)
class CableTestCase(TestCase):
+1 -1
View File
@@ -31,7 +31,7 @@ class RackDimensionsPanel(panels.ObjectAttributesPanel):
outer_width = attrs.NumericAttr('outer_width', unit_accessor='get_outer_unit_display')
outer_height = attrs.NumericAttr('outer_height', unit_accessor='get_outer_unit_display')
outer_depth = attrs.NumericAttr('outer_depth', unit_accessor='get_outer_unit_display')
mounting_depth = attrs.TextAttr('mounting_depth', format_string='{} mm')
mounting_depth = attrs.TextAttr('mounting_depth', format_string=_('{} millimeters'))
class RackNumberingPanel(panels.ObjectAttributesPanel):
+3 -3
View File
@@ -85,13 +85,13 @@ def update_interface_bridges(device, interface_templates, module=None):
interface.save()
def create_port_mappings(device, device_type, module=None):
def create_port_mappings(device, device_or_module_type, module=None):
"""
Replicate all front/rear port mappings from a DeviceType to the given device.
Replicate all front/rear port mappings from a DeviceType or ModuleType to the given device.
"""
from dcim.models import FrontPort, PortMapping, RearPort
templates = device_type.port_mappings.prefetch_related('front_port', 'rear_port')
templates = device_or_module_type.port_mappings.prefetch_related('front_port', 'rear_port')
# Cache front & rear ports for efficient lookups by name
front_ports = {
+10
View File
@@ -880,6 +880,7 @@ class RackTypeView(GetRelatedModelsMixin, generic.ObjectView):
panels.RackWeightPanel(title=_('Weight'), exclude=['total_weight']),
CustomFieldsPanel(),
RelatedObjectsPanel(),
ImageAttachmentsPanel(),
],
)
@@ -3135,6 +3136,14 @@ class InterfaceView(generic.ObjectView):
)
child_interfaces_table.configure(request)
# Get LAG interfaces
lag_interfaces = Interface.objects.restrict(request.user, 'view').filter(lag=instance)
lag_interfaces_table = tables.InterfaceLAGMemberTable(
lag_interfaces,
orderable=False
)
lag_interfaces_table.configure(request)
# Get assigned VLANs and annotate whether each is tagged or untagged
vlans = []
if instance.untagged_vlan is not None:
@@ -3164,6 +3173,7 @@ class InterfaceView(generic.ObjectView):
'bridge_interfaces': bridge_interfaces,
'bridge_interfaces_table': bridge_interfaces_table,
'child_interfaces_table': child_interfaces_table,
'lag_interfaces_table': lag_interfaces_table,
'vlan_table': vlan_table,
'vlan_translation_table': vlan_translation_table,
}
+3 -9
View File
@@ -4,7 +4,6 @@ from drf_spectacular.utils import extend_schema_field
from rest_framework.fields import Field
from rest_framework.serializers import ValidationError
from core.models import ObjectType
from extras.choices import CustomFieldTypeChoices
from extras.constants import CUSTOMFIELD_EMPTY_VALUES
from extras.models import CustomField
@@ -24,13 +23,9 @@ class CustomFieldDefaultValues:
def __call__(self, serializer_field):
self.model = serializer_field.parent.Meta.model
# Retrieve the CustomFields for the parent model
object_type = ObjectType.objects.get_for_model(self.model)
fields = CustomField.objects.filter(object_types=object_type)
# Populate the default value for each CustomField
# Populate the default value for each CustomField on the model
value = {}
for field in fields:
for field in CustomField.objects.get_for_model(self.model):
if field.default is not None:
value[field.name] = field.default
else:
@@ -47,8 +42,7 @@ class CustomFieldsDataField(Field):
Cache CustomFields assigned to this model to avoid redundant database queries
"""
if not hasattr(self, '_custom_fields'):
object_type = ObjectType.objects.get_for_model(self.parent.Meta.model)
self._custom_fields = CustomField.objects.filter(object_types=object_type)
self._custom_fields = CustomField.objects.get_for_model(self.parent.Meta.model)
return self._custom_fields
def to_representation(self, obj):
+11
View File
@@ -4,6 +4,17 @@ from extras.choices import LogLevelChoices
# Custom fields
CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
# ImageAttachment
IMAGE_ATTACHMENT_IMAGE_FORMATS = {
'avif': 'image/avif',
'bmp': 'image/bmp',
'gif': 'image/gif',
'jpeg': 'image/jpeg',
'jpg': 'image/jpeg',
'png': 'image/png',
'webp': 'image/webp',
}
# Template Export
DEFAULT_MIME_TYPE = 'text/plain; charset=utf-8'
+2 -1
View File
@@ -75,10 +75,11 @@ def get_bookmarks_object_type_choices():
def get_models_from_content_types(content_types):
"""
Return a list of models corresponding to the given content types, identified by natural key.
Accepts both lowercase (e.g. "dcim.site") and PascalCase (e.g. "dcim.Site") model names.
"""
models = []
for content_type_id in content_types:
app_label, model_name = content_type_id.split('.')
app_label, model_name = content_type_id.lower().split('.')
try:
content_type = ObjectType.objects.get_by_natural_key(app_label, model_name)
if content_type.model_class():
+73 -53
View File
@@ -1,5 +1,5 @@
import logging
from collections import defaultdict
from collections import UserDict, defaultdict
from django.conf import settings
from django.utils import timezone
@@ -12,7 +12,6 @@ from core.models import ObjectType
from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT
from netbox.models.features import has_feature
from users.models import User
from utilities.api import get_serializer_for_model
from utilities.request import copy_safe_request
from utilities.rqworker import get_rq_retry
@@ -23,6 +22,21 @@ from .models import EventRule
logger = logging.getLogger('netbox.events_processor')
class EventContext(UserDict):
"""
A custom dictionary that automatically serializes its associated object on demand.
"""
# We're emulating a dictionary here (rather than using a custom class) because prior to NetBox v4.5.2, events were
# queued as dictionaries for processing by handles in EVENTS_PIPELINE. We need to avoid introducing any breaking
# changes until a suitable minor release.
def __getitem__(self, item):
if item == 'data' and 'data' not in self:
data = serialize_for_event(self['object'])
self.__setitem__('data', data)
return super().__getitem__(item)
def serialize_for_event(instance):
"""
Return a serialized representation of the given instance suitable for use in a queued event.
@@ -37,18 +51,26 @@ def serialize_for_event(instance):
def get_snapshots(instance, event_type):
snapshots = {
'prechange': getattr(instance, '_prechange_snapshot', None),
'postchange': None,
}
if event_type != OBJECT_DELETED:
# Use model's serialize_object() method if defined; fall back to serialize_object() utility function
if hasattr(instance, 'serialize_object'):
snapshots['postchange'] = instance.serialize_object()
else:
snapshots['postchange'] = serialize_object(instance)
"""
Return a dictionary of pre- and post-change snapshots for the given instance.
"""
if event_type == OBJECT_DELETED:
# Post-change snapshot must be empty for deleted objects
postchange_snapshot = None
elif hasattr(instance, '_postchange_snapshot'):
# Use the cached post-change snapshot if one is available
postchange_snapshot = instance._postchange_snapshot
elif hasattr(instance, 'serialize_object'):
# Use model's serialize_object() method if defined
postchange_snapshot = instance.serialize_object()
else:
# Fall back to the serialize_object() utility function
postchange_snapshot = serialize_object(instance)
return snapshots
return {
'prechange': getattr(instance, '_prechange_snapshot', None),
'postchange': postchange_snapshot,
}
def enqueue_event(queue, instance, request, event_type):
@@ -66,37 +88,42 @@ def enqueue_event(queue, instance, request, event_type):
assert instance.pk is not None
key = f'{app_label}.{model_name}:{instance.pk}'
if key in queue:
queue[key]['data'] = serialize_for_event(instance)
queue[key]['snapshots']['postchange'] = get_snapshots(instance, event_type)['postchange']
# If the object is being deleted, update any prior "update" event to "delete"
if event_type == OBJECT_DELETED:
queue[key]['event_type'] = event_type
else:
queue[key] = {
'object_type': ObjectType.objects.get_for_model(instance),
'object_id': instance.pk,
'event_type': event_type,
'data': serialize_for_event(instance),
'snapshots': get_snapshots(instance, event_type),
'request': request,
queue[key] = EventContext(
object_type=ObjectType.objects.get_for_model(instance),
object_id=instance.pk,
object=instance,
event_type=event_type,
snapshots=get_snapshots(instance, event_type),
request=request,
user=request.user,
# Legacy request attributes for backward compatibility
'username': request.user.username,
'request_id': request.id,
}
username=request.user.username,
request_id=request.id,
)
# Force serialization of objects prior to them actually being deleted
if event_type == OBJECT_DELETED:
queue[key]['data'] = serialize_for_event(instance)
def process_event_rules(event_rules, object_type, event_type, data, username=None, snapshots=None, request=None):
user = User.objects.get(username=username) if username else None
def process_event_rules(event_rules, object_type, event):
"""
Process a list of EventRules against an event.
"""
for event_rule in event_rules:
# Evaluate event rule conditions (if any)
if not event_rule.eval_conditions(data):
if not event_rule.eval_conditions(event['data']):
continue
# Compile event data
event_data = event_rule.action_data or {}
event_data.update(data)
event_data.update(event['data'])
# Webhooks
if event_rule.action_type == EventRuleActionChoices.WEBHOOK:
@@ -109,25 +136,20 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
params = {
"event_rule": event_rule,
"object_type": object_type,
"event_type": event_type,
"event_type": event['event_type'],
"data": event_data,
"snapshots": snapshots,
"snapshots": event.get('snapshots'),
"timestamp": timezone.now().isoformat(),
"username": username,
"username": event['username'],
"retry": get_rq_retry()
}
if snapshots:
params["snapshots"] = snapshots
if request:
if 'request' in event:
# Exclude FILES - webhooks don't need uploaded files,
# which can cause pickle errors with Pillow.
params["request"] = copy_safe_request(request, include_files=False)
params['request'] = copy_safe_request(event['request'], include_files=False)
# Enqueue the task
rq_queue.enqueue(
"extras.webhooks.send_webhook",
**params
)
rq_queue.enqueue('extras.webhooks.send_webhook', **params)
# Scripts
elif event_rule.action_type == EventRuleActionChoices.SCRIPT:
@@ -139,16 +161,16 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
params = {
"instance": event_rule.action_object,
"name": script.name,
"user": user,
"user": event['user'],
"data": event_data
}
if snapshots:
params["snapshots"] = snapshots
if request:
params["request"] = copy_safe_request(request)
ScriptJob.enqueue(
**params
)
if 'snapshots' in event:
params['snapshots'] = event['snapshots']
if 'request' in event:
params['request'] = copy_safe_request(event['request'])
# Enqueue the job
ScriptJob.enqueue(**params)
# Notification groups
elif event_rule.action_type == EventRuleActionChoices.NOTIFICATION:
@@ -157,7 +179,7 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
object_type=object_type,
object_id=event_data['id'],
object_repr=event_data.get('display'),
event_type=event_type
event_type=event['event_type']
)
else:
@@ -169,6 +191,8 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
def process_event_queue(events):
"""
Flush a list of object representation to RQ for EventRule processing.
This is the default processor listed in EVENTS_PIPELINE.
"""
events_cache = defaultdict(dict)
@@ -188,11 +212,7 @@ def process_event_queue(events):
process_event_rules(
event_rules=event_rules,
object_type=object_type,
event_type=event['event_type'],
data=event['data'],
username=event['username'],
snapshots=event['snapshots'],
request=event['request'],
event=event,
)
-4
View File
@@ -271,10 +271,6 @@ class EventRuleImportForm(OwnerCSVMixin, NetBoxModelImportForm):
class TagImportForm(OwnerCSVMixin, CSVModelForm):
slug = SlugField()
weight = forms.IntegerField(
label=_('Weight'),
required=False
)
object_types = CSVMultipleContentTypeField(
label=_('Object types'),
queryset=ObjectType.objects.with_feature('tags'),
+33 -69
View File
@@ -7,13 +7,12 @@ from extras.choices import *
from extras.models import *
from netbox.events import get_event_type_choices
from netbox.forms import NetBoxModelFilterSetForm, PrimaryModelFilterSetForm
from netbox.forms.mixins import SavedFiltersMixin
from netbox.forms.mixins import OwnerFilterMixin, SavedFiltersMixin
from tenancy.models import Tenant, TenantGroup
from users.models import Group, Owner, User
from users.models import Group, User
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import (
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
TagFilterField,
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DateTimePicker
@@ -39,7 +38,7 @@ __all__ = (
)
class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
class CustomFieldFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = CustomField
fieldsets = (
FieldSet('q', 'filter_id'),
@@ -47,6 +46,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
FieldSet('choice_set_id', 'related_object_type_id', name=_('Type Options')),
FieldSet('ui_visible', 'ui_editable', 'is_cloneable', name=_('Behavior')),
FieldSet('validation_minimum', 'validation_maximum', 'validation_regex', name=_('Validation')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
object_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.with_feature('custom_fields'),
@@ -119,18 +119,14 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
label=_('Validation regex'),
required=False
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
class CustomFieldChoiceSetFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = CustomFieldChoiceSet
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('base_choices', 'choice', name=_('Choices')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
base_choices = forms.MultipleChoiceField(
choices=CustomFieldChoiceSetBaseChoices,
@@ -139,18 +135,14 @@ class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
choice = forms.CharField(
required=False
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
class CustomLinkFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = CustomLink
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('object_type_id', 'enabled', 'new_window', 'weight', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
object_type_id = ContentTypeMultipleChoiceField(
label=_('Object types'),
@@ -175,19 +167,15 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
label=_('Weight'),
required=False
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
class ExportTemplateFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = ExportTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'object_type_id'),
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(),
@@ -226,11 +214,6 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
@@ -250,11 +233,12 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
)
class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
class SavedFilterFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = SavedFilter
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('object_type_id', 'enabled', 'shared', 'weight', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
object_type_id = ContentTypeMultipleChoiceField(
label=_('Object types'),
@@ -279,11 +263,6 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
label=_('Weight'),
required=False
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class TableConfigFilterForm(SavedFiltersMixin, FilterForm):
@@ -317,11 +296,12 @@ class TableConfigFilterForm(SavedFiltersMixin, FilterForm):
)
class WebhookFilterForm(NetBoxModelFilterSetForm):
class WebhookFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
model = Webhook
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('payload_url', 'http_method', 'http_content_type', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
http_content_type = forms.CharField(
label=_('HTTP content type'),
@@ -336,19 +316,15 @@ class WebhookFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('HTTP method')
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
tag = TagFilterField(model)
class EventRuleFilterForm(NetBoxModelFilterSetForm):
class EventRuleFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
model = EventRule
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('object_type_id', 'event_type', 'action_type', 'enabled', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
object_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.with_feature('event_rules'),
@@ -372,16 +348,16 @@ class EventRuleFilterForm(NetBoxModelFilterSetForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
tag = TagFilterField(model)
class TagFilterForm(SavedFiltersMixin, FilterForm):
class TagFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = Tag
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('content_type_id', 'for_object_type_id', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
content_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.with_feature('tags'),
required=False,
@@ -392,11 +368,6 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
required=False,
label=_('Allowed object type')
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class ConfigContextProfileFilterForm(PrimaryModelFilterSetForm):
@@ -404,6 +375,7 @@ class ConfigContextProfileFilterForm(PrimaryModelFilterSetForm):
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(),
@@ -420,16 +392,17 @@ class ConfigContextProfileFilterForm(PrimaryModelFilterSetForm):
)
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
class ConfigContextFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = ConfigContext
fieldsets = (
FieldSet('q', 'filter_id', 'tag_id'),
FieldSet('profile', name=_('Config Context')),
FieldSet('profile_id', name=_('Config Context')),
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')),
FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant'))
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
profile_id = DynamicModelMultipleChoiceField(
queryset=ConfigContextProfile.objects.all(),
@@ -514,19 +487,15 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
required=False,
label=_('Tags')
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
class ConfigTemplateFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = ConfigTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('data_source_id', 'data_file_id', 'auto_sync_enabled', name=_('Data')),
FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering'))
FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(),
@@ -568,11 +537,6 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class LocalConfigContextFilterForm(forms.Form):
+13 -6
View File
@@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _
from core.forms.mixins import SyncedDataMixin
from core.models import ObjectType
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from extras.constants import IMAGE_ATTACHMENT_IMAGE_FORMATS
from extras.choices import *
from extras.models import *
from netbox.events import get_event_type_choices
@@ -177,6 +178,13 @@ class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelFor
) + ' <code>choice1:First Choice</code>')
)
fieldsets = (
FieldSet(
'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
name=_('Custom Field Choice Set')
),
)
class Meta:
model = CustomFieldChoiceSet
fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically', 'owner')
@@ -570,10 +578,6 @@ class TagForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
queryset=ObjectType.objects.with_feature('tags'),
required=False
)
weight = forms.IntegerField(
label=_('Weight'),
required=False
)
fieldsets = (
FieldSet('name', 'slug', 'color', 'weight', 'description', 'object_types', name=_('Tag')),
@@ -784,8 +788,11 @@ class ImageAttachmentForm(forms.ModelForm):
fields = [
'image', 'name', 'description',
]
help_texts = {
'name': _("If no name is specified, the file name will be used.")
# Explicitly set 'image/avif' to support AVIF selection in Firefox
widgets = {
'image': forms.ClearableFileInput(
attrs={'accept': ','.join(sorted(set(IMAGE_ATTACHMENT_IMAGE_FORMATS.values())))}
),
}
+14 -1
View File
@@ -19,6 +19,7 @@ from django.utils.translation import gettext_lazy as _
from core.models import ObjectType
from extras.choices import *
from extras.data import CHOICE_SETS
from netbox.context import query_cache
from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin
from netbox.models.mixins import OwnerMixin
@@ -58,8 +59,20 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
"""
Return all CustomFields assigned to the given model.
"""
# Check the request cache before hitting the database
cache = query_cache.get()
if cache is not None:
if custom_fields := cache['custom_fields'].get(model._meta.model):
return custom_fields
content_type = ObjectType.objects.get_for_model(model._meta.concrete_model)
return self.get_queryset().filter(object_types=content_type)
custom_fields = self.get_queryset().filter(object_types=content_type)
# Populate the request cache to avoid redundant lookups
if cache is not None:
cache['custom_fields'][model._meta.model] = custom_fields
return custom_fields
def get_defaults_for_model(self, model):
"""
+1 -1
View File
@@ -137,7 +137,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
module = self.get_module()
except Exception as e:
self.error = e
logger.debug(f"Failed to load script: {self.python_name} error: {e}")
logger.error(f"Failed to load script: {self.python_name} error: {e}")
module = None
scripts = {}
+1 -1
View File
@@ -61,7 +61,7 @@ class ScriptVariable:
self.field_attrs['label'] = label
if description:
self.field_attrs['help_text'] = description
if default:
if default is not None:
self.field_attrs['initial'] = default
if widget:
self.field_attrs['widget'] = widget
+9 -12
View File
@@ -4,11 +4,12 @@ from django.dispatch import receiver
from core.events import *
from core.signals import job_end, job_start
from extras.events import process_event_rules
from extras.events import EventContext, process_event_rules
from extras.models import EventRule, Notification, Subscription
from netbox.config import get_config
from netbox.models.features import has_feature
from netbox.signals import post_clean
from utilities.data import get_config_value_ci
from utilities.exceptions import AbortRequest
from .models import CustomField, TaggedItem
from .utils import run_validators
@@ -65,7 +66,7 @@ def run_save_validators(sender, instance, **kwargs):
Run any custom validation rules for the model prior to calling save().
"""
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config().CUSTOM_VALIDATORS.get(model_name, [])
validators = get_config_value_ci(get_config().CUSTOM_VALIDATORS, model_name, default=[])
run_validators(instance, validators)
@@ -102,14 +103,12 @@ def process_job_start_event_rules(sender, **kwargs):
enabled=True,
object_types=sender.object_type
)
username = sender.user.username if sender.user else None
process_event_rules(
event_rules=event_rules,
object_type=sender.object_type,
event = EventContext(
event_type=JOB_STARTED,
data=sender.data,
username=username
user=sender.user,
)
process_event_rules(event_rules, sender.object_type, event)
@receiver(job_end)
@@ -122,14 +121,12 @@ def process_job_end_event_rules(sender, **kwargs):
enabled=True,
object_types=sender.object_type
)
username = sender.user.username if sender.user else None
process_event_rules(
event_rules=event_rules,
object_type=sender.object_type,
event = EventContext(
event_type=JOB_COMPLETED,
data=sender.data,
username=username
user=sender.user,
)
process_event_rules(event_rules, sender.object_type, event)
#
+1 -1
View File
@@ -43,7 +43,7 @@ IMAGEATTACHMENT_IMAGE = """
<a href="{{ record.image.url }}" target="_blank" class="image-preview" data-bs-placement="top">
<i class="mdi mdi-image"></i></a>
{% endif %}
<a href="{{ record.get_absolute_url }}">{{ record }}</a>
<a href="{{ record.get_absolute_url }}">{{ record.filename|truncate_middle:16 }}</a>
"""
NOTIFICATION_ICON = """
+51 -1
View File
@@ -6,7 +6,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.forms import ValidationError
from django.test import tag, TestCase
from core.models import DataSource, ObjectType
from core.models import AutoSyncRecord, DataSource, ObjectType
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
from extras.models import ConfigContext, ConfigContextProfile, ConfigTemplate, ImageAttachment, Tag, TaggedItem
from tenancy.models import Tenant, TenantGroup
@@ -754,3 +754,53 @@ class ConfigTemplateTest(TestCase):
@tag('regression')
def test_config_template_with_data_source_nested_templates(self):
self.assertEqual(self.BASE_TEMPLATE, self.main_config_template.render({}))
@tag('regression')
def test_autosyncrecord_cleanup_on_detach(self):
"""Test that AutoSyncRecord is deleted when detaching from DataSource."""
with tempfile.TemporaryDirectory() as temp_dir:
templates_dir = Path(temp_dir) / "templates"
templates_dir.mkdir(parents=True, exist_ok=True)
self._create_template_file(templates_dir, 'test.j2', 'Test content')
data_source = DataSource(
name="Test DataSource for Detach",
type="local",
source_url=str(templates_dir),
)
data_source.save()
data_source.sync()
data_file = data_source.datafiles.filter(path__endswith='test.j2').first()
# Create a ConfigTemplate with data_file and auto_sync_enabled
config_template = ConfigTemplate(
name="TestTemplateForDetach",
data_file=data_file,
auto_sync_enabled=True
)
config_template.clean()
config_template.save()
# Verify AutoSyncRecord was created
object_type = ObjectType.objects.get_for_model(ConfigTemplate)
autosync_records = AutoSyncRecord.objects.filter(
object_type=object_type,
object_id=config_template.pk
)
self.assertEqual(autosync_records.count(), 1, "AutoSyncRecord should be created")
# Detach from DataSource
config_template.data_file = None
config_template.data_source = None
config_template.auto_sync_enabled = False
config_template.clean()
config_template.save()
# Verify AutoSyncRecord was deleted
autosync_records = AutoSyncRecord.objects.filter(
object_type=object_type,
object_id=config_template.pk
)
self.assertEqual(autosync_records.count(), 0, "AutoSyncRecord should be deleted after detaching")
+41 -5
View File
@@ -1,6 +1,7 @@
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.test import tag
from unittest.mock import patch, PropertyMock
from core.choices import ManagedFileRootPathChoices
from core.events import *
@@ -906,7 +907,7 @@ class ScriptValidationErrorTest(TestCase):
user_permissions = ['extras.view_script', 'extras.run_script']
class TestScriptMixin:
bar = IntegerVar(min_value=0, max_value=30, default=30)
bar = IntegerVar(min_value=0, max_value=30)
class TestScriptClass(TestScriptMixin, PythonClass):
class Meta:
@@ -930,8 +931,6 @@ class ScriptValidationErrorTest(TestCase):
@tag('regression')
def test_script_validation_error_displays_message(self):
from unittest.mock import patch
url = reverse('extras:script', kwargs={'pk': self.script.pk})
with patch('extras.views.get_workers_for_queue', return_value=['worker']):
@@ -944,8 +943,6 @@ class ScriptValidationErrorTest(TestCase):
@tag('regression')
def test_script_validation_error_no_toast_for_fieldset_fields(self):
from unittest.mock import patch, PropertyMock
class FieldsetScript(PythonClass):
class Meta:
name = 'Fieldset test'
@@ -967,3 +964,42 @@ class ScriptValidationErrorTest(TestCase):
self.assertEqual(response.status_code, 200)
messages = list(response.context['messages'])
self.assertEqual(len(messages), 0)
class ScriptDefaultValuesTest(TestCase):
user_permissions = ['extras.view_script', 'extras.run_script']
class TestScriptClass(PythonClass):
class Meta:
name = 'Test script'
commit_default = False
bool_default_true = BooleanVar(default=True)
bool_default_false = BooleanVar(default=False)
int_with_default = IntegerVar(default=0)
int_without_default = IntegerVar(required=False)
def run(self, data, commit):
return "Complete"
@classmethod
def setUpTestData(cls):
module = ScriptModule.objects.create(file_root=ManagedFileRootPathChoices.SCRIPTS, file_path='test_script.py')
cls.script = Script.objects.create(module=module, name='Test script', is_executable=True)
def setUp(self):
super().setUp()
Script.python_class = property(lambda self: ScriptDefaultValuesTest.TestScriptClass)
def test_default_values_are_used(self):
url = reverse('extras:script', kwargs={'pk': self.script.pk})
with patch('extras.views.get_workers_for_queue', return_value=['worker']):
with patch('extras.jobs.ScriptJob.enqueue') as mock_enqueue:
mock_enqueue.return_value.pk = 1
self.client.post(url, {})
call_kwargs = mock_enqueue.call_args.kwargs
self.assertEqual(call_kwargs['data']['bool_default_true'], True)
self.assertEqual(call_kwargs['data']['bool_default_false'], False)
self.assertEqual(call_kwargs['data']['int_with_default'], 0)
self.assertIsNone(call_kwargs['data']['int_without_default'])
+2 -1
View File
@@ -10,6 +10,7 @@ from taggit.managers import _TaggableManager
from netbox.context import current_request
from .constants import IMAGE_ATTACHMENT_IMAGE_FORMATS
from .validators import CustomValidator
__all__ = (
@@ -78,7 +79,7 @@ def image_upload(instance, filename):
"""
upload_dir = 'image-attachments'
default_filename = 'unnamed'
allowed_img_extensions = ('bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp')
allowed_img_extensions = IMAGE_ATTACHMENT_IMAGE_FORMATS.keys()
# Normalize Windows paths and create a Path object.
normalized_filename = str(filename).replace('\\', '/')
+7 -1
View File
@@ -1511,7 +1511,13 @@ class ScriptView(BaseScriptView):
'script': script,
})
form = script_class.as_form(request.POST, request.FILES)
# Populate missing variables with their default values, if defined
post_data = request.POST.copy()
for name, var in script_class._get_vars().items():
if name not in post_data and (initial := var.field_attrs.get('initial')) is not None:
post_data[name] = initial
form = script_class.as_form(post_data, request.FILES)
# Allow execution only if RQ worker process is running
if not get_workers_for_queue('default'):
+38
View File
@@ -19,6 +19,7 @@ from ..field_serializers import IPAddressField, IPNetworkField
__all__ = (
'AggregateSerializer',
'AvailableIPSerializer',
'AvailableIPRequestSerializer',
'AvailablePrefixSerializer',
'IPAddressSerializer',
'IPRangeSerializer',
@@ -147,6 +148,43 @@ class IPRangeSerializer(PrimaryModelSerializer):
# IP addresses
#
class AvailableIPRequestSerializer(serializers.Serializer):
"""
Request payload for creating IP addresses from the available-ips endpoint.
"""
prefix_length = serializers.IntegerField(required=False)
def to_internal_value(self, data):
data = super().to_internal_value(data)
prefix_length = data.get('prefix_length')
if prefix_length is None:
# No override requested; the parent prefix/range mask length will be used.
return data
parent = self.context.get('parent')
if parent is None:
return data
# Validate the requested prefix length
if prefix_length < parent.mask_length:
raise serializers.ValidationError({
'prefix_length': 'Prefix length must be greater than or equal to the parent mask length ({})'.format(
parent.mask_length
)
})
elif parent.family == 4 and prefix_length > 32:
raise serializers.ValidationError({
'prefix_length': 'Invalid prefix length ({}) for IPv6'.format(prefix_length)
})
elif parent.family == 6 and prefix_length > 128:
raise serializers.ValidationError({
'prefix_length': 'Invalid prefix length ({}) for IPv4'.format(prefix_length)
})
return data
class IPAddressSerializer(PrimaryModelSerializer):
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
address = IPAddressField()
+4 -3
View File
@@ -400,7 +400,7 @@ class AvailablePrefixesView(AvailableObjectsView):
class AvailableIPAddressesView(AvailableObjectsView):
queryset = IPAddress.objects.all()
read_serializer_class = serializers.AvailableIPSerializer
write_serializer_class = serializers.AvailableIPSerializer
write_serializer_class = serializers.AvailableIPRequestSerializer
advisory_lock_key = 'available-ips'
def get_available_objects(self, parent, limit=None):
@@ -421,8 +421,9 @@ class AvailableIPAddressesView(AvailableObjectsView):
def prep_object_data(self, requested_objects, available_objects, parent):
available_ips = iter(available_objects)
for i, request_data in enumerate(requested_objects):
prefix_length = request_data.pop('prefix_length', None) or parent.mask_length
request_data.update({
'address': f'{next(available_ips)}/{parent.mask_length}',
'address': f'{next(available_ips)}/{prefix_length}',
'vrf': parent.vrf.pk if parent.vrf else None,
})
@@ -435,7 +436,7 @@ class AvailableIPAddressesView(AvailableObjectsView):
@extend_schema(
methods=["post"],
responses={201: serializers.IPAddressSerializer(many=True)},
request=serializers.IPAddressSerializer(many=True),
request=serializers.AvailableIPRequestSerializer(many=True),
)
def post(self, request, pk):
return super().post(request, pk)
+33 -17
View File
@@ -45,9 +45,10 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
class VRFFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = VRF
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('import_target_id', 'export_target_id', name=_('Route Targets')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
import_target_id = DynamicModelMultipleChoiceField(
queryset=RouteTarget.objects.all(),
@@ -65,9 +66,10 @@ class VRFFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class RouteTargetFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = RouteTarget
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('importing_vrf_id', 'exporting_vrf_id', name=_('VRF')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
importing_vrf_id = DynamicModelMultipleChoiceField(
queryset=VRF.objects.all(),
@@ -85,8 +87,9 @@ class RouteTargetFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class RIRFilterForm(OrganizationalModelFilterSetForm):
model = RIR
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('is_private', name=_('RIR')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
is_private = forms.NullBooleanField(
required=False,
@@ -101,9 +104,10 @@ class RIRFilterForm(OrganizationalModelFilterSetForm):
class AggregateFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = Aggregate
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('family', 'rir_id', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
family = forms.ChoiceField(
@@ -122,9 +126,10 @@ class AggregateFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryMode
class ASNRangeFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
model = ASNRange
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('rir_id', 'start', 'end', name=_('Range')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
rir_id = DynamicModelMultipleChoiceField(
queryset=RIR.objects.all(),
@@ -145,9 +150,10 @@ class ASNRangeFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
class ASNFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = ASN
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('rir_id', 'site_group_id', 'site_id', name=_('Assignment')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
rir_id = DynamicModelMultipleChoiceField(
queryset=RIR.objects.all(),
@@ -170,7 +176,8 @@ class ASNFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class RoleFilterForm(OrganizationalModelFilterSetForm):
model = Role
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
tag = TagFilterField(model)
@@ -178,7 +185,7 @@ class RoleFilterForm(OrganizationalModelFilterSetForm):
class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = Prefix
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet(
'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized',
name=_('Addressing')
@@ -187,6 +194,7 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFi
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
mask_length__lte = forms.IntegerField(
@@ -284,9 +292,10 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFi
class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = IPRange
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_populated', 'mark_utilized', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
family = forms.ChoiceField(
@@ -331,14 +340,15 @@ class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelF
class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = IPAddress
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet(
'parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name',
name=_('Attributes')
),
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
@@ -409,9 +419,10 @@ class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryMode
class FHRPGroupFilterForm(PrimaryModelFilterSetForm):
model = FHRPGroup
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'protocol', 'group_id', name=_('Attributes')),
FieldSet('auth_type', 'auth_key', name=_('Authentication')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
name = forms.CharField(
label=_('Name'),
@@ -441,11 +452,12 @@ class FHRPGroupFilterForm(PrimaryModelFilterSetForm):
class VLANGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region', 'site_group', 'site', 'location', 'rack', name=_('Location')),
FieldSet('cluster_group', 'cluster', name=_('Cluster')),
FieldSet('contains_vid', name=_('VLANs')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
model = VLANGroup
region = DynamicModelMultipleChoiceField(
@@ -495,8 +507,9 @@ class VLANGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
class VLANTranslationPolicyFilterForm(PrimaryModelFilterSetForm):
model = VLANTranslationPolicy
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
name = forms.CharField(
required=False,
@@ -532,11 +545,12 @@ class VLANTranslationRuleFilterForm(NetBoxModelFilterSetForm):
class VLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = VLAN
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet('group_id', 'status', 'role_id', 'vid', 'l2vpn_id', name=_('Attributes')),
FieldSet('qinq_role', 'qinq_svlan_id', name=_('Q-in-Q/802.1ad')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
selector_fields = ('filter_id', 'q', 'group_id')
region_id = DynamicModelMultipleChoiceField(
@@ -604,8 +618,9 @@ class VLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class ServiceTemplateFilterForm(PrimaryModelFilterSetForm):
model = ServiceTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('protocol', 'port', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
protocol = forms.ChoiceField(
label=_('Protocol'),
@@ -622,9 +637,10 @@ class ServiceTemplateFilterForm(PrimaryModelFilterSetForm):
class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm):
model = Service
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('protocol', 'port', name=_('Attributes')),
FieldSet('device_id', 'virtual_machine_id', 'fhrpgroup_id', name=_('Assignment')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
device_id = DynamicModelMultipleChoiceField(
+4 -4
View File
@@ -20,7 +20,7 @@ from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
from virtualization.models import VMInterface
if TYPE_CHECKING:
from netbox.graphql.filter_lookups import IntegerLookup, IntegerRangeArrayLookup
from netbox.graphql.filter_lookups import BigIntegerLookup, IntegerLookup, IntegerRangeArrayLookup
from circuits.graphql.filters import ProviderFilter
from core.graphql.filters import ContentTypeFilter
from dcim.graphql.filters import SiteFilter
@@ -53,7 +53,7 @@ __all__ = (
class ASNFilter(TenancyFilterMixin, PrimaryModelFilter):
rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
rir_id: ID | None = strawberry_django.filter_field()
asn: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
asn: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
sites: (
@@ -70,10 +70,10 @@ class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilter):
slug: FilterLookup[str] | None = strawberry_django.filter_field()
rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
rir_id: ID | None = strawberry_django.filter_field()
start: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
start: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
end: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
end: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
+3 -1
View File
@@ -87,7 +87,9 @@ class Service(ContactsMixin, ServiceBase, PrimaryModel):
help_text=_("The specific IP addresses (if any) to which this application service is bound")
)
clone_fields = ['protocol', 'ports', 'description', 'parent', 'ipaddresses', ]
clone_fields = (
'protocol', 'ports', 'description', 'parent_object_type', 'parent_object_id', 'ipaddresses',
)
class Meta:
indexes = (
+5
View File
@@ -370,6 +370,11 @@ class AnnotatedIPAddressTable(IPAddressTable):
verbose_name=_('IP Address')
)
def render_pk(self, value, record, bound_column):
if type(record) is not self._meta.model:
return ''
return bound_column.column.render(value, bound_column, record)
class Meta(IPAddressTable.Meta):
pass
+1 -1
View File
@@ -6,7 +6,7 @@ PREFIX_LINK = """
{% if record.pk %}
<a href="{{ record.get_absolute_url }}" id="prefix_{{ record.pk }}">{{ record.prefix }}</a>
{% else %}
<a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
<a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.scope %}&scope_type={{ object.scope_type.pk }}&scope={{ object.scope.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
{% endif %}
"""
+18 -2
View File
@@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
from django_tables2.utils import Accessor
from dcim.models import Interface
from dcim.tables.template_code import INTERFACE_LINKTERMINATION, LINKTERMINATION
from ipam.models import *
from netbox.tables import NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns
from tenancy.tables import TenancyColumnsMixin, TenantColumn
@@ -159,11 +160,26 @@ class VLANDevicesTable(VLANMembersTable):
actions = columns.ActionsColumn(
actions=('edit',)
)
link_peer = columns.TemplateColumn(
accessor='link_peers',
template_code=LINKTERMINATION,
orderable=False,
verbose_name=_('Link Peers'),
)
# Override PathEndpointTable.connection to accommodate virtual circuits
connection = columns.TemplateColumn(
accessor='_path__destinations',
template_code=INTERFACE_LINKTERMINATION,
orderable=False,
verbose_name=_('Connection'),
)
class Meta(NetBoxTable.Meta):
model = Interface
fields = ('device', 'name', 'tagged', 'actions')
exclude = ('id', )
fields = ('device', 'name', 'link_peer', 'connection', 'tagged', 'actions')
default_columns = ('device', 'name', 'connection', 'tagged', 'actions')
exclude = ('id',)
class VLANVirtualMachinesTable(VLANMembersTable):
+25
View File
@@ -595,6 +595,31 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(len(response.data), 8)
def test_create_available_ip_with_mask(self):
"""
Test the creation of an available IP address with a specific prefix length.
"""
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'))
url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
self.add_permissions('ipam.view_prefix', 'ipam.add_ipaddress')
# Create an available IP with a specific prefix length
data = {
'prefix_length': 32,
'description': 'Test IP 1',
}
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['address'], '192.0.2.1/32')
self.assertEqual(response.data['description'], data['description'])
# Attempt to create an available IP with a prefix length less than its parent prefix
data = {
'prefix_length': 23, # Prefix is a /24
}
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
@tag('regression')
def test_graphql_tenant_prefixes_contains_nested_skips_invalid(self):
"""
+41
View File
@@ -0,0 +1,41 @@
from django.test import RequestFactory, TestCase
from netaddr import IPNetwork
from ipam.models import IPAddress, IPRange, Prefix
from ipam.tables import AnnotatedIPAddressTable
from ipam.utils import annotate_ip_space
class AnnotatedIPAddressTableTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.prefix = Prefix.objects.create(
prefix=IPNetwork('10.1.1.0/24'),
status='active'
)
cls.ip_address = IPAddress.objects.create(
address='10.1.1.1/24',
status='active'
)
cls.ip_range = IPRange.objects.create(
start_address=IPNetwork('10.1.1.2/24'),
end_address=IPNetwork('10.1.1.10/24'),
status='active'
)
def test_ipaddress_has_checkbox_iprange_does_not(self):
data = annotate_ip_space(self.prefix)
table = AnnotatedIPAddressTable(data, orderable=False)
table.columns.show('pk')
request = RequestFactory().get('/')
html = table.as_html(request)
ipaddress_checkbox_count = html.count(f'name="pk" value="{self.ip_address.pk}"')
self.assertEqual(ipaddress_checkbox_count, 1)
iprange_checkbox_count = html.count(f'name="pk" value="{self.ip_range.pk}"')
self.assertEqual(iprange_checkbox_count, 0)
+3
View File
@@ -49,6 +49,9 @@ def add_requested_prefixes(parent, prefix_list, show_available=True, show_assign
if prefix_list and show_available:
# Find all unallocated space, add fake Prefix objects to child_prefixes.
# IMPORTANT: These are unsaved Prefix instances (pk=None). If this is ever changed to use
# saved Prefix instances with real pks, bulk delete will fail for mixed-type selections
# due to single-model form validation. See: https://github.com/netbox-community/netbox/issues/21176
available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list])
available_prefixes = [Prefix(prefix=p, status=None) for p in available_prefixes.iter_cidrs()]
child_prefixes = child_prefixes + available_prefixes
+2 -1
View File
@@ -1,3 +1,4 @@
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.db.backends.postgresql.psycopg_any import NumericRange
from django.utils.translation import gettext as _
@@ -109,7 +110,7 @@ class ContentTypeField(RelatedField):
def to_internal_value(self, data):
try:
app_label, model = data.split('.')
return self.queryset.get(app_label=app_label, model=model)
return ContentType.objects.get_by_natural_key(app_label=app_label, model=model)
except ObjectDoesNotExist:
self.fail('does_not_exist', content_type=data)
except (AttributeError, TypeError, ValueError):
+21 -16
View File
@@ -1,9 +1,8 @@
from functools import cached_property
from rest_framework import serializers
from rest_framework.utils.serializer_helpers import BindingDict
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from utilities.api import get_related_object_by_attrs
from .fields import NetBoxAPIHyperlinkedIdentityField, NetBoxURLHyperlinkedIdentityField
@@ -19,16 +18,18 @@ class BaseModelSerializer(serializers.ModelSerializer):
display_url = NetBoxURLHyperlinkedIdentityField()
display = serializers.SerializerMethodField(read_only=True)
def __init__(self, *args, nested=False, fields=None, **kwargs):
def __init__(self, *args, nested=False, fields=None, omit=None, **kwargs):
"""
Extends the base __init__() method to support dynamic fields.
:param nested: Set to True if this serializer is being employed within a parent serializer
:param fields: An iterable of fields to include when rendering the serialized object, If nested is
True but no fields are specified, Meta.brief_fields will be used.
:param omit: An iterable of fields to omit from the serialized object
"""
self.nested = nested
self._requested_fields = fields
self._include_fields = fields or []
self._omit_fields = omit or []
# Disable validators for nested objects (which already exist)
if self.nested:
@@ -36,8 +37,8 @@ class BaseModelSerializer(serializers.ModelSerializer):
# If this serializer is nested but no fields have been specified,
# default to using Meta.brief_fields (if set)
if self.nested and not fields:
self._requested_fields = getattr(self.Meta, 'brief_fields', None)
if self.nested and not fields and not omit:
self._include_fields = getattr(self.Meta, 'brief_fields', None)
super().__init__(*args, **kwargs)
@@ -54,16 +55,19 @@ class BaseModelSerializer(serializers.ModelSerializer):
@cached_property
def fields(self):
"""
Override the fields property to check for requested fields. If defined,
return only the applicable fields.
Override the fields property to return only specifically requested fields if needed.
"""
if not self._requested_fields:
return super().fields
fields = super().fields
# Include only requested fields
if self._include_fields:
for field_name in set(fields) - set(self._include_fields):
fields.pop(field_name, None)
# Remove omitted fields
for field_name in set(self._omit_fields):
fields.pop(field_name, None)
fields = BindingDict(self)
for key, value in self.get_fields().items():
if key in self._requested_fields:
fields[key] = value
return fields
@extend_schema_field(OpenApiTypes.STR)
@@ -108,6 +112,7 @@ class ValidatedModelSerializer(BaseModelSerializer):
for k, v in attrs.items():
setattr(instance, k, v)
instance._m2m_values = m2m_values
instance.full_clean()
# Skip uniqueness validation of individual fields inside `full_clean()` (this is handled by the serializer)
instance.full_clean(validate_unique=False)
return data
+54 -16
View File
@@ -5,13 +5,13 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import router, transaction
from django.db.models import ProtectedError, RestrictedError
from django_pglocks import advisory_lock
from netbox.constants import ADVISORY_LOCK_KEYS
from rest_framework import mixins as drf_mixins
from rest_framework import status
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from netbox.api.serializers.features import ChangeLogMessageSerializer
from netbox.constants import ADVISORY_LOCK_KEYS
from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer
from utilities.exceptions import AbortRequest
from utilities.query import reapply_model_ordering
@@ -59,33 +59,38 @@ class BaseViewSet(GenericViewSet):
serializer_class = self.get_serializer_class()
# Dynamically resolve prefetches for included serializer fields and attach them to the queryset
if prefetch := get_prefetches_for_serializer(serializer_class, fields_to_include=self.requested_fields):
if prefetch := get_prefetches_for_serializer(serializer_class, **self.field_kwargs):
qs = qs.prefetch_related(*prefetch)
# Dynamically resolve annotations for RelatedObjectCountFields on the serializer and attach them to the queryset
if annotations := get_annotations_for_serializer(serializer_class, fields_to_include=self.requested_fields):
if annotations := get_annotations_for_serializer(serializer_class, **self.field_kwargs):
qs = qs.annotate(**annotations)
return qs
def get_serializer(self, *args, **kwargs):
# If specific fields have been requested, pass them to the serializer
if self.requested_fields:
kwargs['fields'] = self.requested_fields
# Pass the fields/omit kwargs (if specified by the request) to the serializer
kwargs.update(**self.field_kwargs)
return super().get_serializer(*args, **kwargs)
@cached_property
def requested_fields(self):
def field_kwargs(self):
"""Return a dictionary of keyword arguments to be passed when instantiating the serializer."""
# An explicit list of fields was requested
if requested_fields := self.request.query_params.get('fields'):
return requested_fields.split(',')
return {'fields': requested_fields.split(',')}
# An explicit list of fields to omit was requested
if omit_fields := self.request.query_params.get('omit'):
return {'omit': omit_fields.split(',')}
# Brief mode has been enabled for this request
elif self.brief:
if self.brief:
serializer_class = self.get_serializer_class()
return getattr(serializer_class.Meta, 'brief_fields', None)
return None
if brief_fields := getattr(serializer_class.Meta, 'brief_fields', None):
return {'fields': brief_fields}
return {}
class NetBoxReadOnlyModelViewSet(
@@ -165,6 +170,28 @@ class NetBoxModelViewSet(
# Creates
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
bulk_create = getattr(serializer, 'many', False)
self.perform_create(serializer)
# After creating the instance(s), re-initialize the serializer with a queryset
# to ensure related objects are prefetched.
if bulk_create:
instance_pks = [obj.pk for obj in serializer.instance]
# Order by PK to ensure that the ordering of objects in the response
# matches the ordering of those in the request.
qs = self.get_queryset().filter(pk__in=instance_pks).order_by('pk')
else:
qs = self.get_queryset().get(pk=serializer.instance.pk)
# Re-serialize the instance(s) with prefetched data
serializer = self.get_serializer(qs, many=bulk_create)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def perform_create(self, serializer):
model = self.queryset.model
logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
@@ -181,9 +208,20 @@ class NetBoxModelViewSet(
# Updates
def update(self, request, *args, **kwargs):
# Hotwire get_object() to ensure we save a pre-change snapshot
self.get_object = self.get_object_with_snapshot
return super().update(request, *args, **kwargs)
partial = kwargs.pop('partial', False)
instance = self.get_object_with_snapshot()
serializer = self.get_serializer(instance, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
# After updating the instance, re-initialize the serializer with a queryset
# to ensure related objects are prefetched.
qs = self.get_queryset().get(pk=serializer.instance.pk)
# Re-serialize the instance(s) with prefetched data
serializer = self.get_serializer(qs)
return Response(serializer.data)
def perform_update(self, serializer):
model = self.queryset.model
+9 -5
View File
@@ -108,13 +108,17 @@ class BulkUpdateModelMixin:
obj.pop('id'): obj for obj in request.data
}
data = self.perform_bulk_update(qs, update_data, partial=partial)
object_pks = self.perform_bulk_update(qs, update_data, partial=partial)
return Response(data, status=status.HTTP_200_OK)
# Prefetch related objects for all updated instances
qs = self.get_queryset().filter(pk__in=object_pks)
serializer = self.get_serializer(qs, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
def perform_bulk_update(self, objects, update_data, partial):
updated_pks = []
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
data_list = []
for obj in objects:
data = update_data.get(obj.id)
if hasattr(obj, 'snapshot'):
@@ -122,9 +126,9 @@ class BulkUpdateModelMixin:
serializer = self.get_serializer(obj, data=data, partial=partial)
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
data_list.append(serializer.data)
updated_pks.append(obj.pk)
return data_list
return updated_pks
def bulk_partial_update(self, request, *args, **kwargs):
kwargs['partial'] = True
+2
View File
@@ -3,8 +3,10 @@ from contextvars import ContextVar
__all__ = (
'current_request',
'events_queue',
'query_cache',
)
current_request = ContextVar('current_request', default=None)
events_queue = ContextVar('events_queue', default=dict())
query_cache = ContextVar('query_cache', default=None)
+4 -1
View File
@@ -1,6 +1,7 @@
from collections import defaultdict
from contextlib import contextmanager
from netbox.context import current_request, events_queue
from netbox.context import current_request, events_queue, query_cache
from netbox.utils import register_request_processor
from extras.events import flush_events
@@ -16,6 +17,7 @@ def event_tracking(request):
"""
current_request.set(request)
events_queue.set({})
query_cache.set(defaultdict(dict))
yield
@@ -26,3 +28,4 @@ def event_tracking(request):
# Clear context vars
current_request.set(None)
events_queue.set({})
query_cache.set(None)
+6 -11
View File
@@ -305,18 +305,13 @@ class NetBoxModelFilterSet(ChangeLoggedModelFilterSet):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Dynamically add a Filter for each CustomField applicable to the parent model
custom_fields = CustomField.objects.filter(
object_types=ContentType.objects.get_for_model(self._meta.model)
).exclude(
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
)
custom_field_filters = {}
for custom_field in custom_fields:
filter_name = f'cf_{custom_field.name}'
filter_instance = custom_field.to_filter()
if filter_instance:
for custom_field in CustomField.objects.get_for_model(self._meta.model):
if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_DISABLED:
# Skip disabled fields
continue
if filter_instance := custom_field.to_filter():
filter_name = f'cf_{custom_field.name}'
custom_field_filters[filter_name] = filter_instance
# Add relevant additional lookups
+5 -4
View File
@@ -31,10 +31,11 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
)
def _get_custom_fields(self, content_type):
return CustomField.objects.filter(
object_types=content_type,
ui_editable=CustomFieldUIEditableChoices.YES
)
# Return only custom fields that are editable in the UI
return [
cf for cf in CustomField.objects.get_for_model(content_type.model_class())
if cf.ui_editable == CustomFieldUIEditableChoices.YES
]
def _get_form_field(self, customfield):
return customfield.to_form_field(for_csv_import=True)
+9 -16
View File
@@ -1,12 +1,10 @@
from django import forms
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from extras.choices import *
from users.models import Owner
from utilities.forms.fields import DynamicModelChoiceField, QueryField
from utilities.forms.fields import QueryField
from utilities.forms.mixins import FilterModifierMixin
from .mixins import CustomFieldsMixin, SavedFiltersMixin
from .mixins import CustomFieldsMixin, OwnerFilterMixin, SavedFiltersMixin
__all__ = (
'NestedGroupModelFilterSetForm',
@@ -36,10 +34,13 @@ class NetBoxModelFilterSetForm(FilterModifierMixin, CustomFieldsMixin, SavedFilt
selector_fields = ('filter_id', 'q')
def _get_custom_fields(self, content_type):
return super()._get_custom_fields(content_type).exclude(
Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
Q(type=CustomFieldTypeChoices.TYPE_JSON)
)
# Return only non-hidden custom fields for which filtering is enabled (excluding JSON fields)
return [
cf for cf in super()._get_custom_fields(content_type) if (
cf.filter_logic != CustomFieldFilterLogicChoices.FILTER_DISABLED and
cf.type != CustomFieldTypeChoices.TYPE_JSON
)
]
def _get_form_field(self, customfield):
return customfield.to_form_field(
@@ -47,14 +48,6 @@ class NetBoxModelFilterSetForm(FilterModifierMixin, CustomFieldsMixin, SavedFilt
)
class OwnerFilterMixin(forms.Form):
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class PrimaryModelFilterSetForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
"""
FilterSet form for models which inherit from PrimaryModel.
+62 -7
View File
@@ -4,13 +4,14 @@ from django.utils.translation import gettext as _
from core.models import ObjectType
from extras.choices import *
from extras.models import *
from users.models import Owner
from users.models import OwnerGroup, Owner
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
__all__ = (
'ChangelogMessageMixin',
'CustomFieldsMixin',
'OwnerMixin',
'OwnerFilterMixin',
'SavedFiltersMixin',
'TagsMixin',
)
@@ -22,7 +23,7 @@ class ChangelogMessageMixin(forms.Form):
"""
changelog_message = forms.CharField(
required=False,
max_length=200
max_length=200,
)
def __init__(self, *args, **kwargs):
@@ -42,6 +43,7 @@ class CustomFieldsMixin:
Attributes:
model: The model class
"""
model = None
def __init__(self, *args, **kwargs):
@@ -63,9 +65,11 @@ class CustomFieldsMixin:
return ObjectType.objects.get_for_model(self.model)
def _get_custom_fields(self, content_type):
return CustomField.objects.filter(object_types=content_type).exclude(
ui_editable=CustomFieldUIEditableChoices.HIDDEN
)
# Return only custom fields that are not hidden from the UI
return [
cf for cf in CustomField.objects.get_for_model(content_type.model_class())
if cf.ui_editable != CustomFieldUIEditableChoices.HIDDEN
]
def _get_form_field(self, customfield):
return customfield.to_form_field()
@@ -86,13 +90,20 @@ class CustomFieldsMixin:
class SavedFiltersMixin(forms.Form):
"""
Form mixin for forms that support saved filters.
Provides a field for selecting a saved filter,
with options limited to those applicable to the form's model.
"""
filter_id = DynamicModelMultipleChoiceField(
queryset=SavedFilter.objects.all(),
required=False,
label=_('Saved Filter'),
query_params={
'usable': True,
}
},
)
def __init__(self, *args, **kwargs):
@@ -107,6 +118,13 @@ class SavedFiltersMixin(forms.Form):
class TagsMixin(forms.Form):
"""
Mixin for forms that support tagging.
Provides a field for selecting tags,
with options limited to those applicable to the form's model.
"""
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False,
@@ -124,10 +142,47 @@ class TagsMixin(forms.Form):
class OwnerMixin(forms.Form):
"""
Add an `owner` field to forms for models which support Owner assignment.
Mixin for forms which adds ownership fields.
Include this mixin in forms for models which
support owner and/or owner group assignment.
"""
owner_group = DynamicModelChoiceField(
label=_('Owner group'),
queryset=OwnerGroup.objects.all(),
required=False,
null_option='None',
initial_params={'members': '$owner'},
)
owner = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
query_params={'group_id': '$owner_group'},
label=_('Owner'),
)
class OwnerFilterMixin(forms.Form):
"""
Mixin for filterset forms which adds owner and owner group filtering.
Include this mixin in filterset forms for models
which support owner and/or owner group assignment.
"""
owner_group_id = DynamicModelMultipleChoiceField(
queryset=OwnerGroup.objects.all(),
required=False,
null_option='None',
label=_('Owner Group'),
)
owner_id = DynamicModelMultipleChoiceField(
queryset=Owner.objects.all(),
required=False,
null_option='None',
query_params={
'group_id': '$owner_group_id'
},
label=_('Owner'),
)
+26
View File
@@ -19,8 +19,11 @@ from strawberry_django import (
process_filters,
)
from netbox.graphql.scalars import BigInt
__all__ = (
'ArrayLookup',
'BigIntegerLookup',
'FloatArrayLookup',
'FloatLookup',
'IntegerArrayLookup',
@@ -78,6 +81,29 @@ class IntegerLookup:
return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)
@strawberry.input(one_of=True, description='Lookup for BigInteger fields. Only one of the lookup fields can be set.')
class BigIntegerLookup:
filter_lookup: FilterLookup[BigInt] | None = strawberry_django.filter_field()
range_lookup: RangeLookup[BigInt] | None = strawberry_django.filter_field()
comparison_lookup: ComparisonFilterLookup[BigInt] | None = strawberry_django.filter_field()
def get_filter(self):
for field in self.__strawberry_definition__.fields:
value = getattr(self, field.name, None)
if value is not strawberry.UNSET:
return value
return None
@strawberry_django.filter_field
def filter(self, info: Info, queryset: QuerySet, prefix: DirectiveValue[str] = '') -> Tuple[QuerySet, Q]:
filters = self.get_filter()
if not filters:
return queryset, Q()
return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)
@strawberry.input(one_of=True, description='Lookup for Float fields. Only one of the lookup fields can be set.')
class FloatLookup:
filter_lookup: FilterLookup[float] | None = strawberry_django.filter_field()
+50
View File
@@ -0,0 +1,50 @@
import strawberry
from strawberry.types.unset import UNSET
from strawberry_django.pagination import _QS, apply
__all__ = (
'OffsetPaginationInfo',
'OffsetPaginationInput',
'apply_pagination',
)
@strawberry.type
class OffsetPaginationInfo:
offset: int = 0
limit: int | None = UNSET
start: int | None = UNSET
@strawberry.input
class OffsetPaginationInput(OffsetPaginationInfo):
"""
Customized implementation of OffsetPaginationInput to support cursor-based pagination.
"""
pass
def apply_pagination(
self,
queryset: _QS,
pagination: OffsetPaginationInput | None = None,
*,
related_field_id: str | None = None,
) -> _QS:
"""
Replacement for the `apply_pagination()` method on StrawberryDjangoField to support cursor-based pagination.
"""
if pagination is not None and pagination.start not in (None, UNSET):
if pagination.offset:
raise ValueError('Cannot specify both `start` and `offset` in pagination.')
if pagination.start < 0:
raise ValueError('`start` must be greater than or equal to zero.')
# Filter the queryset to include only records with a primary key greater than or equal to the start value,
# and force ordering by primary key to ensure consistent pagination across all records.
queryset = queryset.filter(pk__gte=pagination.start).order_by('pk')
# Ignore `offset` when `start` is set
pagination.offset = 0
return apply(pagination, queryset, related_field_id=related_field_id)
+16 -7
View File
@@ -2,7 +2,7 @@ import json
from collections import defaultdict
from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.validators import ValidationError
from django.db import models
@@ -121,9 +121,11 @@ class ChangeLoggingMixin(DeleteMixin, models.Model):
if hasattr(self, '_prechange_snapshot'):
objectchange.prechange_data = self._prechange_snapshot
if action in (ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE):
objectchange.postchange_data = self.serialize_object(exclude=exclude)
self._postchange_snapshot = self.serialize_object(exclude=exclude)
objectchange.postchange_data = self._postchange_snapshot
return objectchange
to_objectchange.alters_data = True
class CloningMixin(models.Model):
@@ -159,6 +161,13 @@ class CloningMixin(models.Model):
elif field_value not in (None, ''):
attrs[field_name] = field_value
# Handle GenericForeignKeys. If the CT and ID fields are being cloned, also
# include the name of the GFK attribute itself, as this is what forms expect.
for field in self._meta.private_fields:
if isinstance(field, GenericForeignKey):
if field.ct_field in attrs and field.fk_field in attrs:
attrs[field.name] = attrs[field.fk_field]
# Include tags (if applicable)
if is_taggable(self):
attrs['tags'] = [tag.pk for tag in self.tags.all()]
@@ -317,9 +326,11 @@ class CustomFieldsMixin(models.Model):
raise ValidationError(_("Missing required custom field '{name}'.").format(name=cf.name))
def save(self, *args, **kwargs):
# Populate default values if omitted
for cf in self.custom_fields.filter(default__isnull=False):
if cf.name not in self.custom_field_data:
from extras.models import CustomField
# Populate default values for custom fields not already present in the object data
for cf in CustomField.objects.get_for_model(self):
if cf.name not in self.custom_field_data and cf.default is not None:
self.custom_field_data[cf.name] = cf.default
super().save(*args, **kwargs)
@@ -569,7 +580,6 @@ class SyncedDataMixin(models.Model):
)
else:
AutoSyncRecord.objects.filter(
datafile=self.data_file,
object_type=object_type,
object_id=self.pk
).delete()
@@ -582,7 +592,6 @@ class SyncedDataMixin(models.Model):
# Delete AutoSyncRecord
object_type = ObjectType.objects.get_for_model(self)
AutoSyncRecord.objects.filter(
datafile=self.data_file,
object_type=object_type,
object_id=self.pk
).delete()
+49 -88
View File
@@ -1,3 +1,5 @@
from functools import cache
from django.utils.translation import gettext_lazy as _
from netbox.registry import registry
@@ -409,60 +411,10 @@ ADMIN_MENU = Menu(
MenuGroup(
label=_('Authentication'),
items=(
MenuItem(
link='users:user_list',
link_text=_('Users'),
staff_only=True,
permissions=['users.view_user'],
buttons=(
MenuItemButton(
link='users:user_add',
title='Add',
icon_class='mdi mdi-plus-thick',
permissions=['users.add_user']
),
MenuItemButton(
link='users:user_bulk_import',
title='Import',
icon_class='mdi mdi-upload',
permissions=['users.add_user']
)
)
),
MenuItem(
link='users:group_list',
link_text=_('Groups'),
staff_only=True,
permissions=['users.view_group'],
buttons=(
MenuItemButton(
link='users:group_add',
title='Add',
icon_class='mdi mdi-plus-thick',
permissions=['users.add_group']
),
MenuItemButton(
link='users:group_bulk_import',
title='Import',
icon_class='mdi mdi-upload',
permissions=['users.add_group']
)
)
),
MenuItem(
link='users:token_list',
link_text=_('API Tokens'),
staff_only=True,
permissions=['users.view_token'],
buttons=get_model_buttons('users', 'token')
),
MenuItem(
link='users:objectpermission_list',
link_text=_('Permissions'),
staff_only=True,
permissions=['users.view_objectpermission'],
buttons=get_model_buttons('users', 'objectpermission', actions=['add'])
),
get_model_item('users', 'user', _('Users')),
get_model_item('users', 'group', _('Groups')),
get_model_item('users', 'token', _('API Tokens')),
get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']),
),
),
MenuGroup(
@@ -501,40 +453,49 @@ ADMIN_MENU = Menu(
),
)
MENUS = [
ORGANIZATION_MENU,
RACKS_MENU,
DEVICES_MENU,
CONNECTIONS_MENU,
WIRELESS_MENU,
IPAM_MENU,
VPN_MENU,
VIRTUALIZATION_MENU,
CIRCUITS_MENU,
POWER_MENU,
PROVISIONING_MENU,
CUSTOMIZATION_MENU,
OPERATIONS_MENU,
]
# Add top-level plugin menus
for menu in registry['plugins']['menus']:
MENUS.append(menu)
# Add the default "plugins" menu
if registry['plugins']['menu_items']:
# Build the default plugins menu
groups = [
MenuGroup(label=label, items=items)
for label, items in registry['plugins']['menu_items'].items()
@cache
def get_menus():
"""
Dynamically build and return the list of navigation menus.
This ensures plugin menus registered during app initialization are included.
The result is cached since menus don't change without a Django restart.
"""
menus = [
ORGANIZATION_MENU,
RACKS_MENU,
DEVICES_MENU,
CONNECTIONS_MENU,
WIRELESS_MENU,
IPAM_MENU,
VPN_MENU,
VIRTUALIZATION_MENU,
CIRCUITS_MENU,
POWER_MENU,
PROVISIONING_MENU,
CUSTOMIZATION_MENU,
OPERATIONS_MENU,
]
plugins_menu = Menu(
label=_("Plugins"),
icon_class="mdi mdi-puzzle",
groups=groups
)
MENUS.append(plugins_menu)
# Add the admin menu last
MENUS.append(ADMIN_MENU)
# Add top-level plugin menus
for menu in registry['plugins']['menus']:
menus.append(menu)
# Add the default "plugins" menu
if registry['plugins']['menu_items']:
# Build the default plugins menu
groups = [
MenuGroup(label=label, items=items)
for label, items in registry['plugins']['menu_items'].items()
]
plugins_menu = Menu(
label=_("Plugins"),
icon_class="mdi mdi-puzzle",
groups=groups
)
menus.append(plugins_menu)
# Add the admin menu last
menus.append(ADMIN_MENU)
return menus
+6 -4
View File
@@ -187,7 +187,6 @@ class CachedValueSearchBackend(SearchBackend):
return ret
def cache(self, instances, indexer=None, remove_existing=True):
object_type = None
custom_fields = None
# Convert a single instance to an iterable
@@ -208,15 +207,18 @@ class CachedValueSearchBackend(SearchBackend):
except KeyError:
break
# Prefetch any associated custom fields
object_type = ObjectType.objects.get_for_model(indexer.model)
custom_fields = CustomField.objects.filter(object_types=object_type).exclude(search_weight=0)
# Prefetch any associated custom fields (excluding those with a zero search weight)
custom_fields = [
cf for cf in CustomField.objects.get_for_model(indexer.model)
if cf.search_weight > 0
]
# Wipe out any previously cached values for the object
if remove_existing:
self.remove(instance)
# Generate cache data
object_type = ObjectType.objects.get_for_model(indexer.model)
for field in indexer.to_cache(instance, custom_fields=custom_fields):
buffer.append(
CachedValue(
+9
View File
@@ -12,10 +12,13 @@ from django.core.validators import URLValidator
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
from rest_framework.utils import field_mapping
from strawberry_django import pagination
from strawberry_django.fields.field import StrawberryDjangoField
from core.exceptions import IncompatiblePluginError
from netbox.config import PARAMS as CONFIG_PARAMS
from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
from netbox.graphql.pagination import OffsetPaginationInput, apply_pagination
from netbox.plugins import PluginConfig
from netbox.registry import registry
import storages.utils # type: ignore
@@ -33,6 +36,12 @@ from .monkey import get_unique_validators
# Override DRF's get_unique_validators() function with our own (see bug #19302)
field_mapping.get_unique_validators = get_unique_validators
# Override strawberry-django's OffsetPaginationInput class to add the `start` parameter
pagination.OffsetPaginationInput = OffsetPaginationInput
# Patch StrawberryDjangoField to use our custom `apply_pagination()` method with support for cursor-based pagination
StrawberryDjangoField.apply_pagination = apply_pagination
#
# Environment setup
+26 -8
View File
@@ -242,14 +242,17 @@ class NetBoxTable(BaseTable):
(name, deepcopy(column)) for name, column in registered_columns.items()
])
# Add custom field & custom link columns
object_type = ObjectType.objects.get_for_model(self._meta.model)
custom_fields = CustomField.objects.filter(
object_types=object_type
).exclude(ui_visible=CustomFieldUIVisibleChoices.HIDDEN)
# Add columns for custom fields
custom_fields = [
cf for cf in CustomField.objects.get_for_model(self._meta.model)
if cf.ui_visible != CustomFieldUIVisibleChoices.HIDDEN
]
extra_columns.extend([
(f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
])
# Add columns for custom links
object_type = ObjectType.objects.get_for_model(self._meta.model)
custom_links = CustomLink.objects.filter(object_types=object_type, enabled=True)
extra_columns.extend([
(f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links
@@ -271,9 +274,14 @@ class NetBoxTable(BaseTable):
class PrimaryModelTable(NetBoxTable):
owner_group = tables.Column(
accessor='owner__group',
linkify=True,
verbose_name=_('Owner Group'),
)
owner = tables.Column(
linkify=True,
verbose_name=_('Owner')
verbose_name=_('Owner'),
)
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
@@ -281,9 +289,14 @@ class PrimaryModelTable(NetBoxTable):
class OrganizationalModelTable(NetBoxTable):
owner_group = tables.Column(
accessor='owner__group',
linkify=True,
verbose_name=_('Owner Group'),
)
owner = tables.Column(
linkify=True,
verbose_name=_('Owner')
verbose_name=_('Owner'),
)
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
@@ -291,9 +304,14 @@ class OrganizationalModelTable(NetBoxTable):
class NestedGroupModelTable(NetBoxTable):
owner_group = tables.Column(
accessor='owner__group',
linkify=True,
verbose_name=_('Owner Group'),
)
owner = tables.Column(
linkify=True,
verbose_name=_('Owner')
verbose_name=_('Owner'),
)
name = columns.MPTTColumn(
verbose_name=_('Name'),
+144 -21
View File
@@ -4,10 +4,8 @@ from django.test import override_settings
from django.urls import reverse
from rest_framework import status
from core.models import ObjectType
from dcim.choices import LocationStatusChoices
from dcim.models import Site, Location
from users.models import ObjectPermission
from utilities.testing import disable_warnings, APITestCase, TestCase
@@ -45,17 +43,28 @@ class GraphQLTestCase(TestCase):
class GraphQLAPITestCase(APITestCase):
@classmethod
def setUpTestData(cls):
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
Site(name='Site 4', slug='site-4'),
Site(name='Site 5', slug='site-5'),
Site(name='Site 6', slug='site-6'),
Site(name='Site 7', slug='site-7'),
)
Site.objects.bulk_create(sites)
@override_settings(LOGIN_REQUIRED=True)
def test_graphql_filter_objects(self):
"""
Test the operation of filters for GraphQL API requests.
"""
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
)
Site.objects.bulk_create(sites)
self.add_permissions('dcim.view_site', 'dcim.view_location')
url = reverse('graphql')
sites = Site.objects.all()[:3]
Location.objects.create(
site=sites[0],
name='Location 1',
@@ -75,18 +84,6 @@ class GraphQLAPITestCase(APITestCase):
status=LocationStatusChoices.STATUS_ACTIVE
),
# Add object-level permission
obj_perm = ObjectPermission(
name='Test permission',
actions=['view']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(Location))
obj_perm.object_types.add(ObjectType.objects.get_for_model(Site))
url = reverse('graphql')
# A valid request should return the filtered list
query = '{location_list(filters: {site_id: "' + str(sites[0].pk) + '"}) {id site {id}}}'
response = self.client.post(url, data={'query': query}, format="json", **self.header)
@@ -133,10 +130,136 @@ class GraphQLAPITestCase(APITestCase):
self.assertEqual(len(data['data']['location_list']), 0)
# Removing the permissions from location should result in an empty locations list
obj_perm.object_types.remove(ObjectType.objects.get_for_model(Location))
self.remove_permissions('dcim.view_location')
query = '{site(id: ' + str(sites[0].pk) + ') {id locations {id}}}'
response = self.client.post(url, data={'query': query}, format="json", **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site']['locations']), 0)
def test_offset_pagination(self):
self.add_permissions('dcim.view_site')
url = reverse('graphql')
# Test `limit` only
query = """
{
site_list(pagination: {limit: 3}) {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site_list']), 3)
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 1')
self.assertEqual(data['data']['site_list'][1]['name'], 'Site 2')
self.assertEqual(data['data']['site_list'][2]['name'], 'Site 3')
# Test `offset` only
query = """
{
site_list(pagination: {offset: 3}) {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site_list']), 4)
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 4')
self.assertEqual(data['data']['site_list'][1]['name'], 'Site 5')
self.assertEqual(data['data']['site_list'][2]['name'], 'Site 6')
self.assertEqual(data['data']['site_list'][3]['name'], 'Site 7')
# Test `offset` & `limit`
query = """
{
site_list(pagination: {offset: 3, limit: 3}) {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site_list']), 3)
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 4')
self.assertEqual(data['data']['site_list'][1]['name'], 'Site 5')
self.assertEqual(data['data']['site_list'][2]['name'], 'Site 6')
def test_cursor_pagination(self):
self.add_permissions('dcim.view_site')
url = reverse('graphql')
# Page 1
query = """
{
site_list(pagination: {start: 0, limit: 3}) {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site_list']), 3)
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 1')
self.assertEqual(data['data']['site_list'][1]['name'], 'Site 2')
self.assertEqual(data['data']['site_list'][2]['name'], 'Site 3')
# Page 2
start_id = int(data['data']['site_list'][-1]['id']) + 1
query = """
{
site_list(pagination: {start: """ + str(start_id) + """, limit: 3}) {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site_list']), 3)
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 4')
self.assertEqual(data['data']['site_list'][1]['name'], 'Site 5')
self.assertEqual(data['data']['site_list'][2]['name'], 'Site 6')
# Page 3
start_id = int(data['data']['site_list'][-1]['id']) + 1
query = """
{
site_list(pagination: {start: """ + str(start_id) + """, limit: 3}) {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site_list']), 1)
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 7')
def test_pagination_conflict(self):
url = reverse('graphql')
query = """
{
site_list(pagination: {start: 1, offset: 1}) {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertIn('errors', data)
self.assertEqual(data['errors'][0]['message'], 'Cannot specify both `start` and `offset` in pagination.')
+61 -1
View File
@@ -1,18 +1,28 @@
from unittest import skipIf
from django.conf import settings
from django.test import TestCase
from core.models import AutoSyncRecord, DataSource
from dcim.models import Site
from extras.models import CustomLink
from ipam.models import Prefix
from netbox.models.features import get_model_features, has_feature, model_is_public
from netbox.tests.dummy_plugin.models import DummyModel
from taggit.models import Tag
class ModelFeaturesTestCase(TestCase):
"""
A test case class for verifying model features and utility functions.
"""
@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, 'dummy_plugin not in settings.PLUGINS')
def test_model_is_public(self):
"""
Test that the is_public() utility function returns True for public models only.
"""
from netbox.tests.dummy_plugin.models import DummyModel
# Public model
self.assertFalse(hasattr(DataSource, '_netbox_private'))
self.assertTrue(model_is_public(DataSource))
@@ -51,3 +61,53 @@ class ModelFeaturesTestCase(TestCase):
features = get_model_features(CustomLink)
self.assertIn('cloning', features)
self.assertNotIn('bookmarks', features)
def test_cloningmixin_injects_gfk_attribute(self):
"""
Tests the cloning mixin with GFK attribute injection in the `clone` method.
This test validates that the `clone` method correctly handles
and retains the General Foreign Key (GFK) attributes on an
object when the cloning fields are explicitly defined.
"""
site = Site.objects.create(name='Test Site', slug='test-site')
prefix = Prefix.objects.create(prefix='10.0.0.0/24', scope=site)
original_clone_fields = getattr(Prefix, 'clone_fields', None)
try:
Prefix.clone_fields = ('scope_type', 'scope_id')
attrs = prefix.clone()
self.assertEqual(attrs['scope_type'], prefix.scope_type_id)
self.assertEqual(attrs['scope_id'], prefix.scope_id)
self.assertEqual(attrs['scope'], prefix.scope_id)
finally:
if original_clone_fields is None:
delattr(Prefix, 'clone_fields')
else:
Prefix.clone_fields = original_clone_fields
def test_cloningmixin_does_not_inject_gfk_attribute_if_incomplete(self):
"""
Tests the cloning mixin with incomplete cloning fields does not inject the GFK attribute.
This test validates that the `clone` method correctly handles
the case where the cloning fields are incomplete, ensuring that
the generic foreign key (GFK) attribute is not injected during
the cloning process.
"""
site = Site.objects.create(name='Test Site', slug='test-site')
prefix = Prefix.objects.create(prefix='10.0.0.0/24', scope=site)
original_clone_fields = getattr(Prefix, 'clone_fields', None)
try:
Prefix.clone_fields = ('scope_type',)
attrs = prefix.clone()
self.assertIn('scope_type', attrs)
self.assertNotIn('scope', attrs)
finally:
if original_clone_fields is None:
delattr(Prefix, 'clone_fields')
else:
Prefix.clone_fields = original_clone_fields
+5 -7
View File
@@ -5,7 +5,6 @@ from copy import deepcopy
from django.contrib import messages
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
from django.db import IntegrityError, router, transaction
from django.db.models import ManyToManyField, ProtectedError, RestrictedError
@@ -484,12 +483,11 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
else:
instance = self.queryset.model()
# For newly created objects, apply any default custom field values
custom_fields = CustomField.objects.filter(
object_types=ContentType.objects.get_for_model(self.queryset.model),
ui_editable=CustomFieldUIEditableChoices.YES
)
for cf in custom_fields:
# For newly created objects, apply any default values for custom fields
for cf in CustomField.objects.get_for_model(self.queryset.model):
if cf.ui_editable != CustomFieldUIEditableChoices.YES:
# Skip custom fields which are not editable via the UI
continue
field_name = f'cf_{cf.name}'
if field_name not in record:
record[field_name] = cf.default
+11 -1
View File
@@ -1,5 +1,6 @@
import re
from collections import namedtuple
import logging
from django.conf import settings
from django.contrib import messages
@@ -28,6 +29,8 @@ __all__ = (
'SearchView',
)
logger = logging.getLogger(f'netbox.{__name__}')
Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count'))
@@ -50,7 +53,14 @@ class HomeView(ConditionalLoginRequiredMixin, View):
# Check whether a new release is available. (Only for superusers.)
new_release = None
if request.user.is_superuser:
latest_release = cache.get('latest_release')
# cache.get() can raise an exception if the cached value can't be unpickled after dependency upgrades
try:
latest_release = cache.get('latest_release')
except Exception:
logger.debug("Failed to read 'latest_release' from cache; deleting key", exc_info=True)
cache.delete('latest_release')
latest_release = None
if latest_release:
release_version, release_url = latest_release
if release_version > version.parse(settings.RELEASE.version):

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