mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-19 03:42:25 -06:00
Merge branch 'closes-20817-Fix-datasource-sync-broken-when-cron-is-set' of https://github.com/ifoughal/netbox into closes-20817-Fix-datasource-sync-broken-when-cron-is-set
This commit is contained in:
@@ -15,7 +15,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v4.4.6
|
placeholder: v4.4.7
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@@ -27,7 +27,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox Version
|
label: NetBox Version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v4.4.6
|
placeholder: v4.4.7
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
|||||||
@@ -186,6 +186,7 @@
|
|||||||
"usb-3-micro-b",
|
"usb-3-micro-b",
|
||||||
"molex-micro-fit-1x2",
|
"molex-micro-fit-1x2",
|
||||||
"molex-micro-fit-2x2",
|
"molex-micro-fit-2x2",
|
||||||
|
"molex-micro-fit-2x3",
|
||||||
"molex-micro-fit-2x4",
|
"molex-micro-fit-2x4",
|
||||||
"dc-terminal",
|
"dc-terminal",
|
||||||
"saf-d-grid",
|
"saf-d-grid",
|
||||||
@@ -293,6 +294,7 @@
|
|||||||
"usb-c",
|
"usb-c",
|
||||||
"molex-micro-fit-1x2",
|
"molex-micro-fit-1x2",
|
||||||
"molex-micro-fit-2x2",
|
"molex-micro-fit-2x2",
|
||||||
|
"molex-micro-fit-2x3",
|
||||||
"molex-micro-fit-2x4",
|
"molex-micro-fit-2x4",
|
||||||
"dc-terminal",
|
"dc-terminal",
|
||||||
"eaton-c39",
|
"eaton-c39",
|
||||||
|
|||||||
6423
contrib/openapi.json
6423
contrib/openapi.json
File diff suppressed because one or more lines are too long
@@ -1,5 +1,36 @@
|
|||||||
# NetBox v4.4
|
# NetBox v4.4
|
||||||
|
|
||||||
|
## 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)
|
## v4.4.6 (2025-11-11)
|
||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from drf_spectacular.utils import Direction
|
|||||||
|
|
||||||
from netbox.api.fields import ChoiceField
|
from netbox.api.fields import ChoiceField
|
||||||
from netbox.api.serializers import WritableNestedSerializer
|
from netbox.api.serializers import WritableNestedSerializer
|
||||||
|
from netbox.api.viewsets import NetBoxModelViewSet
|
||||||
|
|
||||||
# see netbox.api.routers.NetBoxRouter
|
# see netbox.api.routers.NetBoxRouter
|
||||||
BULK_ACTIONS = ("bulk_destroy", "bulk_partial_update", "bulk_update")
|
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):
|
class NetBoxAutoSchema(AutoSchema):
|
||||||
"""
|
"""
|
||||||
Overrides to drf_spectacular.openapi.AutoSchema to fix following issues:
|
Overrides to drf_spectacular.openapi.AutoSchema to fix following issues:
|
||||||
@@ -128,6 +134,36 @@ class NetBoxAutoSchema(AutoSchema):
|
|||||||
|
|
||||||
return response_serializers
|
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:
|
def _get_serializer_name(self, serializer, direction, bypass_extensions=False) -> str:
|
||||||
name = super()._get_serializer_name(serializer, direction, bypass_extensions)
|
name = super()._get_serializer_name(serializer, direction, bypass_extensions)
|
||||||
|
|
||||||
|
|||||||
108
netbox/core/tests/test_openapi_schema.py
Normal file
108
netbox/core/tests/test_openapi_schema.py
Normal 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")
|
||||||
@@ -461,6 +461,7 @@ class PowerPortTypeChoices(ChoiceSet):
|
|||||||
# Molex
|
# Molex
|
||||||
TYPE_MOLEX_MICRO_FIT_1X2 = 'molex-micro-fit-1x2'
|
TYPE_MOLEX_MICRO_FIT_1X2 = 'molex-micro-fit-1x2'
|
||||||
TYPE_MOLEX_MICRO_FIT_2X2 = 'molex-micro-fit-2x2'
|
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'
|
TYPE_MOLEX_MICRO_FIT_2X4 = 'molex-micro-fit-2x4'
|
||||||
# Direct current (DC)
|
# Direct current (DC)
|
||||||
TYPE_DC = 'dc-terminal'
|
TYPE_DC = 'dc-terminal'
|
||||||
@@ -588,6 +589,7 @@ class PowerPortTypeChoices(ChoiceSet):
|
|||||||
('Molex', (
|
('Molex', (
|
||||||
(TYPE_MOLEX_MICRO_FIT_1X2, 'Molex Micro-Fit 1x2'),
|
(TYPE_MOLEX_MICRO_FIT_1X2, 'Molex Micro-Fit 1x2'),
|
||||||
(TYPE_MOLEX_MICRO_FIT_2X2, 'Molex Micro-Fit 2x2'),
|
(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'),
|
(TYPE_MOLEX_MICRO_FIT_2X4, 'Molex Micro-Fit 2x4'),
|
||||||
)),
|
)),
|
||||||
('DC', (
|
('DC', (
|
||||||
@@ -710,6 +712,7 @@ class PowerOutletTypeChoices(ChoiceSet):
|
|||||||
# Molex
|
# Molex
|
||||||
TYPE_MOLEX_MICRO_FIT_1X2 = 'molex-micro-fit-1x2'
|
TYPE_MOLEX_MICRO_FIT_1X2 = 'molex-micro-fit-1x2'
|
||||||
TYPE_MOLEX_MICRO_FIT_2X2 = 'molex-micro-fit-2x2'
|
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'
|
TYPE_MOLEX_MICRO_FIT_2X4 = 'molex-micro-fit-2x4'
|
||||||
# Direct current (DC)
|
# Direct current (DC)
|
||||||
TYPE_DC = 'dc-terminal'
|
TYPE_DC = 'dc-terminal'
|
||||||
@@ -831,6 +834,7 @@ class PowerOutletTypeChoices(ChoiceSet):
|
|||||||
('Molex', (
|
('Molex', (
|
||||||
(TYPE_MOLEX_MICRO_FIT_1X2, 'Molex Micro-Fit 1x2'),
|
(TYPE_MOLEX_MICRO_FIT_1X2, 'Molex Micro-Fit 1x2'),
|
||||||
(TYPE_MOLEX_MICRO_FIT_2X2, 'Molex Micro-Fit 2x2'),
|
(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'),
|
(TYPE_MOLEX_MICRO_FIT_2X4, 'Molex Micro-Fit 2x4'),
|
||||||
)),
|
)),
|
||||||
('DC', (
|
('DC', (
|
||||||
|
|||||||
@@ -278,11 +278,6 @@ class RackBaseFilterForm(NetBoxModelFilterSetForm):
|
|||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
airflow = forms.MultipleChoiceField(
|
|
||||||
label=_('Airflow'),
|
|
||||||
choices=add_blank_choice(RackAirflowChoices),
|
|
||||||
required=False
|
|
||||||
)
|
|
||||||
weight = forms.DecimalField(
|
weight = forms.DecimalField(
|
||||||
label=_('Weight'),
|
label=_('Weight'),
|
||||||
required=False,
|
required=False,
|
||||||
@@ -381,6 +376,11 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo
|
|||||||
},
|
},
|
||||||
label=_('Rack type')
|
label=_('Rack type')
|
||||||
)
|
)
|
||||||
|
airflow = forms.MultipleChoiceField(
|
||||||
|
label=_('Airflow'),
|
||||||
|
choices=add_blank_choice(RackAirflowChoices),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
serial = forms.CharField(
|
serial = forms.CharField(
|
||||||
label=_('Serial'),
|
label=_('Serial'),
|
||||||
required=False
|
required=False
|
||||||
|
|||||||
@@ -269,7 +269,8 @@ class RackForm(TenancyForm, NetBoxModelForm):
|
|||||||
label=_('Rack Type'),
|
label=_('Rack Type'),
|
||||||
queryset=RackType.objects.all(),
|
queryset=RackType.objects.all(),
|
||||||
required=False,
|
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()
|
comments = CommentField()
|
||||||
|
|
||||||
|
|||||||
67
netbox/dcim/migrations/0216_latitude_longitude_validators.py
Normal file
67
netbox/dcim/migrations/0216_latitude_longitude_validators.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
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(-90.0),
|
||||||
|
django.core.validators.MaxValueValidator(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(-180.0),
|
||||||
|
django.core.validators.MaxValueValidator(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(-90.0),
|
||||||
|
django.core.validators.MaxValueValidator(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(-180.0),
|
||||||
|
django.core.validators.MaxValueValidator(180.0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -646,6 +646,7 @@ class Device(
|
|||||||
decimal_places=6,
|
decimal_places=6,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
|
validators=[MinValueValidator(-90.0), MaxValueValidator(90.0)],
|
||||||
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
|
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
|
||||||
)
|
)
|
||||||
longitude = models.DecimalField(
|
longitude = models.DecimalField(
|
||||||
@@ -654,6 +655,7 @@ class Device(
|
|||||||
decimal_places=6,
|
decimal_places=6,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
|
validators=[MinValueValidator(-180.0), MaxValueValidator(180.0)],
|
||||||
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
|
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
|
||||||
)
|
)
|
||||||
services = GenericRelation(
|
services = GenericRelation(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.contrib.contenttypes.fields import GenericRelation
|
from django.contrib.contenttypes.fields import GenericRelation
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from timezone_field import TimeZoneField
|
from timezone_field import TimeZoneField
|
||||||
@@ -210,6 +211,7 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
|
|||||||
decimal_places=6,
|
decimal_places=6,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
|
validators=[MinValueValidator(-90.0), MaxValueValidator(90.0)],
|
||||||
help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
|
help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
|
||||||
)
|
)
|
||||||
longitude = models.DecimalField(
|
longitude = models.DecimalField(
|
||||||
@@ -218,6 +220,7 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
|
|||||||
decimal_places=6,
|
decimal_places=6,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
|
validators=[MinValueValidator(-180.0), MaxValueValidator(180.0)],
|
||||||
help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
|
help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class RackTypeTable(NetBoxTable):
|
|||||||
model = RackType
|
model = RackType
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'model', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
|
'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',
|
'comments', 'instance_count', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
|
|||||||
@@ -23,6 +23,6 @@ class ConfigTemplateSerializer(ChangeLogMessageSerializer, TaggableModelSerializ
|
|||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display_url', 'display', 'name', 'description', 'environment_params', 'template_code',
|
'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',
|
'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')
|
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||||
|
|||||||
@@ -267,6 +267,14 @@ class ScriptViewSet(ModelViewSet):
|
|||||||
_ignore_model_permissions = True
|
_ignore_model_permissions = True
|
||||||
lookup_value_regex = '[^/]+' # Allow dots
|
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):
|
def _get_script(self, pk):
|
||||||
# If pk is numeric, retrieve script by ID
|
# If pk is numeric, retrieve script by ID
|
||||||
if pk.isnumeric():
|
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
|
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)
|
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(
|
input_serializer = serializers.ScriptInputSerializer(
|
||||||
data=request.data,
|
data=request.data,
|
||||||
context={'script': script}
|
context={'script': script}
|
||||||
|
|||||||
@@ -209,7 +209,10 @@ class ObjectCountsWidget(DashboardWidget):
|
|||||||
url = get_action_url(model, action='list')
|
url = get_action_url(model, action='list')
|
||||||
except NoReverseMatch:
|
except NoReverseMatch:
|
||||||
url = None
|
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
|
# Apply any specified filters
|
||||||
if url and (filters := self.config.get('filters')):
|
if url and (filters := self.config.get('filters')):
|
||||||
params = dict_to_querydict(filters)
|
params = dict_to_querydict(filters)
|
||||||
|
|||||||
@@ -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
|
# Enqueue a Job to record the script's execution
|
||||||
from extras.jobs import ScriptJob
|
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(
|
ScriptJob.enqueue(
|
||||||
instance=event_rule.action_object,
|
**params
|
||||||
name=script.name,
|
|
||||||
user=user,
|
|
||||||
data=event_data
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Notification groups
|
# Notification groups
|
||||||
|
|||||||
@@ -398,8 +398,12 @@ class ConfigTemplateBulkEditForm(ChangelogMessageMixin, BulkEditForm):
|
|||||||
required=False,
|
required=False,
|
||||||
widget=BulkEditNullBooleanSelect()
|
widget=BulkEditNullBooleanSelect()
|
||||||
)
|
)
|
||||||
|
auto_sync_enabled = forms.NullBooleanField(
|
||||||
nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension')
|
label=_('Auto sync enabled'),
|
||||||
|
required=False,
|
||||||
|
widget=BulkEditNullBooleanSelect()
|
||||||
|
)
|
||||||
|
nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension', 'auto_sync_enabled',)
|
||||||
|
|
||||||
|
|
||||||
class ImageAttachmentBulkEditForm(ChangelogMessageMixin, BulkEditForm):
|
class ImageAttachmentBulkEditForm(ChangelogMessageMixin, BulkEditForm):
|
||||||
|
|||||||
@@ -42,17 +42,20 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
model = CustomField
|
model = CustomField
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id'),
|
FieldSet('q', 'filter_id'),
|
||||||
FieldSet(
|
FieldSet('object_type_id', 'type', 'group_name', 'weight', 'required', 'unique', name=_('Attributes')),
|
||||||
'type', 'related_object_type_id', 'group_name', 'weight', 'required', 'unique', 'choice_set_id',
|
FieldSet('choice_set_id', 'related_object_type_id', name=_('Type Options')),
|
||||||
name=_('Attributes')
|
|
||||||
),
|
|
||||||
FieldSet('ui_visible', 'ui_editable', 'is_cloneable', name=_('Behavior')),
|
FieldSet('ui_visible', 'ui_editable', 'is_cloneable', name=_('Behavior')),
|
||||||
FieldSet('validation_minimum', 'validation_maximum', 'validation_regex', name=_('Validation')),
|
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'),
|
queryset=ObjectType.objects.with_feature('custom_fields'),
|
||||||
required=False,
|
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(
|
type = forms.MultipleChoiceField(
|
||||||
choices=CustomFieldTypeChoices,
|
choices=CustomFieldTypeChoices,
|
||||||
@@ -136,12 +139,12 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
model = CustomLink
|
model = CustomLink
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id'),
|
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'),
|
label=_('Object types'),
|
||||||
queryset=ObjectType.objects.with_feature('custom_links'),
|
queryset=ObjectType.objects.with_feature('custom_links'),
|
||||||
required=False
|
required=False,
|
||||||
)
|
)
|
||||||
enabled = forms.NullBooleanField(
|
enabled = forms.NullBooleanField(
|
||||||
label=_('Enabled'),
|
label=_('Enabled'),
|
||||||
@@ -230,12 +233,12 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
model = SavedFilter
|
model = SavedFilter
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id'),
|
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'),
|
label=_('Object types'),
|
||||||
queryset=ObjectType.objects.public(),
|
queryset=ObjectType.objects.public(),
|
||||||
required=False
|
required=False,
|
||||||
)
|
)
|
||||||
enabled = forms.NullBooleanField(
|
enabled = forms.NullBooleanField(
|
||||||
label=_('Enabled'),
|
label=_('Enabled'),
|
||||||
@@ -476,7 +479,7 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
model = ConfigTemplate
|
model = ConfigTemplate
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id', 'tag'),
|
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'))
|
FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering'))
|
||||||
)
|
)
|
||||||
data_source_id = DynamicModelMultipleChoiceField(
|
data_source_id = DynamicModelMultipleChoiceField(
|
||||||
@@ -492,6 +495,13 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
'source_id': '$data_source_id'
|
'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)
|
tag = TagFilterField(ConfigTemplate)
|
||||||
mime_type = forms.CharField(
|
mime_type = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
|
|||||||
@@ -632,6 +632,10 @@ class ConfigTemplateTable(NetBoxTable):
|
|||||||
orderable=False,
|
orderable=False,
|
||||||
verbose_name=_('Synced')
|
verbose_name=_('Synced')
|
||||||
)
|
)
|
||||||
|
auto_sync_enabled = columns.BooleanColumn(
|
||||||
|
verbose_name=_('Auto Sync Enabled'),
|
||||||
|
orderable=False,
|
||||||
|
)
|
||||||
mime_type = tables.Column(
|
mime_type = tables.Column(
|
||||||
verbose_name=_('MIME Type')
|
verbose_name=_('MIME Type')
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from django import template
|
from django import template
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
@@ -8,4 +10,16 @@ register = template.Library()
|
|||||||
def render_widget(context, widget):
|
def render_widget(context, widget):
|
||||||
request = context['request']
|
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>
|
||||||
|
""")
|
||||||
|
|||||||
@@ -894,18 +894,13 @@ class ScriptTest(APITestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
self.add_permissions('extras.view_script')
|
||||||
|
|
||||||
# Monkey-patch the Script model to return our TestScriptClass above
|
# Monkey-patch the Script model to return our TestScriptClass above
|
||||||
Script.python_class = self.python_class
|
Script.python_class = self.python_class
|
||||||
|
|
||||||
def test_get_script(self):
|
def test_get_script(self):
|
||||||
module = ScriptModule.objects.get(
|
response = self.client.get(self.url, **self.header)
|
||||||
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)
|
|
||||||
|
|
||||||
self.assertEqual(response.data['name'], self.TestScriptClass.Meta.name)
|
self.assertEqual(response.data['name'], self.TestScriptClass.Meta.name)
|
||||||
self.assertEqual(response.data['vars']['var1'], 'StringVar')
|
self.assertEqual(response.data['vars']['var1'], 'StringVar')
|
||||||
|
|||||||
2
netbox/project-static/dist/netbox.css
vendored
2
netbox/project-static/dist/netbox.css
vendored
File diff suppressed because one or more lines are too long
@@ -30,7 +30,7 @@
|
|||||||
"gridstack": "12.3.3",
|
"gridstack": "12.3.3",
|
||||||
"htmx.org": "2.0.8",
|
"htmx.org": "2.0.8",
|
||||||
"query-string": "9.3.1",
|
"query-string": "9.3.1",
|
||||||
"sass": "1.94.0",
|
"sass": "1.94.2",
|
||||||
"tom-select": "2.4.3",
|
"tom-select": "2.4.3",
|
||||||
"typeface-inter": "3.18.1",
|
"typeface-inter": "3.18.1",
|
||||||
"typeface-roboto-mono": "1.1.13"
|
"typeface-roboto-mono": "1.1.13"
|
||||||
|
|||||||
@@ -162,3 +162,18 @@ pre code {
|
|||||||
vertical-align: .05em;
|
vertical-align: .05em;
|
||||||
height: auto;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3190,10 +3190,10 @@ safe-regex-test@^1.1.0:
|
|||||||
es-errors "^1.3.0"
|
es-errors "^1.3.0"
|
||||||
is-regex "^1.2.1"
|
is-regex "^1.2.1"
|
||||||
|
|
||||||
sass@1.94.0:
|
sass@1.94.2:
|
||||||
version "1.94.0"
|
version "1.94.2"
|
||||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.94.0.tgz#a04198d8940358ca6ad537d2074051edbbe7c1a7"
|
resolved "https://registry.yarnpkg.com/sass/-/sass-1.94.2.tgz#198511fc6fdd2fc0a71b8d1261735c12608d4ef3"
|
||||||
integrity sha512-Dqh7SiYcaFtdv5Wvku6QgS5IGPm281L+ZtVD1U2FJa7Q0EFRlq8Z3sjYtz6gYObsYThUOz9ArwFqPZx+1azILQ==
|
integrity sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==
|
||||||
dependencies:
|
dependencies:
|
||||||
chokidar "^4.0.0"
|
chokidar "^4.0.0"
|
||||||
immutable "^5.0.2"
|
immutable "^5.0.2"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
version: "4.4.6"
|
version: "4.4.7"
|
||||||
edition: "Community"
|
edition: "Community"
|
||||||
published: "2025-11-11"
|
published: "2025-11-25"
|
||||||
|
|||||||
@@ -24,10 +24,6 @@
|
|||||||
<th scope="row">{% trans "Description" %}</th>
|
<th scope="row">{% trans "Description" %}</th>
|
||||||
<td>{{ object.description|placeholder }}</td>
|
<td>{{ object.description|placeholder }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<th scope="row">{% trans "Airflow" %}</th>
|
|
||||||
<td>{{ object.get_airflow_display|placeholder }}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% include 'dcim/inc/panels/racktype_dimensions.html' %}
|
{% include 'dcim/inc/panels/racktype_dimensions.html' %}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
<p>
|
<p>
|
||||||
<i class="mdi mdi-alert"></i>
|
<i class="mdi mdi-alert"></i>
|
||||||
<strong>{% trans "Missing required packages" %}.</strong>
|
<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
|
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
|
<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 freeze</code> from the console and
|
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.
|
compare the output to the list of required packages.
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -8,17 +8,17 @@
|
|||||||
<p>
|
<p>
|
||||||
<i class="mdi mdi-alert"></i>
|
<i class="mdi mdi-alert"></i>
|
||||||
<strong>{% trans "Database migrations missing" %}.</strong>
|
<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
|
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 %}
|
{% endblocktrans %}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<i class="mdi mdi-alert"></i>
|
<i class="mdi mdi-alert"></i>
|
||||||
<strong>{% trans "Unsupported PostgreSQL version" %}.</strong>
|
<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
|
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 %}
|
{% endblocktrans %}
|
||||||
</p>
|
</p>
|
||||||
{% endblock message %}
|
{% endblock message %}
|
||||||
|
|||||||
@@ -62,6 +62,10 @@
|
|||||||
<th scope="row">{% trans "Data Synced" %}</th>
|
<th scope="row">{% trans "Data Synced" %}</th>
|
||||||
<td>{{ object.data_synced|placeholder }}</td>
|
<td>{{ object.data_synced|placeholder }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Auto Sync Enabled" %}</th>
|
||||||
|
<td>{% checkmark object.auto_sync_enabled %}</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% include 'inc/panels/tags.html' %}
|
{% include 'inc/panels/tags.html' %}
|
||||||
|
|||||||
@@ -17,15 +17,17 @@
|
|||||||
|
|
||||||
{% if request.htmx %}
|
{% if request.htmx %}
|
||||||
{# Include the updated object count for display elsewhere on the page #}
|
{# 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 #}
|
{# 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>
|
<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 %}
|
{% endif %}
|
||||||
|
|
||||||
{# Update the bulk action buttons with new query parameters #}
|
{# 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">
|
<div class="bulk-action-buttons" hx-swap-oob="outerHTML:.bulk-action-buttons">
|
||||||
{% action_buttons actions model multi=True %}
|
{% action_buttons actions model multi=True %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,8 +26,8 @@
|
|||||||
<p>{% trans "Check the following" %}:</p>
|
<p>{% trans "Check the following" %}:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li class="tip">
|
<li class="tip">
|
||||||
{% blocktrans trimmed %}
|
{% blocktrans trimmed with command="manage.py collectstatic" %}
|
||||||
<code>manage.py collectstatic</code> was run during the most recent upgrade. This installs the most
|
<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.
|
recent iteration of each static file into the static root path.
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,10 @@
|
|||||||
import binascii
|
import binascii
|
||||||
import os
|
import os
|
||||||
|
import zoneinfo
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MinLengthValidator
|
from django.core.validators import MinLengthValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -86,6 +88,24 @@ class Token(models.Model):
|
|||||||
def partial(self):
|
def partial(self):
|
||||||
return f'**********************************{self.key[-6:]}' if self.key else ''
|
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):
|
def save(self, *args, **kwargs):
|
||||||
if not self.key:
|
if not self.key:
|
||||||
self.key = self.generate_key()
|
self.key = self.generate_key()
|
||||||
|
|||||||
@@ -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):
|
class UserConfigTest(TestCase):
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import django_filters
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from core.models import ObjectType
|
||||||
from dcim.models import Device, Interface
|
from dcim.models import Device, Interface
|
||||||
from ipam.models import IPAddress, RouteTarget, VLAN
|
from ipam.models import IPAddress, RouteTarget, VLAN
|
||||||
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
|
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
|
||||||
@@ -429,6 +430,10 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
|
|||||||
queryset=VLAN.objects.all(),
|
queryset=VLAN.objects.all(),
|
||||||
label=_('VLAN (ID)'),
|
label=_('VLAN (ID)'),
|
||||||
)
|
)
|
||||||
|
assigned_object_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
queryset=ObjectType.objects.all(),
|
||||||
|
field_name='assigned_object_type'
|
||||||
|
)
|
||||||
assigned_object_type = ContentTypeFilter()
|
assigned_object_type = ContentTypeFilter()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox"
|
name = "netbox"
|
||||||
version = "4.4.6"
|
version = "4.4.7"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
description = "The premier source of truth powering network automation."
|
description = "The premier source of truth powering network automation."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ django-pglocks==1.0.4
|
|||||||
django-prometheus==2.4.1
|
django-prometheus==2.4.1
|
||||||
django-redis==6.0.0
|
django-redis==6.0.0
|
||||||
django-rich==2.2.0
|
django-rich==2.2.0
|
||||||
django-rq==3.1
|
django-rq==3.2.1
|
||||||
django-storages==1.14.6
|
django-storages==1.14.6
|
||||||
django-tables2==2.7.5
|
django-tables2==2.8.0
|
||||||
django-taggit==6.1.0
|
django-taggit==6.1.0
|
||||||
django-timezone-field==7.1
|
django-timezone-field==7.1
|
||||||
djangorestframework==3.16.1
|
djangorestframework==3.16.1
|
||||||
@@ -23,21 +23,21 @@ gunicorn==23.0.0
|
|||||||
Jinja2==3.1.6
|
Jinja2==3.1.6
|
||||||
jsonschema==4.25.1
|
jsonschema==4.25.1
|
||||||
Markdown==3.10
|
Markdown==3.10
|
||||||
mkdocs-material==9.6.22
|
mkdocs-material==9.7.0
|
||||||
mkdocstrings==0.30.1
|
mkdocstrings==0.30.1
|
||||||
mkdocstrings-python==1.19.0
|
mkdocstrings-python==1.19.0
|
||||||
netaddr==1.3.0
|
netaddr==1.3.0
|
||||||
nh3==0.3.2
|
nh3==0.3.2
|
||||||
Pillow==12.0.0
|
Pillow==12.0.0
|
||||||
psycopg[c,pool]==3.2.12
|
psycopg[c,pool]==3.2.13
|
||||||
PyYAML==6.0.3
|
PyYAML==6.0.3
|
||||||
requests==2.32.5
|
requests==2.32.5
|
||||||
rq==2.6.0
|
rq==2.6.1
|
||||||
social-auth-app-django==5.6.0
|
social-auth-app-django==5.6.0
|
||||||
social-auth-core==4.8.1
|
social-auth-core==4.8.1
|
||||||
sorl-thumbnail==12.11.0
|
sorl-thumbnail==12.11.0
|
||||||
strawberry-graphql==0.285.0
|
strawberry-graphql==0.287.0
|
||||||
strawberry-graphql-django==0.67.0
|
strawberry-graphql-django==0.67.2
|
||||||
svgwrite==1.4.3
|
svgwrite==1.4.3
|
||||||
tablib==3.9.0
|
tablib==3.9.0
|
||||||
tzdata==2025.2
|
tzdata==2025.2
|
||||||
|
|||||||
Reference in New Issue
Block a user