Compare commits

...

52 Commits

Author SHA1 Message Date
Jeremy Stretch
970f2bd4ed Release v4.4.8 2025-12-09 11:28:36 -05:00
Etienne.BRUNEL
a4ee323cb6 Add tenant filter on device components. 2025-12-09 10:04:41 -05:00
Jason Novinger
17e5184a11 Fixes #20759: Group object types by app in permission form (#20931)
* Fixes #20759: Group object types by app in permission form

Modified the ObjectPermissionForm to use optgroups for organizing
object types by application. This shortens the display names (e.g.,
"permission" instead of "Authentication and Authorization | permission")
while maintaining clear organization through visual grouping.

Changes:
- Updated get_object_types_choices() to return nested optgroup structure
- Enhanced AvailableOptions and SelectedOptions widgets to handle optgroups
- Modified TypeScript moveOptions to preserve optgroup structure
- Added hover text showing full model names
- Styled optgroups with bold, padded labels

* Address PR feedback
2025-12-09 08:43:29 -05:00
github-actions
e1548bb290 Update source translation strings 2025-12-09 05:02:02 +00:00
Jason Novinger
269112a565 Fixes #19918: Resolve {module} placeholders in nested module bay labels
ModuleBayTemplate.instantiate() now calls resolve_name() and resolve_label()
to properly resolve {module} placeholders, making it consistent with other
modular components like InterfaceTemplate.

When a module with nested module bays is installed (e.g., a module with SFP
bays in position "A"), the nested bay labels now correctly show "A-21" instead
of "{module}-21".

This also removes the inconsistent fix from #17436 which only handled name
resolution post-instantiation. The proper resolution now happens during
instantiation using the existing resolve methods.
2025-12-08 10:06:46 -05:00
github-actions
c6672538ac Update source translation strings 2025-12-06 05:02:07 +00:00
bctiemann
6efb258b9f Merge pull request #20908 from netbox-community/20068-import-moduletype-attrs
Closes #20068: Enable defining profile attributes when importing module types
2025-12-05 10:18:53 -05:00
github-actions
da1e0f4b53 Update source translation strings 2025-12-04 05:02:04 +00:00
Arthur Hanson
7f39f75d3d Fixes #20878: Use database routing when running script (#20879) 2025-12-03 17:47:31 -06:00
Jeremy Stretch
ebf8f7fa1b Closes #20068: Enable defining profile attributes when importing module types 2025-12-02 16:50:59 -05:00
github-actions
922b08c0ff Update source translation strings 2025-12-02 05:02:22 +00:00
Bapths
84864fa5e1 Closes #20860: Add changlog message support for component object creation (#20898) 2025-12-01 17:04:21 -06:00
Jeremy Stretch
767dfccd8f Fixes #20888: Pass decimal values for min/max on latitude and longitude fields (#20892) 2025-12-01 10:35:44 -08:00
Tom Gamull
dc4bab7477 docs: fix broken bookmarks link in model features table
The bookmarks link was pointing to ../features/customization.md#bookmarks
but the bookmarks section is actually in ../features/user-preferences.md#bookmarks.

This fixes the broken anchor link.
2025-11-26 15:12:52 -05:00
github-actions
60aa952eb1 Update source translation strings 2025-11-26 05:02:03 +00:00
Jeremy Stretch
8b3f7ce507 Merge pull request #20880 from netbox-community/release-v4.4.7
Release v4.4.7
2025-11-25 14:57:13 -05:00
Jeremy Stretch
adad3745ae Release v4.4.7 2025-11-25 14:37:06 -05:00
Jeremy Stretch
8055fae253 Fixes #20865: Enforce proper min/max values for latitude & longitude (#20872) 2025-11-25 12:52:04 -06:00
Arthur
aac3a51431 20743 add request to Script EventRule run 2025-11-25 09:21:38 -05:00
bctiemann
3e0ad2176f Merge pull request #20855 from ifoughal/20822-add-auto_sync_enabled-property-for-configtemplates
Fixes 20822: add auto sync enabled property for configtemplates
2025-11-25 09:18:31 -05:00
bctiemann
4e8edfb3d6 Merge pull request #20847 from pheus/20839-fix-objecttype-filterform-for-customlinks-and-savedfilters
Fixes #20839: Rename `object_type` to `object_type_id` in FilterForm for `CustomLink` and `SavedFilter`
2025-11-25 09:08:16 -05:00
bctiemann
651557a82b Merge pull request #20838 from pheus/20820-add-objecttype-filterfield-to-customfield-filterform
Closes #20820: Add Object Type Filter to CustomField
2025-11-25 08:59:28 -05:00
Étienne Brunel
c3d66dc42e fix: Add Molex Micro-Fit 2x3 on PowerPortTypeChoices and PowerOutletTypeChoices 2025-11-25 08:46:32 -05:00
github-actions
a50e570f22 Update source translation strings 2025-11-25 05:02:04 +00:00
Jeremy Stretch
a44a79ec79 Fixes #20649: Enforce view permissions on REST API endpoint for custom scripts (#20871) 2025-11-24 18:28:35 -06:00
Martin Hauser
b919868521 Closes #20823: Validate token expiration date on creation (#20862) 2025-11-24 15:05:59 -06:00
Jeremy Stretch
d9aab6bbe2 Fixes #20859: Handle dashboard widget exceptions (#20870) 2025-11-24 12:40:06 -08:00
Jason Novinger
82171fce7a Fixes #20638: Document bulk create support in OpenAPI schema (#20777)
* Fixes #20638: Document bulk create support in OpenAPI schema

POST operations on NetBoxModelViewSet endpoints accept both single
objects and arrays, but the schema only documented single objects.
This prevented API client generators from producing correct code.

Add explicit bulk_create_enabled flag to NetBoxModelViewSet and
update schema generation to emit oneOf for these endpoints.

* Address PR feedback

- Removed brittle serializer marking mechanism in favor of direct checks
  on behavior.
- Attempted to introduce a bulk_create action and then route to it on
  POST in NetBoxRouter, but ran in to several obstacles including
  breaking HTTP status code reporting in the schema. Opted to simply

* Remove unused bulk_create_enabled attr

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

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2025-11-24 09:33:39 -05:00
ifoughali
020eb64eab Feat: added auto_sync_enabled property to configTemplate table 2025-11-21 08:24:26 +01:00
ifoughali
ec7afccd55 Feat: added auto_sync_enabled property to ConfigTemplateTable class 2025-11-21 08:23:23 +01:00
ifoughali
76fd63823c Feat: added auto_sync_enabled property to ConfigTemplateFilter 2025-11-21 08:22:19 +01:00
ifoughali
6c373decd6 Feat: added auto_sync_enabled property for ConfigTemplateBulkEdit class 2025-11-21 08:20:35 +01:00
ifoughali
222b26e060 Feat: added auto_sync_enabled property to serializer of configTemplate 2025-11-21 08:18:45 +01:00
github-actions
066b787777 Update source translation strings 2025-11-21 05:02:13 +00:00
Martin Hauser
90b2732068 Fixes #20840: Remove unused airflow from RackType UI (#20848) 2025-11-20 14:00:54 -06:00
Anton BL
bfba0ccaae Fixes #20827: fix theme toggle visibility for logo and buttons (#20835) 2025-11-20 14:36:49 -05:00
Martin Hauser
d5718357f1 feat(dcim): Add selector widget to RackType field
Introduce the selector widget for the RackType field on the rack edit
form to improve usability when selecting rack types.

Fixes #20841
2025-11-20 14:36:34 -05:00
Martin Hauser
d61737396b fix(filtersets): Respect assigned object type for L2VPN terminations
Add the `assigned_object_type_id` filter to `L2VPNTerminationFilterSet`
so that the "Assigned object type" filter correctly restricts L2VPN
terminations by their assigned object type, using the `ObjectType` model
for lookups.

