Compare commits

...

55 Commits

Author SHA1 Message Date
Arthur
285abe7cc0 20660 cache script storage key 2025-10-22 11:22:53 -07:00
Martin Hauser
c5124cb2e4 feat(templates): Update user menu icon class names for consistency
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
Switch icons in the top-right User dropdown to Tabler’s
`dropdown-item-icon` to standardize spacing between the icon and label.
Improves readability and ensures alignment with the overall UI styling.

Fixes #20608
2025-10-21 08:35:50 -04:00
Jason Novinger
d01d7b4156 Fixes #20551: Support quick-add form prefix in automatic slug generation (#20624)
* Fixes #20551: Support quick-add form prefix in automatic slug generation

The slug generation logic in `reslug.ts` looks for form fields using hard-coded ID selectors like `#id_slug` and `#id_name`. In quick-add modals, Django applies a `quickadd` prefix to form fields (introduced in #20542), resulting in IDs like `#id_quickadd-slug` and `#id_quickadd-name`. The logic couldn't find these prefixed fields, so automatic slug generation failed silently in quick-add modals. This fix updates the field selectors to try both unprefixed and prefixed patterns using the nullish coalescing operator (`??`), checking for the standard field ID first and falling back to the quickadd-prefixed ID if the standard one isn't found.

* Address PR feedback

The slug generation logic required updates to support form prefixes like `quickadd`. Python-side changes
ensure `SlugField.get_bound_field()` updates the `slug-source` attribute to include the form prefix when
present, so JavaScript receives the correct prefixed field ID. `SlugWidget.__init__()` now adds a
`slug-field` class to enable selector-based field discovery. On the frontend, `reslug.ts` now uses class
selectors (`button.reslug` and `input.slug-field`) instead of ID-based lookups, eliminating the need for
fallback logic. The template was updated to use `class="reslug"` instead of `id="reslug"` on the button to
avoid ID duplication issues.
2025-10-21 08:33:10 -04:00
github-actions
4db6123fb2 Update source translation strings
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
2025-10-21 05:03:30 +00:00
Jeremy Stretch
43648d629b Fixes #20606: Enable copying text from badges in UI (#20633) 2025-10-20 17:12:42 -05:00
bctiemann
0b97df0984 Merge pull request #20625 from netbox-community/20498-url-custom-field-validation-regex
Fixes #20498: Apply validation regex to URL custom fields
2025-10-20 15:30:33 -04:00
Martin Hauser
5334c8143c feat(forms): Add context handling for ModuleBay field (#20586) 2025-10-20 10:16:53 -07:00
Martin Hauser
bbb330becf feat(filtersets): Add assigned and primary filters for MACAddress (#20620)
Introduce Boolean filters `assigned` and `primary` to the MACAddress
filterset, improving filtering capabilities. Update forms, tables, and
GraphQL queries to incorporate the new filters. Add tests to validate
the correct functionality.

Fixes #20399
2025-10-20 10:01:25 -07:00
Jeremy Stretch
e4c74ce6a3 Closes #20614: Update ruff for pre-commit check (#20631) 2025-10-20 09:07:12 -07:00
Martin Hauser
a4868f894d feat(ipam): Add ContactsColumnMixin to ServiceTable
Enhance `ServiceTable` by incorporating `ContactsColumnMixin` for better
contact management. Updates the fields to include `contacts`.

Fixes #20567
2025-10-20 09:07:25 -04:00
github-actions
531ea34207 Update source translation strings 2025-10-20 05:03:22 +00:00
Jason Novinger
6747c82a1a Fixes #20498: Apply validation regex to URL custom fields
The validation_regex field was not being enforced for URL type custom
fields. This fix adds regex validation in two places:

1. to_form_field() - Applies regex validator to form fields (UI validation)
2. validate() - Applies regex check in model validation (API/programmatic)

Note: The original issue reported UI validation only, but this fix also
adds API validation for consistency with text field behavior and to
ensure data integrity across all entry points.
2025-10-19 18:30:54 -05:00
Martin Hauser
e251ea10b5 Closes #20605: Document variable prefilling via URL parameters (#20619) 2025-10-19 15:42:09 -05:00
Martin Hauser
a1aaf465ac Fixes #20466: Correct handling of assigned filter logic (#20538) 2025-10-19 12:51:44 -05:00
Martin Hauser
2a1d315d85 Fixes #20524: Enhance API script scheduling validation (#20616) 2025-10-19 12:29:14 -05:00
github-actions
8cc6589a35 Update source translation strings
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
2025-10-16 05:03:49 +00:00
Jason Novinger
bee0080917 Release v4.4.4 (#20594)
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.10) (push) Has been cancelled
2025-10-15 14:25:43 -05:00
bctiemann
389c44e5d6 Merge pull request #20591 from pheus/20554-add-missing-contenttypefilter-to-filtersets
Fixes #20554: Add ContentTypeFilter to several filtersets
2025-10-15 14:16:51 -04:00
bctiemann
9cb2c78e34 Init storage at class level of BaseScript instead of in findsource function (#20575) 2025-10-15 11:09:22 -07:00
Jason Novinger
2ae98f0353 Fixes #20587: Handle stale ContentTypes in has_feature()
When deleting stale ContentTypes during remove_stale_contenttypes, the
pre_delete signal triggers notify_object_changed(), which calls
has_feature() with the ContentType instance. For stale types (those with
no corresponding model class), model_class() returns None, which then gets
passed to issubclass() in the feature test lambda, causing a TypeError.

The previous implementation in has_feature() checked for None before
attempting ObjectType lookup. The optimization in 5ceb6a6 removed this
safety check when refactoring the ContentType code path to use direct
feature registry lookups. This restores the null check to maintain the
original behavior of returning False for stale ContentTypes.
2025-10-15 14:09:04 -04:00
Jeremy Stretch
addda0538f Fixes #20584: Ensure consistent validation between Interface & InterfaceTemplate (#20589) 2025-10-15 11:04:39 -07:00
Jeremy Stretch
c902a1c510 Fixes #20585: Fix AttributeError exception for conditionless single-field UniqueConstraints (#20590) 2025-10-15 12:51:33 -05:00
Martin Hauser
f23ee0a46f feat(filtersets): Add ContentTypeFilter to enhance filtering
Introduce `ContentTypeFilter` across several filtersets, including
`object_type`, `related_object_type`, `assigned_object_type`, and
`parent_object_type`. This improvement enhances filtering specificity
and aligns with existing usability standards.

Closes #20554
2025-10-15 18:24:42 +02:00
github-actions
b4acc3fb36 Update source translation strings
Some checks are pending
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
2025-10-15 05:04:04 +00:00
Jeremy Stretch
a69bbcf651 Release v4.4.3
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
2025-10-14 13:51:41 -04:00
Jeremy Stretch
2edfde5753 Fixes #19302: Fix uniqueness validation in REST API for nullable fields (#20549) 2025-10-14 09:19:10 -07:00
Martin Hauser
cfbd9632ac feat(utilities): Add ranges_to_string_list
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
Introduce `ranges_to_string_list` for converting numeric ranges into a
list of readable strings. Update the `vid_ranges_list` property and
templates to use this method for better readability and maintainability.
Add related tests to ensure functionality.

Closes #20516
2025-10-14 09:39:09 -04:00
bctiemann
c9386bc9c3 Merge pull request #20558 from netbox-community/20557-update-to-django-5.2.7
Some checks are pending
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
Closes #20557: Upgrade Django to v5.2.7
2025-10-13 07:02:44 -04:00
Jason Novinger
c826c5cdb0 Closes #20557: Upgrade Django to v5.2.7
Upgrade Django to v5.2.7 to address upstream vulnerability reports

https://www.djangoproject.com/weblog/2025/oct/01/security-releases/
2025-10-13 01:06:23 -05:00
Aaron
a4ab4f885d Fixes #20156: Fixed rack view not using previous setting (#20556)
Some checks are pending
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
2025-10-13 00:38:45 -05:00
Arthur Hanson
61d77dff14 Fixes #19615: Properly set version request parameter for static files in S3 (#20455) 2025-10-12 18:49:42 -05:00
github-actions
24a83acc34 Update source translation strings
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
2025-10-10 05:03:50 +00:00
bctiemann
dbc71158ec Merge pull request #20525 from mathieumd/19818-hide_primary_ip_at_vm_creation
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
Fixes #19818: Hide IP fields when creating VM
2025-10-09 17:54:22 -04:00
Jason Novinger
f0523611d1 Fixes #20542: Add form prefix to POST handler in ObjectEditView (#20550)
Commit d22246688 added form prefix support to the `GET` handler to fix
Markdown preview functionality in quick add modals. The form prefix
allows Django to properly namespace field names and IDs when rendering
forms within the quick add modal context.

However, the corresponding change was not made to the `POST` handler. This
created a mismatch where form fields were rendered with the `quickadd-`
prefix during `GET` requests, but the `POST` handler instantiated forms
without the prefix. When users submitted quick add forms, Django looked
for unprefixed field names like `address` and `status` in the `POST` data,
but the actual submitted data used prefixed names like `quickadd-address`
and `quickadd-status`. This caused validation to fail immediately with
"This field is required" errors for all required fields, making every
quick add form unusable.

The fix adds the same prefix detection logic to the `POST` handler that was
added to the `GET` handler, checking for the `_quickadd` parameter in the
query string and applying the `quickadd` prefix when present. This ensures
consistent form field naming between rendering and validation.

A regression test has been added to `MACAddressTestCase` to verify that MAC
addresses can be successfully created via the quick add modal, preventing
this issue from recurring. This test should be promoted to a template
test whenever it becomes possible to determine if a model should support
quick-add functionality.
2025-10-09 14:42:59 -07:00
Daniel Sheppard
7719b98697 Fixes #19825: Prevent inaccurate config revision activation when not intended (#20219)
Some checks failed
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
2025-10-09 01:36:41 -05:00
Martin Hauser
f383067ecb Closes #20527: Address deprecation warnings (#20533) 2025-10-09 00:47:09 -05:00
github-actions
20de263565 Update source translation strings 2025-10-09 05:04:28 +00:00
Jeremy Stretch
5ceb6a60da Fixes #20290: Avoid exceptions when upgrading to v4.4 from early releases due to missing ObjectTypes table
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
2025-10-08 13:00:27 -04:00
Martin Hauser
33d4759871 feat(extras): Add range_contains ORM lookup
Introduce a generic lookup for ArrayField(RangeField) that matches rows
where a scalar value is contained by any range in the array
(e.g. VLANGroup.vid_ranges).
Replace the raw-SQL helper in the VLANGroup FilterSet (`contains_vid`)
with the ORM lookup for better maintainability.
Add tests for the lookup and the FilterSet behavior.

Closes #20497
2025-10-08 09:57:15 -04:00
Amir-Bakar
2abc5ac69a Update base.html
Update base.html to account for other cases where passwords are not used, other than LDAP. (SSO solutions, for example.)
2025-10-08 09:56:15 -04:00
bctiemann
f8c074045f Merge pull request #20528 from netbox-community/02496-max-page
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
20496 make max_page_size upper bound
2025-10-07 13:11:59 -04:00
Arthur
4db3d488ad Merge branch 'main' into 02496-max-page
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
2025-10-07 09:12:33 -07:00
Martin Hauser
b7cae04572 fix(api): Update NumericRange handling to use half-open intervals (#20478) 2025-10-07 09:01:29 -07:00
Martin Hauser
51528ae429 fix(utilities): Enhance ranges_to_string for improved clarity (#20479) 2025-10-07 08:47:01 -07:00
Jeremy Stretch
d5e8480367 Update OpenAPI schema (#20519) 2025-10-07 08:22:24 -07:00
Matthew Papaleo
05e26b82c1 Fixes #20507 Contacts returned for ASN via graphql API 2025-10-07 09:08:04 -04:00
Mathieu
d8e4c95bcc Fixes #19818: Hide IP fields when creating VM 2025-10-07 14:03:01 +02:00
github-actions
faa89a53ff Update source translation strings
Some checks are pending
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
2025-10-07 05:02:29 +00:00
Dmitry Smirnov
d18bbe48c1 add tag copy_content and id 'job_data_output'
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
2025-10-06 15:17:39 -04:00
Martin Hauser
99e367cbaf docs(api): Correct IntegerRangeSerializer schema definition
Adjusts the schema mapping for `IntegerRangeSerializer` by setting
`match_subclasses` to `True` and refining the array definition. Adds
an example field for clarity in generated OpenAPI documentation.

Fixes #20494
2025-10-06 15:09:57 -04:00
Daniel Sheppard
f5ed095738 Fixes: #21040 - Registered denormalized fields (#20503)
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
2025-10-06 09:12:27 -05:00
Johannes Erwerle
b70f1211ab Fixed wrong link in plugin filtersets documentation 2025-10-06 10:03:47 -04:00
Arthur
10e8e7b071 20496 fix test 2025-10-03 14:54:08 -07:00
Arthur
c770e6b45d 20496 fix max_page_size for REST API 2025-10-03 14:22:55 -07:00
Jason Novinger
c094699dc0 Fixes #20484: Configure CodeQL to exclude URL redirect false positives
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
2025-10-03 08:48:02 -04:00
109 changed files with 7785 additions and 6843 deletions

View File

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

View File

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

View File

@@ -1,3 +1,11 @@
paths-ignore:
# Ignore compiled JS
- netbox/project-static/dist
query-filters:
# Exclude py/url-redirection: NetBox uses safe_for_redirect() wrapper function
# which validates all redirects via Django's url_has_allowed_host_and_scheme().
# CodeQL's taint tracking doesn't recognize wrapper functions without custom
# query configuration. See #20484.
- exclude:
id: py/url-redirection

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
rev: v0.14.1
hooks:
- id: ruff
name: "Ruff linter"

View File

@@ -12,9 +12,7 @@ django-cors-headers
# Runtime UI tool for debugging Django
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
# django-debug-toolbar v6.0.0 raises "Attribute Error at /: 'function' object has no attribute 'set'"
# see https://github.com/netbox-community/netbox/issues/19974
django-debug-toolbar==5.2.0
django-debug-toolbar
# Library for writing reusable URL query filters
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
@@ -71,7 +69,8 @@ django-timezone-field
# A REST API framework for Django projects
# https://www.django-rest-framework.org/community/release-notes/
djangorestframework
# TODO: Re-evaluate the monkey-patch of get_unique_validators() before upgrading
djangorestframework==3.16.1
# Sane and flexible OpenAPI 3 schema generation for Django REST framework.
# https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst
@@ -167,7 +166,8 @@ strawberry-graphql-django
svgwrite
# Tabular dataset library (for table-based exports)
# https://github.com/jazzband/tablib/blob/master/HISTORY.md
# Current: https://github.com/jazzband/tablib/releases
# Previous: https://github.com/jazzband/tablib/blob/master/HISTORY.md
tablib
# Timezone data (required by django-timezone-field on Python 3.9+)

View File

@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "NetBox REST API",
"version": "4.4.2",
"version": "4.4.4",
"license": {
"name": "Apache v2 License"
}
@@ -19678,14 +19678,14 @@
"in": "query",
"name": "object_type",
"schema": {
"type": "integer"
"type": "string"
}
},
{
"in": "query",
"name": "object_type__n",
"schema": {
"type": "integer"
"type": "string"
}
},
{
@@ -20507,14 +20507,14 @@
"in": "query",
"name": "related_object_type",
"schema": {
"type": "integer"
"type": "string"
}
},
{
"in": "query",
"name": "related_object_type__n",
"schema": {
"type": "integer"
"type": "string"
}
},
{
@@ -60734,14 +60734,14 @@
"in": "query",
"name": "assigned_object_type",
"schema": {
"type": "integer"
"type": "string"
}
},
{
"in": "query",
"name": "assigned_object_type__n",
"schema": {
"type": "integer"
"type": "string"
}
},
{
@@ -136422,14 +136422,14 @@
"in": "query",
"name": "assigned_object_type",
"schema": {
"type": "integer"
"type": "string"
}
},
{
"in": "query",
"name": "assigned_object_type__n",
"schema": {
"type": "integer"
"type": "string"
}
},
{
@@ -148274,14 +148274,14 @@
"in": "query",
"name": "parent_object_type",
"schema": {
"type": "integer"
"type": "string"
}
},
{
"in": "query",
"name": "parent_object_type__n",
"schema": {
"type": "integer"
"type": "string"
}
},
{
@@ -214760,7 +214760,7 @@
},
"mark_utilized": {
"type": "boolean",
"description": "Report space as 100% utilized"
"description": "Report space as fully utilized"
}
},
"required": [
@@ -214869,7 +214869,7 @@
},
"mark_utilized": {
"type": "boolean",
"description": "Report space as 100% utilized"
"description": "Report space as fully utilized"
}
},
"required": [
@@ -215569,24 +215569,26 @@
"IntegerRange": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "integer"
},
"minItems": 2,
"maxItems": 2
}
"type": "integer"
},
"minItems": 2,
"maxItems": 2,
"example": [
10,
20
]
},
"IntegerRangeRequest": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "integer"
},
"minItems": 2,
"maxItems": 2
}
"type": "integer"
},
"minItems": 2,
"maxItems": 2,
"example": [
10,
20
]
},
"Interface": {
"type": "object",
@@ -231880,7 +231882,7 @@
},
"mark_utilized": {
"type": "boolean",
"description": "Report space as 100% utilized"
"description": "Report space as fully utilized"
}
}
},
@@ -252203,7 +252205,7 @@
},
"mark_utilized": {
"type": "boolean",
"description": "Report space as 100% utilized"
"description": "Report space as fully utilized"
}
},
"required": [

View File

@@ -404,6 +404,61 @@ A complete date & time. Returns a `datetime.datetime` object.
Custom scripts can be run via the web UI by navigating to the script, completing any required form data, and clicking the "run script" button. It is possible to schedule a script to be executed at specified time in the future. A scheduled script can be canceled by deleting the associated job result object.
#### Prefilling variables via URL parameters
Script form fields can be prefilled by appending query parameters to the script URL. Each parameter name must match the variable name defined on the script class. Prefilled values are treated as initial values and can be edited before execution. Multiple values can be supplied by repeating the same parameter. Query values must be percentencoded where required (for example, spaces as `%20`).
Examples:
For string and integer variables, when a script defines:
```python
from extras.scripts import Script, StringVar, IntegerVar
class MyScript(Script):
name = StringVar()
count = IntegerVar()
```
the following URL prefills the `name` and `count` fields:
```
https://<netbox>/extras/scripts/<script_id>/?name=Branch42&count=3
```
For object variables (`ObjectVar`), supply the objects primary key (PK):
```
https://<netbox>/extras/scripts/<script_id>/?device=1
```
If an object ID cannot be resolved or the object is not visible to the requesting user, the field remains unpopulated.
Supported variable types:
| Variable class | Expected input | Example query string |
|--------------------------|---------------------------------|---------------------------------------------|
| `StringVar` | string (percentencoded) | `?name=Branch42` |
| `TextVar` | string (percentencoded) | `?notes=Initial%20value` |
| `IntegerVar` | integer | `?count=3` |
| `DecimalVar` | decimal number | `?ratio=0.75` |
| `BooleanVar` | value → `True`; empty → `False` | `?enabled=true` (True), `?enabled=` (False) |
| `ChoiceVar` | choice value (not label) | `?role=edge` |
| `MultiChoiceVar` | choice values (repeat) | `?roles=edge&roles=core` |
| `ObjectVar(Device)` | PK (integer) | `?device=1` |
| `MultiObjectVar(Device)` | PKs (repeat) | `?devices=1&devices=2` |
| `IPAddressVar` | IP address | `?ip=198.51.100.10` |
| `IPAddressWithMaskVar` | IP address with mask | `?addr=192.0.2.1/24` |
| `IPNetworkVar` | IP network prefix | `?network=2001:db8::/64` |
| `DateVar` | date `YYYY-MM-DD` | `?date=2025-01-05` |
| `DateTimeVar` | ISO datetime | `?when=2025-01-05T14:30:00` |
| `FileVar` | — (not supported) | — |
!!! note
- The parameter names above are examples; use the actual variable attribute names defined by the script.
- For `BooleanVar`, only an empty value (`?enabled=`) unchecks the box; any other value including `false` or `0` checks it.
- File uploads (`FileVar`) cannot be prefilled via URL parameters.
### Via the API
To run a script via the REST API, issue a POST request to the script's endpoint specifying the form data and commitment. For example, to run a script named `example.MyReport`, we would make a request such as the following:

View File

@@ -1,6 +1,6 @@
# Filters & Filter Sets
Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI or REST API. (Note that the GraphQL API uses a separate filter class.) NetBox employs the [django-filters2](https://django-tables2.readthedocs.io/en/latest/) library to define filter sets.
Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI or REST API. (Note that the GraphQL API uses a separate filter class.) NetBox employs the [django-filter](https://django-filter.readthedocs.io/en/stable/) library to define filter sets.
## FilterSet Classes

View File

@@ -1,5 +1,44 @@
# NetBox v4.4
## v4.4.4 (2025-10-15)
### Bug Fixes
* [#20554](https://github.com/netbox-community/netbox/issues/20554) - Fix generic relation filters to accept `<app>.<model>` format matching POST requests
* [#20574](https://github.com/netbox-community/netbox/issues/20574) - Fix excessive storage initialization overhead when listing scripts with remote backends
* [#20584](https://github.com/netbox-community/netbox/issues/20584) - Enforce PoE mode requirement on interface templates when PoE type is set
* [#20585](https://github.com/netbox-community/netbox/issues/20585) - Fix API schema generation crash for models with single-field UniqueConstraints
* [#20587](https://github.com/netbox-community/netbox/issues/20587) - Fix upgrade.sh failure when removing stale content types
---
## v4.4.3 (2025-10-14)
### Enhancements
* [#20426](https://github.com/netbox-community/netbox/issues/20426) - Add a copy-to-clipboard button for custom script output
* [#20516](https://github.com/netbox-community/netbox/issues/20516) - Improve rendering of VLAN ID ranges in VLAN group tables
### Bug Fixes
* [#19302](https://github.com/netbox-community/netbox/issues/19302) - Fix uniqueness validation in REST API for nullable fields
* [#19615](https://github.com/netbox-community/netbox/issues/19615) - Fix support for static file parameters in templates when external storage is in use
* [#19818](https://github.com/netbox-community/netbox/issues/19818) - Hide primary IP assignment fields when creating a new virtual machine in the UI
* [#19825](https://github.com/netbox-community/netbox/issues/19825) - Prevent cache for config revisions from being erroneously overwritten when debugging is enabled
* [#20140](https://github.com/netbox-community/netbox/issues/20140) - Changing a site's region or group should update any associated circuit terminations
* [#20156](https://github.com/netbox-community/netbox/issues/20156) - Fix display of rack elevation labels
* [#20290](https://github.com/netbox-community/netbox/issues/20290) - Fix migration error when upgrading to NetBox v4.4 from releases earlier than v4.3
* [#20471](https://github.com/netbox-community/netbox/issues/20471) - Saving an unmodified VLAN group should not generate a change record
* [#20475](https://github.com/netbox-community/netbox/issues/20475) - Collapse singleton VLAN IDs in VLAN group display
* [#20494](https://github.com/netbox-community/netbox/issues/20494) - Correct OpenAPI schema definition for `IntegerRangeSerializer`
* [#20496](https://github.com/netbox-community/netbox/issues/20496) - REST API should always honor `MAX_PAGE_SIZE` value
* [#20497](https://github.com/netbox-community/netbox/issues/20497) - Fix filtering of VLAN groups by VLAN ID range in GraphQL API
* [#20507](https://github.com/netbox-community/netbox/issues/20507) - Fix support for fetching ASN contacts via GraphQL API
* [#20523](https://github.com/netbox-community/netbox/issues/20523) - Hide password change form for users authenticated via SSO
* [#20542](https://github.com/netbox-community/netbox/issues/20542) - Fix the creation of MAC addresses using the "quick add" form
---
## v4.4.2 (2025-09-30)
### Enhancements

View File

@@ -1,5 +1,7 @@
from django.apps import AppConfig
from netbox import denormalized
class CircuitsConfig(AppConfig):
name = "circuits"
@@ -8,6 +10,16 @@ class CircuitsConfig(AppConfig):
def ready(self):
from netbox.models.features import register_models
from . import signals, search # noqa: F401
from .models import CircuitTermination
# Register models
register_models(*self.get_models())
denormalized.register(CircuitTermination, '_site', {
'_region': 'region',
'_site_group': 'group',
})
denormalized.register(CircuitTermination, '_location', {
'_site': 'site',
})

View File

@@ -282,18 +282,18 @@ class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
class FixIntegerRangeSerializerSchema(OpenApiSerializerExtension):
target_class = 'netbox.api.fields.IntegerRangeSerializer'
match_subclasses = True
def map_serializer(self, auto_schema: 'AutoSchema', direction: Direction) -> _SchemaType:
# One range = two integers; many=True will wrap this in an outer array
return {
'type': 'array',
'items': {
'type': 'array',
'items': {
'type': 'integer',
},
'minItems': 2,
'maxItems': 2,
'type': 'integer',
},
'minItems': 2,
'maxItems': 2,
'example': [10, 20],
}

View File

@@ -80,6 +80,7 @@ class JobFilterSet(BaseFilterSet):
method='search',
label=_('Search'),
)
object_type = ContentTypeFilter()
created = django_filters.DateTimeFilter()
created__before = django_filters.DateTimeFilter(
field_name='created',
@@ -169,6 +170,7 @@ class ObjectChangeFilterSet(BaseFilterSet):
changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ContentType.objects.all()
)
related_object_type = ContentTypeFilter()
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
label=_('User (ID)'),

View File

@@ -3,12 +3,12 @@ from typing import Annotated, List, TYPE_CHECKING
import strawberry
import strawberry_django
from django.contrib.contenttypes.models import ContentType
from strawberry.types import Info
from core.models import ObjectChange
if TYPE_CHECKING:
from core.graphql.types import DataFileType, DataSourceType
from netbox.core.graphql.types import ObjectChangeType
from core.graphql.types import DataFileType, DataSourceType, ObjectChangeType
__all__ = (
'ChangelogMixin',
@@ -20,7 +20,7 @@ __all__ = (
class ChangelogMixin:
@strawberry_django.field
def changelog(self, info) -> List[Annotated["ObjectChangeType", strawberry.lazy('.types')]]: # noqa: F821
def changelog(self, info: Info) -> List[Annotated['ObjectChangeType', strawberry.lazy('.types')]]: # noqa: F821
content_type = ContentType.objects.get_for_model(self)
object_changes = ObjectChange.objects.filter(
changed_object_type=content_type,
@@ -31,5 +31,5 @@ class ChangelogMixin:
@strawberry.type
class SyncedDataMixin:
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
data_source: Annotated['DataSourceType', strawberry.lazy('core.graphql.types')] | None
data_file: Annotated['DataFileType', strawberry.lazy('core.graphql.types')] | None

View File

@@ -0,0 +1,48 @@
# Generated by Django 5.2.5 on 2025-09-09 16:48
from django.db import migrations, models
def get_active(apps, schema_editor):
from django.core.cache import cache
ConfigRevision = apps.get_model('core', 'ConfigRevision')
version = None
revision = None
# Try and get the latest version from cache
try:
version = cache.get('config_version')
except Exception:
pass
# If there is a version in cache, attempt to set revision to the current version from cache
# If the version in cache does not exist or there is no version, try the lastest revision in the database
if not version or (version and not (revision := ConfigRevision.objects.filter(pk=version).first())):
revision = ConfigRevision.objects.order_by('-created').first()
# If there is a revision set, set the active revision
if revision:
revision.active = True
revision.save()
class Migration(migrations.Migration):
dependencies = [
('core', '0018_concrete_objecttype'),
]
operations = [
migrations.AddField(
model_name='configrevision',
name='active',
field=models.BooleanField(default=False),
),
migrations.RunPython(code=get_active, reverse_code=migrations.RunPython.noop),
migrations.AddConstraint(
model_name='configrevision',
constraint=models.UniqueConstraint(
condition=models.Q(('active', True)), fields=('active',), name='unique_active_config_revision'
),
),
]

View File

@@ -14,6 +14,9 @@ class ConfigRevision(models.Model):
"""
An atomic revision of NetBox's configuration.
"""
active = models.BooleanField(
default=False
)
created = models.DateTimeField(
verbose_name=_('created'),
auto_now_add=True
@@ -35,6 +38,13 @@ class ConfigRevision(models.Model):
ordering = ['-created']
verbose_name = _('config revision')
verbose_name_plural = _('config revisions')
constraints = [
models.UniqueConstraint(
fields=('active',),
condition=models.Q(active=True),
name='unique_active_config_revision',
)
]
def __str__(self):
if not self.pk:
@@ -59,8 +69,13 @@ class ConfigRevision(models.Model):
"""
cache.set('config', self.data, None)
cache.set('config_version', self.pk, None)
# Set all instances of ConfigRevision to false and set this instance to true
ConfigRevision.objects.all().update(active=False)
ConfigRevision.objects.filter(pk=self.pk).update(active=True)
activate.alters_data = True
@property
def is_active(self):
return cache.get('config_version') == self.pk
return self.active

View File

@@ -5,7 +5,7 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.indexes import GinIndex
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.db import connection, models
from django.db.models import Q
from django.utils.translation import gettext as _
@@ -66,6 +66,14 @@ class ObjectTypeManager(models.Manager):
"""
from netbox.models.features import get_model_features, model_is_public
# 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 inspect.isclass(model):
model = model.__class__
opts = self._get_opts(model, for_concrete_model)

View File

@@ -14,16 +14,16 @@ from netbox.filtersets import (
AttributeFiltersMixin, BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet,
OrganizationalModelFilterSet,
)
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from tenancy.models import *
from users.models import User
from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
NumericArrayFilter, TreeNodeMultipleChoiceFilter,
)
from virtualization.models import Cluster, ClusterGroup, VMInterface, VirtualMachine
from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
from vpn.models import L2VPN
from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
from wireless.models import WirelessLAN, WirelessLink
from .choices import *
from .constants import *
@@ -1764,6 +1764,7 @@ class PowerOutletFilterSet(
class MACAddressFilterSet(NetBoxModelFilterSet):
mac_address = MultiValueMACAddressFilter()
assigned_object_type = ContentTypeFilter()
device = MultiValueCharFilter(
method='filter_device',
field_name='name',
@@ -1806,6 +1807,14 @@ class MACAddressFilterSet(NetBoxModelFilterSet):
queryset=VMInterface.objects.all(),
label=_('VM interface (ID)'),
)
assigned = django_filters.BooleanFilter(
method='filter_assigned',
label=_('Is assigned'),
)
primary = django_filters.BooleanFilter(
method='filter_primary',
label=_('Is primary'),
)
class Meta:
model = MACAddress
@@ -1842,6 +1851,29 @@ class MACAddressFilterSet(NetBoxModelFilterSet):
vminterface__in=interface_ids
)
def filter_assigned(self, queryset, name, value):
params = {
'assigned_object_type__isnull': True,
'assigned_object_id__isnull': True,
}
if value:
return queryset.exclude(**params)
else:
return queryset.filter(**params)
def filter_primary(self, queryset, name, value):
interface_mac_ids = Interface.objects.filter(primary_mac_address_id__isnull=False).values_list(
'primary_mac_address_id', flat=True
)
vminterface_mac_ids = VMInterface.objects.filter(primary_mac_address_id__isnull=False).values_list(
'primary_mac_address_id', flat=True
)
query = Q(pk__in=interface_mac_ids) | Q(pk__in=vminterface_mac_ids)
if value:
return queryset.filter(query)
else:
return queryset.exclude(query)
class CommonInterfaceFilterSet(django_filters.FilterSet):
mode = django_filters.MultipleChoiceFilter(

View File

@@ -1676,12 +1676,16 @@ class MACAddressFilterForm(NetBoxModelFilterSetForm):
model = MACAddress
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('mac_address', 'device_id', 'virtual_machine_id', name=_('MAC address')),
FieldSet('mac_address', name=_('Attributes')),
FieldSet(
'device_id', 'virtual_machine_id', 'assigned', 'primary',
name=_('Assignments'),
),
)
selector_fields = ('filter_id', 'q', 'device_id', 'virtual_machine_id')
mac_address = forms.CharField(
required=False,
label=_('MAC address')
label=_('MAC address'),
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
@@ -1693,6 +1697,20 @@ class MACAddressFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('Assigned VM'),
)
assigned = forms.NullBooleanField(
required=False,
label=_('Assigned to an interface'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
),
)
primary = forms.NullBooleanField(
required=False,
label=_('Primary MAC of an interface'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
),
)
tag = TagFilterField(model)

View File

@@ -755,7 +755,10 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm):
queryset=ModuleBay.objects.all(),
query_params={
'device_id': '$device'
}
},
context={
'disabled': 'installed_module',
},
)
module_type = DynamicModelChoiceField(
label=_('Module type'),

View File

@@ -18,7 +18,9 @@ from netbox.graphql.filter_mixins import (
ImageAttachmentFilterMixin,
WeightFilterMixin,
)
from tenancy.graphql.filter_mixins import TenancyFilterMixin, ContactFilterMixin
from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
from virtualization.models import VMInterface
from .filter_mixins import (
CabledObjectModelFilterMixin,
ComponentModelFilterMixin,
@@ -419,6 +421,24 @@ class MACAddressFilter(PrimaryModelFilterMixin):
)
assigned_object_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter_field()
def assigned(self, value: bool, prefix) -> Q:
return Q(**{f'{prefix}assigned_object_id__isnull': (not value)})
@strawberry_django.filter_field()
def primary(self, value: bool, prefix) -> Q:
interface_mac_ids = models.Interface.objects.filter(primary_mac_address_id__isnull=False).values_list(
'primary_mac_address_id', flat=True
)
vminterface_mac_ids = VMInterface.objects.filter(primary_mac_address_id__isnull=False).values_list(
'primary_mac_address_id', flat=True
)
query = Q(**{f'{prefix}pk__in': interface_mac_ids}) | Q(**{f'{prefix}pk__in': vminterface_mac_ids})
if value:
return Q(query)
else:
return ~Q(query)
@strawberry_django.filter_type(models.Interface, lookups=True)
class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin, CabledObjectModelFilterMixin):

View File

@@ -1,3 +1,5 @@
from strawberry.types import Info
from circuits.graphql.types import CircuitTerminationType, ProviderNetworkType
from circuits.models import CircuitTermination, ProviderNetwork
from dcim.graphql.types import (
@@ -49,7 +51,7 @@ class InventoryItemTemplateComponentType:
)
@classmethod
def resolve_type(cls, instance, info):
def resolve_type(cls, instance, info: Info):
if type(instance) is ConsolePortTemplate:
return ConsolePortTemplateType
if type(instance) is ConsoleServerPortTemplate:
@@ -79,7 +81,7 @@ class InventoryItemComponentType:
)
@classmethod
def resolve_type(cls, instance, info):
def resolve_type(cls, instance, info: Info):
if type(instance) is ConsolePort:
return ConsolePortType
if type(instance) is ConsoleServerPort:
@@ -112,7 +114,7 @@ class ConnectedEndpointType:
)
@classmethod
def resolve_type(cls, instance, info):
def resolve_type(cls, instance, info: Info):
if type(instance) is CircuitTermination:
return CircuitTerminationType
if type(instance) is ConsolePortType:

View File

@@ -7,6 +7,7 @@ from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import *
from dcim.constants import *
from dcim.models.mixins import InterfaceValidationMixin
from netbox.models import ChangeLoggedModel
from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager
@@ -405,7 +406,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
}
class InterfaceTemplate(ModularComponentTemplateModel):
class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel):
"""
A template for a physical data interface on a new Device.
"""
@@ -469,8 +470,6 @@ class InterfaceTemplate(ModularComponentTemplateModel):
super().clean()
if self.bridge:
if self.pk and self.bridge_id == self.pk:
raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
if self.device_type and self.device_type != self.bridge.device_type:
raise ValidationError({
'bridge': _(
@@ -484,11 +483,6 @@ class InterfaceTemplate(ModularComponentTemplateModel):
).format(bridge=self.bridge)
})
if self.rf_role and self.type not in WIRELESS_IFACE_TYPES:
raise ValidationError({
'rf_role': "Wireless role may be set only on wireless interfaces."
})
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),

View File

@@ -11,6 +11,7 @@ from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import *
from dcim.constants import *
from dcim.fields import WWNField
from dcim.models.mixins import InterfaceValidationMixin
from netbox.choices import ColorChoices
from netbox.models import OrganizationalModel, NetBoxModel
from utilities.fields import ColorField, NaturalOrderingField
@@ -676,7 +677,14 @@ class BaseInterface(models.Model):
return self.primary_mac_address.mac_address
class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint, TrackingModelMixin):
class Interface(
InterfaceValidationMixin,
ModularComponentModel,
BaseInterface,
CabledObjectModel,
PathEndpoint,
TrackingModelMixin,
):
"""
A network interface within a Device. A physical Interface can connect to exactly one other Interface.
"""
@@ -893,10 +901,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
# Bridge validation
# An interface cannot be bridged to itself
if self.pk and self.bridge_id == self.pk:
raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
# A bridged interface belongs to the same device or virtual chassis
if self.bridge and self.bridge.device != self.device:
if self.device.virtual_chassis is None:
@@ -942,29 +946,9 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
)
})
# PoE validation
# Only physical interfaces may have a PoE mode/type assigned
if self.poe_mode and self.is_virtual:
raise ValidationError({
'poe_mode': _("Virtual interfaces cannot have a PoE mode.")
})
if self.poe_type and self.is_virtual:
raise ValidationError({
'poe_type': _("Virtual interfaces cannot have a PoE type.")
})
# An interface with a PoE type set must also specify a mode
if self.poe_type and not self.poe_mode:
raise ValidationError({
'poe_type': _("Must specify PoE mode when designating a PoE type.")
})
# Wireless validation
# RF role & channel may only be set for wireless interfaces
if self.rf_role and not self.is_wireless:
raise ValidationError({'rf_role': _("Wireless role may be set only on wireless interfaces.")})
# RF channel may only be set for wireless interfaces
if self.rf_channel and not self.is_wireless:
raise ValidationError({'rf_channel': _("Channel may be set only on wireless interfaces.")})

View File

@@ -4,8 +4,11 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from dcim.constants import VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES
__all__ = (
'CachedScopeMixin',
'InterfaceValidationMixin',
'RenderConfigMixin',
)
@@ -116,3 +119,33 @@ class CachedScopeMixin(models.Model):
self._site = self.scope.site
self._location = self.scope
cache_related_objects.alters_data = True
class InterfaceValidationMixin:
def clean(self):
super().clean()
# An interface cannot be bridged to itself
if self.pk and self.bridge_id == self.pk:
raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
# Only physical interfaces may have a PoE mode/type assigned
if self.poe_mode and self.type in VIRTUAL_IFACE_TYPES:
raise ValidationError({
'poe_mode': _("Virtual interfaces cannot have a PoE mode.")
})
if self.poe_type and self.type in VIRTUAL_IFACE_TYPES:
raise ValidationError({
'poe_type': _("Virtual interfaces cannot have a PoE type.")
})
# An interface with a PoE type set must also specify a mode
if self.poe_type and not self.poe_mode:
raise ValidationError({
'poe_type': _("Must specify PoE mode when designating a PoE type.")
})
# RF role may be set only for wireless interfaces
if self.rf_role and self.type not in WIRELESS_IFACE_TYPES:
raise ValidationError({'rf_role': _("Wireless role may be set only on wireless interfaces.")})

View File

@@ -196,7 +196,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
verbose_name=_('Type')
)
u_height = columns.TemplateColumn(
accessor=tables.A('device_type.u_height'),
accessor=tables.A('device_type__u_height'),
verbose_name=_('U Height'),
template_code='{{ value|floatformat }}'
)
@@ -1174,6 +1174,9 @@ class MACAddressTable(NetBoxTable):
orderable=False,
verbose_name=_('Parent')
)
is_primary = columns.BooleanColumn(
verbose_name=_('Primary')
)
tags = columns.TagColumn(
url_name='dcim:macaddress_list'
)
@@ -1184,7 +1187,7 @@ class MACAddressTable(NetBoxTable):
class Meta(DeviceComponentTable.Meta):
model = models.MACAddress
fields = (
'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description', 'comments', 'tags',
'created', 'last_updated',
'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description', 'is_primary',
'comments', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description')

View File

@@ -10,7 +10,7 @@ from netbox.choices import ColorChoices, WeightUnitChoices
from tenancy.models import Tenant, TenantGroup
from users.models import User
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
from virtualization.models import Cluster, ClusterType, ClusterGroup, VMInterface, VirtualMachine
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
from wireless.models import WirelessLink
@@ -7164,9 +7164,20 @@ class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
MACAddress(mac_address='00-00-00-05-01-01', assigned_object=vm_interfaces[1]),
MACAddress(mac_address='00-00-00-06-01-01', assigned_object=vm_interfaces[2]),
MACAddress(mac_address='00-00-00-06-01-02', assigned_object=vm_interfaces[2]),
# unassigned
MACAddress(mac_address='00-00-00-07-01-01'),
)
MACAddress.objects.bulk_create(mac_addresses)
# Set MAC addresses as primary
for idx, interface in enumerate(interfaces):
interface.primary_mac_address = mac_addresses[idx]
interface.save()
for idx, vm_interface in enumerate(vm_interfaces):
# Offset by 4 for device MACs
vm_interface.primary_mac_address = mac_addresses[idx + 4]
vm_interface.save()
def test_mac_address(self):
params = {'mac_address': ['00-00-00-01-01-01', '00-00-00-02-01-01']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -7198,3 +7209,15 @@ class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'vminterface': [vm_interfaces[0].name, vm_interfaces[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_assigned(self):
params = {'assigned': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
params = {'assigned': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_primary(self):
params = {'primary': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'primary': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)

View File

@@ -7,13 +7,14 @@ from django.test import override_settings, tag
from django.urls import reverse
from netaddr import EUI
from core.models import ObjectType
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from ipam.models import ASN, RIR, VLAN, VRF
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, WeightUnitChoices
from tenancy.models import Tenant
from users.models import User
from users.models import ObjectPermission, User
from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
from wireless.models import WirelessLAN
@@ -3728,3 +3729,29 @@ class MACAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.bulk_edit_data = {
'description': 'New description',
}
@tag('regression') # Issue #20542
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_create_macaddress_via_quickadd(self):
"""
Test creating a MAC address via quick-add modal (e.g., from Interface form).
Regression test for issue #20542 where form prefix was missing in POST handler.
"""
obj_perm = ObjectPermission(name='Test permission', actions=['add'])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
# Simulate quick-add form submission with 'quickadd-' prefix
formatted_data = post_data(self.form_data)
quickadd_data = {f'quickadd-{k}': v for k, v in formatted_data.items()}
quickadd_data['_quickadd'] = 'True'
initial_count = self._get_queryset().count()
url = f"{self._get_url('add')}?_quickadd=True&target=id_primary_mac_address"
response = self.client.post(url, data=quickadd_data)
# Should successfully create the MAC address and return the quick_add_created template
self.assertHttpStatus(response, 200)
self.assertIn(b'quick-add-object', response.content)
self.assertEqual(initial_count + 1, self._get_queryset().count())

View File

@@ -5,6 +5,7 @@ from rest_framework import serializers
from core.api.serializers_.jobs import JobSerializer
from extras.models import Script
from netbox.api.serializers import ValidatedModelSerializer
from utilities.datetime import local_now
__all__ = (
'ScriptDetailSerializer',
@@ -66,11 +67,31 @@ class ScriptInputSerializer(serializers.Serializer):
interval = serializers.IntegerField(required=False, allow_null=True)
def validate_schedule_at(self, value):
if value and not self.context['script'].python_class.scheduling_enabled:
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
"""
Validates the specified schedule time for a script execution.
"""
if value:
if not self.context['script'].python_class.scheduling_enabled:
raise serializers.ValidationError(_('Scheduling is not enabled for this script.'))
if value < local_now():
raise serializers.ValidationError(_('Scheduled time must be in the future.'))
return value
def validate_interval(self, value):
"""
Validates the provided interval based on the script's scheduling configuration.
"""
if value and not self.context['script'].python_class.scheduling_enabled:
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
raise serializers.ValidationError(_('Scheduling is not enabled for this script.'))
return value
def validate(self, data):
"""
Validates the given data and ensures the necessary fields are populated.
"""
# Set the schedule_at time to now if only an interval is provided
# while handling the case where schedule_at is null.
if data.get('interval') and not data.get('schedule_at'):
data['schedule_at'] = local_now()
return super().validate(data)

View File

@@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Annotated, List
import strawberry
import strawberry_django
from strawberry.types import Info
__all__ = (
'ConfigContextMixin',
@@ -37,7 +38,7 @@ class CustomFieldsMixin:
class ImageAttachmentsMixin:
@strawberry_django.field
def image_attachments(self, info) -> List[Annotated["ImageAttachmentType", strawberry.lazy('.types')]]:
def image_attachments(self, info: Info) -> List[Annotated['ImageAttachmentType', strawberry.lazy('.types')]]:
return self.images.restrict(info.context.request.user, 'view')
@@ -45,17 +46,17 @@ class ImageAttachmentsMixin:
class JournalEntriesMixin:
@strawberry_django.field
def journal_entries(self, info) -> List[Annotated["JournalEntryType", strawberry.lazy('.types')]]:
def journal_entries(self, info: Info) -> List[Annotated['JournalEntryType', strawberry.lazy('.types')]]:
return self.journal_entries.all()
@strawberry.type
class TagsMixin:
tags: List[Annotated["TagType", strawberry.lazy('.types')]]
tags: List[Annotated['TagType', strawberry.lazy('.types')]]
@strawberry.type
class ContactsMixin:
contacts: List[Annotated["ContactAssignmentType", strawberry.lazy('tenancy.graphql.types')]]
contacts: List[Annotated['ContactAssignmentType', strawberry.lazy('tenancy.graphql.types')]]

View File

@@ -1,9 +1,39 @@
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.fields.ranges import RangeField
from django.db.models import CharField, JSONField, Lookup
from django.db.models.fields.json import KeyTextTransform
from .fields import CachedValueField
class RangeContains(Lookup):
"""
Filter ArrayField(RangeField) columns where ANY element-range contains the scalar RHS.
Usage (ORM):
Model.objects.filter(<range_array_field>__range_contains=<scalar>)
Works with int4range[], int8range[], daterange[], tstzrange[], etc.
"""
lookup_name = 'range_contains'
def as_sql(self, compiler, connection):
# Compile LHS (the array-of-ranges column/expression) and RHS (scalar)
lhs, lhs_params = self.process_lhs(compiler, connection)
rhs, rhs_params = self.process_rhs(compiler, connection)
# Guard: only allow ArrayField whose base_field is a PostgreSQL RangeField
field = getattr(self.lhs, 'output_field', None)
if not (isinstance(field, ArrayField) and isinstance(field.base_field, RangeField)):
raise TypeError('range_contains is only valid for ArrayField(RangeField) columns')
# Range-contains-element using EXISTS + UNNEST keeps the range on the LHS: r @> value
sql = f"EXISTS (SELECT 1 FROM unnest({lhs}) AS r WHERE r @> {rhs})"
params = lhs_params + rhs_params
return sql, params
class Empty(Lookup):
"""
Filter on whether a string is empty.
@@ -25,7 +55,7 @@ class JSONEmpty(Lookup):
A key is considered empty if it is "", null, or does not exist.
"""
lookup_name = "empty"
lookup_name = 'empty'
def as_sql(self, compiler, connection):
# self.lhs.lhs is the parent expression (could be a JSONField or another KeyTransform)
@@ -69,6 +99,7 @@ class NetContainsOrEquals(Lookup):
return 'CAST(%s AS INET) >>= %s' % (lhs, rhs), params
ArrayField.register_lookup(RangeContains)
CharField.register_lookup(Empty)
JSONField.register_lookup(JSONEmpty)
CachedValueField.register_lookup(NetHost)

View File

@@ -535,6 +535,15 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# URL
elif self.type == CustomFieldTypeChoices.TYPE_URL:
field = LaxURLField(assume_scheme='https', required=required, initial=initial)
if self.validation_regex:
field.validators = [
RegexValidator(
regex=self.validation_regex,
message=mark_safe(_("Values must match this regex: <code>{regex}</code>").format(
regex=escape(self.validation_regex)
))
)
]
# JSON
elif self.type == CustomFieldTypeChoices.TYPE_JSON:
@@ -684,6 +693,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
if self.validation_regex and not re.match(self.validation_regex, value):
raise ValidationError(_("Value must match regex '{regex}'").format(regex=self.validation_regex))
# Validate URL field
elif self.type == CustomFieldTypeChoices.TYPE_URL:
if type(value) is not str:
raise ValidationError(_("Value must be a string."))
if self.validation_regex and not re.match(self.validation_regex, value):
raise ValidationError(_("Value must match regex '{regex}'").format(regex=self.validation_regex))
# Validate integer
elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
if type(value) is not int:

View File

@@ -3,6 +3,7 @@ import importlib.util
import os
import sys
from django.core.cache import cache
from django.core.files.storage import storages
from django.db import models
from django.http import HttpResponse
@@ -30,7 +31,14 @@ class CustomStoragesLoader(importlib.abc.Loader):
return None # Use default module creation
def exec_module(self, module):
storage = storages.create_storage(storages.backends["scripts"])
# Cache storage for 5 minutes (300 seconds)
cache_key = "storage_scripts"
storage = cache.get(cache_key)
if storage is None:
storage = storages['scripts']
cache.set(cache_key, storage, timeout=300) # 5 minutes
with storage.open(self.filename, 'rb') as f:
code = f.read()
exec(code, module.__dict__)

View File

@@ -90,7 +90,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
ConfigContext.objects.filter(
self._get_config_context_filters()
).annotate(
_data=EmptyGroupByJSONBAgg('data', ordering=['weight', 'name'])
_data=EmptyGroupByJSONBAgg('data', order_by=['weight', 'name'])
).values("_data").order_by()
)
)

View File

@@ -329,6 +329,9 @@ class BaseScript:
# Declare the placeholder for the current request
self.request = None
# Initiate the storage backend (local, S3, etc) as a class attr
self.storage = storages.create_storage(storages.backends["scripts"])
# Compile test methods and initialize results skeleton
for method in dir(self):
if method.startswith('test_') and callable(getattr(self, method)):
@@ -394,8 +397,7 @@ class BaseScript:
return inspect.getfile(self.__class__)
def findsource(self, object):
storage = storages.create_storage(storages.backends["scripts"])
with storage.open(os.path.basename(self.filename), 'r') as f:
with self.storage.open(os.path.basename(self.filename), 'r') as f:
data = f.read()
# Break the source code into lines

View File

@@ -3,6 +3,7 @@ import datetime
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils.timezone import make_aware, now
from rest_framework import status
from core.choices import ManagedFileRootPathChoices
from core.events import *
@@ -858,16 +859,16 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
class ScriptTest(APITestCase):
class TestScriptClass(PythonClass):
class Meta:
name = "Test script"
name = 'Test script'
commit = True
scheduling_enabled = True
var1 = StringVar()
var2 = IntegerVar()
var3 = BooleanVar()
def run(self, data, commit=True):
self.log_info(data['var1'])
self.log_success(data['var2'])
self.log_failure(data['var3'])
@@ -878,14 +879,16 @@ class ScriptTest(APITestCase):
def setUpTestData(cls):
module = ScriptModule.objects.create(
file_root=ManagedFileRootPathChoices.SCRIPTS,
file_path='/var/tmp/script.py'
file_path='script.py',
)
Script.objects.create(
script = Script.objects.create(
module=module,
name="Test script",
name='Test script',
is_executable=True,
)
cls.url = reverse('extras-api:script-detail', kwargs={'pk': script.pk})
@property
def python_class(self):
return self.TestScriptClass
@@ -898,7 +901,7 @@ class ScriptTest(APITestCase):
def test_get_script(self):
module = ScriptModule.objects.get(
file_root=ManagedFileRootPathChoices.SCRIPTS,
file_path='/var/tmp/script.py'
file_path='script.py',
)
script = module.scripts.all().first()
url = reverse('extras-api:script-detail', kwargs={'pk': script.pk})
@@ -909,6 +912,76 @@ class ScriptTest(APITestCase):
self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
def test_schedule_script_past_time_rejected(self):
"""
Scheduling with past schedule_at should fail.
"""
self.add_permissions('extras.run_script')
payload = {
'data': {'var1': 'hello', 'var2': 1, 'var3': False},
'commit': True,
'schedule_at': now() - datetime.timedelta(hours=1),
}
response = self.client.post(self.url, payload, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertIn('schedule_at', response.data)
# Be tolerant of exact wording but ensure we failed on schedule_at being in the past
self.assertIn('future', str(response.data['schedule_at']).lower())
def test_schedule_script_interval_only(self):
"""
Interval without schedule_at should auto-set schedule_at now.
"""
self.add_permissions('extras.run_script')
payload = {
'data': {'var1': 'hello', 'var2': 1, 'var3': False},
'commit': True,
'interval': 60,
}
response = self.client.post(self.url, payload, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
# The latest job is returned in the script detail serializer under "result"
self.assertIn('result', response.data)
self.assertEqual(response.data['result']['interval'], 60)
# Ensure a start time was autopopulated
self.assertIsNotNone(response.data['result']['scheduled'])
def test_schedule_script_when_disabled(self):
"""
Scheduling should fail when script.scheduling_enabled=False.
"""
self.add_permissions('extras.run_script')
# Temporarily disable scheduling on the in-test Python class
original = getattr(self.TestScriptClass.Meta, 'scheduling_enabled', True)
self.TestScriptClass.Meta.scheduling_enabled = False
base = {
'data': {'var1': 'hello', 'var2': 1, 'var3': False},
'commit': True,
}
# Check both schedule_at and interval paths
cases = [
{**base, 'schedule_at': now() + datetime.timedelta(minutes=5)},
{**base, 'interval': 60},
]
try:
for case in cases:
with self.subTest(case=list(case.keys())):
response = self.client.post(self.url, case, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
# Error should be attached to whichever field we used
key = 'schedule_at' if 'schedule_at' in case else 'interval'
self.assertIn(key, response.data)
self.assertIn('scheduling is not enabled', str(response.data[key]).lower())
finally:
# Restore the original setting for other tests
self.TestScriptClass.Meta.scheduling_enabled = original
class CreatedUpdatedFilterTest(APITestCase):

View File

@@ -1300,6 +1300,28 @@ class CustomFieldAPITest(APITestCase):
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
def test_url_regex_validation(self):
"""
Test that validation_regex is applied to URL custom fields (fixes #20498).
"""
site2 = Site.objects.get(name='Site 2')
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
self.add_permissions('dcim.change_site')
cf_url = CustomField.objects.get(name='url_field')
cf_url.validation_regex = r'^https://' # Require HTTPS
cf_url.save()
# Test invalid URL (http instead of https)
data = {'custom_fields': {'url_field': 'http://example.com'}}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
# Test valid URL (https)
data = {'custom_fields': {'url_field': 'https://example.com'}}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
def test_uniqueness_validation(self):
# Create a unique custom field
cf_text = CustomField.objects.get(name='text_field')

View File

@@ -595,6 +595,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFil
to_field_name='rd',
label=_('VRF (RD)'),
)
assigned_object_type = ContentTypeFilter()
device = MultiValueCharFilter(
method='filter_device',
field_name='name',
@@ -908,7 +909,8 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
method='filter_scope'
)
contains_vid = django_filters.NumberFilter(
method='filter_contains_vid'
field_name='vid_ranges',
lookup_expr='range_contains',
)
class Meta:
@@ -931,21 +933,6 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
scope_id=value
)
def filter_contains_vid(self, queryset, name, value):
"""
Return all VLANGroups which contain the given VLAN ID.
"""
table_name = VLANGroup._meta.db_table
# TODO: See if this can be optimized without compromising queryset integrity
# Expand VLAN ID ranges to query by integer
groups = VLANGroup.objects.raw(
f'SELECT id FROM {table_name}, unnest(vid_ranges) vid_range WHERE %s <@ vid_range',
params=(value,)
)
return queryset.filter(
pk__in=[g.id for g in groups]
)
class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
@@ -1166,6 +1153,7 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet):
class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet):
parent_object_type = ContentTypeFilter()
device = MultiValueCharFilter(
method='filter_device',
field_name='name',

View File

@@ -19,7 +19,7 @@ from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
from virtualization.models import VMInterface
if TYPE_CHECKING:
from netbox.graphql.filter_lookups import IntegerArrayLookup, IntegerLookup
from netbox.graphql.filter_lookups import IntegerLookup, IntegerRangeArrayLookup
from circuits.graphql.filters import ProviderFilter
from core.graphql.filters import ContentTypeFilter
from dcim.graphql.filters import SiteFilter
@@ -170,7 +170,7 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
@strawberry_django.filter_field()
def assigned(self, value: bool, prefix) -> Q:
return Q(assigned_object_id__isnull=(not value))
return Q(**{f"{prefix}assigned_object_id__isnull": not value})
@strawberry_django.filter_field()
def parent(self, value: list[str], prefix) -> Q:
@@ -340,7 +340,7 @@ class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
@strawberry_django.filter_type(models.VLANGroup, lookups=True)
class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilterMixin):
vid_ranges: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
vid_ranges: Annotated['IntegerRangeArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)

View File

@@ -74,7 +74,7 @@ class BaseIPAddressFamilyType:
filters=ASNFilter,
pagination=True
)
class ASNType(NetBoxObjectType):
class ASNType(NetBoxObjectType, ContactsMixin):
asn: BigInt
rir: Annotated["RIRType", strawberry.lazy('ipam.graphql.types')] | None
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None

View File

@@ -10,9 +10,9 @@ from django.utils.translation import gettext_lazy as _
from dcim.models import Interface, Site, SiteGroup
from ipam.choices import *
from ipam.constants import *
from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
from ipam.querysets import VLANGroupQuerySet, VLANQuerySet
from netbox.models import OrganizationalModel, PrimaryModel, NetBoxModel
from utilities.data import check_ranges_overlap, ranges_to_string
from utilities.data import check_ranges_overlap, ranges_to_string, ranges_to_string_list
from virtualization.models import VMInterface
__all__ = (
@@ -164,8 +164,18 @@ class VLANGroup(OrganizationalModel):
"""
return VLAN.objects.filter(group=self).order_by('vid')
@property
def vid_ranges_items(self):
"""
Property that converts VID ranges to a list of string representations.
"""
return ranges_to_string_list(self.vid_ranges)
@property
def vid_ranges_list(self):
"""
Property that converts VID ranges into a string representation.
"""
return ranges_to_string(self.vid_ranges)

View File

@@ -3,6 +3,7 @@ import django_tables2 as tables
from ipam.models import *
from netbox.tables import NetBoxTable, columns
from tenancy.tables import ContactsColumnMixin
__all__ = (
'ServiceTable',
@@ -35,7 +36,7 @@ class ServiceTemplateTable(NetBoxTable):
default_columns = ('pk', 'name', 'protocol', 'ports', 'description')
class ServiceTable(NetBoxTable):
class ServiceTable(ContactsColumnMixin, NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True
@@ -60,7 +61,7 @@ class ServiceTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Service
fields = (
'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags',
'created', 'last_updated',
'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'contacts', 'comments',
'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')

View File

@@ -41,7 +41,8 @@ class VLANGroupTable(TenancyColumnsMixin, NetBoxTable):
linkify=True,
orderable=False
)
vid_ranges_list = tables.Column(
vid_ranges_list = columns.ArrayColumn(
accessor='vid_ranges_items',
verbose_name=_('VID Ranges'),
orderable=False
)

View File

@@ -1723,6 +1723,10 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'contains_vid': 1}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
params = {'contains_vid': 12} # 11 is NOT in [1,11)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'contains_vid': 4095}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
def test_region(self):
params = {'region': Region.objects.first().pk}

View File

@@ -0,0 +1,66 @@
from django.test import TestCase
from django.db.backends.postgresql.psycopg_any import NumericRange
from ipam.models import VLANGroup
class VLANGroupRangeContainsLookupTests(TestCase):
@classmethod
def setUpTestData(cls):
# Two ranges: [1,11) and [20,31)
cls.g1 = VLANGroup.objects.create(
name='VlanGroup-A',
slug='VlanGroup-A',
vid_ranges=[NumericRange(1, 11), NumericRange(20, 31)],
)
# One range: [100,201)
cls.g2 = VLANGroup.objects.create(
name='VlanGroup-B',
slug='VlanGroup-B',
vid_ranges=[NumericRange(100, 201)],
)
cls.g_empty = VLANGroup.objects.create(
name='VlanGroup-empty',
slug='VlanGroup-empty',
vid_ranges=[],
)
def test_contains_value_in_first_range(self):
"""
Tests whether a specific value is contained within the first range in a queried
set of VLANGroup objects.
"""
names = list(
VLANGroup.objects.filter(vid_ranges__range_contains=10).values_list('name', flat=True).order_by('name')
)
self.assertEqual(names, ['VlanGroup-A'])
def test_contains_value_in_second_range(self):
"""
Tests if a value exists in the second range of VLANGroup objects and
validates the result against the expected list of names.
"""
names = list(
VLANGroup.objects.filter(vid_ranges__range_contains=25).values_list('name', flat=True).order_by('name')
)
self.assertEqual(names, ['VlanGroup-A'])
def test_upper_bound_is_exclusive(self):
"""
Tests if the upper bound of the range is exclusive in the filter method.
"""
# 11 is NOT in [1,11)
self.assertFalse(VLANGroup.objects.filter(vid_ranges__range_contains=11).exists())
def test_no_match_far_outside(self):
"""
Tests that no VLANGroup contains a VID within a specified range far outside
common VID bounds and returns `False`.
"""
self.assertFalse(VLANGroup.objects.filter(vid_ranges__range_contains=4095).exists())
def test_empty_array_never_matches(self):
"""
Tests the behavior of VLANGroup objects when an empty array is used to match a
specific condition.
"""
self.assertFalse(VLANGroup.objects.filter(pk=self.g_empty.pk, vid_ranges__range_contains=1).exists())

View File

@@ -169,7 +169,7 @@ class IntegerRangeSerializer(serializers.Serializer):
if type(data[0]) is not int or type(data[1]) is not int:
raise ValidationError(_("Range boundaries must be defined as integers."))
return NumericRange(data[0], data[1], bounds='[]')
return NumericRange(data[0], data[1] + 1, bounds='[)')
def to_representation(self, instance):
return instance.lower, instance.upper - 1

View File

@@ -44,22 +44,28 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
return list(queryset[self.offset:])
def get_limit(self, request):
max_limit = self.default_limit
MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
if MAX_PAGE_SIZE:
max_limit = min(max_limit, MAX_PAGE_SIZE)
if self.limit_query_param:
MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
if MAX_PAGE_SIZE:
MAX_PAGE_SIZE = max(MAX_PAGE_SIZE, self.default_limit)
try:
limit = int(request.query_params[self.limit_query_param])
if limit < 0:
raise ValueError()
# Enforce maximum page size, if defined
if MAX_PAGE_SIZE:
return MAX_PAGE_SIZE if limit == 0 else min(limit, MAX_PAGE_SIZE)
return limit
if limit == 0:
max_limit = MAX_PAGE_SIZE
else:
max_limit = min(MAX_PAGE_SIZE, limit)
else:
max_limit = limit
except (KeyError, ValueError):
pass
return self.default_limit
return max_limit
def get_queryset_count(self, queryset):
return queryset.count()

View File

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

View File

@@ -7,6 +7,7 @@ from django.core.exceptions import FieldDoesNotExist
from django.db.models import Q, QuerySet
from django.db.models.fields.related import ForeignKey, ManyToManyField, ManyToManyRel, ManyToOneRel
from strawberry import ID
from strawberry.directive import DirectiveValue
from strawberry.types import Info
from strawberry_django import (
ComparisonFilterLookup,
@@ -24,6 +25,7 @@ __all__ = (
'FloatLookup',
'IntegerArrayLookup',
'IntegerLookup',
'IntegerRangeArrayLookup',
'JSONFilter',
'StringArrayLookup',
'TreeNodeFilter',
@@ -67,7 +69,7 @@ class IntegerLookup:
return None
@strawberry_django.filter_field
def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
def filter(self, info: Info, queryset: QuerySet, prefix: DirectiveValue[str] = '') -> Tuple[QuerySet, Q]:
filters = self.get_filter()
if not filters:
@@ -90,7 +92,7 @@ class FloatLookup:
return None
@strawberry_django.filter_field
def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
def filter(self, info: Info, queryset: QuerySet, prefix: DirectiveValue[str] = '') -> Tuple[QuerySet, Q]:
filters = self.get_filter()
if not filters:
@@ -109,7 +111,7 @@ class JSONFilter:
lookup: JSONLookup
@strawberry_django.filter_field
def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
def filter(self, info: Info, queryset: QuerySet, prefix: DirectiveValue[str] = '') -> Tuple[QuerySet, Q]:
filters = self.lookup.get_filter()
if not filters:
@@ -136,7 +138,7 @@ class TreeNodeFilter:
match_type: TreeNodeMatch
@strawberry_django.filter_field
def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
def filter(self, info: Info, queryset: QuerySet, prefix: DirectiveValue[str] = '') -> Tuple[QuerySet, Q]:
model_field_name = prefix.removesuffix('__').removesuffix('_id')
model_field = None
try:
@@ -217,3 +219,30 @@ class FloatArrayLookup(ArrayLookup[float]):
@strawberry.input(one_of=True, description='Lookup for Array fields. Only one of the lookup fields can be set.')
class StringArrayLookup(ArrayLookup[str]):
pass
@strawberry.input(one_of=True, description='Lookups for an ArrayField(RangeField). Only one may be set.')
class RangeArrayValueLookup(Generic[T]):
"""
class for Array field of Range fields lookups
"""
contains: T | None = strawberry.field(
default=strawberry.UNSET, description='Return rows where any stored range contains this value.'
)
@strawberry_django.filter_field
def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
"""
Map GraphQL: { <field>: { contains: <T> } } To Django ORM: <field>__range_contains=<T>
"""
if self.contains is strawberry.UNSET or self.contains is None:
return queryset, Q()
# Build '<prefix>range_contains' so it works for nested paths too
return queryset, Q(**{f'{prefix}range_contains': self.contains})
@strawberry.input(one_of=True, description='Lookups for an ArrayField(IntegerRangeField). Only one may be set.')
class IntegerRangeArrayLookup(RangeArrayValueLookup[int]):
pass

View File

@@ -1,5 +1,6 @@
import strawberry
import strawberry_django
from strawberry.types import Info
from django.contrib.contenttypes.models import ContentType
from core.graphql.mixins import ChangelogMixin
@@ -26,7 +27,7 @@ class BaseObjectType:
"""
@classmethod
def get_queryset(cls, queryset, info, **kwargs):
def get_queryset(cls, queryset, info: Info, **kwargs):
# Enforce object permissions on the queryset
if hasattr(queryset, 'restrict'):
return queryset.restrict(info.context.request.user, 'view')

View File

@@ -673,10 +673,17 @@ def has_feature(model_or_ct, feature):
# If an ObjectType was passed, we can use it directly
if type(model_or_ct) is ObjectType:
ot = model_or_ct
# If a ContentType was passed, resolve its model class
# If a ContentType was passed, resolve its model class and run the associated feature test
elif type(model_or_ct) is ContentType:
model_class = model_or_ct.model_class()
ot = ObjectType.objects.get_for_model(model_class) if model_class else None
model = model_or_ct.model_class()
if model is None: # Stale content type
return False
try:
test_func = registry['model_features'][feature]
except KeyError:
# Unknown feature
return False
return test_func(model)
# For anything else, look up the ObjectType
else:
ot = ObjectType.objects.get_for_model(model_or_ct)

39
netbox/netbox/monkey.py Normal file
View File

@@ -0,0 +1,39 @@
from django.db.models import UniqueConstraint
from rest_framework.utils.field_mapping import get_unique_error_message
from rest_framework.validators import UniqueValidator
__all__ = (
'get_unique_validators',
)
def get_unique_validators(field_name, model_field):
"""
Extend Django REST Framework's get_unique_validators() function to attach a UniqueValidator to a field *only* if the
associated UniqueConstraint does NOT have a condition which references another field. See bug #19302.
"""
field_set = {field_name}
conditions = {
c.condition
for c in model_field.model._meta.constraints
if isinstance(c, UniqueConstraint) and set(c.fields) == field_set
}
# START custom logic
conditions = {
cond for cond in conditions
if cond is None or cond.referenced_base_fields == field_set
}
# END custom logic
if getattr(model_field, 'unique', False):
conditions.add(None)
if not conditions:
return
unique_error_message = get_unique_error_message(model_field)
queryset = model_field.model._default_manager
for condition in conditions:
yield UniqueValidator(
queryset=queryset if condition is None else queryset.filter(condition),
message=unique_error_message
)

View File

@@ -11,6 +11,7 @@ from django.core.exceptions import ImproperlyConfigured, ValidationError
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 core.exceptions import IncompatiblePluginError
from netbox.config import PARAMS as CONFIG_PARAMS
@@ -20,6 +21,17 @@ from netbox.registry import registry
import storages.utils # type: ignore
from utilities.release import load_release_data
from utilities.string import trailing_slash
from .monkey import get_unique_validators
#
# Monkey-patching
#
# TODO: Remove this once #20547 has been implemented
# Override DRF's get_unique_validators() function with our own (see bug #19302)
field_mapping.get_unique_validators = get_unique_validators
#
# Environment setup

View File

@@ -281,7 +281,8 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
obj = self.alter_object(obj, request, args, kwargs)
form = self.form(data=request.POST, files=request.FILES, instance=obj)
form_prefix = 'quickadd' if request.GET.get('_quickadd') else None
form = self.form(data=request.POST, files=request.FILES, instance=obj, prefix=form_prefix)
restrict_form_fields(form, request.user)
if form.is_valid():

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -41,7 +41,7 @@
"@types/node": "^22.3.0",
"@typescript-eslint/eslint-plugin": "^8.37.0",
"@typescript-eslint/parser": "^8.37.0",
"esbuild": "^0.25.6",
"esbuild": "^0.25.11",
"esbuild-sass-plugin": "^3.3.1",
"eslint": "<9.0",
"eslint-config-prettier": "^9.1.0",

View File

@@ -20,11 +20,13 @@ function slugify(slug: string, chars: number): string {
* For any slug fields, add event listeners to handle automatically generating slug values.
*/
export function initReslug(): void {
for (const slugButton of getElements<HTMLButtonElement>('button#reslug')) {
for (const slugButton of getElements<HTMLButtonElement>('button.reslug')) {
const form = slugButton.form;
if (form == null) continue;
const slugField = form.querySelector('#id_slug') as HTMLInputElement;
const slugField = form.querySelector('input.slug-field') as HTMLInputElement;
if (slugField == null) continue;
const sourceId = slugField.getAttribute('slug-source');
const sourceField = form.querySelector(`#id_${sourceId}`) as HTMLInputElement;

View File

@@ -83,7 +83,7 @@ export function initRackElevation(): void {
}
for (const element of getElements<HTMLObjectElement>('.rack_elevation')) {
element.addEventListener('load', () => {
element.addEventListener('htmx:afterSettle', () => {
setRackView(initialView, element);
});
}

View File

@@ -16,6 +16,11 @@ pre {
background: var(--#{$prefix}bg-surface);
}
// Permit copying of badge text
.badge {
user-select: text;
}
// Button adjustments
.btn {
// Tabler sets display: flex

View File

@@ -19,135 +19,135 @@
resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb"
integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==
"@esbuild/aix-ppc64@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz#a1414903bb38027382f85f03dda6065056757727"
integrity sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==
"@esbuild/aix-ppc64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz#2ae33300598132cc4cf580dbbb28d30fed3c5c49"
integrity sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==
"@esbuild/android-arm64@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz#c859994089e9767224269884061f89dae6fb51c6"
integrity sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==
"@esbuild/android-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz#927708b3db5d739d6cb7709136924cc81bec9b03"
integrity sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==
"@esbuild/android-arm@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.8.tgz#96a8f2ca91c6cd29ea90b1af79d83761c8ba0059"
integrity sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==
"@esbuild/android-arm@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.11.tgz#571f94e7f4068957ec4c2cfb907deae3d01b55ae"
integrity sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==
"@esbuild/android-x64@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.8.tgz#a3a626c4fec4a024a9fa8c7679c39996e92916f0"
integrity sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==
"@esbuild/android-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.11.tgz#8a3bf5cae6c560c7ececa3150b2bde76e0fb81e6"
integrity sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==
"@esbuild/darwin-arm64@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz#a5e1252ca2983d566af1c0ea39aded65736fc66d"
integrity sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==
"@esbuild/darwin-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz#0a678c4ac4bf8717e67481e1a797e6c152f93c84"
integrity sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==
"@esbuild/darwin-x64@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz#5271b0df2bb12ce8df886704bfdd1c7cc01385d2"
integrity sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==
"@esbuild/darwin-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz#70f5e925a30c8309f1294d407a5e5e002e0315fe"
integrity sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==
"@esbuild/freebsd-arm64@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz#d0a0e7fdf19733b8bb1566b81df1aa0bb7e46ada"
integrity sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==
"@esbuild/freebsd-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz#4ec1db687c5b2b78b44148025da9632397553e8a"
integrity sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==
"@esbuild/freebsd-x64@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz#2de8b2e0899d08f1cb1ef3128e159616e7e85343"
integrity sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==
"@esbuild/freebsd-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz#4c81abd1b142f1e9acfef8c5153d438ca53f44bb"
integrity sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==
"@esbuild/linux-arm64@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz#a4209efadc0c2975716458484a4e90c237c48ae9"
integrity sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==
"@esbuild/linux-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz#69517a111acfc2b93aa0fb5eaeb834c0202ccda5"
integrity sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==
"@esbuild/linux-arm@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz#ccd9e291c24cd8d9142d819d463e2e7200d25b19"
integrity sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==
"@esbuild/linux-arm@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz#58dac26eae2dba0fac5405052b9002dac088d38f"
integrity sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==
"@esbuild/linux-ia32@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz#006ad1536d0c2b28fb3a1cf0b53bcb85aaf92c4d"
integrity sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==
"@esbuild/linux-ia32@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz#b89d4efe9bdad46ba944f0f3b8ddd40834268c2b"
integrity sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==
"@esbuild/linux-loong64@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz#127b3fbfb2c2e08b1397e985932f718f09a8f5c4"
integrity sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==
"@esbuild/linux-loong64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz#11f603cb60ad14392c3f5c94d64b3cc8b630fbeb"
integrity sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==
"@esbuild/linux-mips64el@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz#837d1449517791e3fa7d82675a2d06d9f56cb340"
integrity sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==
"@esbuild/linux-mips64el@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz#b7d447ff0676b8ab247d69dac40a5cf08e5eeaf5"
integrity sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==
"@esbuild/linux-ppc64@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz#aa2e3bd93ab8df084212f1895ca4b03c42d9e0fe"
integrity sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==
"@esbuild/linux-ppc64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz#b3a28ed7cc252a61b07ff7c8fd8a984ffd3a2f74"
integrity sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==
"@esbuild/linux-riscv64@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz#a340620e31093fef72767dd28ab04214b3442083"
integrity sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==
"@esbuild/linux-riscv64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz#ce75b08f7d871a75edcf4d2125f50b21dc9dc273"
integrity sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==
"@esbuild/linux-s390x@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz#ddfed266c8c13f5efb3105a0cd47f6dcd0e79e71"
integrity sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==
"@esbuild/linux-s390x@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz#cd08f6c73b6b6ff9ccdaabbd3ff6ad3dca99c263"
integrity sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==
"@esbuild/linux-x64@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz#9a4f78c75c051e8c060183ebb39a269ba936a2ac"
integrity sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==
"@esbuild/linux-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz#3c3718af31a95d8946ebd3c32bb1e699bdf74910"
integrity sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==
"@esbuild/netbsd-arm64@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz#902c80e1d678047926387230bc037e63e00697d0"
integrity sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==
"@esbuild/netbsd-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz#b4c767082401e3a4e8595fe53c47cd7f097c8077"
integrity sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==
"@esbuild/netbsd-x64@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz#2d9eb4692add2681ff05a14ce99de54fbed7079c"
integrity sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==
"@esbuild/netbsd-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz#f2a930458ed2941d1f11ebc34b9c7d61f7a4d034"
integrity sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==
"@esbuild/openbsd-arm64@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz#89c3b998c6de739db38ab7fb71a8a76b3fa84a45"
integrity sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==
"@esbuild/openbsd-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz#b4ae93c75aec48bc1e8a0154957a05f0641f2dad"
integrity sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==
"@esbuild/openbsd-x64@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz#2f01615cf472b0e48c077045cfd96b5c149365cc"
integrity sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==
"@esbuild/openbsd-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz#b42863959c8dcf9b01581522e40012d2c70045e2"
integrity sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==
"@esbuild/openharmony-arm64@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz#a201f720cd2c3ebf9a6033fcc3feb069a54b509a"
integrity sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==
"@esbuild/openharmony-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz#b2e717141c8fdf6bddd4010f0912e6b39e1640f1"
integrity sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==
"@esbuild/sunos-x64@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz#07046c977985a3334667f19e6ab3a01a80862afb"
integrity sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==
"@esbuild/sunos-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz#9fbea1febe8778927804828883ec0f6dd80eb244"
integrity sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==
"@esbuild/win32-arm64@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz#4a5470caf0d16127c05d4833d4934213c69392d1"
integrity sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==
"@esbuild/win32-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz#501539cedb24468336073383989a7323005a8935"
integrity sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==
"@esbuild/win32-ia32@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz#3de3e8470b7b328d99dbc3e9ec1eace207e5bbc4"
integrity sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==
"@esbuild/win32-ia32@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz#8ac7229aa82cef8f16ffb58f1176a973a7a15343"
integrity sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==
"@esbuild/win32-x64@0.25.8":
version "0.25.8"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz#610d7ea539d2fcdbe39237b5cc175eb2c4451f9c"
integrity sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==
"@esbuild/win32-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz#5ecda6f3fe138b7e456f4e429edde33c823f392f"
integrity sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==
"@eslint-community/eslint-utils@^4.2.0":
version "4.4.0"
@@ -1642,37 +1642,37 @@ esbuild-sass-plugin@^3.3.1:
safe-identifier "^0.4.2"
sass "^1.71.1"
esbuild@^0.25.6:
version "0.25.8"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.8.tgz#482d42198b427c9c2f3a81b63d7663aecb1dda07"
integrity sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==
esbuild@^0.25.11:
version "0.25.11"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.11.tgz#0f31b82f335652580f75ef6897bba81962d9ae3d"
integrity sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==
optionalDependencies:
"@esbuild/aix-ppc64" "0.25.8"
"@esbuild/android-arm" "0.25.8"
"@esbuild/android-arm64" "0.25.8"
"@esbuild/android-x64" "0.25.8"
"@esbuild/darwin-arm64" "0.25.8"
"@esbuild/darwin-x64" "0.25.8"
"@esbuild/freebsd-arm64" "0.25.8"
"@esbuild/freebsd-x64" "0.25.8"
"@esbuild/linux-arm" "0.25.8"
"@esbuild/linux-arm64" "0.25.8"
"@esbuild/linux-ia32" "0.25.8"
"@esbuild/linux-loong64" "0.25.8"
"@esbuild/linux-mips64el" "0.25.8"
"@esbuild/linux-ppc64" "0.25.8"
"@esbuild/linux-riscv64" "0.25.8"
"@esbuild/linux-s390x" "0.25.8"
"@esbuild/linux-x64" "0.25.8"
"@esbuild/netbsd-arm64" "0.25.8"
"@esbuild/netbsd-x64" "0.25.8"
"@esbuild/openbsd-arm64" "0.25.8"
"@esbuild/openbsd-x64" "0.25.8"
"@esbuild/openharmony-arm64" "0.25.8"
"@esbuild/sunos-x64" "0.25.8"
"@esbuild/win32-arm64" "0.25.8"
"@esbuild/win32-ia32" "0.25.8"
"@esbuild/win32-x64" "0.25.8"
"@esbuild/aix-ppc64" "0.25.11"
"@esbuild/android-arm" "0.25.11"
"@esbuild/android-arm64" "0.25.11"
"@esbuild/android-x64" "0.25.11"
"@esbuild/darwin-arm64" "0.25.11"
"@esbuild/darwin-x64" "0.25.11"
"@esbuild/freebsd-arm64" "0.25.11"
"@esbuild/freebsd-x64" "0.25.11"
"@esbuild/linux-arm" "0.25.11"
"@esbuild/linux-arm64" "0.25.11"
"@esbuild/linux-ia32" "0.25.11"
"@esbuild/linux-loong64" "0.25.11"
"@esbuild/linux-mips64el" "0.25.11"
"@esbuild/linux-ppc64" "0.25.11"
"@esbuild/linux-riscv64" "0.25.11"
"@esbuild/linux-s390x" "0.25.11"
"@esbuild/linux-x64" "0.25.11"
"@esbuild/netbsd-arm64" "0.25.11"
"@esbuild/netbsd-x64" "0.25.11"
"@esbuild/openbsd-arm64" "0.25.11"
"@esbuild/openbsd-x64" "0.25.11"
"@esbuild/openharmony-arm64" "0.25.11"
"@esbuild/sunos-x64" "0.25.11"
"@esbuild/win32-arm64" "0.25.11"
"@esbuild/win32-ia32" "0.25.11"
"@esbuild/win32-x64" "0.25.11"
escape-string-regexp@^4.0.0:
version "4.0.0"

View File

@@ -1,3 +1,3 @@
version: "4.4.2"
version: "4.4.4"
edition: "Community"
published: "2025-09-30"
published: "2025-10-15"

View File

@@ -18,7 +18,7 @@
<li class="nav-item">
<a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'account:preferences' %}">{% trans "Preferences" %}</a>
</li>
{% if not request.user.ldap_username %}
{% if request.user.has_usable_password %}
<li class="nav-item">
<a class="nav-link{% if active_tab == 'password' %} active{% endif %}" href="{% url 'account:change_password' %}">{% trans "Password" %}</a>
</li>

View File

@@ -26,7 +26,7 @@
{# Initialize color mode #}
<script
type="text/javascript"
src="{% static 'setmode.js' %}?v={{ settings.RELEASE.version }}"
src="{% static_with_params 'setmode.js' v=settings.RELEASE.version %}"
onerror="window.location='{% url 'media_failure' %}?filename=setmode.js'">
</script>
<script type="text/javascript">
@@ -39,12 +39,12 @@
{# Static resources #}
<link
rel="stylesheet"
href="{% static 'netbox-external.css'%}?v={{ settings.RELEASE.version }}"
href="{% static_with_params 'netbox-external.css' v=settings.RELEASE.version %}"
onerror="window.location='{% url 'media_failure' %}?filename=netbox-external.css'"
/>
<link
rel="stylesheet"
href="{% static 'netbox.css'%}?v={{ settings.RELEASE.version }}"
href="{% static_with_params 'netbox.css' v=settings.RELEASE.version %}"
onerror="window.location='{% url 'media_failure' %}?filename=netbox.css'"
/>
<link rel="icon" type="image/png" href="{% static 'netbox.ico' %}" />
@@ -53,7 +53,7 @@
{# Javascript #}
<script
type="text/javascript"
src="{% static 'netbox.js' %}?v={{ settings.RELEASE.version }}"
src="{% static_with_params 'netbox.js' v=settings.RELEASE.version %}"
onerror="window.location='{% url 'media_failure' %}?filename=netbox.js'">
</script>
{% django_htmx_script %}

View File

@@ -44,8 +44,8 @@
<div class="htmx-container table-responsive"
hx-get="{% url 'extras:script_result' job_pk=job.pk %}?embedded=True&log=True&log_threshold={{log_threshold}}"
hx-target="this"
hx-trigger="load" hx-select=".htmx-container" hx-swap="outerHTML"
></div>
hx-trigger="load" hx-select=".htmx-container" hx-swap="outerHTML">
</div>
</div>
</div>
{% endif %}
@@ -60,11 +60,12 @@
<a href="?export=output" class="btn btn-sm btn-primary" role="button">
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
</a>
{% copy_content "job_data_output" %}
</div>
{% endif %}
</h2>
{% if job.data.output %}
<pre class="card-body font-monospace">{{ job.data.output }}</pre>
<pre class="card-body font-monospace" id="job_data_output">{{ job.data.output }}</pre>
{% else %}
<div class="card-body text-muted">{% trans "None" %}</div>
{% endif %}

View File

@@ -37,23 +37,23 @@
</a>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow" {% htmx_boost %}>
<a href="{% url 'account:profile' %}" class="dropdown-item">
<i class="mdi mdi-account"></i> {% trans "Profile" %}
<i class="dropdown-item-icon mdi mdi-account"></i> {% trans "Profile" %}
</a>
<a href="{% url 'account:bookmarks' %}" class="dropdown-item">
<i class="mdi mdi-bookmark"></i> {% trans "Bookmarks" %}
<i class="dropdown-item-icon mdi mdi-bookmark"></i> {% trans "Bookmarks" %}
</a>
<a href="{% url 'account:subscriptions' %}" class="dropdown-item">
<i class="mdi mdi-bell"></i> {% trans "Subscriptions" %}
<i class="dropdown-item-icon mdi mdi-bell"></i> {% trans "Subscriptions" %}
</a>
<a href="{% url 'account:preferences' %}" class="dropdown-item">
<i class="mdi mdi-wrench"></i> {% trans "Preferences" %}
<i class="dropdown-item-icon mdi mdi-wrench"></i> {% trans "Preferences" %}
</a>
<a href="{% url 'account:usertoken_list' %}" class="dropdown-item">
<i class="mdi mdi-key"></i> {% trans "API Tokens" %}
<i class="dropdown-item-icon mdi mdi-key"></i> {% trans "API Tokens" %}
</a>
<hr class="dropdown-divider" />
<a href="{% url 'logout' %}" hx-disable="true" class="dropdown-item">
<i class="mdi mdi-logout-variant"></i> {% trans "Log Out" %}
<i class="dropdown-item-icon mdi mdi-logout-variant"></i> {% trans "Log Out" %}
</a>
</div>
</div>

View File

@@ -40,7 +40,7 @@
</tr>
<tr>
<th scope="row">{% trans "VLAN IDs" %}</th>
<td>{{ object.vid_ranges_list }}</td>
<td>{{ object.vid_ranges_items|join:", " }}</td>
</tr>
<tr>
<th scope="row">Utilization</th>

View File

@@ -1,3 +1,4 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _
@@ -25,7 +26,7 @@ class TenantGroupImportForm(NetBoxModelImportForm):
queryset=TenantGroup.objects.all(),
required=False,
to_field_name='name',
help_text=_('Parent group')
help_text=_('Parent group'),
)
slug = SlugField()
@@ -41,7 +42,7 @@ class TenantImportForm(NetBoxModelImportForm):
queryset=TenantGroup.objects.all(),
required=False,
to_field_name='name',
help_text=_('Assigned group')
help_text=_('Assigned group'),
)
class Meta:
@@ -59,7 +60,7 @@ class ContactGroupImportForm(NetBoxModelImportForm):
queryset=ContactGroup.objects.all(),
required=False,
to_field_name='name',
help_text=_('Parent group')
help_text=_('Parent group'),
)
slug = SlugField()
@@ -81,7 +82,12 @@ class ContactImportForm(NetBoxModelImportForm):
queryset=ContactGroup.objects.all(),
required=False,
to_field_name='name',
help_text=_('Group names separated by commas, encased with double quotes (e.g. "Group 1,Group 2")')
help_text=_('Group names separated by commas, encased with double quotes (e.g. "Group 1,Group 2")'),
)
link = forms.URLField(
label=_('Link'),
assume_scheme='https',
required=False,
)
class Meta:

View File

@@ -100,6 +100,11 @@ class ContactForm(NetBoxModelForm):
queryset=ContactGroup.objects.all(),
required=False
)
link = forms.URLField(
label=_('Link'),
assume_scheme='https',
required=False,
)
comments = CommentField()
fieldsets = (

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
import decimal
from django.db.backends.postgresql.psycopg_any import NumericRange
from itertools import count, groupby
from django.db.backends.postgresql.psycopg_any import NumericRange
__all__ = (
'array_to_ranges',
'array_to_string',
@@ -10,6 +11,7 @@ __all__ = (
'drange',
'flatten_dict',
'ranges_to_string',
'ranges_to_string_list',
'shallow_compare_dict',
'string_to_ranges',
)
@@ -73,8 +75,10 @@ def shallow_compare_dict(source_dict, destination_dict, exclude=tuple()):
def array_to_ranges(array):
"""
Convert an arbitrary array of integers to a list of consecutive values. Nonconsecutive values are returned as
single-item tuples. For example:
[0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]"
single-item tuples.
Example:
[0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]
"""
group = (
list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x)
@@ -87,7 +91,8 @@ def array_to_ranges(array):
def array_to_string(array):
"""
Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField.
For example:
Example:
[0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
"""
ret = []
@@ -135,26 +140,60 @@ def check_ranges_overlap(ranges):
return False
def ranges_to_string_list(ranges):
"""
Convert numeric ranges to a list of display strings.
Each range is rendered as "lower-upper" or "lower" (for singletons).
Bounds are normalized to inclusive values using ``lower_inc``/``upper_inc``.
This underpins ``ranges_to_string()``, which joins the result with commas.
Example:
[NumericRange(1, 6), NumericRange(8, 9), NumericRange(10, 13)] => ["1-5", "8", "10-12"]
"""
if not ranges:
return []
output: list[str] = []
for r in ranges:
# Compute inclusive bounds regardless of how the DB range is stored.
lower = r.lower if r.lower_inc else r.lower + 1
upper = r.upper if r.upper_inc else r.upper - 1
output.append(f"{lower}-{upper}" if lower != upper else str(lower))
return output
def ranges_to_string(ranges):
"""
Generate a human-friendly string from a set of ranges. Intended for use with ArrayField. For example:
[[1, 100)], [200, 300)] => "1-99,200-299"
Converts a list of ranges into a string representation.
This function takes a list of range objects and produces a string
representation of those ranges. Each range is represented as a
hyphen-separated pair of lower and upper bounds, with inclusive or
exclusive bounds adjusted accordingly. If the lower and upper bounds
of a range are the same, only the single value is added to the string.
Intended for use with ArrayField.
Example:
[NumericRange(1, 5), NumericRange(8, 9), NumericRange(10, 12)] => "1-5,8,10-12"
"""
if not ranges:
return ''
output = []
for r in ranges:
lower = r.lower if r.lower_inc else r.lower + 1
upper = r.upper if r.upper_inc else r.upper - 1
output.append(f'{lower}-{upper}')
return ','.join(output)
return ','.join(ranges_to_string_list(ranges))
def string_to_ranges(value):
"""
Given a string in the format "1-100, 200-300" return an list of NumericRanges. Intended for use with ArrayField.
For example:
"1-99,200-299" => [NumericRange(1, 100), NumericRange(200, 300)]
Converts a string representation of numeric ranges into a list of NumericRange objects.
This function parses a string containing numeric values and ranges separated by commas (e.g.,
"1-5,8,10-12") and converts it into a list of NumericRange objects.
In the case of a single integer, it is treated as a range where the start and end
are equal. The returned ranges are represented as half-open intervals [lower, upper).
Intended for use with ArrayField.
Example:
"1-5,8,10-12" => [NumericRange(1, 6), NumericRange(8, 9), NumericRange(10, 13)]
"""
if not value:
return None
@@ -172,5 +211,5 @@ def string_to_ranges(value):
upper = dash_range[1]
else:
return None
values.append(NumericRange(int(lower), int(upper), bounds='[]'))
values.append(NumericRange(int(lower), int(upper) + 1, bounds='[)'))
return values

View File

@@ -53,6 +53,14 @@ class SlugField(forms.SlugField):
self.widget.attrs['slug-source'] = slug_source
def get_bound_field(self, form, field_name):
if prefix := form.prefix:
slug_source = self.widget.attrs.get('slug-source')
if slug_source and not slug_source.startswith(f'{prefix}-'):
self.widget.attrs['slug-source'] = f"{prefix}-{slug_source}"
return super().get_bound_field(form, field_name)
class ColorField(forms.CharField):
"""

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