Merge branch 'main' into feature

# Conflicts:
#	contrib/openapi.json
#	docs/release-notes/version-4.6.md
#	netbox/dcim/choices.py
#	netbox/dcim/forms/mixins.py
#	netbox/dcim/models/device_component_templates.py
#	netbox/dcim/models/device_components.py
#	netbox/extras/dashboard/widgets.py
#	netbox/extras/graphql/types.py
#	netbox/extras/models/configs.py
#	netbox/extras/tests/test_templatetags.py
#	netbox/ipam/choices.py
#	netbox/ipam/forms/model_forms.py
#	netbox/netbox/configuration_example.py
#	netbox/netbox/filtersets.py
#	netbox/netbox/tests/test_api.py
#	netbox/netbox/tests/test_scaffold.py
#	netbox/netbox/tests/test_tables.py
#	netbox/project-static/dist/netbox.js
#	netbox/project-static/dist/netbox.js.map
#	netbox/project-static/package.json
#	netbox/project-static/yarn.lock
#	netbox/release.yaml
#	netbox/translations/cs/LC_MESSAGES/django.mo
#	netbox/translations/cs/LC_MESSAGES/django.po
#	netbox/translations/da/LC_MESSAGES/django.mo
#	netbox/translations/da/LC_MESSAGES/django.po
#	netbox/translations/de/LC_MESSAGES/django.mo
#	netbox/translations/de/LC_MESSAGES/django.po
#	netbox/translations/en/LC_MESSAGES/django.po
#	netbox/translations/es/LC_MESSAGES/django.mo
#	netbox/translations/es/LC_MESSAGES/django.po
#	netbox/translations/fr/LC_MESSAGES/django.mo
#	netbox/translations/fr/LC_MESSAGES/django.po
#	netbox/translations/it/LC_MESSAGES/django.mo
#	netbox/translations/it/LC_MESSAGES/django.po
#	netbox/translations/ja/LC_MESSAGES/django.mo
#	netbox/translations/ja/LC_MESSAGES/django.po
#	netbox/translations/ko/LC_MESSAGES/django.mo
#	netbox/translations/ko/LC_MESSAGES/django.po
#	netbox/translations/lv/LC_MESSAGES/django.mo
#	netbox/translations/lv/LC_MESSAGES/django.po
#	netbox/translations/nl/LC_MESSAGES/django.mo
#	netbox/translations/nl/LC_MESSAGES/django.po
#	netbox/translations/pl/LC_MESSAGES/django.mo
#	netbox/translations/pl/LC_MESSAGES/django.po
#	netbox/translations/pt/LC_MESSAGES/django.mo
#	netbox/translations/pt/LC_MESSAGES/django.po
#	netbox/translations/ru/LC_MESSAGES/django.mo
#	netbox/translations/ru/LC_MESSAGES/django.po
#	netbox/translations/tr/LC_MESSAGES/django.mo
#	netbox/translations/tr/LC_MESSAGES/django.po
#	netbox/translations/uk/LC_MESSAGES/django.mo
#	netbox/translations/uk/LC_MESSAGES/django.po
#	netbox/translations/zh/LC_MESSAGES/django.mo
#	netbox/translations/zh/LC_MESSAGES/django.po
#	netbox/utilities/jinja2.py
#	netbox/utilities/tests/test_filters.py
#	requirements.txt
This commit is contained in:
Jeremy Stretch 2026-07-28 14:24:24 -04:00
commit d2df19790f
109 changed files with 19241 additions and 12186 deletions

File diff suppressed because one or more lines are too long

View File