Fixes #20844
2025-11-20 14:26:09 -05:00
Elliott Balsley
c6248f1142 check object-level permission constraints (#20830) 2025-11-20 11:06:49 -08:00
Jason Novinger
05f254a768 Fixes #20134: Prevent HTMX OOB swaps in embedded tables (#20811)
The htmx/table.html template was unconditionally including out-of-band
(OOB) swaps for UI elements that only exist on list pages, causing
htmx:oobErrorNoTarget errors when tables were embedded on detail pages.

This change adds checks for table.embedded to conditionally exclude OOB
swaps for .total-object-count, #table_save_link, and .bulk-action-buttons
when rendering embedded tables via the htmx_table template tag.
2025-11-20 09:04:37 -08:00
github-actions
0cb10f806a Update source translation strings 2025-11-20 05:02:09 +00:00
bctiemann
8ac7f6f8de Merge pull request #20810 from netbox-community/20766-fix-german-translation-code-literals
Fixes #20766: Prevent translation of code/commands in error templates
2025-11-19 19:07:12 -05:00
Martin Hauser
cd8087ab43 fix(forms): Rename object_type to object_type_id
Update references from `object_type` to `object_type_id` in forms and
fieldsets for `CustomLink` and `SavedFilter` models to match the related
field definition and the expected query parameter.

Fixes #20839
2025-11-19 21:50:12 +01:00
Martin Hauser
da5ae21150 feat(forms): Add object type filter to CustomField
Add `object_type_id` to filter CustomFields by assigned object types.
Reorganize fieldsets to separate common attributes from type-specific
options (“Type Options”), improving usability and consistency.

Fixes #20820
2025-11-19 21:15:55 +01:00
github-actions
fbb948d30e Update source translation strings 2025-11-18 05:02:06 +00:00
Grische
975e0ff398 Fix examples for type of class Meta() (#20799) 2025-11-17 09:14:46 -08:00
Idris Foughali
d7877b7627 Fixes #20731 add data file data source to config template bulk import (#20778) 2025-11-17 09:00:39 -05:00
Arthur
b685df7c9c 20775 fix bulk rename if no name 2025-11-17 08:51:59 -05:00
Arthur
9dcf9475cc 20465 fix script re-upload 2025-11-17 08:47:53 -05:00
github-actions
e1bf27e4db Update source translation strings 2025-11-15 05:02:05 +00:00
Daniel Sheppard
9b89af75e4 Fixes #20432: Allow cablepaths with CircuitTerminations that have different parent Circuit's (#20770) 2025-11-14 17:09:53 -06:00
Jason Novinger
9e13d89baa Fixes #20766: Prevent translation of code/commands in error templates
Use blocktrans 'with' clause to pass literal code/commands as variables,
preventing them from being translated. This fixes issues where commands
like 'manage.py collectstatic' were incorrectly translated to nonsensical
strings in non-English locales.

Updated templates:
- media_failure.html: manage.py collectstatic
- programming_error.html: python3 manage.py migrate, SELECT VERSION()
- import_error.html: requirements.txt, local_requirements.txt, pip freeze
2025-11-14 16:24:17 -06:00
92 changed files with 18769 additions and 13926 deletions

View File

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

View File

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

View File

@@ -186,6 +186,7 @@
"usb-3-micro-b",
"molex-micro-fit-1x2",
"molex-micro-fit-2x2",
"molex-micro-fit-2x3",
"molex-micro-fit-2x4",
"dc-terminal",
"saf-d-grid",
@@ -293,6 +294,7 @@
"usb-c",
"molex-micro-fit-1x2",
"molex-micro-fit-2x2",
"molex-micro-fit-2x3",
"molex-micro-fit-2x4",
"dc-terminal",
"eaton-c39",

File diff suppressed because one or more lines are too long

View File

@@ -232,6 +232,9 @@ STORAGES = {
},
"scripts": {
"BACKEND": "extras.storage.ScriptFileSystemStorage",
"OPTIONS": {
"allow_overwrite": True,
},
},
}
```
@@ -247,6 +250,7 @@ STORAGES = {
"OPTIONS": {
'access_key': 'access key',
'secret_key': 'secret key',
"allow_overwrite": True,
}
},
}

View File

@@ -95,7 +95,7 @@ An example fieldset definition is provided below:
```python
class MyScript(Script):
class Meta:
class Meta(Script.Meta):
fieldsets = (
('First group', ('field1', 'field2', 'field3')),
('Second group', ('field4', 'field5')),
@@ -510,7 +510,7 @@ from extras.scripts import *
class NewBranchScript(Script):
class Meta:
class Meta(Script.Meta):
name = "New Branch"
description = "Provision a new branch site"
field_order = ['site_name', 'switch_count', 'switch_model']

View File

@@ -12,7 +12,7 @@ Depending on its classification, each NetBox model may support various features
| Feature | Feature Mixin | Registry Key | Description |
|------------------------------------------------------------|-------------------------|---------------------|-----------------------------------------------------------------------------------------|
| [Bookmarks](../features/customization.md#bookmarks) | `BookmarksMixin` | `bookmarks` | These models can be bookmarked natively in the user interface |
| [Bookmarks](../features/user-preferences.md#bookmarks) | `BookmarksMixin` | `bookmarks` | These models can be bookmarked natively in the user interface |
| [Change logging](../features/change-logging.md) | `ChangeLoggingMixin` | `change_logging` | Changes to these objects are automatically recorded in the change log |
| Cloning | `CloningMixin` | `cloning` | Provides the `clone()` method to prepare a copy |
| [Contacts](../features/contacts.md) | `ContactsMixin` | `contacts` | Contacts can be associated with these models |

View File

@@ -1,5 +1,53 @@
# NetBox v4.4
## v4.4.8 (2025-12-09)
### Enhancements
* [#20068](https://github.com/netbox-community/netbox/issues/20068) - Support the assignment of module type profile attributes via bulk import
* [#20914](https://github.com/netbox-community/netbox/issues/20914) - Enable filtering device components by tenant assigned to device
### Bug Fixes
* [#19918](https://github.com/netbox-community/netbox/issues/19918) - Fix support for `{module}` resolution of components of child modules
* [#20759](https://github.com/netbox-community/netbox/issues/20759) - Improve legibility of object types in permissions form
* [#20860](https://github.com/netbox-community/netbox/issues/20860) - Ensure user-provided changelog message is recorded when creating device components via the UI
* [#20878](https://github.com/netbox-community/netbox/issues/20878) - Use the active database connection when executing custom scripts
* [#20888](https://github.com/netbox-community/netbox/issues/20888) - Resolve warnings about non-decimal values for min/max latitude & longitude fields
---
## v4.4.7 (2025-11-25)
### Enhancements
* [#20371](https://github.com/netbox-community/netbox/issues/20371) - Add Molex Micro-Fit 2x3 for power ports & power outlets
* [#20731](https://github.com/netbox-community/netbox/issues/20731) - Enable specifying `data_source` & `data_file` when bulk import config templates
* [#20820](https://github.com/netbox-community/netbox/issues/20820) - Enable filtering of custom fields by object type
* [#20823](https://github.com/netbox-community/netbox/issues/20823) - Disallow creation of API tokens with an expiration date in the past
* [#20841](https://github.com/netbox-community/netbox/issues/20841) - Support advanced filtering for available rack types when creating/editing a rack
### Bug Fixes
* [#20134](https://github.com/netbox-community/netbox/issues/20134) - Prevent out-of-band HTMX content swaps in embedded tables
* [#20432](https://github.com/netbox-community/netbox/issues/20432) - Fix tracing of cables across multiple circuits in parallel
* [#20465](https://github.com/netbox-community/netbox/issues/20465) - Ensure that scripts are updated immediately when a new file is uploaded
* [#20638](https://github.com/netbox-community/netbox/issues/20638) - Correct OpenAPI schema for bulk create operations
* [#20649](https://github.com/netbox-community/netbox/issues/20649) - Enforce view permissions on REST API endpoint for custom scripts
* [#20740](https://github.com/netbox-community/netbox/issues/20740) - Ensure permissions constraints are enforced when executing custom scripts via the REST API
* [#20743](https://github.com/netbox-community/netbox/issues/20743) - Pass request context to custom script when triggered by an event rule
* [#20766](https://github.com/netbox-community/netbox/issues/20766) - Fix inadvertent translations on server error page
* [#20775](https://github.com/netbox-community/netbox/issues/20775) - Fix `TypeError` exception when bulk renaming unnamed devices
* [#20822](https://github.com/netbox-community/netbox/issues/20822) - Add missing `auto_sync_enabled` field in bulk edit forms
* [#20827](https://github.com/netbox-community/netbox/issues/20827) - Fix UI styling issue when toggling between light and dark mode
* [#20839](https://github.com/netbox-community/netbox/issues/20839) - Fix filtering by object type in UI for custom links and saved filters
* [#20840](https://github.com/netbox-community/netbox/issues/20840) - Remove extraneous references to airflow for RackType model
* [#20844](https://github.com/netbox-community/netbox/issues/20844) - Fix object type filter for L2VPN terminations
* [#20859](https://github.com/netbox-community/netbox/issues/20859) - Prevent dashboard crash due to exception raised by a widget
* [#20865](https://github.com/netbox-community/netbox/issues/20865) - Enforce proper min/max values for latitude & longitude fields
---
## v4.4.6 (2025-11-11)
### Enhancements

View File

@@ -12,6 +12,7 @@ from drf_spectacular.utils import Direction
from netbox.api.fields import ChoiceField
from netbox.api.serializers import WritableNestedSerializer
from netbox.api.viewsets import NetBoxModelViewSet
# see netbox.api.routers.NetBoxRouter
BULK_ACTIONS = ("bulk_destroy", "bulk_partial_update", "bulk_update")
@@ -49,6 +50,11 @@ class ChoiceFieldFix(OpenApiSerializerFieldExtension):
)
def viewset_handles_bulk_create(view):
"""Check if view automatically provides list-based bulk create"""
return isinstance(view, NetBoxModelViewSet)
class NetBoxAutoSchema(AutoSchema):
"""
Overrides to drf_spectacular.openapi.AutoSchema to fix following issues:
@@ -128,6 +134,36 @@ class NetBoxAutoSchema(AutoSchema):
return response_serializers
def _get_request_for_media_type(self, serializer, direction='request'):
"""
Override to generate oneOf schema for serializers that support both
single object and array input (NetBoxModelViewSet POST operations).
Refs: #20638
"""
# Get the standard schema first
schema, required = super()._get_request_for_media_type(serializer, direction)
# If this serializer supports arrays (marked in get_request_serializer),
# wrap the schema in oneOf to allow single object OR array
if (
direction == 'request' and
schema is not None and
getattr(self.view, 'action', None) == 'create' and
viewset_handles_bulk_create(self.view)
):
return {
'oneOf': [
schema, # Single object
{
'type': 'array',
'items': schema, # Array of objects
}
]
}, required
return schema, required
def _get_serializer_name(self, serializer, direction, bypass_extensions=False) -> str:
name = super()._get_serializer_name(serializer, direction, bypass_extensions)

View File

@@ -0,0 +1,108 @@
"""
Unit tests for OpenAPI schema generation.
Refs: #20638
"""
import json
from django.test import TestCase
class OpenAPISchemaTestCase(TestCase):
"""Tests for OpenAPI schema generation."""
def setUp(self):
"""Fetch schema via API endpoint."""
response = self.client.get('/api/schema/', {'format': 'json'})
self.assertEqual(response.status_code, 200)
self.schema = json.loads(response.content)
def test_post_operation_documents_single_or_array(self):
"""
POST operations on NetBoxModelViewSet endpoints should document
support for both single objects and arrays via oneOf.
Refs: #20638
"""
# Test representative endpoints across different apps
test_paths = [
'/api/core/data-sources/',
'/api/dcim/sites/',
'/api/users/users/',
'/api/ipam/ip-addresses/',
]
for path in test_paths:
with self.subTest(path=path):
operation = self.schema['paths'][path]['post']
# Get the request body schema
request_schema = operation['requestBody']['content']['application/json']['schema']
# Should have oneOf with two options
self.assertIn('oneOf', request_schema, f"POST {path} should have oneOf schema")
self.assertEqual(
len(request_schema['oneOf']), 2,
f"POST {path} oneOf should have exactly 2 options"
)
# First option: single object (has $ref or properties)
single_schema = request_schema['oneOf'][0]
self.assertTrue(
'$ref' in single_schema or 'properties' in single_schema,
f"POST {path} first oneOf option should be single object"
)
# Second option: array of objects
array_schema = request_schema['oneOf'][1]
self.assertEqual(
array_schema['type'], 'array',
f"POST {path} second oneOf option should be array"
)
self.assertIn('items', array_schema, f"POST {path} array should have items")
def test_bulk_update_operations_require_array_only(self):
"""
Bulk update/patch operations should require arrays only, not oneOf.
They don't support single object input.
Refs: #20638
"""
test_paths = [
'/api/dcim/sites/',
'/api/users/users/',
]
for path in test_paths:
for method in ['put', 'patch']:
with self.subTest(path=path, method=method):
operation = self.schema['paths'][path][method]
request_schema = operation['requestBody']['content']['application/json']['schema']
# Should be array-only, not oneOf
self.assertNotIn(
'oneOf', request_schema,
f"{method.upper()} {path} should NOT have oneOf (array-only)"
)
self.assertEqual(
request_schema['type'], 'array',
f"{method.upper()} {path} should require array"
)
self.assertIn(
'items', request_schema,
f"{method.upper()} {path} array should have items"
)
def test_bulk_delete_requires_array(self):
"""
Bulk delete operations should require arrays.
Refs: #20638
"""
path = '/api/dcim/sites/'
operation = self.schema['paths'][path]['delete']
request_schema = operation['requestBody']['content']['application/json']['schema']
# Should be array-only
self.assertNotIn('oneOf', request_schema, "DELETE should NOT have oneOf")
self.assertEqual(request_schema['type'], 'array', "DELETE should require array")
self.assertIn('items', request_schema, "DELETE array should have items")

View File

@@ -461,6 +461,7 @@ class PowerPortTypeChoices(ChoiceSet):
# Molex
TYPE_MOLEX_MICRO_FIT_1X2 = 'molex-micro-fit-1x2'
TYPE_MOLEX_MICRO_FIT_2X2 = 'molex-micro-fit-2x2'
TYPE_MOLEX_MICRO_FIT_2X3 = 'molex-micro-fit-2x3'
TYPE_MOLEX_MICRO_FIT_2X4 = 'molex-micro-fit-2x4'
# Direct current (DC)
TYPE_DC = 'dc-terminal'
@@ -588,6 +589,7 @@ class PowerPortTypeChoices(ChoiceSet):
('Molex', (
(TYPE_MOLEX_MICRO_FIT_1X2, 'Molex Micro-Fit 1x2'),
(TYPE_MOLEX_MICRO_FIT_2X2, 'Molex Micro-Fit 2x2'),
(TYPE_MOLEX_MICRO_FIT_2X3, 'Molex Micro-Fit 2x3'),
(TYPE_MOLEX_MICRO_FIT_2X4, 'Molex Micro-Fit 2x4'),
)),
('DC', (
@@ -710,6 +712,7 @@ class PowerOutletTypeChoices(ChoiceSet):
# Molex
TYPE_MOLEX_MICRO_FIT_1X2 = 'molex-micro-fit-1x2'
TYPE_MOLEX_MICRO_FIT_2X2 = 'molex-micro-fit-2x2'
TYPE_MOLEX_MICRO_FIT_2X3 = 'molex-micro-fit-2x3'
TYPE_MOLEX_MICRO_FIT_2X4 = 'molex-micro-fit-2x4'
# Direct current (DC)
TYPE_DC = 'dc-terminal'
@@ -831,6 +834,7 @@ class PowerOutletTypeChoices(ChoiceSet):
('Molex', (
(TYPE_MOLEX_MICRO_FIT_1X2, 'Molex Micro-Fit 1x2'),
(TYPE_MOLEX_MICRO_FIT_2X2, 'Molex Micro-Fit 2x2'),
(TYPE_MOLEX_MICRO_FIT_2X3, 'Molex Micro-Fit 2x3'),
(TYPE_MOLEX_MICRO_FIT_2X4, 'Molex Micro-Fit 2x4'),
)),
('DC', (

View File

@@ -1626,6 +1626,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
choices=DeviceStatusChoices,
field_name='device__status',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__tenant',
queryset=Tenant.objects.all(),
label=_('Tenant (ID)'),
)
tenant = django_filters.ModelMultipleChoiceFilter(
field_name='device__tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label=_('Tenant (slug)'),
)
def search(self, queryset, name, value):
if not value.strip():

View File

@@ -472,14 +472,30 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
required=False,
help_text=_('Unit for module weight')
)
attribute_data = forms.JSONField(
label=_('Attributes'),
required=False,
help_text=_('Attribute values for the assigned profile, passed as a dictionary')
)
class Meta:
model = ModuleType
fields = [
'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'profile',
'comments', 'tags'
'attribute_data', 'comments', 'tags',
]
def clean(self):
super().clean()
# Attribute data may be included only if a profile is specified
if self.cleaned_data.get('attribute_data') and not self.cleaned_data.get('profile'):
raise forms.ValidationError(_("Profile must be specified if attribute data is provided."))
# Default attribute_data to an empty dictionary if a profile is specified (to enforce schema validation)
if self.cleaned_data.get('profile') and not self.cleaned_data.get('attribute_data'):
self.cleaned_data['attribute_data'] = {}
class DeviceRoleImportForm(NetBoxModelImportForm):
parent = CSVModelChoiceField(

View File

@@ -10,6 +10,7 @@ from ipam.models import ASN, VRF, VLANTranslationPolicy
from netbox.choices import *
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from tenancy.models import Tenant
from users.models import User
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
@@ -120,6 +121,11 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('Device role')
)
tenant_id = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
required=False,
label=_('Tenant')
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
@@ -128,7 +134,8 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
'location_id': '$location_id',
'virtual_chassis_id': '$virtual_chassis_id',
'device_type_id': '$device_type_id',
'role_id': '$role_id'
'role_id': '$role_id',
'tenant_id': '$tenant_id'
},
label=_('Device')
)
@@ -278,11 +285,6 @@ class RackBaseFilterForm(NetBoxModelFilterSetForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
airflow = forms.MultipleChoiceField(
label=_('Airflow'),
choices=add_blank_choice(RackAirflowChoices),
required=False
)
weight = forms.DecimalField(
label=_('Weight'),
required=False,
@@ -381,6 +383,11 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo
},
label=_('Rack type')
)
airflow = forms.MultipleChoiceField(
label=_('Airflow'),
choices=add_blank_choice(RackAirflowChoices),
required=False
)
serial = forms.CharField(
label=_('Serial'),
required=False
@@ -1317,7 +1324,8 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
)
@@ -1341,7 +1349,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
@@ -1366,7 +1374,8 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
FieldSet('name', 'label', 'type', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
)
@@ -1385,7 +1394,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
@@ -1418,7 +1427,8 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'vdc_id',
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
'vdc_id',
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
@@ -1539,7 +1549,8 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
FieldSet('cabled', 'occupied', name=_('Cable')),
)
@@ -1563,7 +1574,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
FieldSet('cabled', 'occupied', name=_('Cable')),
@@ -1587,7 +1598,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
FieldSet('name', 'label', 'position', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
)
@@ -1605,7 +1616,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
FieldSet('name', 'label', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
)
@@ -1622,7 +1633,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
)

View File

@@ -269,7 +269,8 @@ class RackForm(TenancyForm, NetBoxModelForm):
label=_('Rack Type'),
queryset=RackType.objects.all(),
required=False,
help_text=_("Select a pre-defined rack type, or set physical characteristics below.")
selector=True,
help_text=_("Select a pre-defined rack type, or set physical characteristics below."),
)
comments = CommentField()

View File

@@ -0,0 +1,69 @@
import decimal
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0215_rackreservation_status'),
]
operations = [
migrations.AlterField(
model_name='device',
name='latitude',
field=models.DecimalField(
blank=True,
decimal_places=6,
max_digits=8,
null=True,
validators=[
django.core.validators.MinValueValidator(decimal.Decimal('-90.0')),
django.core.validators.MaxValueValidator(decimal.Decimal('90.0'))
],
),
),
migrations.AlterField(
model_name='device',
name='longitude',
field=models.DecimalField(
blank=True,
decimal_places=6,
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(decimal.Decimal('-180.0')),
django.core.validators.MaxValueValidator(decimal.Decimal('180.0'))
],
),
),
migrations.AlterField(
model_name='site',
name='latitude',
field=models.DecimalField(
blank=True,
decimal_places=6,
max_digits=8,
null=True,
validators=[
django.core.validators.MinValueValidator(decimal.Decimal('-90.0')),
django.core.validators.MaxValueValidator(decimal.Decimal('90.0'))
],
),
),
migrations.AlterField(
model_name='site',
name='longitude',
field=models.DecimalField(
blank=True,
decimal_places=6,
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(decimal.Decimal('-180.0')),
django.core.validators.MaxValueValidator(decimal.Decimal('180.0'))
],
),
),
]

View File

@@ -10,6 +10,7 @@ from django.utils.translation import gettext_lazy as _
from core.models import ObjectType
from dcim.choices import *
from dcim.constants import *
from dcim.exceptions import UnsupportedCablePath
from dcim.fields import PathField
from dcim.utils import decompile_path_node, object_to_path_node
from netbox.choices import ColorChoices
@@ -28,8 +29,6 @@ __all__ = (
'CableTermination',
)
from ..exceptions import UnsupportedCablePath
trace_paths = Signal()
@@ -615,7 +614,7 @@ class CablePath(models.Model):
Cable or WirelessLink connects (interfaces, console ports, circuit termination, etc.). All terminations must be
of the same type and must belong to the same parent object.
"""
from circuits.models import CircuitTermination
from circuits.models import CircuitTermination, Circuit
if not terminations:
return None
@@ -637,8 +636,11 @@ class CablePath(models.Model):
raise UnsupportedCablePath(_("All mid-span terminations must have the same termination type"))
# All mid-span terminations must all be attached to the same device
if (not isinstance(terminations[0], PathEndpoint) and not
all(t.parent_object == terminations[0].parent_object for t in terminations[1:])):
if (
not isinstance(terminations[0], PathEndpoint) and
not isinstance(terminations[0].parent_object, Circuit) and
not all(t.parent_object == terminations[0].parent_object for t in terminations[1:])
):
raise UnsupportedCablePath(_("All mid-span terminations must have the same parent object"))
# Check for a split path (e.g. rear port fanning out to multiple front ports with
@@ -782,32 +784,39 @@ class CablePath(models.Model):
elif isinstance(remote_terminations[0], CircuitTermination):
# Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
if len(remote_terminations) > 1:
is_split = True
qs = Q()
for remote_termination in remote_terminations:
qs |= Q(
circuit=remote_termination.circuit,
term_side='Z' if remote_termination.term_side == 'A' else 'A'
)
# Get all circuit terminations
circuit_terminations = CircuitTermination.objects.filter(qs)
if not circuit_terminations.exists():
break
circuit_termination = CircuitTermination.objects.filter(
circuit=remote_terminations[0].circuit,
term_side='Z' if remote_terminations[0].term_side == 'A' else 'A'
).first()
if circuit_termination is None:
break
elif circuit_termination._provider_network:
elif all([ct._provider_network for ct in circuit_terminations]):
# Circuit terminates to a ProviderNetwork
path.extend([
[object_to_path_node(circuit_termination)],
[object_to_path_node(circuit_termination._provider_network)],
[object_to_path_node(ct) for ct in circuit_terminations],
[object_to_path_node(ct._provider_network) for ct in circuit_terminations],
])
is_complete = True
break
elif circuit_termination.termination and not circuit_termination.cable:
elif all([ct.termination and not ct.cable for ct in circuit_terminations]):
# Circuit terminates to a Region/Site/etc.
path.extend([
[object_to_path_node(circuit_termination)],
[object_to_path_node(circuit_termination.termination)],
[object_to_path_node(ct) for ct in circuit_terminations],
[object_to_path_node(ct.termination) for ct in circuit_terminations],
])
break
elif any([ct.cable in links for ct in circuit_terminations]):
# No valid path
is_split = True
break
terminations = [circuit_termination]
terminations = circuit_terminations
else:
# Check for non-symmetric path

View File

@@ -681,8 +681,8 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs):
return self.component_model(
name=self.name,
label=self.label,
name=self.resolve_name(kwargs.get('module')),
label=self.resolve_label(kwargs.get('module')),
position=self.position,
**kwargs
)

View File

@@ -646,6 +646,10 @@ class Device(
decimal_places=6,
blank=True,
null=True,
validators=[
MinValueValidator(decimal.Decimal('-90.0')),
MaxValueValidator(decimal.Decimal('90.0'))
],
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
)
longitude = models.DecimalField(
@@ -654,6 +658,10 @@ class Device(
decimal_places=6,
blank=True,
null=True,
validators=[
MinValueValidator(decimal.Decimal('-180.0')),
MaxValueValidator(decimal.Decimal('180.0'))
],
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
)
services = GenericRelation(

View File

@@ -7,7 +7,6 @@ from django.utils.translation import gettext_lazy as _
from jsonschema.exceptions import ValidationError as JSONValidationError
from dcim.choices import *
from dcim.constants import MODULE_TOKEN
from dcim.utils import update_interface_bridges
from extras.models import ConfigContextModel, CustomField
from netbox.models import PrimaryModel
@@ -331,7 +330,6 @@ class Module(PrimaryModel, ConfigContextModel):
else:
# ModuleBays must be saved individually for MPTT
for instance in create_instances:
instance.name = instance.name.replace(MODULE_TOKEN, str(self.module_bay.position))
instance.save()
update_fields = ['module']

View File

@@ -1,5 +1,8 @@
import decimal
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneField
@@ -210,6 +213,10 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
decimal_places=6,
blank=True,
null=True,
validators=[
MinValueValidator(decimal.Decimal('-90.0')),
MaxValueValidator(decimal.Decimal('90.0'))
],
help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
)
longitude = models.DecimalField(
@@ -218,6 +225,10 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
decimal_places=6,
blank=True,
null=True,
validators=[
MinValueValidator(decimal.Decimal('-180.0')),
MaxValueValidator(decimal.Decimal('180.0'))
],
help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
)

View File

@@ -100,7 +100,7 @@ class RackTypeTable(NetBoxTable):
model = RackType
fields = (
'pk', 'id', 'model', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description',
'outer_height', 'outer_depth', 'mounting_depth', 'weight', 'max_weight', 'description',
'comments', 'instance_count', 'tags', 'created', 'last_updated',
)
default_columns = (

View File

@@ -2270,6 +2270,80 @@ class CablePathTestCase(TestCase):
CableTraceSVG(interface1).render()
CableTraceSVG(interface2).render()
def test_223_interface_to_interface_via_multiple_circuit_terminations(self):
provider = Provider.objects.first()
circuit_type = CircuitType.objects.first()
circuit1 = self.circuit
circuit2 = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 2')
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
circuittermination1_A = CircuitTermination.objects.create(
circuit=circuit1,
termination=self.site,
term_side='A'
)
circuittermination1_Z = CircuitTermination.objects.create(
circuit=circuit1,
termination=self.site,
term_side='Z'
)
circuittermination2_A = CircuitTermination.objects.create(
circuit=circuit2,
termination=self.site,
term_side='A'
)
circuittermination2_Z = CircuitTermination.objects.create(
circuit=circuit2,
termination=self.site,
term_side='Z'
)
# Create cables
cable1 = Cable(
a_terminations=[interface1],
b_terminations=[circuittermination1_A, circuittermination2_A]
)
cable2 = Cable(
a_terminations=[interface2],
b_terminations=[circuittermination1_Z, circuittermination2_Z]
)
cable1.save()
cable2.save()
self.assertEqual(CablePath.objects.count(), 2)
path1 = self.assertPathExists(
(
interface1,
cable1,
(circuittermination1_A, circuittermination2_A),
(circuittermination1_Z, circuittermination2_Z),
cable2,
interface2
),
is_active=True,
is_complete=True,
)
interface1.refresh_from_db()
self.assertPathIsSet(interface1, path1)
path2 = self.assertPathExists(
(
interface2,
cable2,
(circuittermination1_Z, circuittermination2_Z),
(circuittermination1_A, circuittermination2_A),
cable1,
interface1
),
is_active=True,
is_complete=True,
)
interface2.refresh_from_db()
self.assertPathIsSet(interface2, path2)
def test_301_create_path_via_existing_cable(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
@@ -2510,3 +2584,33 @@ class CablePathTestCase(TestCase):
is_active=True
)
self.assertEqual(CablePath.objects.count(), 0)
def test_402_exclude_circuit_loopback(self):
interface = Interface.objects.create(device=self.device, name='Interface 1')
circuittermination1 = CircuitTermination.objects.create(
circuit=self.circuit,
termination=self.site,
term_side='A'
)
circuittermination2 = CircuitTermination.objects.create(
circuit=self.circuit,
termination=self.site,
term_side='Z'
)
# Create cables
cable = Cable(
a_terminations=[interface],
b_terminations=[circuittermination1, circuittermination2]
)
cable.save()
path = self.assertPathExists(
(interface, cable, (circuittermination1, circuittermination2)),
is_active=True,
is_complete=False,
is_split=True
)
self.assertEqual(CablePath.objects.count(), 1)
interface.refresh_from_db()
self.assertPathIsSet(interface, path)

View File

@@ -43,6 +43,13 @@ class DeviceComponentFilterSetTests:
params = {'device_status': ['active', 'planned']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant(self):
tenants = Tenant.objects.all()[:2]
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class DeviceComponentTemplateFilterSetTests:
@@ -3377,9 +3384,17 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
)
Rack.objects.bulk_create(racks)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
devices = (
Device(
name='Device 1',
tenant=tenants[0],
device_type=device_types[0],
role=roles[0],
site=sites[0],
@@ -3389,6 +3404,7 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
),
Device(
name='Device 2',
tenant=tenants[1],
device_type=device_types[1],
role=roles[1],
site=sites[1],
@@ -3398,6 +3414,7 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
),
Device(
name='Device 3',
tenant=tenants[2],
device_type=device_types[2],
role=roles[2],
site=sites[2],
@@ -3617,9 +3634,17 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
)
Rack.objects.bulk_create(racks)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
devices = (
Device(
name='Device 1',
tenant=tenants[0],
device_type=device_types[0],
role=roles[0],
site=sites[0],
@@ -3629,6 +3654,7 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
),
Device(
name='Device 2',
tenant=tenants[1],
device_type=device_types[1],
role=roles[1],
site=sites[1],
@@ -3638,6 +3664,7 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
),
Device(
name='Device 3',
tenant=tenants[2],
device_type=device_types[2],
role=roles[2],
site=sites[2],
@@ -3857,9 +3884,17 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
)
Rack.objects.bulk_create(racks)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
devices = (
Device(
name='Device 1',
tenant=tenants[0],
device_type=device_types[0],
role=roles[0],
site=sites[0],
@@ -3869,6 +3904,7 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 2',
tenant=tenants[1],
device_type=device_types[1],
role=roles[1],
site=sites[1],
@@ -3878,6 +3914,7 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 3',
tenant=tenants[2],
device_type=device_types[2],
role=roles[2],
site=sites[2],
@@ -4111,9 +4148,17 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
)
Rack.objects.bulk_create(racks)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
devices = (
Device(
name='Device 1',
tenant=tenants[0],
device_type=device_types[0],
role=roles[0],
site=sites[0],
@@ -4123,6 +4168,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
),
Device(
name='Device 2',
tenant=tenants[1],
device_type=device_types[1],
role=roles[1],
site=sites[1],
@@ -4132,6 +4178,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
),
Device(
name='Device 3',
tenant=tenants[2],
device_type=device_types[2],
role=roles[2],
site=sites[2],
@@ -4390,9 +4437,17 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
virtual_chassis = VirtualChassis(name='Virtual Chassis')
virtual_chassis.save()
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
devices = (
Device(
name='Device 1A',
tenant=tenants[0],
device_type=device_types[0],
role=roles[0],
site=sites[0],
@@ -4405,6 +4460,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 1B',
tenant=tenants[1],
device_type=device_types[2],
role=roles[2],
site=sites[2],
@@ -4417,6 +4473,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 2',
tenant=tenants[2],
device_type=device_types[1],
role=roles[1],
site=sites[1],
@@ -4426,6 +4483,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 3',
tenant=tenants[2],
device_type=device_types[2],
role=roles[2],
site=sites[2],
@@ -5011,9 +5069,17 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
)
Rack.objects.bulk_create(racks)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
devices = (
Device(
name='Device 1',
tenant=tenants[0],
device_type=device_types[0],
role=roles[0],
site=sites[0],
@@ -5023,6 +5089,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 2',
tenant=tenants[1],
device_type=device_types[1],
role=roles[1],
site=sites[1],
@@ -5032,6 +5099,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 3',
tenant=tenants[2],
device_type=device_types[2],
role=roles[2],
site=sites[2],
@@ -5302,9 +5370,17 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
)
Rack.objects.bulk_create(racks)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
devices = (
Device(
name='Device 1',
tenant=tenants[0],
device_type=device_types[0],
role=roles[0],
site=sites[0],
@@ -5314,6 +5390,7 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
),
Device(
name='Device 2',
tenant=tenants[1],
device_type=device_types[1],
role=roles[1],
site=sites[1],
@@ -5323,6 +5400,7 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
),
Device(
name='Device 3',
tenant=tenants[2],
device_type=device_types[2],
role=roles[2],
site=sites[2],
@@ -5579,9 +5657,17 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
)
Rack.objects.bulk_create(racks)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
devices = (
Device(
name='Device 1',
tenant=tenants[0],
device_type=device_types[0],
role=roles[0],
site=sites[0],
@@ -5591,6 +5677,7 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 2',
tenant=tenants[1],
device_type=device_types[1],
role=roles[1],
site=sites[1],
@@ -5600,6 +5687,7 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 3',
tenant=tenants[2],
device_type=device_types[2],
role=roles[2],
site=sites[2],
@@ -5752,9 +5840,17 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
)
Rack.objects.bulk_create(racks)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
devices = (
Device(
name='Device 1',
tenant=tenants[0],
device_type=device_types[0],
role=roles[0],
site=sites[0],
@@ -5764,6 +5860,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 2',
tenant=tenants[1],
device_type=device_types[1],
role=roles[1],
site=sites[1],
@@ -5773,6 +5870,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
),
Device(
name='Device 3',
tenant=tenants[2],
device_type=device_types[2],
role=roles[2],
site=sites[2],

View File

@@ -792,8 +792,54 @@ class ModuleBayTestCase(TestCase):
)
device.consoleports.first()
def test_nested_module_token(self):
pass
@tag('regression') # #19918
def test_nested_module_bay_label_resolution(self):
"""Test that nested module bay labels properly resolve {module} placeholders"""
manufacturer = Manufacturer.objects.first()
site = Site.objects.first()
device_role = DeviceRole.objects.first()
# Create device type with module bay template (position='A')
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='Device with Bays',
slug='device-with-bays'
)
ModuleBayTemplate.objects.create(
device_type=device_type,
name='Bay A',
position='A'
)
# Create module type with nested bay template using {module} placeholder
module_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='Module with Nested Bays'
)
ModuleBayTemplate.objects.create(
module_type=module_type,
name='SFP {module}-21',
label='{module}-21',
position='21'
)
# Create device and install module
device = Device.objects.create(
name='Test Device',
device_type=device_type,
role=device_role,
site=site
)
module_bay = device.modulebays.get(name='Bay A')
module = Module.objects.create(
device=device,
module_bay=module_bay,
module_type=module_type
)
# Verify nested bay label resolves {module} to parent position
nested_bay = module.modulebays.get(name='SFP A-21')
self.assertEqual(nested_bay.label, 'A-21')
class CableTestCase(TestCase):

View File

@@ -23,6 +23,6 @@ class ConfigTemplateSerializer(ChangeLogMessageSerializer, TaggableModelSerializ
fields = [
'id', 'url', 'display_url', 'display', 'name', 'description', 'environment_params', 'template_code',
'mime_type', 'file_name', 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file',
'data_synced', 'tags', 'created', 'last_updated',
'auto_sync_enabled', 'data_synced', 'tags', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@@ -267,6 +267,14 @@ class ScriptViewSet(ModelViewSet):
_ignore_model_permissions = True
lookup_value_regex = '[^/]+' # Allow dots
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
# Restrict the view's QuerySet to allow only the permitted objects
if request.user.is_authenticated:
action = 'run' if request.method == 'POST' else 'view'
self.queryset = self.queryset.restrict(request.user, action)
def _get_script(self, pk):
# If pk is numeric, retrieve script by ID
if pk.isnumeric():
@@ -290,10 +298,12 @@ class ScriptViewSet(ModelViewSet):
"""
Run a Script identified by its numeric PK or module & name and return the pending Job as the result
"""
if not request.user.has_perm('extras.run_script'):
raise PermissionDenied("This user does not have permission to run scripts.")
script = self._get_script(pk)
if not request.user.has_perm('extras.run_script', obj=script):
raise PermissionDenied("This user does not have permission to run this script.")
input_serializer = serializers.ScriptInputSerializer(
data=request.data,
context={'script': script}

View File

@@ -209,7 +209,10 @@ class ObjectCountsWidget(DashboardWidget):
url = get_action_url(model, action='list')
except NoReverseMatch:
url = None
qs = model.objects.restrict(request.user, 'view')
try:
qs = model.objects.restrict(request.user, 'view')
except AttributeError:
qs = model.objects.all()
# Apply any specified filters
if url and (filters := self.config.get('filters')):
params = dict_to_querydict(filters)

View File

@@ -134,11 +134,18 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
# Enqueue a Job to record the script's execution
from extras.jobs import ScriptJob
params = {
"instance": event_rule.action_object,
"name": script.name,
"user": user,
"data": event_data
}
if snapshots:
params["snapshots"] = snapshots
if request:
params["request"] = copy_safe_request(request)
ScriptJob.enqueue(
instance=event_rule.action_object,
name=script.name,
user=user,
data=event_data
**params
)
# Notification groups

View File

@@ -398,8 +398,12 @@ class ConfigTemplateBulkEditForm(ChangelogMessageMixin, BulkEditForm):
required=False,
widget=BulkEditNullBooleanSelect()
)
nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension')
auto_sync_enabled = forms.NullBooleanField(
label=_('Auto sync enabled'),
required=False,
widget=BulkEditNullBooleanSelect()
)
nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension', 'auto_sync_enabled',)
class ImageAttachmentBulkEditForm(ChangelogMessageMixin, BulkEditForm):

View File

@@ -5,7 +5,7 @@ from django.contrib.postgres.forms import SimpleArrayField
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext_lazy as _
from core.models import ObjectType
from core.models import DataFile, DataSource, ObjectType
from extras.choices import *
from extras.models import *
from netbox.events import get_event_type_choices
@@ -160,14 +160,41 @@ class ConfigContextProfileImportForm(NetBoxModelImportForm):
class ConfigTemplateImportForm(CSVModelForm):
data_source = CSVModelChoiceField(
label=_('Data source'),
queryset=DataSource.objects.all(),
required=False,
to_field_name='name',
help_text=_('Data source which provides the data file')
)
data_file = CSVModelChoiceField(
label=_('Data file'),
queryset=DataFile.objects.all(),
required=False,
to_field_name='path',
help_text=_('Data file containing the template code')
)
auto_sync_enabled = forms.BooleanField(
required=False,
label=_('Auto sync enabled'),
help_text=_("Enable automatic synchronization of template content when the data file is updated")
)
class Meta:
model = ConfigTemplate
fields = (
'name', 'description', 'template_code', 'environment_params', 'mime_type', 'file_name', 'file_extension',
'as_attachment', 'tags',
'name', 'description', 'template_code', 'data_source', 'data_file', 'auto_sync_enabled',
'environment_params', 'mime_type', 'file_name', 'file_extension', 'as_attachment', 'tags',
)
def clean(self):
super().clean()
# Make sure template_code is None when it's not included in the uploaded data
if not self.data.get('template_code') and not self.data.get('data_file'):
raise forms.ValidationError(_("Must specify either local content or a data file"))
return self.cleaned_data['template_code']
class SavedFilterImportForm(CSVModelForm):
object_types = CSVMultipleContentTypeField(

View File

@@ -42,17 +42,20 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
model = CustomField
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet(
'type', 'related_object_type_id', 'group_name', 'weight', 'required', 'unique', 'choice_set_id',
name=_('Attributes')
),
FieldSet('object_type_id', 'type', 'group_name', 'weight', 'required', 'unique', name=_('Attributes')),
FieldSet('choice_set_id', 'related_object_type_id', name=_('Type Options')),
FieldSet('ui_visible', 'ui_editable', 'is_cloneable', name=_('Behavior')),
FieldSet('validation_minimum', 'validation_maximum', 'validation_regex', name=_('Validation')),
)
related_object_type_id = ContentTypeMultipleChoiceField(
object_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.with_feature('custom_fields'),
required=False,
label=_('Related object type')
label=_('Object types'),
)
related_object_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.public(),
required=False,
label=_('Related object type'),
)
type = forms.MultipleChoiceField(
choices=CustomFieldTypeChoices,
@@ -136,12 +139,12 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
model = CustomLink
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('object_type', 'enabled', 'new_window', 'weight', name=_('Attributes')),
FieldSet('object_type_id', 'enabled', 'new_window', 'weight', name=_('Attributes')),
)
object_type = ContentTypeMultipleChoiceField(
object_type_id = ContentTypeMultipleChoiceField(
label=_('Object types'),
queryset=ObjectType.objects.with_feature('custom_links'),
required=False
required=False,
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
@@ -230,12 +233,12 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
model = SavedFilter
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('object_type', 'enabled', 'shared', 'weight', name=_('Attributes')),
FieldSet('object_type_id', 'enabled', 'shared', 'weight', name=_('Attributes')),
)
object_type = ContentTypeMultipleChoiceField(
object_type_id = ContentTypeMultipleChoiceField(
label=_('Object types'),
queryset=ObjectType.objects.public(),
required=False
required=False,
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
@@ -476,7 +479,7 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
model = ConfigTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
FieldSet('data_source_id', 'data_file_id', 'auto_sync_enabled', name=_('Data')),
FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering'))
)
data_source_id = DynamicModelMultipleChoiceField(
@@ -492,6 +495,13 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
'source_id': '$data_source_id'
}
)
auto_sync_enabled = forms.NullBooleanField(
label=_('Auto sync enabled'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
tag = TagFilterField(ConfigTemplate)
mime_type = forms.CharField(
required=False,

View File

@@ -2,11 +2,14 @@ import logging
import traceback
from contextlib import ExitStack
from django.db import transaction
from django.db import router, transaction
from django.db import DEFAULT_DB_ALIAS
from django.utils.translation import gettext as _
from core.signals import clear_events
from dcim.models import Device
from extras.models import Script as ScriptModel
from netbox.context_managers import event_tracking
from netbox.jobs import JobRunner
from netbox.registry import registry
from utilities.exceptions import AbortScript, AbortTransaction
@@ -42,10 +45,21 @@ class ScriptJob(JobRunner):
# A script can modify multiple models so need to do an atomic lock on
# both the default database (for non ChangeLogged models) and potentially
# any other database (for ChangeLogged models)
with transaction.atomic():
script.output = script.run(data, commit)
if not commit:
raise AbortTransaction()
changeloged_db = router.db_for_write(Device)
with transaction.atomic(using=DEFAULT_DB_ALIAS):
# If branch database is different from default, wrap in a second atomic transaction
# Note: Don't add any extra code between the two atomic transactions,
# otherwise the changes might get committed to the default database
# if there are any raised exceptions.
if changeloged_db != DEFAULT_DB_ALIAS:
with transaction.atomic(using=changeloged_db):
script.output = script.run(data, commit)
if not commit:
raise AbortTransaction()
else:
script.output = script.run(data, commit)
if not commit:
raise AbortTransaction()
except AbortTransaction:
script.log_info(message=_("Database changes have been reverted automatically."))
if script.failed:
@@ -108,14 +122,14 @@ class ScriptJob(JobRunner):
script.request = request
self.logger.debug(f"Request ID: {request.id if request else None}")
# Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
# change logging, event rules, etc.
if commit:
self.logger.info("Executing script (commit enabled)")
with ExitStack() as stack:
for request_processor in registry['request_processors']:
stack.enter_context(request_processor(request))
self.run_script(script, request, data, commit)
else:
self.logger.warning("Executing script (commit disabled)")
with ExitStack() as stack:
for request_processor in registry['request_processors']:
if not commit and request_processor is event_tracking:
continue
stack.enter_context(request_processor(request))
self.run_script(script, request, data, commit)

View File

@@ -632,6 +632,10 @@ class ConfigTemplateTable(NetBoxTable):
orderable=False,
verbose_name=_('Synced')
)
auto_sync_enabled = columns.BooleanColumn(
verbose_name=_('Auto Sync Enabled'),
orderable=False,
)
mime_type = tables.Column(
verbose_name=_('MIME Type')
)

View File

@@ -1,4 +1,6 @@
from django import template
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
register = template.Library()
@@ -8,4 +10,16 @@ register = template.Library()
def render_widget(context, widget):
request = context['request']
return widget.render(request)
try:
return widget.render(request)
except Exception as e:
message1 = _('An error was encountered when attempting to render this widget:')
message2 = _('Please try reconfiguring the widget, or remove it from your dashboard.')
return mark_safe(f"""
<p>
<span class="text-danger"><i class="mdi mdi-alert"></i></span>
{message1}
</p>
<p class="font-monospace ps-3">{e}</p>
<p>{message2}</p>
""")

View File

@@ -894,18 +894,13 @@ class ScriptTest(APITestCase):
def setUp(self):
super().setUp()
self.add_permissions('extras.view_script')
# Monkey-patch the Script model to return our TestScriptClass above
Script.python_class = self.python_class
def test_get_script(self):
module = ScriptModule.objects.get(
file_root=ManagedFileRootPathChoices.SCRIPTS,
file_path='script.py',
)
script = module.scripts.all().first()
url = reverse('extras-api:script-detail', kwargs={'pk': script.pk})
response = self.client.get(url, **self.header)
response = self.client.get(self.url, **self.header)
self.assertEqual(response.data['name'], self.TestScriptClass.Meta.name)
self.assertEqual(response.data['vars']['var1'], 'StringVar')

View File

@@ -243,6 +243,9 @@ SESSION_FILE_PATH = None
# },
# "scripts": {
# "BACKEND": "extras.storage.ScriptFileSystemStorage",
# "OPTIONS": {
# "allow_overwrite": True,
# },
# },
# }

View File

@@ -291,6 +291,9 @@ DEFAULT_STORAGES = {
},
"scripts": {
"BACKEND": "extras.storage.ScriptFileSystemStorage",
"OPTIONS": {
"allow_overwrite": True,
},
},
}
STORAGES = DEFAULT_STORAGES | STORAGES

View File

@@ -851,12 +851,12 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
replace = form.cleaned_data['replace']
if form.cleaned_data['use_regex']:
try:
obj.new_name = re.sub(find, replace, getattr(obj, self.field_name, ''))
obj.new_name = re.sub(find, replace, getattr(obj, self.field_name, '') or '')
# Catch regex group reference errors
except re.error:
obj.new_name = getattr(obj, self.field_name)
else:
obj.new_name = getattr(obj, self.field_name, '').replace(find, replace)
obj.new_name = (getattr(obj, self.field_name, '') or '').replace(find, replace)
renamed_pks.append(obj.pk)
return renamed_pks

View File

@@ -559,6 +559,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
form.instance._replicated_base = hasattr(self.form, "replication_fields")
if form.is_valid():
changelog_message = form.cleaned_data.pop('changelog_message', '')
new_components = []
data = deepcopy(request.POST)
pattern_count = len(form.cleaned_data[self.form.replication_fields[0]])
@@ -585,6 +586,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
# Create the new components
new_objs = []
for component_form in new_components:
# Record changelog message (if any)
if changelog_message:
component_form.instance._changelog_message = changelog_message
obj = component_form.save()
new_objs.append(obj)

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

@@ -30,7 +30,7 @@
"gridstack": "12.3.3",
"htmx.org": "2.0.8",
"query-string": "9.3.1",
"sass": "1.94.0",
"sass": "1.95.0",
"tom-select": "2.4.3",
"typeface-inter": "3.18.1",
"typeface-roboto-mono": "1.1.13"

View File

@@ -1,7 +1,7 @@
import { getElements } from '../util';
/**
* Move selected options from one select element to another.
* Move selected options from one select element to another, preserving optgroup structure.
*
* @param source Select Element
* @param target Select Element
@@ -9,14 +9,42 @@ import { getElements } from '../util';
function moveOption(source: HTMLSelectElement, target: HTMLSelectElement): void {
for (const option of Array.from(source.options)) {
if (option.selected) {
target.appendChild(option.cloneNode(true));
// Check if option is inside an optgroup
const parentOptgroup = option.parentElement as HTMLElement;
if (parentOptgroup.tagName === 'OPTGROUP') {
// Find or create matching optgroup in target
const groupLabel = parentOptgroup.getAttribute('label');
let targetOptgroup = Array.from(target.children).find(
child => child.tagName === 'OPTGROUP' && child.getAttribute('label') === groupLabel,
) as HTMLOptGroupElement;
if (!targetOptgroup) {
// Create new optgroup in target
targetOptgroup = document.createElement('optgroup');
targetOptgroup.setAttribute('label', groupLabel!);
target.appendChild(targetOptgroup);
}
// Move option to target optgroup
targetOptgroup.appendChild(option.cloneNode(true));
} else {
// Option is not in an optgroup, append directly
target.appendChild(option.cloneNode(true));
}
option.remove();
// Clean up empty optgroups in source
if (parentOptgroup.tagName === 'OPTGROUP' && parentOptgroup.children.length === 0) {
parentOptgroup.remove();
}
}
}
}
/**
* Move selected options of a select element up in order.
* Move selected options of a select element up in order, respecting optgroup boundaries.
*
* Adapted from:
* @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
@@ -27,14 +55,21 @@ function moveOptionUp(element: HTMLSelectElement): void {
for (let i = 1; i < options.length; i++) {
const option = options[i];
if (option.selected) {
element.removeChild(option);
element.insertBefore(option, element.options[i - 1]);
const parent = option.parentElement as HTMLElement;
const previousOption = element.options[i - 1];
const previousParent = previousOption.parentElement as HTMLElement;
// Only move if previous option is in the same parent (optgroup or select)
if (parent === previousParent) {
parent.removeChild(option);
parent.insertBefore(option, previousOption);
}
}
}
}
/**
* Move selected options of a select element down in order.
* Move selected options of a select element down in order, respecting optgroup boundaries.
*
* Adapted from:
* @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
@@ -43,12 +78,18 @@ function moveOptionUp(element: HTMLSelectElement): void {
function moveOptionDown(element: HTMLSelectElement): void {
const options = Array.from(element.options);
for (let i = options.length - 2; i >= 0; i--) {
let option = options[i];
const option = options[i];
if (option.selected) {
let next = element.options[i + 1];
option = element.removeChild(option);
next = element.replaceChild(option, next);
element.insertBefore(next, option);
const parent = option.parentElement as HTMLElement;
const nextOption = element.options[i + 1];
const nextParent = nextOption.parentElement as HTMLElement;
// Only move if next option is in the same parent (optgroup or select)
if (parent === nextParent) {
const optionClone = parent.removeChild(option);
const nextClone = parent.replaceChild(optionClone, nextOption);
parent.insertBefore(nextClone, optionClone);
}
}
}
}

View File

@@ -162,3 +162,18 @@ pre code {
vertical-align: .05em;
height: auto;
}
// Theme-based visibility utilities
// Tabler's .hide-theme-* utilities expect data-bs-theme on :root, but NetBox applies
// it to body. These overrides use higher specificity selectors to ensure theme-based
// visibility works correctly. The :root:not(.dummy) pattern provides the additional
// specificity needed to override Tabler's :root:not() rules.
:root:not(.dummy) body[data-bs-theme='light'] .hide-theme-light,
:root:not(.dummy) body[data-bs-theme='dark'] .hide-theme-dark {
display: none !important;
}
:root:not(.dummy) body[data-bs-theme='dark'] .hide-theme-light,
:root:not(.dummy) body[data-bs-theme='light'] .hide-theme-dark {
display: inline-flex !important;
}

View File

@@ -32,3 +32,17 @@ form.object-edit {
border: 1px solid $red;
}
}
// Make optgroup labels sticky when scrolling through select elements
select[multiple] {
optgroup {
position: sticky;
top: 0;
background-color: var(--bs-body-bg);
font-style: normal;
font-weight: bold;
}
option {
padding-left: 0.5rem;
}
}

View File

@@ -3190,10 +3190,10 @@ safe-regex-test@^1.1.0:
es-errors "^1.3.0"
is-regex "^1.2.1"
sass@1.94.0:
version "1.94.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.94.0.tgz#a04198d8940358ca6ad537d2074051edbbe7c1a7"
integrity sha512-Dqh7SiYcaFtdv5Wvku6QgS5IGPm281L+ZtVD1U2FJa7Q0EFRlq8Z3sjYtz6gYObsYThUOz9ArwFqPZx+1azILQ==
sass@1.95.0:
version "1.95.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.95.0.tgz#3a3a4d4d954313ab50eaf16f6e2548a2f6ec0811"
integrity sha512-9QMjhLq+UkOg/4bb8Lt8A+hJZvY3t+9xeZMKSBtBEgxrXA3ed5Ts4NDreUkYgJP1BTmrscQE/xYhf7iShow6lw==
dependencies:
chokidar "^4.0.0"
immutable "^5.0.2"

View File

@@ -1,3 +1,3 @@
version: "4.4.6"
version: "4.4.8"
edition: "Community"
published: "2025-11-11"
published: "2025-12-09"

View File

@@ -24,10 +24,6 @@
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Airflow" %}</th>
<td>{{ object.get_airflow_display|placeholder }}</td>
</tr>
</table>
</div>
{% include 'dcim/inc/panels/racktype_dimensions.html' %}

View File

@@ -8,10 +8,10 @@
<p>
<i class="mdi mdi-alert"></i>
<strong>{% trans "Missing required packages" %}.</strong>
{% blocktrans trimmed %}
{% blocktrans trimmed with req_file="requirements.txt" local_req_file="local_requirements.txt" pip_cmd="pip freeze" %}
This installation of NetBox might be missing one or more required Python packages. These packages are listed in
<code>requirements.txt</code> and <code>local_requirements.txt</code>, and are normally installed as part of the
installation or upgrade process. To verify installed packages, run <code>pip freeze</code> from the console and
<code>{{ req_file }}</code> and <code>{{ local_req_file }}</code>, and are normally installed as part of the
installation or upgrade process. To verify installed packages, run <code>{{ pip_cmd }}</code> from the console and
compare the output to the list of required packages.
{% endblocktrans %}
</p>

View File

@@ -8,17 +8,17 @@
<p>
<i class="mdi mdi-alert"></i>
<strong>{% trans "Database migrations missing" %}.</strong>
{% blocktrans trimmed %}
{% blocktrans trimmed with command="python3 manage.py migrate" %}
When upgrading to a new NetBox release, the upgrade script must be run to apply any new database migrations. You
can run migrations manually by executing <code>python3 manage.py migrate</code> from the command line.
can run migrations manually by executing <code>{{ command }}</code> from the command line.
{% endblocktrans %}
</p>
<p>
<i class="mdi mdi-alert"></i>
<strong>{% trans "Unsupported PostgreSQL version" %}.</strong>
{% blocktrans trimmed %}
{% blocktrans trimmed with sql_query="SELECT VERSION()" %}
Ensure that PostgreSQL version 14 or later is in use. You can check this by connecting to the database using
NetBox's credentials and issuing a query for <code>SELECT VERSION()</code>.
NetBox's credentials and issuing a query for <code>{{ sql_query }}</code>.
{% endblocktrans %}
</p>
{% endblock message %}

View File

@@ -62,6 +62,10 @@
<th scope="row">{% trans "Data Synced" %}</th>
<td>{{ object.data_synced|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Auto Sync Enabled" %}</th>
<td>{% checkmark object.auto_sync_enabled %}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}

View File

@@ -17,15 +17,17 @@
{% if request.htmx %}
{# Include the updated object count for display elsewhere on the page #}
<div hx-swap-oob="innerHTML:.total-object-count">{{ table.rows|length }}</div>
{% if not table.embedded %}
<div hx-swap-oob="innerHTML:.total-object-count">{{ table.rows|length }}</div>
{% endif %}
{# Include the updated "save" link for the table configuration #}
{% if table.config_params %}
{% if table.config_params and not table.embedded %}
<a class="dropdown-item" hx-swap-oob="outerHTML:#table_save_link" href="{% url 'extras:tableconfig_add' %}?{{ table.config_params }}&return_url={{ request.path }}" id="table_save_link">Save</a>
{% endif %}
{# Update the bulk action buttons with new query parameters #}
{% if actions %}
{% if actions and not table.embedded %}
<div class="bulk-action-buttons" hx-swap-oob="outerHTML:.bulk-action-buttons">
{% action_buttons actions model multi=True %}
</div>

View File

@@ -26,8 +26,8 @@
<p>{% trans "Check the following" %}:</p>
<ul>
<li class="tip">
{% blocktrans trimmed %}
<code>manage.py collectstatic</code> was run during the most recent upgrade. This installs the most
{% blocktrans trimmed with command="manage.py collectstatic" %}
<code>{{ command }}</code> was run during the most recent upgrade. This installs the most
recent iteration of each static file into the static root path.
{% endblocktrans %}
</li>

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,6 +1,8 @@
import json
from collections import defaultdict
from django import forms
from django.apps import apps
from django.conf import settings
from django.contrib.auth import password_validation
from django.contrib.postgres.forms import SimpleArrayField
@@ -21,6 +23,7 @@ from utilities.forms.fields import (
DynamicModelMultipleChoiceField,
JSONField,
)
from utilities.string import title
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget
from utilities.permissions import qs_filter_from_constraints
@@ -283,10 +286,24 @@ class GroupForm(forms.ModelForm):
def get_object_types_choices():
return [
(ot.pk, str(ot))
for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model')
]
"""
Generate choices for object types grouped by app label using optgroups.
Returns nested structure: [(app_label, [(id, model_name), ...]), ...]
"""
app_label_map = {
app_config.label: app_config.verbose_name
for app_config in apps.get_app_configs()
}
choices_by_app = defaultdict(list)
for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model'):
app_label = app_label_map.get(ot.app_label, ot.app_label)
model_class = ot.model_class()
model_name = model_class._meta.verbose_name if model_class else ot.model
choices_by_app[app_label].append((ot.pk, title(model_name)))
return list(choices_by_app.items())
class ObjectPermissionForm(forms.ModelForm):

View File

@@ -1,8 +1,10 @@
import binascii
import os
import zoneinfo
from django.conf import settings
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.core.validators import MinLengthValidator
from django.db import models
from django.urls import reverse
@@ -86,6 +88,24 @@ class Token(models.Model):
def partial(self):
return f'**********************************{self.key[-6:]}' if self.key else ''
def clean(self):
super().clean()
# Prevent creating a token with a past expiration date
# while allowing updates to existing tokens.
if self.pk is None and self.is_expired:
current_tz = zoneinfo.ZoneInfo(settings.TIME_ZONE)
now = timezone.now().astimezone(current_tz)
current_time_str = f'{now.date().isoformat()} {now.time().isoformat(timespec="seconds")}'
# Translators: {current_time} is the current server date and time in ISO format,
# {timezone} is the configured server time zone (for example, "UTC" or "Europe/Berlin").
message = _('Expiration time must be in the future. '
'Current server time is {current_time} ({timezone}).'
).format(current_time=current_time_str, timezone=current_tz.key)
raise ValidationError({'expires': message})
def save(self, *args, **kwargs):
if not self.key:
self.key = self.generate_key()

View File

@@ -1,6 +1,72 @@
from django.test import TestCase
from datetime import timedelta
from users.models import User
from django.core.exceptions import ValidationError
from django.test import TestCase
from django.utils import timezone
from users.models import User, Token
from utilities.testing import create_test_user
class TokenTest(TestCase):
"""
Test class for testing the functionality of the Token model.
"""
@classmethod
def setUpTestData(cls):
"""
Set up test data for the Token model.
"""
cls.user = create_test_user('User 1')
def test_is_expired(self):
"""
Test the is_expired property.
"""
# Token with no expiration
token = Token(user=self.user, expires=None)
self.assertFalse(token.is_expired)
# Token with future expiration
token.expires = timezone.now() + timedelta(days=1)
self.assertFalse(token.is_expired)
# Token with past expiration
token.expires = timezone.now() - timedelta(days=1)
self.assertTrue(token.is_expired)
def test_cannot_create_token_with_past_expiration(self):
"""
Test that creating a token with an expiration date in the past raises a ValidationError.
"""
past_date = timezone.now() - timedelta(days=1)
token = Token(user=self.user, expires=past_date)
with self.assertRaises(ValidationError) as cm:
token.clean()
self.assertIn('expires', cm.exception.error_dict)
def test_can_update_existing_expired_token(self):
"""
Test that updating an already expired token does NOT raise a ValidationError.
"""
# Create a valid token first with an expiration date in the past
# bypasses the clean() method
token = Token.objects.create(user=self.user)
token.expires = timezone.now() - timedelta(days=1)
token.save()
# Try to update the description
token.description = 'New Description'
try:
token.clean()
token.save()
except ValidationError:
self.fail('Updating an expired token should not raise ValidationError')
token.refresh_from_db()
self.assertEqual(token.description, 'New Description')
class UserConfigTest(TestCase):

View File

@@ -66,17 +66,45 @@ class SelectWithPK(forms.Select):
option_template_name = 'widgets/select_option_with_pk.html'
class AvailableOptions(forms.SelectMultiple):
class SelectMultipleBase(forms.SelectMultiple):
"""
Base class for select widgets that filter choices based on selected values.
Subclasses should set `include_selected` to control filtering behavior.
"""
include_selected = False
def optgroups(self, name, value, attrs=None):
filtered_choices = []
include_selected = self.include_selected
for choice in self.choices:
if isinstance(choice[1], (list, tuple)): # optgroup
group_label, group_choices = choice
filtered_group = [
c for c in group_choices if (str(c[0]) in value) == include_selected
]
if filtered_group: # Only include optgroup if it has choices left
filtered_choices.append((group_label, filtered_group))
else: # option, e.g. flat choice
if (str(choice[0]) in value) == include_selected:
filtered_choices.append(choice)
self.choices = filtered_choices
value = [] # Clear selected choices
return super().optgroups(name, value, attrs)
def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
option = super().create_option(name, value, label, selected, index, subindex, attrs)
option['attrs']['title'] = label # Add title attribute to show full text on hover
return option
class AvailableOptions(SelectMultipleBase):
"""
Renders a <select multiple=true> including only choices that have been selected. (For unbound fields, this list
will be empty.) Employed by SplitMultiSelectWidget.
"""
def optgroups(self, name, value, attrs=None):
self.choices = [
choice for choice in self.choices if str(choice[0]) not in value
]
value = [] # Clear selected choices
return super().optgroups(name, value, attrs)
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
@@ -87,17 +115,12 @@ class AvailableOptions(forms.SelectMultiple):
return context
class SelectedOptions(forms.SelectMultiple):
class SelectedOptions(SelectMultipleBase):
"""
Renders a <select multiple=true> including only choices that have _not_ been selected. (For unbound fields, this
will include _all_ choices.) Employed by SplitMultiSelectWidget.
"""
def optgroups(self, name, value, attrs=None):
self.choices = [
choice for choice in self.choices if str(choice[0]) in value
]
value = [] # Clear selected choices
return super().optgroups(name, value, attrs)
include_selected = True
class SplitMultiSelectWidget(forms.MultiWidget):

View File

@@ -7,6 +7,7 @@ from utilities.forms.bulk_import import BulkImportForm
from utilities.forms.fields.csv import CSVSelectWidget
from utilities.forms.forms import BulkRenameForm
from utilities.forms.utils import get_field_value, expand_alphanumeric_pattern, expand_ipaddress_pattern
from utilities.forms.widgets.select import AvailableOptions, SelectedOptions
class ExpandIPAddress(TestCase):
@@ -481,3 +482,71 @@ class CSVSelectWidgetTest(TestCase):
widget = CSVSelectWidget()
data = {'test_field': 'valid_value'}
self.assertFalse(widget.value_omitted_from_data(data, {}, 'test_field'))
class SelectMultipleWidgetTest(TestCase):
"""
Validate filtering behavior of AvailableOptions and SelectedOptions widgets.
"""
def test_available_options_flat_choices(self):
"""AvailableOptions should exclude selected values from flat choices"""
widget = AvailableOptions(choices=[
(1, 'Option 1'),
(2, 'Option 2'),
(3, 'Option 3'),
])
widget.optgroups('test', ['2'], None)
self.assertEqual(len(widget.choices), 2)
self.assertEqual(widget.choices[0], (1, 'Option 1'))
self.assertEqual(widget.choices[1], (3, 'Option 3'))
def test_available_options_optgroups(self):
"""AvailableOptions should exclude selected values from optgroups"""
widget = AvailableOptions(choices=[
('Group A', [(1, 'Option 1'), (2, 'Option 2')]),
('Group B', [(3, 'Option 3'), (4, 'Option 4')]),
])
# Select options 2 and 3
widget.optgroups('test', ['2', '3'], None)
# Should have 2 groups with filtered choices
self.assertEqual(len(widget.choices), 2)
self.assertEqual(widget.choices[0][0], 'Group A')
self.assertEqual(widget.choices[0][1], [(1, 'Option 1')])
self.assertEqual(widget.choices[1][0], 'Group B')
self.assertEqual(widget.choices[1][1], [(4, 'Option 4')])
def test_selected_options_flat_choices(self):
"""SelectedOptions should include only selected values from flat choices"""
widget = SelectedOptions(choices=[
(1, 'Option 1'),
(2, 'Option 2'),
(3, 'Option 3'),
])
# Select option 2
widget.optgroups('test', ['2'], None)
# Should only have option 2
self.assertEqual(len(widget.choices), 1)
self.assertEqual(widget.choices[0], (2, 'Option 2'))
def test_selected_options_optgroups(self):
"""SelectedOptions should include only selected values from optgroups"""
widget = SelectedOptions(choices=[
('Group A', [(1, 'Option 1'), (2, 'Option 2')]),
('Group B', [(3, 'Option 3'), (4, 'Option 4')]),
])
# Select options 2 and 3
widget.optgroups('test', ['2', '3'], None)
# Should have 2 groups with only selected choices
self.assertEqual(len(widget.choices), 2)
self.assertEqual(widget.choices[0][0], 'Group A')
self.assertEqual(widget.choices[0][1], [(2, 'Option 2')])
self.assertEqual(widget.choices[1][0], 'Group B')
self.assertEqual(widget.choices[1][1], [(3, 'Option 3')])

View File

@@ -2,6 +2,7 @@ import django_filters
from django.db.models import Q
from django.utils.translation import gettext as _
from core.models import ObjectType
from dcim.models import Device, Interface
from ipam.models import IPAddress, RouteTarget, VLAN
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
@@ -429,6 +430,10 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
queryset=VLAN.objects.all(),
label=_('VLAN (ID)'),
)
assigned_object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ObjectType.objects.all(),
field_name='assigned_object_type'
)
assigned_object_type = ContentTypeFilter()
class Meta:

View File

@@ -3,7 +3,7 @@
[project]
name = "netbox"
version = "4.4.6"
version = "4.4.7"
requires-python = ">=3.10"
description = "The premier source of truth powering network automation."
readme = "README.md"

View File

@@ -1,43 +1,43 @@
colorama==0.4.6
Django==5.2.8
Django==5.2.9
django-cors-headers==4.9.0
django-debug-toolbar==6.1.0
django-filter==25.2
django-graphiql-debug-toolbar==0.2.0
django-htmx==1.26.0
django-htmx==1.27.0
django-mptt==0.17.0
django-pglocks==1.0.4
django-prometheus==2.4.1
django-redis==6.0.0
django-rich==2.2.0
django-rq==3.1
django-rq==3.2.1
django-storages==1.14.6
django-tables2==2.7.5
django-tables2==2.8.0
django-taggit==6.1.0
django-timezone-field==7.1
django-timezone-field==7.2.1
djangorestframework==3.16.1
drf-spectacular==0.29.0
drf-spectacular-sidecar==2025.10.1
drf-spectacular-sidecar==2025.12.1
feedparser==6.0.12
gunicorn==23.0.0
Jinja2==3.1.6
jsonschema==4.25.1
Markdown==3.10
mkdocs-material==9.6.22
mkdocstrings==0.30.1
mkdocstrings-python==1.19.0
mkdocs-material==9.7.0
mkdocstrings==1.0.0
mkdocstrings-python==2.0.1
netaddr==1.3.0
nh3==0.3.2
Pillow==12.0.0
psycopg[c,pool]==3.2.12
psycopg[c,pool]==3.3.2
PyYAML==6.0.3
requests==2.32.5
rq==2.6.0
rq==2.6.1
social-auth-app-django==5.6.0
social-auth-core==4.8.1
sorl-thumbnail==12.11.0
strawberry-graphql==0.285.0
strawberry-graphql-django==0.67.0
strawberry-graphql==0.287.2
strawberry-graphql-django==0.70.1
svgwrite==1.4.3
tablib==3.9.0
tzdata==2025.2