@ -31,6 +31,9 @@ Some models have registered actions that appear as checkboxes in the "Actions" s
Constraints are expressed as a JSON object or list representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below.
!!! note
Constraint definitions must be valid JSON. Because a backslash (`\`) is an escape character in a JSON string, a backslash that is part of a string value must itself be escaped. For example, a regular expression containing `\.` must be entered as `\\.` in the constraint definition.
All attributes defined within a single JSON object are applied with a logical AND. For example, suppose you assign a permission for the site model with the following constraints.
```json
@ -83,6 +86,7 @@ While permissions are typically assigned to specific groups and/or users, it is
| `{"status": "active", "role": "testing"}` | Status is active **AND** role is testing |
| `{"name__startswith": "Foo"}` | Name starts with "Foo" (case-sensitive) |
| `{"name__iendswith": "bar"}` | Name ends with "bar" (case-insensitive) |
| `{"name__regex": "^foo\\.bar$"}` | Name matches the regular expression `^foo\.bar$` |
| `{"vid__gte": 100, "vid__lt": 200}` | VLAN ID is greater than or equal to 100 **AND** less than 200 |
| `[{"vid__lt": 200}, {"status": "reserved"}]` | VLAN ID is less than 200 **OR** status is reserved |

View File

@ -36,6 +36,16 @@ The following data is available as context for Jinja2 templates:
* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
### Sanitizing Header Values
When rendering the `additional_headers` field, a `header_safe` filter is made available for sanitizing a value for safe inclusion in a raw HTTP header. It strips newlines and other control characters from the rendered value, preventing HTTP header (CR/LF) injection.
Whenever a header value incorporates data which may be influenced by other users (such as an object's attributes), pass it through this filter to avoid smuggling of additional headers. For example:
```
X-Object-Name: {{ data.name | header_safe }}
```
### Default Request Body
If no body template is specified, the request body will be populated with a JSON object containing the context data. For example, a newly created site might appear as follows:

View File

@ -52,6 +52,13 @@ The content type to indicate in the outgoing HTTP request header. See [this list
Any additional header to include with the outgoing HTTP request. These should be defined in the format `Name: Value`, with each header on a separate line. Jinja2 templating is supported for this field.
!!! warning "Sanitize interpolated header values"
When interpolating data which may be influenced by other users (such as object attributes) into a header value, apply the `header_safe` filter to guard against HTTP header (CR/LF) injection. This filter strips newlines and other control characters which could otherwise be used to smuggle additional headers into the request. For example:
```
X-Object-Name: {{ data.name | header_safe }}
```
### Body Template
Jinja2 template for a custom request body, if desired. If not defined, NetBox will populate the request body with a raw dump of the webhook context.

View File

@ -110,6 +110,23 @@ expression: `n`. Here is an example of a lookup expression on a foreign key, it
GET /api/ipam/vlans/?group_id__n=3203
```
### Tags
The `tag` and `tag_id` filters support negation (`n`) as well as an `any` lookup expression:
| Filter | Description |
|--------|----------------------------------------------------|
| `n` | Does not have any of these tags |
| `any` | Has any of these tags (logical OR) |
Passing multiple values for `tag`/`tag_id` without a lookup expression uses a logical AND: `GET /api/dcim/sites/?tag=foo&tag=bar` returns only sites tagged with both `foo` _and_ `bar`. To instead match sites tagged with `foo` _or_ `bar`, use the `any` lookup expression:
```no-highlight
GET /api/dcim/sites/?tag__any=foo&tag__any=bar
```
Note that `n` is not the logical complement of the default (AND) behavior: passing multiple values applies NOR logic, matching only objects which have _none_ of the specified tags, rather than objects which are simply missing at least one of them.
## Ordering Objects
To order results by a particular field, include the `ordering` query parameter. For example, order the list of sites according to their facility values:

View File

@ -1,5 +1,53 @@
# NetBox v4.6
## v4.6.6 (2026-07-28)
### Enhancements
* [#19273](https://github.com/netbox-community/netbox/issues/19273) - Enable the selection of VLANs scoped to a device's cluster or cluster group when assigning VLANs to interfaces
* [#22522](https://github.com/netbox-community/netbox/issues/22522) - Render colored badges for custom field choices in tables
* [#22623](https://github.com/netbox-community/netbox/issues/22623) - Change the default color of the DHCP IP address status from green to purple to distinguish it from "available"
* [#22685](https://github.com/netbox-community/netbox/issues/22685) - Introduce an "any" lookup for the `tag` and `tag_id` filters to match objects assigned any of the specified tags
* [#22753](https://github.com/netbox-community/netbox/issues/22753) - Add a `header_safe` Jinja2 filter for sanitizing HTTP header values
### Performance Improvements
* [#22497](https://github.com/netbox-community/netbox/issues/22497) - Improve the speed of bulk object deletion by avoiding per-object cascade handling and N+1 counter updates
* [#22687](https://github.com/netbox-community/netbox/issues/22687) - Avoid an unnecessary queryset evaluation when rendering export templates
### Bug Fixes
* [#21988](https://github.com/netbox-community/netbox/issues/21988) - Ensure view permissions are enforced when referencing a related object by its attributes in the REST API
* [#22513](https://github.com/netbox-community/netbox/issues/22513) - Make `JournalEntry.created_by` immutable after creation to prevent audit trail spoofing
* [#22565](https://github.com/netbox-community/netbox/issues/22565) - Include circuit distance when calculating the total length of a cable path
* [#22588](https://github.com/netbox-community/netbox/issues/22588) - Restrict the VLANs available for assignment to a prefix scoped to a site group
* [#22644](https://github.com/netbox-community/netbox/issues/22644) - Record changes to front/rear port mappings in the changelog
* [#22654](https://github.com/netbox-community/netbox/issues/22654) - Redact server filesystem paths from tracebacks rendered by ConfigTemplate debug mode
* [#22656](https://github.com/netbox-community/netbox/issues/22656) - Pre-populate interface attributes when using "Create & Add Another"
* [#22662](https://github.com/netbox-community/netbox/issues/22662) - Avoid raising a `DataError` exception when a cable length exceeds the maximum supported value
* [#22675](https://github.com/netbox-community/netbox/issues/22675) - Validate the URL scheme of RSS feed entries to prevent DOM-based cross-site scripting
* [#22677](https://github.com/netbox-community/netbox/issues/22677) - Display validation errors for form fields which lack HTML5 constraints
* [#22682](https://github.com/netbox-community/netbox/issues/22682) - Prevent the deletion of a site group from cascading to prefixes scoped to its member sites
* [#22690](https://github.com/netbox-community/netbox/issues/22690) - Restore the left border on the quick search field
* [#22697](https://github.com/netbox-community/netbox/issues/22697) - Return to the scripts list when cancelling out of the "add script" form
* [#22707](https://github.com/netbox-community/netbox/issues/22707) - Fix the resolution of port mappings when `{vc_position}` is used on device type component templates
* [#22712](https://github.com/netbox-community/netbox/issues/22712) - Highlight relevant dropdown fields when form validation fails
* [#22717](https://github.com/netbox-community/netbox/issues/22717) - Fix `KeyError` raised when validating a device assigned to a cluster scoped to a different location
* [#22719](https://github.com/netbox-community/netbox/issues/22719) - Avoid raising a `KeyError` for malformed IP address and prefix values submitted via the REST API
* [#22720](https://github.com/netbox-community/netbox/issues/22720) - Raise a protected-deletion error rather than a `TypeError` when deleting a virtual chassis with a cross-chassis LAG
* [#22729](https://github.com/netbox-community/netbox/issues/22729) - Escape object names when populating the `Content-Disposition` header of file responses
* [#22736](https://github.com/netbox-community/netbox/issues/22736) - Include the `comments` field of ASNs in the global search index
* [#22737](https://github.com/netbox-community/netbox/issues/22737) - Clear stale connector metadata from cable endpoints when deleting a profiled cable
* [#22748](https://github.com/netbox-community/netbox/issues/22748) - Ensure `ContentTypeField` respects its declared queryset to prevent the selection of non-public object types
* [#22752](https://github.com/netbox-community/netbox/issues/22752) - Restore the rear port fields on the front port bulk import form
* [#22766](https://github.com/netbox-community/netbox/issues/22766) - Fix the GraphQL `length` lookup for array filters
* [#22767](https://github.com/netbox-community/netbox/issues/22767) - Include the `comments` field of several models in the global search indexes
* [#22768](https://github.com/netbox-community/netbox/issues/22768) - Store a null value rather than an empty string for `cable_end` when removing a cable
* [#22773](https://github.com/netbox-community/netbox/issues/22773) - Fix `TypeError` exception when bulk adding module bays to devices
* [#22790](https://github.com/netbox-community/netbox/issues/22790) - Enforce saved filter visibility when applied via the `filter` or `filter_id` query parameter
---
## v4.6.5 (2026-07-14)
### Enhancements

View File

@ -0,0 +1,30 @@
from django.db import migrations
from django.db.models import Q
def clear_stale_cable_profile_data(apps, schema_editor):
"""
Clear cached cable connector and position data from circuit terminations which no
longer have a cable attached. Earlier versions failed to clear these values when a
profiled cable was deleted, causing subsequent validation to fail.
"""
CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
db_alias = schema_editor.connection.alias
CircuitTermination.objects.using(db_alias).filter(
Q(cable_connector__isnull=False) | Q(cable_positions__isnull=False),
cable__isnull=True,
).update(
cable_connector=None,
cable_positions=None,
)
class Migration(migrations.Migration):
dependencies = [
('circuits', '0057_default_ordering_indexes'),
]
operations = [
migrations.RunPython(clear_stale_cable_profile_data, migrations.RunPython.noop),
]

View File

@ -0,0 +1,23 @@
from django.db import migrations
def nullify_empty_cable_end(apps, schema_editor):
"""
Replace empty strings with null values on cached cable end data. Earlier versions
wrote an empty string when a cable termination was deleted, leaving disconnected
terminations inconsistent with those which have never been cabled.
"""
CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
db_alias = schema_editor.connection.alias
CircuitTermination.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
class Migration(migrations.Migration):
dependencies = [
('circuits', '0058_clear_stale_cable_profile_data'),
]
operations = [
migrations.RunPython(nullify_empty_cable_end, migrations.RunPython.noop),
]

View File

@ -10,9 +10,9 @@ from utilities.migration import InstallDenormalizationTrigger
class Migration(migrations.Migration):
dependencies = [
('circuits', '0057_default_ordering_indexes'),
('circuits', '0059_nullify_empty_cable_end'),
# Source tables (dcim_site, dcim_location) must already exist.
('dcim', '0240_ltree_paths'),
('dcim', '0242_ltree_paths'),
]
operations = [

View File

@ -387,7 +387,10 @@ class BackgroundTaskTestCase(RQQueueTestMixin, TestCase):
queue = get_queue('default')
worker = get_worker('default')
job = queue.enqueue(self.dummy_job_default)
worker.prepare_job_execution(job)
# prepare_job_execution() invokes the worker heartbeat, which logs a "re-registering"
# warning for this freshly-created (unregistered) worker; suppress the expected noise.
with disable_logging():
worker.prepare_job_execution(job)
url = reverse('core-api:rqtask-stop', args=[job.id])
self.assertEqual(job.get_status(), JobStatus.STARTED)

View File

@ -423,8 +423,11 @@ class BackgroundTaskTestCase(RQQueueTestMixin, TestCase):
worker = get_worker('default')
job = queue.enqueue(self.dummy_job_default)
worker.prepare_job_execution(job)
worker.prepare_execution(job)
# prepare_job_execution() invokes the worker heartbeat, which logs a "re-registering"
# warning for this freshly-created (unregistered) worker; suppress the expected noise.
with disable_logging():
worker.prepare_job_execution(job)
worker.prepare_execution(job)
self.assertEqual(job.get_status(), JobStatus.STARTED)

View File

@ -12,6 +12,7 @@ from django.db import DatabaseError, connection
from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.http import content_disposition_header
from django.utils.translation import gettext_lazy as _
from django.views.generic import View
from django_rq.queues import get_queue_by_index, get_redis_connection
@ -767,7 +768,7 @@ class SystemView(UserPassesTestMixin, View):
},
}
response = HttpResponse(json.dumps(data, cls=ConfigJSONEncoder, indent=4), content_type='text/json')
response['Content-Disposition'] = 'attachment; filename="netbox.json"'
response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename='netbox.json')
return response
# Serialize any JSON-based classes

View File

@ -115,7 +115,7 @@ class ModuleBayBulkCreateForm(
):
model = ModuleBay
field_order = ('name', 'label', 'position', 'enabled', 'description', 'tags')
replication_fields = ('name', 'label', 'position', 'enabled')
replication_fields = ('name', 'label', 'position')
position = ExpandableNameField(
label=_('Position'),
required=False,

View File

@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from dcim.utils import reconcile_port_mappings
from extras.models import ConfigTemplate
from ipam.choices import VLANQinQRoleChoices
from ipam.models import VLAN, VRF, IPAddress, VLANGroup
@ -1135,13 +1136,100 @@ class FrontPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
choices=PortTypeChoices,
help_text=_('Physical medium classification')
)
rear_port = CSVModelChoiceField(
label=_('Rear port'),
queryset=RearPort.objects.all(),
to_field_name='name',
help_text=_('Corresponding rear port (mapped to the front port\'s first position)')
)
rear_port_position = forms.IntegerField(
label=_('Rear port position'),
required=False,
help_text=_('Mapped position on the corresponding rear port (defaults to 1)')
)
class Meta:
model = FrontPort
fields = (
'device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description', 'owner', 'tags'
'device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'rear_port',
'rear_port_position', 'description', 'owner', 'tags'
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit RearPort choices to those belonging to this device (or VC master)
if self.is_bound and 'device' in self.data:
try:
device = self.fields['device'].to_python(self.data['device'])
except forms.ValidationError:
device = None
else:
try:
device = self.instance.device
except Device.DoesNotExist:
device = None
if device:
self.fields['rear_port'].queryset = RearPort.objects.filter(
device__in=[device, device.get_vc_master()]
)
else:
self.fields['rear_port'].queryset = RearPort.objects.none()
def clean(self):
super().clean()
rear_port = self.cleaned_data.get('rear_port')
rear_port_position = self.cleaned_data.get('rear_port_position') or 1
if not rear_port:
return
# Validate the rear port position against the selected rear port
if rear_port_position > rear_port.positions:
raise forms.ValidationError({
'rear_port_position': _(
"Invalid rear port position ({rear_port_position}): Rear port {name} has only {positions} "
"positions."
).format(
rear_port_position=rear_port_position,
name=rear_port.name,
positions=rear_port.positions
)
})
# Ensure the target rear port position isn't already occupied. reconcile_port_mappings() creates the
# mapping via create() (bypassing validate_unique()), so without this check a collision would surface
# as an uncaught IntegrityError (HTTP 500) rather than a row-level validation error.
occupied = PortMapping.objects.filter(
rear_port=rear_port, rear_port_position=rear_port_position
).exclude(front_port=self.instance.pk)
if occupied.exists():
raise forms.ValidationError({
'rear_port_position': _(
"Rear port {name} position {rear_port_position} is already occupied."
).format(
name=rear_port.name,
rear_port_position=rear_port_position
)
})
def _save_m2m(self):
super()._save_m2m()
# Map the front port's first position to the specified rear port & position
if rear_port := self.cleaned_data.get('rear_port'):
reconcile_port_mappings(
PortMapping,
parent_field='front_port',
parent=self.instance,
desired=[{
'front_port_position': 1,
'rear_port_id': rear_port.pk,
'rear_port_position': self.cleaned_data.get('rear_port_position') or 1,
}],
)
class RearPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
device = CSVModelChoiceField(

View File

@ -0,0 +1,42 @@
from django.db import migrations
from django.db.models import Q
CABLED_MODELS = (
'ConsolePort',
'ConsoleServerPort',
'FrontPort',
'Interface',
'PowerFeed',
'PowerOutlet',
'PowerPort',
'RearPort',
)
def clear_stale_cable_profile_data(apps, schema_editor):
"""
Clear cached cable connector and position data from endpoints which no longer have
a cable attached. Earlier versions failed to clear these values when a profiled
cable was deleted, causing subsequent validation of the endpoint to fail.
"""
db_alias = schema_editor.connection.alias
for model_name in CABLED_MODELS:
model = apps.get_model('dcim', model_name)
model.objects.using(db_alias).filter(
Q(cable_connector__isnull=False) | Q(cable_positions__isnull=False),
cable__isnull=True,
).update(
cable_connector=None,
cable_positions=None,
)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0239_add_portmapping_objectchange'),
]
operations = [
migrations.RunPython(clear_stale_cable_profile_data, migrations.RunPython.noop),
]

View File

@ -0,0 +1,35 @@
from django.db import migrations
CABLED_MODELS = (
'ConsolePort',
'ConsoleServerPort',
'FrontPort',
'Interface',
'PowerFeed',
'PowerOutlet',
'PowerPort',
'RearPort',
)
def nullify_empty_cable_end(apps, schema_editor):
"""
Replace empty strings with null values on cached cable end data. Earlier versions
wrote an empty string when a cable termination was deleted, leaving disconnected
endpoints inconsistent with those which have never been cabled.
"""
db_alias = schema_editor.connection.alias
for model_name in CABLED_MODELS:
model = apps.get_model('dcim', model_name)
model.objects.using(db_alias).filter(cable_end='').update(cable_end=None)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0240_clear_stale_cable_profile_data'),
]
operations = [
migrations.RunPython(nullify_empty_cable_end, migrations.RunPython.noop),
]

View File

@ -77,7 +77,7 @@ LEGACY_FIELDS = ('lft', 'rght', 'tree_id', 'level')
class Migration(migrations.Migration):
dependencies = [
('dcim', '0239_add_portmapping_objectchange'),
('dcim', '0241_nullify_empty_cable_end'),
]
operations = [

View File

@ -30,7 +30,7 @@ COMPONENT_TABLES = (
class Migration(migrations.Migration):
dependencies = [
('dcim', '0240_ltree_paths'),
('dcim', '0242_ltree_paths'),
]
operations = [

View File

@ -3,7 +3,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0241_denormalization_triggers'),
('dcim', '0243_denormalization_triggers'),
]
operations = [

View File

@ -4,7 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0242_device__config_context_data'),
('dcim', '0244_device__config_context_data'),
('extras', '0139_alter_customfieldchoiceset_extra_choices'),
('tenancy', '0025_ltree_paths'),
('users', '0016_default_ordering_indexes'),

View File

@ -4,7 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("dcim", "0243_consolidate_unique_constraints"),
("dcim", "0245_consolidate_unique_constraints"),
]
operations = [

View File

@ -10,7 +10,7 @@ import utilities.json
class Migration(migrations.Migration):
dependencies = [
('dcim', '0244_add_devicetype_end_of_life'),
('dcim', '0246_add_devicetype_end_of_life'),
('extras', '0141_custom_field_nulls_first'),
('users', '0016_default_ordering_indexes'),
]

View File

@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0245_modulebaytype'),
('dcim', '0247_modulebaytype'),
]
operations = [

View File

@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0246_interfacetemplate_channels'),
('dcim', '0248_interfacetemplate_channels'),
]
operations = [

View File

@ -946,7 +946,7 @@ class Device(
if self.cluster and self.cluster._location is not None and self.cluster._location != self.location:
raise ValidationError({
'cluster': _("The assigned cluster belongs to a different location ({location})").format(
site=self.cluster._location
location=self.cluster._location
)
})
@ -1235,10 +1235,13 @@ class VirtualChassis(PrimaryModel):
lag__device=F('device')
)
if interfaces:
raise ProtectedError(_(
"Unable to delete virtual chassis {self}. There are member interfaces which form a cross-chassis LAG "
"interfaces."
).format(self=self, interfaces=InterfaceSpeedChoices))
raise ProtectedError(
_(
"Unable to delete virtual chassis {virtual_chassis}. One or more member interfaces form a "
"cross-chassis LAG."
).format(virtual_chassis=self),
set(interfaces),
)
# Clear vc_position and vc_priority on member devices BEFORE calling super().delete()
# This must be done here because on_delete=SET_NULL executes before pre_delete signal

View File

@ -84,6 +84,7 @@ class DeviceRoleIndex(SearchIndex):
('name', 100),
('slug', 110),
('description', 500),
('comments', 5000),
)
display_attrs = ('description',)
@ -117,6 +118,7 @@ class MACAddressIndex(SearchIndex):
fields = (
('mac_address', 100),
('description', 500),
('comments', 5000),
)
display_attrs = ('assigned_object', 'description')
@ -251,6 +253,7 @@ class PlatformIndex(SearchIndex):
('name', 100),
('slug', 110),
('description', 500),
('comments', 5000),
)
display_attrs = ('manufacturer', 'description')

View File

@ -155,7 +155,12 @@ def nullify_connected_endpoints(instance, **kwargs):
Disassociate the Cable from the termination object, and retrace any affected CablePaths.
"""
model = instance.termination_type.model_class()
model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
model.objects.filter(pk=instance.termination_id).update(
cable=None,
cable_end=None,
cable_connector=None,
cable_positions=None,
)
# If the removed termination was a channelized interface, also clear the cable attributes mirrored onto its channel
# subinterfaces. This must happen before the retrace below so that each channel's (now dead) path is torn down

View File

@ -60,11 +60,11 @@ class LegacyCablePathTestCase(BaseCablePathTestCase):
interface2.refresh_from_db()
self.assertIsNone(interface1.cable_id)
self.assertEqual(interface1.cable_end, '')
self.assertIsNone(interface1.cable_end)
self.assertPathIsNotSet(interface1)
self.assertIsNone(interface2.cable_id)
self.assertEqual(interface2.cable_end, '')
self.assertIsNone(interface2.cable_end)
self.assertPathIsNotSet(interface2)
def test_102_consoleport_to_consoleserverport(self):

View File

@ -1,6 +1,7 @@
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.db.models import ProtectedError
from django.db.models.signals import post_save
from django.test import TestCase, tag
@ -855,6 +856,50 @@ class DeviceTestCase(TestCase):
cluster=clusters[1]
).full_clean()
@tag('regression') # Ref: #22717
def test_device_mismatched_location_cluster(self):
"""
A cluster scoped to a different location than the device must be rejected
with a field validation error naming that location.
"""
site = Site.objects.create(name='Site 1', slug='site-1')
locations = (
Location(site=site, name='Location A', slug='location-a'),
Location(site=site, name='Location B', slug='location-b'),
)
for location in locations:
location.save()
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type, scope=locations[0])
device_type = DeviceType.objects.first()
device_role = DeviceRole.objects.first()
# Device in the cluster's location should pass
Device(
name='device1',
site=site,
location=locations[0],
device_type=device_type,
role=device_role,
cluster=cluster
).full_clean()
# Device in a different location of the same site should fail
with self.assertRaisesMessage(
ValidationError,
'The assigned cluster belongs to a different location (Location A)'
):
Device(
name='device1',
site=site,
location=locations[1],
device_type=device_type,
role=device_role,
cluster=cluster
).full_clean()
class DeviceBayTestCase(TestCase):
@ -2573,6 +2618,59 @@ class VirtualChassisTestCase(TestCase):
self.assertIsNone(device2.vc_position)
self.assertIsNone(device2.vc_priority)
@tag('regression') # Ref: #22720
def test_virtualchassis_deletion_blocked_by_cross_chassis_lag(self):
"""
Deleting a VirtualChassis whose members form a cross-chassis LAG must
raise ProtectedError exposing the blocking interfaces, leaving the VC
and its member assignments unchanged.
"""
device1 = Device.objects.get(name='TestDevice1')
device2 = Device.objects.get(name='TestDevice2')
vc = VirtualChassis.objects.create(name='Test VC', master=device1)
device1.virtual_chassis = vc
device1.vc_position = 1
device1.vc_priority = 10
device1.save()
device2.virtual_chassis = vc
device2.vc_position = 2
device2.vc_priority = 20
device2.save()
lag = Interface.objects.create(device=device1, name='lag0', type=InterfaceTypeChoices.TYPE_LAG)
member_interface = Interface(
device=device2,
name='eth0',
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
lag=lag,
)
# A cross-chassis LAG member is valid while both devices share the VC
member_interface.full_clean()
member_interface.save()
with self.assertRaises(ProtectedError) as cm:
vc.delete()
self.assertEqual(
cm.exception.args[0],
'Unable to delete virtual chassis Test VC. One or more member interfaces form a cross-chassis LAG.'
)
self.assertEqual(set(cm.exception.protected_objects), {member_interface})
# The failed deletion must not clear the VC or its member assignments
self.assertTrue(VirtualChassis.objects.filter(pk=vc.pk).exists())
device1.refresh_from_db()
device2.refresh_from_db()
self.assertEqual(device1.virtual_chassis, vc)
self.assertEqual(device1.vc_position, 1)
self.assertEqual(device1.vc_priority, 10)
self.assertEqual(device2.virtual_chassis, vc)
self.assertEqual(device2.vc_position, 2)
self.assertEqual(device2.vc_priority, 20)
def test_virtualchassis_duplicate_vc_position(self):
"""
Test that two devices cannot be assigned to the same vc_position

View File

@ -7,7 +7,7 @@ from django.db import connection
from django.test import SimpleTestCase, TestCase
from dcim import signals
from dcim.choices import CableEndChoices, LinkStatusChoices
from dcim.choices import CableEndChoices, CableProfileChoices, LinkStatusChoices
from dcim.models import (
Cable,
CablePath,
@ -298,8 +298,38 @@ class CableSignalTestCase(TestCase):
self.assertIsNone(interface_b._path_id)
self.assertIsNone(interface_a.cable_id)
self.assertIsNone(interface_b.cable_id)
self.assertEqual(interface_a.cable_end, '')
self.assertEqual(interface_b.cable_end, '')
self.assertIsNone(interface_a.cable_end)
self.assertIsNone(interface_b.cable_end)
def test_deleting_profiled_cable_nullifies_endpoints(self):
"""
Deleting a profiled cable must clear the cached connector and position data on both endpoints.
"""
interface_a = Interface.objects.create(device=self.device, name='Interface A')
interface_b = Interface.objects.create(device=self.device, name='Interface B')
cable = Cable(
a_terminations=[interface_a],
b_terminations=[interface_b],
profile=CableProfileChoices.SINGLE_1C1P,
)
cable.save()
# Confirm the profile metadata was cached on both endpoints.
interface_a.refresh_from_db()
interface_b.refresh_from_db()
self.assertEqual(interface_a.cable_connector, 1)
self.assertEqual(interface_a.cable_positions, [1])
self.assertEqual(interface_b.cable_connector, 1)
self.assertEqual(interface_b.cable_positions, [1])
cable.delete()
for interface in (interface_a, interface_b):
interface.refresh_from_db()
self.assertIsNone(interface.cable_id)
self.assertIsNone(interface.cable_end)
self.assertIsNone(interface.cable_connector)
self.assertIsNone(interface.cable_positions)
def test_deleting_cable_skips_per_termination_retrace(self):
"""
@ -363,7 +393,7 @@ class CableSignalTestCase(TestCase):
termination.delete()
interface_a.refresh_from_db()
self.assertIsNone(interface_a.cable_id)
self.assertEqual(interface_a.cable_end, '')
self.assertIsNone(interface_a.cable_end)
class MACAddressInterfaceSignalTestCase(TestCase):

View File

@ -3628,10 +3628,10 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
}
cls.csv_data = (
"device,name,type,positions",
"Device 1,Front Port 4,8p8c,1",
"Device 1,Front Port 5,8p8c,1",
"Device 1,Front Port 6,8p8c,1",
"device,name,type,positions,rear_port,rear_port_position",
"Device 1,Front Port 4,8p8c,1,Rear Port 4,1",
"Device 1,Front Port 5,8p8c,1,Rear Port 5,1",
"Device 1,Front Port 6,8p8c,1,Rear Port 6,1",
)
cls.csv_update_data = (
@ -3641,6 +3641,49 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
f"{front_ports[2].pk},Front Port 9,New description9",
)
def test_bulk_import_objects_with_permission(self):
# Importing front ports with a rear_port (and position) should create the corresponding PortMapping
def check_port_mappings(scenario_name):
front_port = FrontPort.objects.get(name='Front Port 4')
mapping = PortMapping.objects.get(front_port=front_port)
self.assertEqual(mapping.rear_port.name, 'Rear Port 4')
self.assertEqual(mapping.front_port_position, 1)
self.assertEqual(mapping.rear_port_position, 1)
super().test_bulk_import_objects_with_permission(post_import_callback=check_port_mappings)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_bulk_import_rear_port_position_exceeds_capacity(self):
# A rear_port_position beyond the rear port's capacity is rejected without creating the front port
self.add_permissions('dcim.add_frontport')
csv_data = (
"device,name,type,positions,rear_port,rear_port_position",
"Device 1,Front Port 10,8p8c,1,Rear Port 4,2",
)
response = self.client.post(self._get_url('bulk_import'), {
'data': '\n'.join(csv_data),
'format': ImportFormatChoices.CSV,
'csv_delimiter': CSVDelimiterChoices.AUTO,
})
self.assertEqual(response.status_code, 200)
self.assertFalse(FrontPort.objects.filter(name='Front Port 10').exists())
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_bulk_import_rear_port_position_occupied(self):
# An already-occupied rear port position is rejected (rather than raising an IntegrityError)
self.add_permissions('dcim.add_frontport')
csv_data = (
"device,name,type,positions,rear_port,rear_port_position",
"Device 1,Front Port 10,8p8c,1,Rear Port 1,1",
)
response = self.client.post(self._get_url('bulk_import'), {
'data': '\n'.join(csv_data),
'format': ImportFormatChoices.CSV,
'csv_delimiter': CSVDelimiterChoices.AUTO,
})
self.assertEqual(response.status_code, 200)
self.assertFalse(FrontPort.objects.filter(name='Front Port 10').exists())
def test_trace(self):
self.add_permissions(
'dcim.view_frontport',
@ -3783,6 +3826,69 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
f"{module_bays[2].pk},Module Bay 9,New description9",
)
@tag('regression') # Issue #22773
def test_bulk_add_module_bays_to_devices(self):
"""
Bulk-adding module bays expands the name pattern per device and applies enabled to every new bay.
"""
self.add_permissions('dcim.add_modulebay')
device1 = Device.objects.get(name='Device 1')
device2 = create_test_device('Device 2')
initial_count = self._get_queryset().count()
# An unchecked box is not submitted by the browser at all
request = {
'path': reverse('dcim:device_bulk_add_modulebay'),
'data': post_data({
'pk': [device1.pk, device2.pk],
'name': 'PCI-Slot[1-2]',
'_create': True,
}),
}
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
self.assertEqual(
list(
ModuleBay.objects.filter(name__startswith='PCI-Slot')
.order_by('device_id', 'name')
.values_list('device_id', 'name', 'enabled')
),
[
(device1.pk, 'PCI-Slot1', False),
(device1.pk, 'PCI-Slot2', False),
(device2.pk, 'PCI-Slot1', False),
(device2.pk, 'PCI-Slot2', False),
]
)
# A checked box applies True to every bay created
request = {
'path': reverse('dcim:device_bulk_add_modulebay'),
'data': post_data({
'pk': [device1.pk, device2.pk],
'name': 'PSU-Slot[1-2]',
'enabled': True,
'_create': True,
}),
}
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
self.assertEqual(
list(
ModuleBay.objects.filter(name__startswith='PSU-Slot')
.order_by('device_id', 'name')
.values_list('device_id', 'name', 'enabled')
),
[
(device1.pk, 'PSU-Slot1', True),
(device1.pk, 'PSU-Slot2', True),
(device2.pk, 'PSU-Slot1', True),
(device2.pk, 'PSU-Slot2', True),
]
)
self.assertEqual(initial_count + 8, self._get_queryset().count())
class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = DeviceBay

View File

@ -9,6 +9,7 @@ from django.core.exceptions import ValidationError
from django.core.files.storage import storages
from django.db import models
from django.http import HttpResponse
from django.utils.http import content_disposition_header
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
@ -265,6 +266,7 @@ class RenderTemplateMixin(models.Model):
filename = filename_from_object(context)
else:
filename = "output"
response['Content-Disposition'] = f'attachment; filename="{filename}{extension}"'
filename = f'{filename}{extension}'
response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename=filename)
return response

View File

@ -34,7 +34,7 @@ from netbox.models.features import (
)
from netbox.models.mixins import OwnerMixin
from utilities.html import clean_html
from utilities.jinja2 import render_jinja2
from utilities.jinja2 import render_jinja2, sanitize_http_header
from utilities.querydict import dict_to_querydict
from utilities.querysets import RestrictedQuerySet
from utilities.tables import get_table_for_model
@ -212,7 +212,9 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, OwnerMixin, Ch
help_text=_(
"User-supplied HTTP headers to be sent with the request in addition to the HTTP content type. Headers "
"should be defined in the format <code>Name: Value</code>. Jinja2 template processing is supported with "
"the same context as the request body (below)."
"the same context as the request body (below). When interpolating untrusted data (such as object "
"attributes) into a header value, apply the <code>header_safe</code> filter to guard against HTTP header "
"injection, e.g. <code>X-Object: {{ data.name | header_safe }}</code>."
)
)
body_template = models.TextField(
@ -297,8 +299,12 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, OwnerMixin, Ch
if not self.additional_headers:
return {}
ret = {}
data = render_jinja2(self.additional_headers, context)
# Expose the `header_safe` filter so template authors can sanitize interpolated values (e.g. user-controlled
# object data) against HTTP header (CR/LF) injection. See utilities.jinja2.sanitize_http_header.
data = render_jinja2(self.additional_headers, context, filters={'header_safe': sanitize_http_header})
for line in data.splitlines():
if ':' not in line:
continue
header, value = line.split(':', 1)
ret[header.strip()] = value.strip()
return ret

View File

@ -8,7 +8,7 @@ from unittest.mock import Mock, patch
import django_rq
from django.conf import settings
from django.http import HttpResponse
from django.test import RequestFactory, tag
from django.test import RequestFactory, TestCase, tag
from django.urls import reverse
from PIL import Image
from requests import Session
@ -711,6 +711,13 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
Pre-existing non-dict action_data must not cause flush_events() to
raise.
"""
# flush_events() logs a warning about the invalid action_data; mute it so the expected
# message doesn't clutter the test runner's output.
events_logger = logging.getLogger('netbox.events_processor')
original_level = events_logger.level
events_logger.setLevel(logging.CRITICAL)
self.addCleanup(events_logger.setLevel, original_level)
site_type = ObjectType.objects.get_for_model(Site)
webhook = Webhook.objects.get(name='Webhook 1')
webhook_type = ObjectType.objects.get_for_model(Webhook)
@ -874,3 +881,71 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
job = self.queue.jobs[0]
self.assertEqual(job.kwargs['event_rule'], event_rule)
self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
class WebhookRenderHeadersTest(TestCase):
def test_render_headers(self):
"""Basic header rendering with Jinja2 interpolation."""
webhook = Webhook(
name='Webhook 1',
payload_url='http://localhost:9000/',
additional_headers='X-Foo: Bar\nX-Object: {{ data.name }}',
)
headers = webhook.render_headers({'data': {'name': 'Site 1'}})
self.assertEqual(headers, {'X-Foo': 'Bar', 'X-Object': 'Site 1'})
def test_render_headers_multiline_block(self):
"""A multi-line Jinja2 block (e.g. a loop generating headers) must render against the full template."""
webhook = Webhook(
name='Webhook 1',
payload_url='http://localhost:9000/',
additional_headers=(
'{% for k, v in data.headers.items() %}X-{{ k }}: {{ v }}\n'
'{% endfor %}'
),
)
headers = webhook.render_headers({'data': {'headers': {'Foo': '1', 'Bar': '2'}}})
self.assertEqual(headers, {'X-Foo': '1', 'X-Bar': '2'})
def test_render_headers_skips_blank_lines(self):
"""Blank lines in the rendered output (e.g. from Jinja2 block tags) must be skipped, not raise."""
webhook = Webhook(
name='Webhook 1',
payload_url='http://localhost:9000/',
# Block tags on their own lines leave behind blank lines once rendered
additional_headers=(
'{% for k, v in data.headers.items() %}\n'
'X-{{ k }}: {{ v }}\n'
'{% endfor %}'
),
)
headers = webhook.render_headers({'data': {'headers': {'Foo': '1', 'Bar': '2'}}})
self.assertEqual(headers, {'X-Foo': '1', 'X-Bar': '2'})
def test_render_headers_skips_lines_without_separator(self):
"""A non-blank line lacking a 'Name: Value' separator must be skipped, not raise."""
webhook = Webhook(
name='Webhook 1',
payload_url='http://localhost:9000/',
additional_headers='X-Foo: Bar\nthis line has no colon\nX-Baz: Qux',
)
headers = webhook.render_headers({})
self.assertEqual(headers, {'X-Foo': 'Bar', 'X-Baz': 'Qux'})
def test_render_headers_header_safe_filter_available(self):
"""
The `header_safe` filter must be available when rendering headers, and must strip control characters
(including CR/LF) so that untrusted data cannot smuggle additional headers via CR/LF injection.
"""
webhook = Webhook(
name='Webhook 1',
payload_url='http://localhost:9000/',
additional_headers='X-Object: {{ data.name | header_safe }}',
)
headers = webhook.render_headers({'data': {'name': 'legit\r\nX-Injected: evil\x00'}})
# The injected newline is stripped, so only a single (sanitized) header is produced
self.assertEqual(list(headers.keys()), ['X-Object'])
self.assertNotIn('X-Injected', headers)
self.assertEqual(headers['X-Object'], 'legitX-Injected: evil')

View File

@ -1,3 +1,4 @@
import logging
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@ -45,6 +46,16 @@ class DummyScript:
class RunScriptTestCase(TestCase):
def setUp(self):
super().setUp()
# The failure/abort paths log via the module logger `netbox.scripts.<full_name>`. These
# tests deliberately exercise those paths, so mute the logger to keep the expected error
# messages and tracebacks out of the test runner's output.
logger = logging.getLogger('netbox.scripts')
original_level = logger.level
logger.setLevel(logging.CRITICAL)
self.addCleanup(logger.setLevel, original_level)
def test_run_script_success_commit_true_sets_output_and_job_data(self):
runner = _make_runner()
script = DummyScript(run_result='hello')

View File

@ -36,7 +36,7 @@ from extras.models import (
from extras.models.mixins import RenderTemplateMixin
from tenancy.models import Tenant, TenantGroup
from utilities.exceptions import AbortRequest
from utilities.jinja2 import env_filter, render_jinja2
from utilities.jinja2 import env_filter, render_jinja2, sanitize_http_header
from utilities.tables import get_table_for_model
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@ -1274,6 +1274,48 @@ class JinjaEnvFilterTestCase(TestCase):
self.assertEqual(output, 'overridden')
class SanitizeHTTPHeaderFilterTestCase(TestCase):
"""
Tests for the sanitize_http_header() Jinja2 filter (exposed as `header_safe`) and the render_jinja2()
`filters` argument used to make it available.
"""
def test_strips_crlf(self):
self.assertEqual(sanitize_http_header('legit\r\nX-Injected: evil'), 'legitX-Injected: evil')
def test_strips_control_characters(self):
self.assertEqual(sanitize_http_header('foo\x00\x1f\x7fbar'), 'foobar')
def test_preserves_normal_value(self):
self.assertEqual(sanitize_http_header('application/json'), 'application/json')
def test_coerces_non_string(self):
self.assertEqual(sanitize_http_header(42), '42')
def test_available_via_render_filters_argument(self):
output = render_jinja2(
"{{ value | header_safe }}",
{'value': 'a\r\nb'},
filters={'header_safe': sanitize_http_header},
)
self.assertEqual(output, 'ab')
def test_render_filters_take_precedence_over_user_config(self):
# A per-render filter cannot be shadowed by a user-configured filter of the same name
with self.settings(JINJA_FILTERS={'header_safe': lambda v: 'shadowed'}):
output = render_jinja2(
"{{ value | header_safe }}",
{'value': 'a\r\nb'},
filters={'header_safe': sanitize_http_header},
)
self.assertEqual(output, 'ab')
def test_not_registered_without_filters_argument(self):
# The filter must not leak into general-purpose rendering
with self.assertRaises(TemplateError):
render_jinja2("{{ 'x' | header_safe }}", {})
class ExportTemplateContextTestCase(TestCase):
"""
Tests for ExportTemplate.get_context() including public model population.

View File

@ -1,3 +1,4 @@
import logging
import uuid
from unittest.mock import PropertyMock, patch
@ -467,6 +468,16 @@ class ImageAttachmentTestCase(
# placeholder URLs instead of real images on disk.
model = ImageAttachment
def setUp(self):
super().setUp()
# The fixtures use placeholder image URLs with no file on disk, so rendering the thumbnail
# column logs a FileNotFoundError traceback for every attachment. The missing files are
# expected here, so mute the sorl-thumbnail logger to keep the test output clean.
logger = logging.getLogger('sorl.thumbnail')
original_level = logger.level
logger.setLevel(logging.CRITICAL)
self.addCleanup(logger.setLevel, original_level)
@classmethod
def setUpTestData(cls):
ct = ContentType.objects.get_for_model(Site)
@ -1150,6 +1161,20 @@ class ScriptListViewTestCase(TestCase):
self.assertTemplateUsed(response, 'extras/inc/script_list_content.html')
class ScriptModuleCreateViewTestCase(TestCase):
user_permissions = ['core.add_managedfile', 'extras.add_scriptmodule']
@tag('regression')
def test_default_return_url(self):
"""
The add view should fall back to the scripts list as its return URL.
"""
response = self.client.get(reverse('extras:scriptmodule_add'))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['return_url'], reverse('extras:script_list'))
class ScriptValidationErrorTestCase(TestCase):
user_permissions = ['extras.view_script', 'extras.run_script']

View File

@ -9,6 +9,7 @@ from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpRespo
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.utils.http import content_disposition_header
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
from django.views.generic import View
@ -1273,7 +1274,7 @@ class ObjectRenderConfigView(generic.ObjectView):
content = context['rendered_config'] or context['error_message']
response = HttpResponse(content, content_type='text')
filename = f"{instance.name or 'config'}.txt"
response['Content-Disposition'] = f'attachment; filename="{filename}"'
response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename=filename)
return response
return render(
@ -1636,6 +1637,7 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View):
class ScriptModuleCreateView(generic.ObjectEditView):
queryset = ScriptModule.objects.all()
form = forms.ScriptFileForm
default_return_url = 'extras:script_list'
def alter_object(self, obj, *args, **kwargs):
obj.file_root = ManagedFileRootPathChoices.SCRIPTS
@ -1882,7 +1884,7 @@ class ScriptResultView(TableMixin, generic.ObjectView):
content = (job.data.get("output") or "").encode()
response = HttpResponse(content, content_type='text')
filename = f"{job.object.name or 'script-output'}_{job.completed.strftime('%Y-%m-%d_%H%M%S')}.txt"
response['Content-Disposition'] = f'attachment; filename="{filename}"'
response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename=filename)
return response
if job.completed:

View File

@ -20,7 +20,7 @@ class IPAddressField(serializers.CharField):
try:
return IPNetwork(data)
except AddrFormatError:
raise serializers.ValidationError(_("Invalid IP address format: {data}").format(data))
raise serializers.ValidationError(_("Invalid IP address format: {data}").format(data=data))
except (TypeError, ValueError) as e:
raise serializers.ValidationError(e)
@ -40,7 +40,7 @@ class IPNetworkField(serializers.CharField):
try:
return IPNetwork(data)
except AddrFormatError:
raise serializers.ValidationError(_("Invalid IP prefix format: {data}").format(data))
raise serializers.ValidationError(_("Invalid IP prefix format: {data}").format(data=data))
except (TypeError, ValueError) as e:
raise serializers.ValidationError(e)

View File

@ -1069,6 +1069,10 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
queryset=Site.objects.all(),
method='get_for_site'
)
available_at_site_group = django_filters.ModelChoiceFilter(
queryset=SiteGroup.objects.all(),
method='get_for_site_group'
)
available_on_device = django_filters.ModelChoiceFilter(
queryset=Device.objects.select_related('cluster'),
method='get_for_device'
@ -1130,6 +1134,10 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
def get_for_site(self, queryset, name, value):
return queryset.get_for_site(value)
@extend_schema_field(OpenApiTypes.STR)
def get_for_site_group(self, queryset, name, value):
return queryset.get_for_site_group(value)
@extend_schema_field(OpenApiTypes.STR)
def get_for_device(self, queryset, name, value):
return queryset.get_for_device(value)

View File

@ -5,7 +5,7 @@ from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from dcim.forms.mixins import ScopedForm
from dcim.models import Device, Interface, Site
from dcim.models import Device, Interface, Site, SiteGroup
from ipam.choices import *
from ipam.constants import *
from ipam.formfields import IPNetworkFormField
@ -251,9 +251,17 @@ class PrefixForm(TenancyForm, ScopedForm, PrimaryModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# #18605: only filter VLAN select list if the selected scope is a Site (or none is selected yet)
# #18605: only filter the VLAN select list if the selected scope is a Site (or none is selected yet).
# #22588: a Site Group scope filters VLANs by the group's member sites instead.
if scope_field := self.fields.get('scope', None):
if scope_field.selected_model not in (None, Site):
selected_model = scope_field.selected_model
if selected_model is SiteGroup:
self.fields['vlan'].widget.dynamic_params.clear()
self.fields['vlan'].widget.attrs.pop('data-dynamic-params', None)
self.fields['vlan'].widget.add_query_params({
'available_at_site_group': '$scope_object_id',
})
elif selected_model not in (None, Site):
self.fields['vlan'].widget.attrs.pop('data-dynamic-params', None)

View File

@ -12,7 +12,7 @@ class Migration(migrations.Migration):
dependencies = [
('ipam', '0093_alter_prefix__region_alter_prefix__site_group'),
# Source tables (dcim_site, dcim_location) must already exist.
('dcim', '0240_ltree_paths'),
('dcim', '0242_ltree_paths'),
]
operations = [

View File

@ -290,6 +290,23 @@ class VLANQuerySet(RestrictedQuerySet):
Q(group__isnull=True, site__isnull=True) # Global VLANs
)
def get_for_site_group(self, site_group):
"""
Return all VLANs available to the specified site group.
"""
if site_group is None:
return self.none()
from .models import VLANGroup
q = Q(
scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
scope_id__in=site_group.get_ancestors(include_self=True)
)
return self.filter(
Q(group__in=VLANGroup.objects.filter(q)) |
Q(group__scope_id__isnull=True, site__isnull=True) | # Global group VLANs
Q(group__isnull=True, site__isnull=True) # Global VLANs
)
def get_for_device(self, device):
"""
Return all VLANs available to the specified Device.

View File

@ -22,6 +22,7 @@ class ASNIndex(SearchIndex):
('asn', 100),
('prefixed_name', 110),
('description', 500),
('comments', 5000),
)
display_attrs = ('rir', 'role', 'tenant', 'description')
@ -171,6 +172,7 @@ class VLANTranslationPolicyIndex(SearchIndex):
fields = (
('name', 100),
('description', 500),
('comments', 5000),
)
display_attrs = ('description',)

View File

@ -452,6 +452,19 @@ class PrefixTestCase(APIViewTestCases.APIViewTestCase):
)
Prefix.objects.bulk_create(prefixes)
@tag('regression')
def test_create_with_invalid_prefix(self):
"""
POST of a malformed prefix value returns a 400 validation error.
"""
self.add_permissions('ipam.add_prefix')
url = reverse('ipam-api:prefix-list')
response = self.client.post(url, {'prefix': 'invalid'}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.data['prefix'][0], 'Invalid IP prefix format: invalid')
@tag('regression')
def test_clean_validates_scope(self):
prefix = Prefix.objects.first()
@ -858,6 +871,19 @@ class IPAddressTestCase(APIViewTestCases.APIViewTestCase):
)
IPAddress.objects.bulk_create(ip_addresses)
@tag('regression')
def test_create_with_invalid_address(self):
"""
POST of a malformed address value returns a 400 validation error.
"""
self.add_permissions('ipam.add_ipaddress')
url = reverse('ipam-api:ipaddress-list')
response = self.client.post(url, {'address': 'invalid'}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.data['address'][0], 'Invalid IP address format: invalid')
def test_assign_object(self):
"""
Test the creation of available IP addresses within a parent IP range.

View File

@ -2221,6 +2221,11 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'available_at_site': site_id}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) # 4 scoped + 1 global group + 1 global
def test_available_at_site_group(self):
site_group = SiteGroup.objects.get(name='Site Group 1')
params = {'available_at_site_group': site_group.pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) # 1 scoped + 1 global group + 1 global
def test_interface(self):
interface_id = Interface.objects.first().pk
params = {'interface_id': interface_id}

View File

@ -29,12 +29,21 @@ class PrefixFormTestCase(TestCase):
assert form.fields['vlan'].widget.attrs['data-dynamic-params'] == self.default_dynamic_params
def test_vlan_field_sets_dynamic_params_for_scope_site_group(self):
"""data-dynamic-params present with available_at_site_group when scope type is Site Group"""
site_group = SiteGroup.objects.create(name='Site Group 1', slug='site-group-1')
form = PrefixForm(data={
'scope_content_type': ContentType.objects.get_for_model(SiteGroup).id,
'scope_object_id': site_group.pk,
})
expected = '[{"fieldName":"scope_object_id","queryParam":"available_at_site_group"}]'
assert form.fields['vlan'].widget.attrs['data-dynamic-params'] == expected
def test_vlan_field_does_not_set_dynamic_params_for_other_scopes(self):
"""data-dynamic-params not present when scope type is populated by is not Site"""
"""data-dynamic-params not present when scope type is not Site or Site Group"""
cases = [
Region(name='Region 1', slug='region-1'),
Location(site=self.site, name='Location 1', slug='location-1'),
SiteGroup(name='Site Group 1', slug='site-group-1'),
]
for case in cases:
case.save()
@ -42,7 +51,6 @@ class PrefixFormTestCase(TestCase):
'scope_content_type': ContentType.objects.get_for_model(case._meta.model).id,
'scope_object_id': case.pk,
})
assert 'data-dynamic-params' not in form.fields['vlan'].widget.attrs

View File

@ -1,4 +1,3 @@
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.db.backends.postgresql.psycopg_any import NumericRange
from django.utils.translation import gettext as _
@ -112,7 +111,8 @@ class ContentTypeField(RelatedField):
def to_internal_value(self, data):
try:
app_label, model = data.split('.')
return ContentType.objects.get_by_natural_key(app_label=app_label, model=model)
# Scoped to the field's declared queryset, not the raw ContentType table (#22748).
return self.get_queryset().get(app_label=app_label, model=model)
except ObjectDoesNotExist:
self.fail('does_not_exist', content_type=data)
except (AttributeError, TypeError, ValueError):

View File

@ -2,6 +2,7 @@ import json
from copy import deepcopy
import django_filters
from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models import Q
@ -20,6 +21,7 @@ from utilities.constants import (
FILTER_CHAR_BASED_LOOKUP_MAP,
FILTER_NEGATION_LOOKUP_MAP,
FILTER_NUMERIC_BASED_LOOKUP_MAP,
FILTER_TAG_LOOKUP_MAP,
FILTER_TREENODE_NEGATION_LOOKUP_MAP,
)
from utilities.forms.fields import MACAddressField
@ -117,7 +119,11 @@ class BaseFilterSet(django_filters.FilterSet):
except (ValueError, TypeError):
pass
saved_filters = SavedFilter.objects.filter(
# Only apply SavedFilters the requesting user is permitted to see (#22790). Fall back to
# anonymous visibility (shared filters only) when no request is available.
request = kwargs.get('request')
user = request.user if request else AnonymousUser()
saved_filters = SavedFilter.objects.restrict_to_shared(user).filter(
Q(slug__in=data.pop('filter', [])) |
Q(pk__in=filter_ids)
)
@ -153,10 +159,13 @@ class BaseFilterSet(django_filters.FilterSet):
# TreeNodeMultipleChoiceFilter only support negation but must maintain the `in` lookup expression
return FILTER_TREENODE_NEGATION_LOOKUP_MAP
if isinstance(existing_filter, (TagFilter, TagIDFilter)):
# Tags additionally support an "any of" (OR) mode, unlike other model choice filters
return FILTER_TAG_LOOKUP_MAP
if isinstance(existing_filter, (
django_filters.ModelChoiceFilter,
django_filters.ModelMultipleChoiceFilter,
TagFilter
)):
# These filter types support only negation
return FILTER_NEGATION_LOOKUP_MAP
@ -237,6 +246,10 @@ class BaseFilterSet(django_filters.FilterSet):
# Of course setting the negation of the existing filter's exclude attribute handles both cases
new_filter.exclude = not existing_filter.exclude
if lookup_name == 'any' and isinstance(new_filter, (TagFilter, TagIDFilter)):
# "Any of" is an OR match, whereas TagFilter/TagIDFilter default to AND (conjoined=True)
new_filter.conjoined = False
new_filters[new_filter_name] = new_filter
return new_filters

View File

@ -308,10 +308,24 @@ class ArrayLookup(Generic[T]):
Class for Array field lookups
"""
contains: list[T] | None = strawberry_django.filter_field(description='Contains the value')
contained_by: list[T] | None = strawberry_django.filter_field(description='Contained by the value')
overlap: list[T] | None = strawberry_django.filter_field(description='Overlaps with the value')
length: int | None = strawberry_django.filter_field(description='Length of the array')
contains: list[T] | None = strawberry.field(default=strawberry.UNSET, description='Contains the value')
contained_by: list[T] | None = strawberry.field(default=strawberry.UNSET, description='Contained by the value')
overlap: list[T] | None = strawberry.field(default=strawberry.UNSET, description='Overlaps with the value')
length: int | None = strawberry.field(default=strawberry.UNSET, description='Length of the array')
@strawberry_django.filter_field
def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> tuple[QuerySet, Q]:
# Map the public GraphQL ``length`` field to Django's ``len`` array transform; the
# remaining lookups share their name with the corresponding ORM transform.
if self.contains is not strawberry.UNSET and self.contains is not None:
return queryset, Q(**{f'{prefix}contains': self.contains})
if self.contained_by is not strawberry.UNSET and self.contained_by is not None:
return queryset, Q(**{f'{prefix}contained_by': self.contained_by})
if self.overlap is not strawberry.UNSET and self.overlap is not None:
return queryset, Q(**{f'{prefix}overlap': self.overlap})
if self.length is not strawberry.UNSET and self.length is not None:
return queryset, Q(**{f'{prefix}len': self.length})
return queryset, Q()
@strawberry.input(one_of=True, description='Lookup for Array fields. Only one of the lookup fields can be set.')

View File

@ -79,7 +79,9 @@ class DeleteMixin:
)
)
collector = CustomCollector(using=using)
# Pass origin=self (matching Django's Model.delete) so signal receivers can tell that
# cascaded child objects are being deleted as part of deleting this object.
collector = CustomCollector(using=using, origin=self)
collector.collect([self], keep_parents=keep_parents)
return collector.delete()

View File

@ -570,9 +570,37 @@ class CustomFieldColumn(tables.Column):
return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')
return escape(value)
if self.customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
return self.customfield.get_choice_label(value)
if value is None:
return self.default
label = self.customfield.get_choice_label(value)
color = self.customfield.get_choice_color(value)
if color:
return mark_safe(
f'<span class="badge text-bg-{escape(color)}">{escape(label)}</span>'
)
return label
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
return ', '.join(self.customfield.get_choice_label(v) for v in value)
if not value:
return ''
has_color = False
parts = []
for v in value:
label = self.customfield.get_choice_label(v)
color = self.customfield.get_choice_color(v)
if color:
has_color = True
parts.append((label, color))
if has_color:
badges = []
for label, color in parts:
badges.append(
f'<span class="badge text-bg-{escape(color or "secondary")}">{escape(label)}</span>'
)
return mark_safe(' '.join(badges))
return ', '.join(label for label, _ in parts)
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
return mark_safe(', '.join(
self._linkify_item(obj) for obj in self.customfield.deserialize(value)

View File

@ -1,5 +1,6 @@
import uuid
from django.contrib.contenttypes.models import ContentType
from django.db.backends.postgresql.psycopg_any import NumericRange
from django.test import RequestFactory, TestCase
from django.urls import reverse
@ -7,8 +8,9 @@ from rest_framework.exceptions import ValidationError
from rest_framework.request import Request
from dcim.api.serializers import RackSerializer
from dcim.models import Device, Site
from netbox.api.exceptions import QuerySetNotOrdered
from netbox.api.fields import IntegerRangeSerializer, RelatedObjectCountField
from netbox.api.fields import ContentTypeField, IntegerRangeSerializer, RelatedObjectCountField
from netbox.api.pagination import NetBoxPagination
from users.models import Token
from utilities.testing import APITestCase
@ -178,3 +180,60 @@ class IntegerRangeSerializerTestCase(TestCase):
serializer.to_internal_value(['100', '200'])
with self.assertRaises(ValidationError):
serializer.to_internal_value([100.5, 200.5])
class ContentTypeFieldTestCase(TestCase):
def test_to_internal_value_resolves_content_type_within_queryset(self):
"""A content type present in the field's declared queryset resolves successfully."""
site_ct = ContentType.objects.get_for_model(Site)
field = ContentTypeField(queryset=ContentType.objects.filter(pk=site_ct.pk))
self.assertEqual(field.to_internal_value('dcim.site'), site_ct)
def test_to_internal_value_rejects_content_type_outside_queryset(self):
"""
Regression test for #22748: ContentTypeField.to_internal_value() previously resolved
against the raw, unfiltered ContentType table via get_by_natural_key(), ignoring the
field's own declared queryset entirely. A content type that is real and resolvable in
general, but falls outside the specific queryset a given field declares, must be
rejected rather than silently accepted.
"""
site_ct = ContentType.objects.get_for_model(Site)
device_ct = ContentType.objects.get_for_model(Device)
# Scope the field to a single, unrelated content type so `dcim.site` is guaranteed to
# fall outside it.
field = ContentTypeField(queryset=ContentType.objects.filter(pk=device_ct.pk))
with self.assertRaises(ValidationError):
field.to_internal_value('dcim.site')
# Sanity check: the rejected content type is a genuine, generally-resolvable content
# type, so the rejection above is attributable to queryset scoping and not a bogus value.
self.assertTrue(ContentType.objects.filter(pk=site_ct.pk).exists())
def test_to_internal_value_rejects_malformed_input(self):
"""Input must be exactly '<app_label>.<model>'."""
field = ContentTypeField(queryset=ContentType.objects.all())
with self.assertRaises(ValidationError):
field.to_internal_value('not-a-valid-format')
with self.assertRaises(ValidationError):
field.to_internal_value('too.many.dots')
def test_to_internal_value_rejects_nonexistent_content_type(self):
"""A syntactically valid but nonexistent content type must be rejected."""
field = ContentTypeField(queryset=ContentType.objects.all())
with self.assertRaises(ValidationError):
field.to_internal_value('nonexistent_app.nonexistent_model')
def test_to_internal_value_many_rejects_content_type_outside_queryset(self):
"""
many=True wraps the field in a ManyRelatedField, which delegates per-item validation to
the child field's to_internal_value() unconditionally; the same queryset scoping must
hold there too.
"""
device_ct = ContentType.objects.get_for_model(Device)
field = ContentTypeField(queryset=ContentType.objects.filter(pk=device_ct.pk), many=True)
self.assertEqual(field.to_internal_value(['dcim.device']), [device_ct])
with self.assertRaises(ValidationError):
field.to_internal_value(['dcim.device', 'dcim.site'])

View File

@ -14,7 +14,17 @@ from strawberry.extensions import QueryDepthLimiter
from strawberry.schema.config import StrawberryConfig
from dcim.choices import LocationStatusChoices
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Site, VirtualChassis
from dcim.models import (
Device,
DeviceRole,
DeviceType,
Location,
Manufacturer,
Rack,
RackReservation,
Site,
VirtualChassis,
)
from extras.models import TableConfig, Tag
from netbox.graphql.scalars import BigInt, BigIntScalar
from netbox.graphql.schema import Query, get_schema_extensions, schema
@ -337,6 +347,68 @@ class GraphQLAPITestCase(APITestCase):
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['device_list']), 3)
def test_graphql_array_length_lookup(self):
"""
The public GraphQL ``length`` array lookup must map to Django's ``len`` transform.
Regression test for #22766 using a standard array field (RackReservation.units).
"""
self.add_permissions('dcim.view_rackreservation')
url = reverse('graphql')
site = Site.objects.first()
rack = Rack.objects.create(name='Reservation Rack', site=site)
RackReservation.objects.create(rack=rack, units=[1, 2], user=self.user, description='Two units')
RackReservation.objects.create(rack=rack, units=[3, 4, 5], user=self.user, description='Three units')
query = """
{
rack_reservation_list(filters: {units: {length: 2}}) {
id units
}
}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['rack_reservation_list']), 1)
self.assertEqual(data['data']['rack_reservation_list'][0]['units'], [1, 2])
def test_graphql_array_lookups(self):
"""
The ``contains``, ``contained_by``, and ``overlap`` array lookups share their name with the
corresponding ORM transform. Verify they still resolve after #22766 replaced the auto-generated
filter fields with a manual ``filter()`` method.
"""
self.add_permissions('dcim.view_rackreservation')
url = reverse('graphql')
site = Site.objects.first()
rack = Rack.objects.create(name='Array Lookup Rack', site=site)
RackReservation.objects.create(rack=rack, units=[1, 2], user=self.user, description='Low units')
RackReservation.objects.create(rack=rack, units=[3, 4, 5], user=self.user, description='High units')
def run(lookup):
query = f"""
{{
rack_reservation_list(filters: {{units: {lookup}}}) {{
id units
}}
}}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
return [r['units'] for r in data['data']['rack_reservation_list']]
# contains: arrays that include all of the given elements
self.assertEqual(run('{contains: [1]}'), [[1, 2]])
# contained_by: arrays whose elements all fall within the given set
self.assertEqual(run('{contained_by: [1, 2, 3]}'), [[1, 2]])
# overlap: arrays sharing at least one element with the given set
self.assertCountEqual(run('{overlap: [2, 3]}'), [[1, 2], [3, 4, 5]])
def test_graphql_tableconfig_object_type_exposes_id(self):
"""TableConfigType.object_type must expose ContentType fields (e.g. id)."""
self.add_permissions('extras.view_tableconfig')

View File

@ -44,6 +44,11 @@ class ScaffoldInstanceTest(SimpleTestCase):
self.enterContext(patch('netbox.scaffold._config_template', return_value=template))
self.enterContext(patch('netbox.scaffold._contrib_dir', return_value=contrib_src))
# scaffold_instance()/main() print per-file progress to stdout (legitimate `netbox setup`
# CLI feedback); swallow it here so it doesn't clutter the test runner's output. Tests that
# assert on captured output redirect stdout themselves within the individual test method.
self.enterContext(contextlib.redirect_stdout(StringIO()))
def test_scaffolds_configuration_and_contrib_examples(self):
"""A fresh target gets conf/__init__.py, conf/configuration.py, local_requirements.txt, and contrib/."""
written = scaffold.scaffold_instance(self.target)

View File

@ -2,10 +2,11 @@ from django.contrib.auth.models import AnonymousUser
from django.template import Context, Template
from django.test import RequestFactory, TestCase
from core.models import ObjectType
from dcim.models import Device, Site
from dcim.tables import DeviceTable
from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField
from extras.choices import CustomFieldChoiceColorChoices, CustomFieldTypeChoices
from extras.models import CustomField, CustomFieldChoiceSet
from netbox.tables import NetBoxTable, columns
from utilities.testing import create_tags, create_test_device, create_test_user
@ -124,26 +125,136 @@ class TagColumnTestCase(TestCase):
class CustomFieldColumnTestCase(TestCase):
"""
A URL custom field value is rendered directly into an href, so its scheme must be validated
against ALLOWED_URL_SCHEMES to avoid rendering dangerous schemes (e.g. javascript:) as clickable
links (fixes #22640).
"""
@classmethod
def setUpTestData(cls):
cls.object_type = ObjectType.objects.get_for_model(Site)
def _render(self, value):
# Choice set containing one colored and two uncolored choices
cls.mixed_choice_set = CustomFieldChoiceSet.objects.create(
name='Mixed Choice Set',
extra_choices=(
('a', 'Option A'),
('b', 'Option B'),
('c', 'Option C'),
),
choice_colors={
'a': CustomFieldChoiceColorChoices.RED,
},
)
cls.select_cf = CustomField.objects.create(
name='select_field',
type=CustomFieldTypeChoices.TYPE_SELECT,
choice_set=cls.mixed_choice_set,
required=False,
)
cls.select_cf.object_types.set([cls.object_type])
cls.multiselect_cf = CustomField.objects.create(
name='multiselect_field',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
choice_set=cls.mixed_choice_set,
required=False,
)
cls.multiselect_cf.object_types.set([cls.object_type])
def test_colored_single_select(self):
column = columns.CustomFieldColumn(self.select_cf)
rendered = str(column.render('a'))
self.assertIn('badge', rendered)
self.assertIn('text-bg-red', rendered)
self.assertIn('Option A', rendered)
def test_uncolored_single_select(self):
column = columns.CustomFieldColumn(self.select_cf)
rendered = str(column.render('b'))
self.assertEqual(rendered, 'Option B')
self.assertNotIn('badge', rendered)
def test_empty_multiselect(self):
column = columns.CustomFieldColumn(self.multiselect_cf)
rendered = column.render([])
self.assertEqual(rendered, '')
def test_multiselect_without_selected_colored_choices(self):
column = columns.CustomFieldColumn(self.multiselect_cf)
rendered = str(column.render(['b', 'c']))
self.assertEqual(rendered, 'Option B, Option C')
self.assertNotIn('badge', rendered)
def test_multiselect_with_mixed_colored_choices(self):
column = columns.CustomFieldColumn(self.multiselect_cf)
rendered = str(column.render(['a', 'b']))
self.assertIn('Option A', rendered)
self.assertIn('Option B', rendered)
self.assertIn('text-bg-red', rendered)
self.assertIn('text-bg-secondary', rendered)
self.assertNotIn(',', rendered)
def test_html_sensitive_multiselect_labels(self):
choice_set = CustomFieldChoiceSet.objects.create(
name='HTML Choice Set',
extra_choices=(
('x', '<b>Bold Option</b>'),
('y', "<script>alert('xss')</script>"),
),
choice_colors={
'x': CustomFieldChoiceColorChoices.RED,
},
)
custom_field = CustomField.objects.create(
name='html_multiselect_field',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
choice_set=choice_set,
required=False,
)
custom_field.object_types.set([self.object_type])
column = columns.CustomFieldColumn(custom_field)
rendered = str(column.render(['x', 'y']))
self.assertIn('&lt;b&gt;Bold Option&lt;/b&gt;', rendered)
self.assertNotIn('&amp;lt;', rendered)
self.assertIn('&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;', rendered,)
self.assertNotIn('<script>', rendered)
self.assertIn('text-bg-red', rendered)
self.assertIn('text-bg-secondary', rendered)
def _render_url(self, value):
customfield = CustomField(name='url_field', type=CustomFieldTypeChoices.TYPE_URL)
return columns.CustomFieldColumn(customfield).render(value)
# A URL custom field value is rendered directly into an href, so its scheme must be validated
# against ALLOWED_URL_SCHEMES to avoid rendering dangerous schemes (e.g. javascript:) as
# clickable links (fixes #22640).
def test_url_allowed_scheme_rendered_as_link(self):
self.assertEqual(self._render('https://example.com'), '<a href="https://example.com">https://example.com</a>')
self.assertEqual(
self._render_url('https://example.com'), '<a href="https://example.com">https://example.com</a>'
)
def test_url_disallowed_scheme_not_rendered_as_link(self):
rendered = self._render('javascript:alert(1)')
rendered = self._render_url('javascript:alert(1)')
self.assertNotIn('href', rendered)
self.assertIn('javascript:alert(1)', rendered)
def test_url_percent_encoded_scheme_rendered_as_relative_link(self):
# A percent-encoded scheme is inert: a browser will not decode "%3A" to execute javascript:,
# so the value has no scheme and is rendered as a link as-is.
rendered = self._render('javascript%3Aalert(1)')
rendered = self._render_url('javascript%3Aalert(1)')
self.assertEqual(rendered, '<a href="javascript%3Aalert(1)">javascript%3Aalert(1)</a>')

View File

@ -15,6 +15,7 @@ from django.db.models.fields.reverse_related import ManyToManyRel
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.http import content_disposition_header
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
@ -227,7 +228,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
if hasattr(model, 'to_yaml'):
response = HttpResponse(self.export_yaml(), content_type='text/yaml')
filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename=filename)
return response
# Fall back to default table/YAML export

View File

@ -31,8 +31,9 @@
"gridstack": "12.6.0",
"htmx.org": "2.0.10",
"markdown-it": "^14.3.0",
"picomatch": "4.0.5",
"query-string": "9.4.1",
"sass": "1.101.0",
"sass": "1.102.0",
"tom-select": "2.6.2",
"typeface-inter": "3.18.1",
"typeface-roboto-mono": "1.1.13"
@ -44,16 +45,16 @@
"@types/bootstrap": "5.2.11",
"@types/cookie": "^1.0.0",
"@types/node": "^24.10.1",
"@typescript-eslint/eslint-plugin": "^8.64.0",
"@typescript-eslint/parser": "^8.64.0",
"@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.65.0",
"esbuild": "^0.28.1",
"esbuild-sass-plugin": "^3.7.0",
"eslint": "^10.7.0",
"eslint": "^10.8.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-prettier": "^5.5.6",
"globals": "^17.7.0",
"globals": "^17.8.0",
"prettier": "^3.9.5",
"typescript": "^5.9.3"
},

View File

@ -182,10 +182,10 @@
debug "^4.3.1"
minimatch "^10.2.4"
"@eslint/config-helpers@^0.6.0":
version "0.6.0"
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.6.0.tgz#ef9a36881d39dfd5dbeac22b0da997fabfb08b03"
integrity sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==
"@eslint/config-helpers@^0.7.0":
version "0.7.0"
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.7.0.tgz#09ee4aa07b73f059ec2d4c74bf4b2ff02b322377"
integrity sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==
dependencies:
"@eslint/core" "^1.2.1"
@ -917,100 +917,100 @@
dependencies:
"@types/estree" "*"
"@typescript-eslint/eslint-plugin@^8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz#71a0c3d5f8a5e6c5dfdb4f0f04bd1bfb572d5e24"
integrity sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==
"@typescript-eslint/eslint-plugin@^8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz#0a58df6fea8c0bf6b396f518077099bc8b762bb5"
integrity sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==
dependencies:
"@eslint-community/regexpp" "^4.12.2"
"@typescript-eslint/scope-manager" "8.64.0"
"@typescript-eslint/type-utils" "8.64.0"
"@typescript-eslint/utils" "8.64.0"
"@typescript-eslint/visitor-keys" "8.64.0"
"@typescript-eslint/scope-manager" "8.65.0"
"@typescript-eslint/type-utils" "8.65.0"
"@typescript-eslint/utils" "8.65.0"
"@typescript-eslint/visitor-keys" "8.65.0"
ignore "^7.0.5"
natural-compare "^1.4.0"
ts-api-utils "^2.5.0"
"@typescript-eslint/parser@^8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.64.0.tgz#c9864a1cc28a13ff29a7314fbdef0528bb122f72"
integrity sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==
"@typescript-eslint/parser@^8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.65.0.tgz#5295c1058c0a1dd746ef28baaf9c0341dbdf03dc"
integrity sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==
dependencies:
"@typescript-eslint/scope-manager" "8.64.0"
"@typescript-eslint/types" "8.64.0"
"@typescript-eslint/typescript-estree" "8.64.0"
"@typescript-eslint/visitor-keys" "8.64.0"
"@typescript-eslint/scope-manager" "8.65.0"
"@typescript-eslint/types" "8.65.0"
"@typescript-eslint/typescript-estree" "8.65.0"
"@typescript-eslint/visitor-keys" "8.65.0"
debug "^4.4.3"
"@typescript-eslint/project-service@8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.64.0.tgz#14c4e29390d7325a7f8a1218c2788fd649b85da6"
integrity sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==
"@typescript-eslint/project-service@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.65.0.tgz#65fbbc9a1591abffaeab5513200f848271cb0aa5"
integrity sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.64.0"
"@typescript-eslint/types" "^8.64.0"
"@typescript-eslint/tsconfig-utils" "^8.65.0"
"@typescript-eslint/types" "^8.65.0"
debug "^4.4.3"
"@typescript-eslint/scope-manager@8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz#d45f15304a94c85c39db317b717b158fb6259958"
integrity sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==
"@typescript-eslint/scope-manager@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz#9547202ce7e608e7b6283df585703b980a0ea70d"
integrity sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==
dependencies:
"@typescript-eslint/types" "8.64.0"
"@typescript-eslint/visitor-keys" "8.64.0"
"@typescript-eslint/types" "8.65.0"
"@typescript-eslint/visitor-keys" "8.65.0"
"@typescript-eslint/tsconfig-utils@8.64.0", "@typescript-eslint/tsconfig-utils@^8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz#c62ac8ea9173c3cac8b38b8e66e30a046b548851"
integrity sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==
"@typescript-eslint/tsconfig-utils@8.65.0", "@typescript-eslint/tsconfig-utils@^8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz#36f168fcdbb1295f7446ff0379667f98c3cf1bf3"
integrity sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==
"@typescript-eslint/type-utils@8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz#106fa7d58cf9cf7758f3dd8e426ac8237eceacf3"
integrity sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==
"@typescript-eslint/type-utils@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz#d316d7522d93cff4cd14f305e02f3df2d804f9c1"
integrity sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==
dependencies:
"@typescript-eslint/types" "8.64.0"
"@typescript-eslint/typescript-estree" "8.64.0"
"@typescript-eslint/utils" "8.64.0"
"@typescript-eslint/types" "8.65.0"
"@typescript-eslint/typescript-estree" "8.65.0"
"@typescript-eslint/utils" "8.65.0"
debug "^4.4.3"
ts-api-utils "^2.5.0"
"@typescript-eslint/types@8.64.0", "@typescript-eslint/types@^8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.64.0.tgz#b41f8ef5dd40616908658b991197a9d486cda60b"
integrity sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==
"@typescript-eslint/types@8.65.0", "@typescript-eslint/types@^8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.65.0.tgz#3e86738416a777c8b8925ab46745f48ecf904c9f"
integrity sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==
"@typescript-eslint/typescript-estree@8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz#b8d51255e2d726eb4bd80d397a4fb4170c02eecc"
integrity sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==
"@typescript-eslint/typescript-estree@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz#f1f514808f6aa713e2d678ae8ff592a65e1632af"
integrity sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==
dependencies:
"@typescript-eslint/project-service" "8.64.0"
"@typescript-eslint/tsconfig-utils" "8.64.0"
"@typescript-eslint/types" "8.64.0"
"@typescript-eslint/visitor-keys" "8.64.0"
"@typescript-eslint/project-service" "8.65.0"
"@typescript-eslint/tsconfig-utils" "8.65.0"
"@typescript-eslint/types" "8.65.0"
"@typescript-eslint/visitor-keys" "8.65.0"
debug "^4.4.3"
minimatch "^10.2.2"
semver "^7.7.3"
tinyglobby "^0.2.15"
ts-api-utils "^2.5.0"
"@typescript-eslint/utils@8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.64.0.tgz#98bb2010cfb754b41985b9c93e6e8b3dcd7bd600"
integrity sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==
"@typescript-eslint/utils@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.65.0.tgz#afedd974a0c8deeef553b509df5800bafd615a72"
integrity sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==
dependencies:
"@eslint-community/eslint-utils" "^4.9.1"
"@typescript-eslint/scope-manager" "8.64.0"
"@typescript-eslint/types" "8.64.0"
"@typescript-eslint/typescript-estree" "8.64.0"
"@typescript-eslint/scope-manager" "8.65.0"
"@typescript-eslint/types" "8.65.0"
"@typescript-eslint/typescript-estree" "8.65.0"
"@typescript-eslint/visitor-keys@8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz#7a08421d10e54960733352cd7c95fab1784e8473"
integrity sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==
"@typescript-eslint/visitor-keys@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz#e3704c13cb4a1c22454c1abf28ff4737e15018c6"
integrity sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==
dependencies:
"@typescript-eslint/types" "8.64.0"
"@typescript-eslint/types" "8.65.0"
eslint-visitor-keys "^5.0.0"
"@unrs/resolver-binding-android-arm-eabi@1.11.1":
@ -1137,7 +1137,7 @@ ajv@^6.14.0:
argparse@^2.0.1:
version "2.0.1"
resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz"
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
aria-hidden@^1.2.3, aria-hidden@^1.2.4:
@ -1539,7 +1539,7 @@ dunder-proto@^1.0.0, dunder-proto@^1.0.1:
entities@^4.4.0, entities@^4.5.0:
version "4.5.0"
resolved "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz"
resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23.2:
@ -1881,15 +1881,15 @@ eslint-visitor-keys@^5.0.0, eslint-visitor-keys@^5.0.1:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
eslint@^10.7.0:
version "10.7.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.7.0.tgz#cd3b8022f3b1e3b183760d90dfc58e9d3644106b"
integrity sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==
eslint@^10.8.0:
version "10.8.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.8.0.tgz#e6d19907a3f090a53a022261ba34c5ff6d04908b"
integrity sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==
dependencies:
"@eslint-community/eslint-utils" "^4.8.0"
"@eslint-community/regexpp" "^4.12.2"
"@eslint/config-array" "^0.23.5"
"@eslint/config-helpers" "^0.6.0"
"@eslint/config-helpers" "^0.7.0"
"@eslint/core" "^1.2.1"
"@eslint/plugin-kit" "^0.7.2"
"@humanfs/node" "^0.16.6"
@ -1913,7 +1913,7 @@ eslint@^10.7.0:
imurmurhash "^0.1.4"
is-glob "^4.0.0"
json-stable-stringify-without-jsonify "^1.0.1"
minimatch "^10.2.4"
minimatch "^10.2.5"
natural-compare "^1.4.0"
optionator "^0.9.3"
@ -2168,10 +2168,10 @@ globals@^14.0.0:
resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz"
integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
globals@^17.7.0:
version "17.7.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.7.0.tgz#553d55090b4dde8209ec2da42580d6e7e7d8b10d"
integrity sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==
globals@^17.8.0:
version "17.8.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.8.0.tgz#a1f213a06adcd0eec38004c5cd39fef7af7e0830"
integrity sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==
globalthis@^1.0.3, globalthis@^1.0.4:
version "1.0.4"
@ -2697,14 +2697,7 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
linkify-it@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.1.tgz#10c4cecbb5c6828eabf81d3c801adc4a542dfb55"
integrity sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==
dependencies:
uc.micro "^2.0.0"
linkify-it@^5.0.2:
linkify-it@^5.0.1, linkify-it@^5.0.2:
version "5.0.2"
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.2.tgz#d3be0a693af3da9df3883f1e346a0e97461a8c19"
integrity sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==
@ -2743,9 +2736,9 @@ math-intrinsics@^1.1.0:
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
mdurl@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz"
integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==
version "2.1.0"
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.1.0.tgz#d711d3f7bce7f22c487c91be78545f356fa96573"
integrity sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==
meros@^1.1.4:
version "1.3.2"
@ -2767,7 +2760,7 @@ minimatch@^10.2.2:
dependencies:
brace-expansion "^5.0.5"
minimatch@^10.2.4, minimatch@^3.1.2, minimatch@^3.1.3, minimatch@^3.1.5:
minimatch@^10.2.4, minimatch@^10.2.5, minimatch@^3.1.2, minimatch@^3.1.3, minimatch@^3.1.5:
version "3.1.5"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e"
integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==
@ -2949,6 +2942,11 @@ picomatch@4.0.4, picomatch@^4.0.3, picomatch@^4.0.4:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
picomatch@4.0.5:
version "4.0.5"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab"
integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==
possible-typed-array-names@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz"
@ -2973,7 +2971,7 @@ prettier@^3.9.5:
punycode.js@^2.3.1:
version "2.3.1"
resolved "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz"
resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
punycode@^2.1.0:
@ -3185,10 +3183,10 @@ safe-regex-test@^1.1.0:
es-errors "^1.3.0"
is-regex "^1.2.1"
sass@1.101.0:
version "1.101.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.101.0.tgz#c2db5bbf2f956be7277f6223b899d0d4be3c899b"
integrity sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==
sass@1.102.0:
version "1.102.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.102.0.tgz#4ed9378f37ca4186a76d6d1f52a6680c92b6bd80"
integrity sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==
dependencies:
chokidar "^5.0.0"
immutable "^5.1.5"
@ -3610,7 +3608,7 @@ typescript@^5.9.3:
uc.micro@^2.0.0, uc.micro@^2.1.0:
version "2.1.0"
resolved "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz"
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
unbox-primitive@^1.0.2:

View File

@ -1,3 +1,3 @@
version: "4.6.5"
version: "4.6.6"
edition: "Community"
published: "2026-07-14"
published: "2026-07-28"

View File

@ -59,13 +59,23 @@
{% render_field form.label %}
{% render_field form.description %}
{% render_field form.color %}
<div class="row mb-3">
<div class="row mb-3{% if form.length.errors or form.length_unit.errors %} has-errors{% endif %}">
<label class="col-sm-3 col-form-label text-lg-end">{{ form.length.label }}</label>
<div class="col-md-5">
{{ form.length }}
{% render_field_with_aria form.length %}
{% if form.length.errors %}
<div class="form-text text-danger" id="{{ form.length.auto_id }}_errors" role="alert">
{% for error in form.length.errors %}{{ error }}{% if not forloop.last %}<br />{% endif %}{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-4">
{{ form.length_unit }}
{% render_field_with_aria form.length_unit %}
{% if form.length_unit.errors %}
<div class="form-text text-danger" id="{{ form.length_unit.auto_id }}_errors" role="alert">
{% for error in form.length_unit.errors %}{{ error }}{% if not forloop.last %}<br />{% endif %}{% endfor %}
</div>
{% endif %}
</div>
</div>
{% render_field form.tags %}

View File

@ -7,8 +7,8 @@
<div class="row mb-3" id="results">
<div class="col-auto d-print-none">
<label for="quicksearch" class="visually-hidden">{% trans "Quick search" %}</label>
<div class="input-group input-group-flat me-2 quicksearch" hx-disinherit="hx-select hx-swap">
<label for="quicksearch" class="visually-hidden">{% trans "Quick search" %}</label>
<input type="search" results="5" name="q" id="quicksearch" class="form-control" placeholder="{% trans "Quick search" %}"
hx-get="{{ request.full_path }}" hx-target="#object_list" hx-trigger="keyup changed delay:500ms, search"/>
<span class="input-group-text py-1">

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -30,6 +30,11 @@ FILTER_NEGATION_LOOKUP_MAP = dict(
n='exact'
)
FILTER_TAG_LOOKUP_MAP = dict(
n='exact',
any='exact',
)
FILTER_TREENODE_NEGATION_LOOKUP_MAP = dict(
n='in'
)

View File

@ -1,5 +1,5 @@
from django.apps import apps
from django.db.models import Count, F, OuterRef, Subquery
from django.db.models import Count, F, OuterRef, QuerySet, Subquery
from django.db.models.signals import post_delete, post_save, pre_delete
from netbox.registry import registry
@ -64,22 +64,59 @@ def post_save_receiver(sender, instance, created, **kwargs):
update_counter(parent_model, new_pk, counter_name, 1)
def _parent_is_being_deleted(origin, parent_model, parent_pk):
"""
Return True if `origin` (the object or queryset that `delete()` was called on) indicates that
the parent identified by (parent_model, parent_pk) is itself being deleted as part of the same
operation. In that case, decrementing its counter is wasted work: the parent row is going away,
so the UPDATE would be a no-op. Skipping it avoids an N+1 storm of pointless UPDATEs when a
parent with many tracked children is deleted (e.g. a Device with thousands of Interfaces).
Note: only the *direct* parent is detected, since `origin` is just the top-level object/queryset
delete() was called on. In a deeper cascade (DeviceType -> Device -> Interface) `origin` stays
the DeviceType, so intermediate Devices' interface counters still get the (harmless) no-op
UPDATE. Suppressing that would require the full deletion set, which the signals don't expose.
"""
if origin is None:
return False
if isinstance(origin, QuerySet):
# A bulk delete; every collected child belongs to an object in this queryset by construction
return origin.model is parent_model
# A single object delete
return isinstance(origin, parent_model) and origin.pk == parent_pk
def pre_delete_receiver(sender, instance, origin, **kwargs):
model = instance._meta.model
if not model.objects.filter(pk=instance.pk).exists():
instance._previously_removed = True
"""
Before a tracked object is deleted, check whether its row has already been removed (e.g. by an
earlier cascade) and, if so, flag it so post_delete_receiver skips the now-redundant counter
update. The existence check is skipped when the tracked parent is itself being deleted, since
the counter update would be skipped regardless this avoids a SELECT per cascaded child.
"""
for field_name, counter_name in get_counters_for_model(sender):
parent_model = sender._meta.get_field(field_name).related_model
parent_pk = getattr(instance, field_name, None)
if parent_pk is None or _parent_is_being_deleted(origin, parent_model, parent_pk):
continue
# A tracked parent will survive this operation, so the double-delete guard is needed
if not sender.objects.filter(pk=instance.pk).exists():
instance._previously_removed = True
return
def post_delete_receiver(sender, instance, origin, **kwargs):
"""
Update counter fields on related objects when a TrackingModelMixin subclass is deleted.
"""
if hasattr(instance, '_previously_removed'):
return
for field_name, counter_name in get_counters_for_model(sender):
parent_model = sender._meta.get_field(field_name).related_model
parent_pk = getattr(instance, field_name, None)
# Decrement the parent's counter by one
if parent_pk is not None and not hasattr(instance, '_previously_removed'):
# Decrement the parent's counter by one, unless the parent is itself being deleted
if parent_pk is not None and not _parent_is_being_deleted(origin, parent_model, parent_pk):
update_counter(parent_model, parent_pk, counter_name, -1)

View File

@ -2,6 +2,7 @@ import csv
from django.http import StreamingHttpResponse
from django.utils.encoding import force_str
from django.utils.http import content_disposition_header
from django.utils.translation import gettext_lazy as _
from django_tables2.data import TableQuerysetData
from django_tables2.export import TableExport as TableExport_
@ -88,5 +89,5 @@ def stream_table_csv_response(table, exclude_columns=None, filename=None, delimi
response = StreamingHttpResponse(row_generator(), content_type='text/csv; charset=utf-8')
if filename is not None:
response['Content-Disposition'] = f'attachment; filename="{filename}"'
response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename=filename)
return response

View File

@ -83,6 +83,7 @@ FORM_FIELD_LOOKUPS = {
],
TagFilterField: [
('exact', _('has these tags')),
('any', _('has any of these tags')),
('n', _('does not have these tags')),
(MODIFIER_EMPTY_TRUE, _('is empty')),
(MODIFIER_EMPTY_FALSE, _('is not empty')),

View File

@ -1,5 +1,6 @@
import fnmatch
import os
import re
from django.apps import apps
from jinja2 import BaseLoader, TemplateNotFound
@ -14,8 +15,13 @@ __all__ = (
'DataFileLoader',
'env_filter',
'render_jinja2',
'sanitize_http_header',
)
# Control characters (C0 range plus DEL) which are invalid in an HTTP header value. Notably, this includes the
# carriage return and line feed characters used to smuggle additional headers (CR/LF injection).
HTTP_HEADER_INVALID_CHARS_RE = re.compile(r'[\x00-\x1f\x7f]')
def env_filter(name):
"""
@ -29,6 +35,15 @@ def env_filter(name):
return os.environ.get(name)
def sanitize_http_header(value):
"""
Jinja2 filter which sanitizes a value for safe inclusion in a raw HTTP header by stripping newlines and other
control characters. This guards against HTTP header (CR/LF) injection when interpolating untrusted data (e.g.
user-controlled object attributes) into a webhook's additional headers.
"""
return HTTP_HEADER_INVALID_CHARS_RE.sub('', str(value))
DEFAULT_JINJA2_FILTERS = {
'env': env_filter,
}
@ -72,11 +87,14 @@ class DataFileLoader(BaseLoader):
# Utility functions
#
def render_jinja2(template_code, context, environment_params=None, data_file=None, debug=False):
def render_jinja2(template_code, context, environment_params=None, data_file=None, debug=False, filters=None):
"""
Render a Jinja2 template with the provided context. Return the rendered content.
If debug is True, the Jinja2 debug extension is enabled to assist with template development.
The optional `filters` argument is a mapping of additional Jinja2 filters to make available for this render only
(e.g. context-specific sanitization filters). These take precedence over the default and user-configured filters.
"""
environment_params = dict(environment_params or {})
@ -98,14 +116,17 @@ def render_jinja2(template_code, context, environment_params=None, data_file=Non
environment = SandboxedEnvironment(**environment_params)
# Build filter table: default < plugin-registered < instance JINJA_FILTERS.
# Instance-level config always wins so site admins can override anything.
filters = {
# Build filter table: default < plugin-registered < instance JINJA_FILTERS < per-render filters.
# Instance-level config wins over plugin-registered filters so site admins can override anything.
# Filters passed for this render take precedence over all of them, so that context-specific
# (e.g. sanitization) filters cannot be shadowed.
all_filters = {
**DEFAULT_JINJA2_FILTERS,
**registry['plugins'].get('jinja_filters', {}),
**get_config().JINJA_FILTERS,
**(filters or {}),
}
environment.filters.update(filters)
environment.filters.update(all_filters)
if data_file:
template = environment.get_template(data_file.path)

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