Compare commits

..

12 Commits

Author SHA1 Message Date
Jeremy Stretch
67defb3228 Fixes #21531: Fix search functionality for location when combined with other filters (#21599) 2026-03-06 11:54:10 -06:00
Martin Hauser
cca4cc61b6 Fixes #21512: Fix GraphQL filtering for device, module components, templates (#21602) 2026-03-06 11:23:45 -06:00
Martin Hauser
758b230403 docs(webhooks): Update context variables and example payload (#21607)
Clarify webhook context variable names and event types.
Replace `model` with `object_type`, update event values to match actual
output (`created` vs. `create`), and refresh example JSON to reflect the
current API response format, including new fields like `display` and
`display_url`.

Fixes #21489
2026-03-06 09:04:30 -08:00
Jeremy Stretch
8ea33df148 Fixes #20915: Ensure preferred language is applied during SSO login (#21590) 2026-03-06 10:00:33 -06:00
Jeremy Stretch
685c1afdcf Update CONTRIBUTING.md (#21606)
- Enforce a limit of three open PRs per community contributor
- Clarify AI content policy
- Misc rewording
2026-03-06 16:32:19 +01:00
Martin Hauser
d62a0d7d8d fix(extras): Add missing COOKIES and method to NetBoxFakeRequest
Populate COOKIES dict and set method to POST in runscript command's
NetBoxFakeRequest. Ensures the fake request object more closely mimics
a real Django request, preventing potential issues with code expecting
these attributes.

Fixes #21486
2026-03-06 09:52:26 -05:00
bctiemann
1c527366c9 Merge pull request #21597 from netbox-community/21012-interface-vlans-list
Fixes #21012: Ensure all tagged VLANs assigned to an interface are listed under the interface detail UI view
2026-03-06 09:18:33 -05:00
Jeremy Stretch
e1684fb645 Display the interface's untagged VLAN in the attributes table 2026-03-06 07:37:46 -05:00
Jeremy Stretch
969ae81574 Fixes #21380: Fix display of the background workers list on small screens (#21598)
Wrap the table in a `.table-responsive` to enable horizontal scrolling
within the table body.
2026-03-06 07:45:01 +01:00
github-actions
baec71fcaf Update source translation strings 2026-03-06 05:17:32 +00:00
Jeremy Stretch
44abeeff5a Fixes #21012: Ensure all tagged VLANs assigned to an interface are listed under the interface detail UI view 2026-03-05 16:35:31 -05:00
Martin Hauser
93e01d5b07 fix(dcim): Correct object type for child Site Group actions
Replace `dcim.Region` with `dcim.SiteGroup` in child Site Group actions
for the DCIM view. Ensures the correct model is referenced when adding
child Site Groups, improving functionality and aligning with the
expected behavior.

Fixes #21586
2026-03-05 13:59:18 -05:00
29 changed files with 283 additions and 859 deletions

View File

@@ -84,6 +84,8 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
* It's very important that you not submit a pull request until a relevant issue has been opened **and** assigned to you. Otherwise, you risk wasting time on work that may ultimately not be needed.
* Community members are limited to a maximum of **three open PRs** at any time. This is to avoid the accumulation of too much parallel work and maintain focus on already PRs under review. If you already have three NetBox PRs open, please wait for at least one of them to be merged (or closed) before opening another.
* New pull requests should generally be based off of the `main` branch. This branch, in keeping with the [trunk-based development](https://trunkbaseddevelopment.com/) approach, is used for ongoing development and bug fixes and always represents the newest stable code, from which releases are periodically branched. (If you're developing for an upcoming minor release, use `feature` instead.)
* In most cases, it is not necessary to add a changelog entry: A maintainer will take care of this when the PR is merged. (This helps avoid merge conflicts resulting from multiple PRs being submitted simultaneously.)
@@ -96,10 +98,10 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
greater than 80 characters in length
> [!CAUTION]
> Any contributions which include AI-generated or reproduced content will be rejected.
> Any contributions which include solely AI-generated or reproduced content will be rejected. All PRs must be submitted by a human.
* Some other tips to keep in mind:
* If you'd like to volunteer for someone else's issue, please post a comment on that issue letting us know. (This will allow the maintainers to assign it to you.)
* If you'd like to volunteer for someone else's issue, please post a comment on that issue letting us know. (GitHub allows only people who have commented on an issue to be assigned as its owner.)
* Check out our [developer docs](https://docs.netbox.dev/en/stable/development/getting-started/) for tips on setting up your development environment.
* All new functionality must include relevant tests where applicable.

View File

@@ -4,7 +4,7 @@ colorama
# The Python web framework on which NetBox is built
# https://docs.djangoproject.com/en/stable/releases/
Django==6.0.*
Django==5.2.*
# Django middleware which permits cross-domain API requests
# https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst
@@ -35,9 +35,7 @@ django-pglocks
# Prometheus metrics library for Django
# https://github.com/korfuri/django-prometheus/blob/master/CHANGELOG.md
# TODO: 2.4.1 is incompatible with Django>=6.0, but a fixed release is expected
# https://github.com/django-commons/django-prometheus/issues/494
django-prometheus>=2.4.0,<2.5.0,!=2.4.1
django-prometheus
# Django caching backend using Redis
# https://github.com/jazzband/django-redis/blob/master/CHANGELOG.rst

View File

@@ -23,19 +23,14 @@ For example, you might create a NetBox webhook to [trigger a Slack message](http
The following data is available as context for Jinja2 templates:
* `event` - The type of event which triggered the webhook: created, updated, or deleted.
* `model` - The NetBox model which triggered the change.
* `event` - The type of event which triggered the webhook: `created`, `updated`, or `deleted`.
* `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format).
* `object_type` - The NetBox model which triggered the change in the form `app_label.model_name`.
* `username` - The name of the user account associated with the change.
* `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
* `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.
!!! warning "Deprecation of legacy fields"
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.
### 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:
@@ -43,18 +38,20 @@ If no body template is specified, the request body will be populated with a JSON
```json
{
"event": "created",
"timestamp": "2021-03-09 17:55:33.968016+00:00",
"model": "site",
"timestamp": "2026-03-06T15:11:23.503186+00:00",
"object_type": "dcim.site",
"username": "jstretch",
"request_id": "fdbca812-3142-4783-b364-2e2bd5c16c6a",
"request_id": "17af32f0-852a-46ca-a7d4-33ecd0c13de6",
"data": {
"id": 19,
"id": 4,
"url": "/api/dcim/sites/4/",
"display_url": "/dcim/sites/4/",
"display": "Site 1",
"name": "Site 1",
"slug": "site-1",
"status":
"status": {
"value": "active",
"label": "Active",
"id": 1
"label": "Active"
},
"region": null,
...
@@ -62,8 +59,10 @@ If no body template is specified, the request body will be populated with a JSON
"snapshots": {
"prechange": null,
"postchange": {
"created": "2021-03-09",
"last_updated": "2021-03-09T17:55:33.851Z",
"created": "2026-03-06T15:11:23.484Z",
"owner": null,
"description": "",
"comments": "",
"name": "Site 1",
"slug": "site-1",
"status": "active",

View File

@@ -77,19 +77,14 @@ The file path to a particular certificate authority (CA) file to use when valida
## Context Data
The following context variables are available in to the text and link templates.
The following context variables are available to the text and link templates.
| Variable | Description |
|--------------|----------------------------------------------------|
| `event` | The event type (`create`, `update`, or `delete`) |
| `timestamp` | The time at which the event occured |
| `model` | The type of object impacted |
| `username` | The name of the user associated with the change |
| `request_id` | The unique request ID |
| `data` | A complete serialized representation of the object |
| `snapshots` | Pre- and post-change snapshots of the object |
!!! warning "Deprecation of legacy fields"
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.
| Variable | Description |
|---------------|------------------------------------------------------|
| `event` | The event type (`created`, `updated`, or `deleted`) |
| `timestamp` | The time at which the event occurred |
| `object_type` | The type of object impacted (`app_label.model_name`) |
| `username` | The name of the user associated with the change |
| `request_id` | The unique request ID |
| `data` | A complete serialized representation of the object |
| `snapshots` | Pre- and post-change snapshots of the object |

View File

@@ -43,11 +43,6 @@ The resulting webhook payload will look like the following:
}
```
!!! warning "Deprecation of legacy fields"
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.
!!! note "Consider namespacing webhook data"
The data returned from all webhook callbacks will be compiled into a single `context` dictionary. Any existing keys within this dictionary will be overwritten by subsequent callbacks which include those keys. To avoid collisions with webhook data provided by other plugins, consider namespacing your plugin's data within a nested dictionary as such:

View File

@@ -1,6 +1,4 @@
import django_tables2 as tables
from django.utils.html import conditional_escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from core.constants import JOB_LOG_ENTRY_LEVELS
@@ -84,9 +82,3 @@ class JobLogEntryTable(BaseTable):
class Meta(BaseTable.Meta):
empty_text = _('No log entries')
fields = ('timestamp', 'level', 'message')
def render_message(self, record, value):
if record.get('level') == 'error' and '\n' in value:
value = conditional_escape(value)
return mark_safe(f'<pre class="p-0">{value}</pre>')
return value

View File

@@ -6,7 +6,7 @@ from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from dcim.choices import *
from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS, MODULE_TOKEN
from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS
from dcim.models import Device, DeviceBay, MACAddress, Module, VirtualDeviceContext
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
from ipam.api.serializers_.ip import IPAddressSerializer
@@ -150,144 +150,15 @@ class ModuleSerializer(PrimaryModelSerializer):
module_bay = NestedModuleBaySerializer()
module_type = ModuleTypeSerializer(nested=True)
status = ChoiceField(choices=ModuleStatusChoices, required=False)
replicate_components = serializers.BooleanField(
required=False,
default=True,
write_only=True,
label=_('Replicate components'),
help_text=_('Automatically populate components associated with this module type (default: true)')
)
adopt_components = serializers.BooleanField(
required=False,
default=False,
write_only=True,
label=_('Adopt components'),
help_text=_('Adopt already existing components')
)
class Meta:
model = Module
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial',
'asset_tag', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'replicate_components', 'adopt_components',
]
brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description')
def validate(self, data):
# When used as a nested serializer (e.g. as the `module` field on device component
# serializers), `data` is already a resolved Module instance — skip our custom logic.
if self.nested:
return super().validate(data)
# Pop write-only transient fields before ValidatedModelSerializer tries to
# construct a Module instance for full_clean(); restore them afterwards.
replicate_components = data.pop('replicate_components', True)
adopt_components = data.pop('adopt_components', False)
data = super().validate(data)
# For updates these fields are not meaningful; omit them from validated_data so that
# ModelSerializer.update() does not set unexpected attributes on the instance.
if self.instance:
return data
# Always pass the flags to create() so it can set the correct private attributes.
data['replicate_components'] = replicate_components
data['adopt_components'] = adopt_components
# Skip conflict checks when no component operations are requested.
if not replicate_components and not adopt_components:
return data
device = data.get('device')
module_type = data.get('module_type')
module_bay = data.get('module_bay')
# Required-field validation fires separately; skip here if any are missing.
if not all([device, module_type, module_bay]):
return data
# Build module bay tree for MODULE_TOKEN placeholder resolution (outermost to innermost)
module_bays = []
current_bay = module_bay
while current_bay:
module_bays.append(current_bay)
current_bay = current_bay.module.module_bay if current_bay.module else None
module_bays.reverse()
for templates_attr, component_attr in [
('consoleporttemplates', 'consoleports'),
('consoleserverporttemplates', 'consoleserverports'),
('interfacetemplates', 'interfaces'),
('powerporttemplates', 'powerports'),
('poweroutlettemplates', 'poweroutlets'),
('rearporttemplates', 'rearports'),
('frontporttemplates', 'frontports'),
]:
installed_components = {
component.name: component
for component in getattr(device, component_attr).all()
}
for template in getattr(module_type, templates_attr).all():
resolved_name = template.name
if MODULE_TOKEN in template.name:
if not module_bay.position:
raise serializers.ValidationError(
_("Cannot install module with placeholder values in a module bay with no position defined.")
)
if template.name.count(MODULE_TOKEN) != len(module_bays):
raise serializers.ValidationError(
_(
"Cannot install module with {tokens} placeholder(s) in a module bay at depth {level}."
).format(
level=len(module_bays), tokens=template.name.count(MODULE_TOKEN)
)
)
for bay in module_bays:
resolved_name = resolved_name.replace(MODULE_TOKEN, bay.position, 1)
existing_item = installed_components.get(resolved_name)
if adopt_components and existing_item and existing_item.module:
raise serializers.ValidationError(
_("Cannot adopt {model} {name} as it already belongs to a module").format(
model=template.component_model.__name__,
name=resolved_name
)
)
if not adopt_components and replicate_components and resolved_name in installed_components:
raise serializers.ValidationError(
_("A {model} named {name} already exists").format(
model=template.component_model.__name__,
name=resolved_name
)
)
return data
def create(self, validated_data):
replicate_components = validated_data.pop('replicate_components', True)
adopt_components = validated_data.pop('adopt_components', False)
# Tags are handled after save; pop them here to pass to _save_tags()
tags = validated_data.pop('tags', None)
# _adopt_components and _disable_replication must be set on the instance before
# save() is called, so we cannot delegate to super().create() here.
instance = self.Meta.model(**validated_data)
if adopt_components:
instance._adopt_components = True
if not replicate_components:
instance._disable_replication = True
instance.save()
if tags is not None:
self._save_tags(instance, tags)
return instance
class MACAddressSerializer(PrimaryModelSerializer):
assigned_object_type = ContentTypeField(

View File

@@ -306,12 +306,9 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupMode
fields = ('id', 'name', 'slug', 'facility', 'description')
def search(self, queryset, name, value):
# extended in order to include querying on Location.facility
queryset = super().search(queryset, name, value)
# Extend `search()` to include querying on Location.facility
if value.strip():
queryset = queryset | queryset.model.objects.filter(facility__icontains=value)
return super().search(queryset, name, value) | queryset.filter(facility__icontains=value)
return queryset

View File

@@ -267,32 +267,32 @@ class DeviceFilter(
longitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
console_ports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
consoleports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_ports')
)
console_server_ports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
consoleserverports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_server_ports')
)
power_outlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
poweroutlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_outlets')
)
power_ports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
powerports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_ports')
)
interfaces: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
front_ports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
frontports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='front_ports')
)
rear_ports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
rearports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='rear_ports')
)
device_bays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
devicebays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='device_bays')
)
module_bays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
modulebays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='module_bays')
)
modules: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -383,36 +383,36 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
rear_image: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = (
strawberry_django.filter_field()
)
console_port_templates: (
Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
console_server_port_templates: (
consoleporttemplates: Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_port_templates')
)
consoleserverporttemplates: (
Annotated['ConsoleServerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
power_port_templates: (
Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
power_outlet_templates: (
Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
interface_templates: (
Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
front_port_templates: (
Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
rear_port_templates: (
Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
device_bay_templates: (
Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
module_bay_templates: (
Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
inventory_item_templates: (
Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
) = strawberry_django.filter_field(name='console_server_port_templates')
powerporttemplates: Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_port_templates')
)
poweroutlettemplates: Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_outlet_templates')
)
interfacetemplates: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='interface_templates')
)
frontporttemplates: Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='front_port_templates')
)
rearporttemplates: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='rear_port_templates')
)
devicebaytemplates: Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='device_bay_templates')
)
modulebaytemplates: Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='module_bay_templates')
)
inventoryitemtemplates: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='inventory_item_templates')
)
console_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
console_server_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
power_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
@@ -696,32 +696,32 @@ class ModuleFilter(ConfigContextFilterMixin, PrimaryModelFilter):
)
serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
console_ports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
consoleports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_ports')
)
console_server_ports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
consoleserverports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_server_ports')
)
power_outlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
poweroutlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_outlets')
)
power_ports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
powerports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_ports')
)
interfaces: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
front_ports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
frontports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='front_ports')
)
rear_ports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
rearports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='rear_ports')
)
device_bays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
devicebays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='device_bays')
)
module_bays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
modulebays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='module_bays')
)
modules: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -765,36 +765,33 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
airflow: BaseFilterLookup[Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
console_port_templates: (
Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
console_server_port_templates: (
consoleporttemplates: Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_port_templates')
)
consoleserverporttemplates: (
Annotated['ConsoleServerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
power_port_templates: (
Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
power_outlet_templates: (
Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
interface_templates: (
Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
front_port_templates: (
Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
rear_port_templates: (
Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
device_bay_templates: (
Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
module_bay_templates: (
Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
inventory_item_templates: (
Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
) = strawberry_django.filter_field(name='console_server_port_templates')
powerporttemplates: Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_port_templates')
)
poweroutlettemplates: Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_outlet_templates')
)
interfacetemplates: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='interface_templates')
)
frontporttemplates: Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='front_port_templates')
)
rearporttemplates: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='rear_port_templates')
)
devicebaytemplates: Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='device_bay_templates')
)
modulebaytemplates: Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='module_bay_templates')
)
module_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()

View File

@@ -1699,189 +1699,6 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
},
]
def test_replicate_components(self):
"""
Installing a module with replicate_components=True (the default) should create
components from the module type's templates on the parent device.
"""
self.add_permissions('dcim.add_module')
manufacturer = Manufacturer.objects.get(name='Generic')
device = create_test_device('Device for Replication Test')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Replication Test Module Type')
InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
module_bay = ModuleBay.objects.create(device=device, name='Replication Bay')
url = reverse('dcim-api:module-list')
data = {
'device': device.pk,
'module_bay': module_bay.pk,
'module_type': module_type.pk,
'replicate_components': True,
}
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertTrue(device.interfaces.filter(name='eth0').exists())
def test_no_replicate_components(self):
"""
Installing a module with replicate_components=False should NOT create components
from the module type's templates.
"""
self.add_permissions('dcim.add_module')
manufacturer = Manufacturer.objects.get(name='Generic')
device = create_test_device('Device for No Replication Test')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='No Replication Test Module Type')
InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
module_bay = ModuleBay.objects.create(device=device, name='No Replication Bay')
url = reverse('dcim-api:module-list')
data = {
'device': device.pk,
'module_bay': module_bay.pk,
'module_type': module_type.pk,
'replicate_components': False,
}
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertFalse(device.interfaces.filter(name='eth0').exists())
def test_adopt_components(self):
"""
Installing a module with adopt_components=True should assign existing unattached
device components to the new module.
"""
self.add_permissions('dcim.add_module')
manufacturer = Manufacturer.objects.get(name='Generic')
device = create_test_device('Device for Adopt Test')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Adopt Test Module Type')
InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
module_bay = ModuleBay.objects.create(device=device, name='Adopt Bay')
existing_iface = Interface.objects.create(device=device, name='eth0', type='1000base-t')
url = reverse('dcim-api:module-list')
data = {
'device': device.pk,
'module_bay': module_bay.pk,
'module_type': module_type.pk,
'adopt_components': True,
'replicate_components': False,
}
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
existing_iface.refresh_from_db()
self.assertIsNotNone(existing_iface.module)
def test_replicate_components_conflict(self):
"""
Installing a module with replicate_components=True when a component with the same name
already exists should return a validation error.
"""
self.add_permissions('dcim.add_module')
manufacturer = Manufacturer.objects.get(name='Generic')
device = create_test_device('Device for Conflict Test')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Conflict Test Module Type')
InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
module_bay = ModuleBay.objects.create(device=device, name='Conflict Bay')
Interface.objects.create(device=device, name='eth0', type='1000base-t')
url = reverse('dcim-api:module-list')
data = {
'device': device.pk,
'module_bay': module_bay.pk,
'module_type': module_type.pk,
'replicate_components': True,
}
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
def test_adopt_components_already_owned(self):
"""
Installing a module with adopt_components=True when an existing component already
belongs to another module should return a validation error.
"""
self.add_permissions('dcim.add_module')
manufacturer = Manufacturer.objects.get(name='Generic')
device = create_test_device('Device for Adopt Owned Test')
owner_module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Owner Module Type')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Adopt Owned Test Module Type')
InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
owner_bay = ModuleBay.objects.create(device=device, name='Owner Bay')
target_bay = ModuleBay.objects.create(device=device, name='Adopt Owned Bay')
# Install a module that owns the interface
owner_module = Module.objects.create(device=device, module_bay=owner_bay, module_type=owner_module_type)
Interface.objects.create(device=device, name='eth0', type='1000base-t', module=owner_module)
url = reverse('dcim-api:module-list')
data = {
'device': device.pk,
'module_bay': target_bay.pk,
'module_type': module_type.pk,
'adopt_components': True,
}
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
def test_patch_ignores_replicate_and_adopt(self):
"""
PATCH requests that include replicate_components or adopt_components should not
trigger component replication or adoption (these fields are create-only).
"""
self.add_permissions('dcim.change_module')
manufacturer = Manufacturer.objects.get(name='Generic')
device = create_test_device('Device for PATCH Test')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='PATCH Test Module Type')
InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
module_bay = ModuleBay.objects.create(device=device, name='PATCH Bay')
# Create the module without replication so we can verify PATCH doesn't trigger it
module = Module(device=device, module_bay=module_bay, module_type=module_type)
module._disable_replication = True
module.save()
url = reverse('dcim-api:module-detail', kwargs={'pk': module.pk})
data = {
'replicate_components': True,
'adopt_components': True,
'serial': 'PATCHED',
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['serial'], 'PATCHED')
# No interfaces should have been created by the PATCH
self.assertFalse(device.interfaces.exists())
def test_adopt_and_replicate_components(self):
"""
Installing a module with both adopt_components=True and replicate_components=True
should adopt existing unowned components and create new components for templates
that have no matching existing component.
"""
self.add_permissions('dcim.add_module')
manufacturer = Manufacturer.objects.get(name='Generic')
device = create_test_device('Device for Adopt+Replicate Test')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Adopt+Replicate Test Module Type')
InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
InterfaceTemplate.objects.create(module_type=module_type, name='eth1', type='1000base-t')
module_bay = ModuleBay.objects.create(device=device, name='Adopt+Replicate Bay')
# eth0 already exists (unowned); eth1 does not
existing_iface = Interface.objects.create(device=device, name='eth0', type='1000base-t')
url = reverse('dcim-api:module-list')
data = {
'device': device.pk,
'module_bay': module_bay.pk,
'module_type': module_type.pk,
'adopt_components': True,
'replicate_components': True,
}
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
# eth0 should have been adopted (now owned by the new module)
existing_iface.refresh_from_db()
self.assertIsNotNone(existing_iface.module)
# eth1 should have been created
self.assertTrue(device.interfaces.filter(name='eth1').exists())
class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = ConsolePort

View File

@@ -16,7 +16,7 @@ from circuits.models import Circuit, CircuitTermination
from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import ASN, VLAN, IPAddress, Prefix, VLANGroup
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from ipam.tables import VLANTranslationRuleTable
from netbox.object_actions import *
from netbox.ui import actions, layout
from netbox.ui.panels import (
@@ -389,7 +389,7 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
title=_('Child Groups'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}),
actions.AddObject('dcim.SiteGroup', url_params={'parent': lambda ctx: ctx['object'].pk}),
],
),
]
@@ -3230,21 +3230,6 @@ class InterfaceView(generic.ObjectView):
)
lag_interfaces_table.configure(request)
# Get assigned VLANs and annotate whether each is tagged or untagged
vlans = []
if instance.untagged_vlan is not None:
vlans.append(instance.untagged_vlan)
vlans[0].tagged = False
for vlan in instance.tagged_vlans.restrict(request.user).prefetch_related('site', 'group', 'tenant', 'role'):
vlan.tagged = True
vlans.append(vlan)
vlan_table = InterfaceVLANTable(
interface=instance,
data=vlans,
orderable=False
)
vlan_table.configure(request)
# Get VLAN translation rules
vlan_translation_table = None
if instance.vlan_translation_policy:
@@ -3260,7 +3245,6 @@ class InterfaceView(generic.ObjectView):
'bridge_interfaces_table': bridge_interfaces_table,
'child_interfaces_table': child_interfaces_table,
'lag_interfaces_table': lag_interfaces_table,
'vlan_table': vlan_table,
'vlan_translation_table': vlan_translation_table,
}

View File

@@ -1,4 +1,3 @@
import warnings
from datetime import timedelta
from importlib import import_module
@@ -18,12 +17,11 @@ class Command(BaseCommand):
help = "Perform nightly housekeeping tasks [DEPRECATED]"
def handle(self, *args, **options):
warnings.warn(
"\n\nDEPRECATION WARNING\n"
self.stdout.write(
"Running this command is no longer necessary: All housekeeping tasks\n"
"are addressed automatically via NetBox's built-in job scheduler. It\n"
"will be removed in a future release.\n",
category=FutureWarning,
"will be removed in a future release.",
self.style.WARNING
)
config = Config()

View File

@@ -81,7 +81,7 @@ class Command(BaseCommand):
logger.error(f'\t{field}: {error.get("message")}')
raise CommandError()
# Remove extra fields from ScriptForm before passng data to script
# Remove extra fields from ScriptForm before passing data to script
form.cleaned_data.pop('_schedule_at')
form.cleaned_data.pop('_interval')
form.cleaned_data.pop('_commit')
@@ -94,10 +94,12 @@ class Command(BaseCommand):
data=form.cleaned_data,
request=NetBoxFakeRequest({
'META': {},
'COOKIES': {},
'POST': data,
'GET': {},
'FILES': {},
'user': user,
'method': 'POST',
'path': '',
'id': uuid.uuid4()
}),

View File

@@ -677,19 +677,15 @@ class ConfigContextTest(TestCase):
if hasattr(node, 'children'):
for child in node.children:
try:
# In Django 6.0+, rhs is a Query directly; older Django wraps it in Subquery
rhs_query = getattr(child.rhs, 'query', child.rhs)
if rhs_query.model is TaggedItem:
subqueries.append(rhs_query)
if child.rhs.query.model is TaggedItem:
subqueries.append(child.rhs.query)
except AttributeError:
traverse(child)
traverse(where_node)
return subqueries
# In Django 6.0+, the annotation is a Query directly; older Django wraps it in Subquery
annotation_query = getattr(config_annotation, 'query', config_annotation)
# Find subqueries in the WHERE clause that should have DISTINCT
tag_subqueries = find_tag_subqueries(annotation_query.where)
tag_subqueries = find_tag_subqueries(config_annotation.query.where)
distinct_subqueries = [sq for sq in tag_subqueries if sq.distinct]
# Verify we found at least one DISTINCT subquery for tags

View File

@@ -94,11 +94,9 @@ class NetHost(Lookup):
rhs, rhs_params = self.process_rhs(qn, connection)
# Query parameters are automatically converted to IPNetwork objects, which are then turned to strings. We need
# to omit the mask portion of the object's string representation to match PostgreSQL's HOST() function.
# Note: params may be tuples (Django 6.0+) or lists (older Django), so convert before mutating.
rhs_params = list(rhs_params)
if rhs_params:
rhs_params[0] = rhs_params[0].split('/')[0]
params = list(lhs_params) + rhs_params
params = lhs_params + rhs_params
return f'HOST({lhs}) = {rhs}', params

View File

@@ -1,19 +1,17 @@
import django_tables2 as tables
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django_tables2.utils import Accessor
from dcim.models import Interface
from dcim.tables.template_code import INTERFACE_LINKTERMINATION, LINKTERMINATION
from ipam.models import *
from netbox.tables import NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns
from tenancy.tables import TenancyColumnsMixin, TenantColumn
from tenancy.tables import TenancyColumnsMixin
from virtualization.models import VMInterface
from .template_code import *
__all__ = (
'InterfaceVLANTable',
'VLANDevicesTable',
'VLANGroupTable',
'VLANMembersTable',
@@ -198,47 +196,6 @@ class VLANVirtualMachinesTable(VLANMembersTable):
exclude = ('id', )
class InterfaceVLANTable(NetBoxTable):
"""
List VLANs assigned to a specific Interface.
"""
vid = tables.Column(
linkify=True,
verbose_name=_('VID')
)
tagged = columns.BooleanColumn(
verbose_name=_('Tagged'),
false_mark=None
)
site = tables.Column(
verbose_name=_('Site'),
linkify=True
)
group = tables.Column(
accessor=Accessor('group__name'),
verbose_name=_('Group')
)
tenant = TenantColumn(
verbose_name=_('Tenant'),
)
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
)
role = tables.Column(
verbose_name=_('Role'),
linkify=True
)
class Meta(NetBoxTable.Meta):
model = VLAN
fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
exclude = ('id', )
def __init__(self, interface, *args, **kwargs):
self.interface = interface
super().__init__(*args, **kwargs)
#
# VLAN Translation
#

View File

@@ -13,7 +13,7 @@ from rest_framework.viewsets import GenericViewSet
from netbox.api.serializers.features import ChangeLogMessageSerializer
from netbox.constants import ADVISORY_LOCK_KEYS
from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer
from utilities.exceptions import AbortRequest, PreconditionFailed
from utilities.exceptions import AbortRequest
from utilities.query import reapply_model_ordering
from . import mixins
@@ -34,50 +34,6 @@ HTTP_ACTIONS = {
}
class ETagMixin:
"""
Adds ETag header support to ViewSets. Generates weak ETags (W/ prefix per
RFC 7232 §2.1) from `last_updated` (or `created` if unavailable). Weak ETags
are appropriate here because the tag is derived from a modification timestamp
rather than a hash of the serialized payload.
"""
@staticmethod
def _get_etag(obj):
"""Return a weak ETag string for the given object, or None."""
if ts := getattr(obj, 'last_updated', None) or getattr(obj, 'created', None):
return f'W/"{ts.isoformat()}"'
return None
@staticmethod
def _get_if_match(request):
"""Return the list of If-Match header values (if specified)."""
if (if_match := request.META.get('HTTP_IF_MATCH')) and if_match != '*':
return [e.strip() for e in if_match.split(',')]
return []
def _validate_etag(self, request, instance):
"""Validate the request's ETag"""
if provided := self._get_if_match(request):
current_etag = self._get_etag(instance)
if current_etag and current_etag not in provided:
raise PreconditionFailed(etag=current_etag)
def handle_exception(self, exc):
response = super().handle_exception(exc)
if isinstance(exc, PreconditionFailed) and exc.etag:
response['ETag'] = exc.etag
return response
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer(instance)
response = Response(serializer.data)
if etag := self._get_etag(instance):
response['ETag'] = etag
return response
class BaseViewSet(GenericViewSet):
"""
Base class for all API ViewSets. This is responsible for the enforcement of object-based permissions.
@@ -139,7 +95,6 @@ class BaseViewSet(GenericViewSet):
class NetBoxReadOnlyModelViewSet(
ETagMixin,
mixins.CustomFieldsMixin,
mixins.ExportTemplatesMixin,
drf_mixins.RetrieveModelMixin,
@@ -150,7 +105,6 @@ class NetBoxReadOnlyModelViewSet(
class NetBoxModelViewSet(
ETagMixin,
mixins.BulkUpdateModelMixin,
mixins.BulkDestroyModelMixin,
mixins.ObjectValidationMixin,
@@ -237,14 +191,7 @@ class NetBoxModelViewSet(
serializer = self.get_serializer(qs, many=bulk_create)
headers = self.get_success_headers(serializer.data)
response = Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
# Add ETag for single-object creation only (bulk returns a list, no single ETag)
if not bulk_create:
if etag := self._get_etag(qs):
response['ETag'] = etag
return response
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def perform_create(self, serializer):
model = self.queryset.model
@@ -264,10 +211,6 @@ class NetBoxModelViewSet(
def update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', False)
instance = self.get_object_with_snapshot()
# Enforce If-Match precondition (RFC 9110 §13.1.1)
self._validate_etag(self.request, instance)
serializer = self.get_serializer(instance, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
@@ -278,12 +221,8 @@ class NetBoxModelViewSet(
# Re-serialize the instance(s) with prefetched data
serializer = self.get_serializer(qs)
response = Response(serializer.data)
if etag := self._get_etag(qs):
response['ETag'] = etag
return response
return Response(serializer.data)
def perform_update(self, serializer):
model = self.queryset.model
@@ -293,11 +232,6 @@ class NetBoxModelViewSet(
# Enforce object-level permissions on save()
try:
with transaction.atomic(using=router.db_for_write(model)):
# Re-check the If-Match ETag under a row-level lock to close the TOCTOU window
# between the initial check in update() and the actual write.
if self._get_if_match(self.request):
locked = model.objects.select_for_update().get(pk=serializer.instance.pk)
self._validate_etag(self.request, locked)
instance = serializer.save()
self._validate_objects(instance)
except ObjectDoesNotExist:
@@ -308,9 +242,6 @@ class NetBoxModelViewSet(
def destroy(self, request, *args, **kwargs):
instance = self.get_object_with_snapshot()
# Enforce If-Match precondition (RFC 9110 §13.1.1)
self._validate_etag(request, instance)
# Attach changelog message (if any)
serializer = ChangeLogMessageSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
@@ -325,16 +256,7 @@ class NetBoxModelViewSet(
logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
try:
with transaction.atomic(using=router.db_for_write(model)):
# Re-check the If-Match ETag under a row-level lock to close the TOCTOU window
# between the initial check in destroy() and the actual delete.
if self._get_if_match(self.request):
locked = model.objects.select_for_update().get(pk=instance.pk)
self._validate_etag(self.request, locked)
super().perform_destroy(instance)
except ObjectDoesNotExist:
raise PermissionDenied()
return super().perform_destroy(instance)
class MPTTLockedMixin:

View File

@@ -1,9 +1,6 @@
import logging
import os
import traceback
from abc import ABC, abstractmethod
from datetime import timedelta
from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
from django.utils import timezone
@@ -24,11 +21,6 @@ __all__ = (
'system_job',
)
# The installation root, e.g. "/opt/netbox/". Used to strip absolute path
# prefixes from traceback file paths before recording them in the job log.
# jobs.py lives at <root>/netbox/netbox/jobs.py, so parents[2] is the root.
_INSTALL_ROOT = str(Path(__file__).resolve().parents[2]) + os.sep
def system_job(interval):
"""
@@ -115,13 +107,6 @@ class JobRunner(ABC):
job.terminate(status=JobStatusChoices.STATUS_FAILED)
except Exception as e:
tb_str = traceback.format_exc().replace(_INSTALL_ROOT, '')
tb_record = logging.makeLogRecord({
'levelno': logging.ERROR,
'levelname': 'ERROR',
'msg': tb_str,
})
job.log(tb_record)
job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
if type(e) is JobTimeoutException:
logger.error(e)

View File

@@ -40,15 +40,24 @@ class CoreMiddleware:
with apply_request_processors(request):
response = self.get_response(request)
# Check if language cookie should be renewed
if request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
if language := request.user.config.get('locale.language'):
response.set_cookie(
key=settings.LANGUAGE_COOKIE_NAME,
value=language,
max_age=request.session.get_expiry_age(),
secure=settings.SESSION_COOKIE_SECURE,
)
# Set or renew the language cookie based on the user's preference. This handles two cases:
# 1. The user just logged in (via any auth backend): the user_logged_in signal stores the preferred language on
# the request so we set the cookie here on the login response.
# 2. SESSION_SAVE_EVERY_REQUEST is enabled: renew the language cookie on every request to keep it in sync with
# the session expiry.
if hasattr(request, '_language_cookie'):
language = request._language_cookie
elif request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
language = request.user.config.get('locale.language')
else:
language = None
if language:
response.set_cookie(
key=settings.LANGUAGE_COOKIE_NAME,
value=language,
max_age=request.session.get_expiry_age(),
secure=settings.SESSION_COOKIE_SECURE,
)
# Attach the unique request ID as an HTTP header.
response['X-Request-ID'] = request.id

View File

@@ -435,7 +435,6 @@ INSTALLED_APPS = [
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.humanize',
'django.contrib.postgres',
'django.forms',
'corsheaders',
'debug_toolbar',

View File

@@ -10,7 +10,6 @@ from core.models import DataSource, Job
from utilities.testing import disable_warnings
from ..jobs import *
from ..jobs import _INSTALL_ROOT
class TestJobRunner(JobRunner):
@@ -84,12 +83,6 @@ class JobRunnerTest(JobRunnerTestCase):
self.assertEqual(job.status, JobStatusChoices.STATUS_ERRORED)
self.assertEqual(job.error, repr(ErroredJobRunner.EXP))
self.assertEqual(len(job.log_entries), 1)
self.assertEqual(job.log_entries[0]['level'], 'error')
tb_message = job.log_entries[0]['message']
self.assertIn('Traceback', tb_message)
self.assertIn('Test error', tb_message)
self.assertNotIn(_INSTALL_ROOT, tb_message)
class EnqueueTest(JobRunnerTestCase):

View File

@@ -28,7 +28,7 @@
</div>
</div>
<div class="card">
<div class="card table-responsive">
{% render_table table %}
</div>
{% endblock content %}

View File

@@ -86,6 +86,11 @@
<th scope="row">{% trans "Q-in-Q SVLAN" %}</th>
<td>{{ object.qinq_svlan|linkify|placeholder }}</td>
</tr>
{% elif object.mode %}
<tr>
<th scope="row">{% trans "Untagged VLAN" %}</th>
<td>{{ object.untagged_vlan|linkify|placeholder }}</td>
</tr>
{% endif %}
<tr>
<th scope="row">{% trans "Transmit power (dBm)" %}</th>
@@ -411,7 +416,10 @@
</div>
<div class="row mb-3">
<div class="col col-md-12">
{% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
<div class="card">
<h2 class="card-header">{% trans "VLANs" %}</h2>
{% htmx_table 'ipam:vlan_list' interface_id=object.pk %}
</div>
</div>
</div>
{% if object.is_lag %}

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-04 05:17+0000\n"
"POT-Creation-Date: 2026-03-06 05:17+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -473,8 +473,7 @@ msgstr ""
#: netbox/extras/forms/bulk_edit.py:306 netbox/extras/tables/tables.py:552
#: netbox/netbox/ui/attrs.py:193 netbox/templates/circuits/circuittype.html:30
#: netbox/templates/circuits/virtualcircuittype.html:30
#: netbox/templates/dcim/cable.html:44 netbox/templates/dcim/devicerole.html:38
#: netbox/templates/dcim/frontport.html:40
#: netbox/templates/dcim/cable.html:44 netbox/templates/dcim/frontport.html:40
#: netbox/templates/dcim/inventoryitemrole.html:26
#: netbox/templates/dcim/poweroutlet.html:48
#: netbox/templates/dcim/rearport.html:40 netbox/templates/extras/tag.html:26
@@ -601,7 +600,7 @@ msgstr ""
#: netbox/templates/core/rq_task.html:81 netbox/templates/core/system.html:19
#: netbox/templates/dcim/cable.html:19
#: netbox/templates/dcim/inventoryitem.html:36
#: netbox/templates/dcim/module.html:69 netbox/templates/dcim/powerfeed.html:36
#: netbox/templates/dcim/powerfeed.html:36
#: netbox/templates/dcim/poweroutlet.html:40
#: netbox/templates/extras/inc/script_list_content.html:35
#: netbox/templates/ipam/ipaddress.html:37
@@ -771,17 +770,17 @@ msgstr ""
#: netbox/dcim/forms/filtersets.py:1864 netbox/dcim/forms/filtersets.py:1879
#: netbox/dcim/forms/filtersets.py:1890 netbox/dcim/forms/filtersets.py:1936
#: netbox/dcim/forms/filtersets.py:1972 netbox/dcim/tables/modules.py:25
#: netbox/extras/forms/bulk_edit.py:94 netbox/extras/forms/filtersets.py:48
#: netbox/extras/forms/filtersets.py:147 netbox/extras/forms/filtersets.py:226
#: netbox/extras/forms/filtersets.py:243 netbox/extras/forms/filtersets.py:275
#: netbox/extras/forms/filtersets.py:306 netbox/extras/forms/filtersets.py:329
#: netbox/extras/forms/filtersets.py:361 netbox/extras/forms/filtersets.py:560
#: netbox/ipam/forms/filtersets.py:108 netbox/ipam/forms/filtersets.py:296
#: netbox/ipam/forms/filtersets.py:346 netbox/ipam/forms/filtersets.py:423
#: netbox/ipam/forms/filtersets.py:511 netbox/ipam/forms/filtersets.py:525
#: netbox/ipam/forms/filtersets.py:550 netbox/ipam/forms/filtersets.py:622
#: netbox/ipam/forms/filtersets.py:641 netbox/netbox/tables/tables.py:355
#: netbox/templates/dcim/moduletype.html:68
#: netbox/dcim/views.py:1679 netbox/extras/forms/bulk_edit.py:94
#: netbox/extras/forms/filtersets.py:48 netbox/extras/forms/filtersets.py:147
#: netbox/extras/forms/filtersets.py:226 netbox/extras/forms/filtersets.py:243
#: netbox/extras/forms/filtersets.py:275 netbox/extras/forms/filtersets.py:306
#: netbox/extras/forms/filtersets.py:329 netbox/extras/forms/filtersets.py:361
#: netbox/extras/forms/filtersets.py:560 netbox/ipam/forms/filtersets.py:108
#: netbox/ipam/forms/filtersets.py:296 netbox/ipam/forms/filtersets.py:346
#: netbox/ipam/forms/filtersets.py:423 netbox/ipam/forms/filtersets.py:511
#: netbox/ipam/forms/filtersets.py:525 netbox/ipam/forms/filtersets.py:550
#: netbox/ipam/forms/filtersets.py:622 netbox/ipam/forms/filtersets.py:641
#: netbox/netbox/tables/tables.py:355
#: netbox/virtualization/forms/filtersets.py:52
#: netbox/virtualization/forms/filtersets.py:116
#: netbox/virtualization/forms/filtersets.py:217
@@ -834,7 +833,7 @@ msgstr ""
#: netbox/extras/tables/tables.py:97 netbox/ipam/tables/vlans.py:257
#: netbox/ipam/tables/vlans.py:284 netbox/netbox/forms/bulk_edit.py:79
#: netbox/netbox/forms/bulk_edit.py:91 netbox/netbox/forms/bulk_edit.py:103
#: netbox/netbox/ui/panels.py:196 netbox/netbox/ui/panels.py:205
#: netbox/netbox/ui/panels.py:199 netbox/netbox/ui/panels.py:208
#: netbox/templates/circuits/circuit.html:69
#: netbox/templates/circuits/circuitgroup.html:32
#: netbox/templates/circuits/circuittype.html:26
@@ -850,15 +849,12 @@ msgstr ""
#: netbox/templates/dcim/consoleport.html:44
#: netbox/templates/dcim/consoleserverport.html:44
#: netbox/templates/dcim/devicebay.html:32
#: netbox/templates/dcim/devicerole.html:30
#: netbox/templates/dcim/frontport.html:54
#: netbox/templates/dcim/interface.html:69
#: netbox/templates/dcim/inventoryitem.html:64
#: netbox/templates/dcim/inventoryitemrole.html:22
#: netbox/templates/dcim/macaddress.html:21
#: netbox/templates/dcim/module.html:73 netbox/templates/dcim/modulebay.html:42
#: netbox/templates/dcim/moduletype.html:43
#: netbox/templates/dcim/platform.html:33
#: netbox/templates/dcim/modulebay.html:42
#: netbox/templates/dcim/powerfeed.html:40
#: netbox/templates/dcim/poweroutlet.html:44
#: netbox/templates/dcim/powerpanel.html:30
@@ -1703,7 +1699,7 @@ msgstr ""
#: netbox/ipam/tables/vlans.py:35 netbox/ipam/tables/vlans.py:88
#: netbox/ipam/tables/vlans.py:248 netbox/ipam/tables/vrfs.py:26
#: netbox/ipam/tables/vrfs.py:65 netbox/netbox/tables/tables.py:325
#: netbox/netbox/ui/panels.py:195 netbox/netbox/ui/panels.py:204
#: netbox/netbox/ui/panels.py:198 netbox/netbox/ui/panels.py:207
#: netbox/templates/circuits/circuitgroup.html:28
#: netbox/templates/circuits/circuittype.html:22
#: netbox/templates/circuits/provideraccount.html:28
@@ -1714,7 +1710,6 @@ msgstr ""
#: netbox/templates/dcim/consoleport.html:28
#: netbox/templates/dcim/consoleserverport.html:28
#: netbox/templates/dcim/devicebay.html:24
#: netbox/templates/dcim/devicerole.html:26
#: netbox/templates/dcim/frontport.html:28
#: netbox/templates/dcim/inc/interface_vlans_table.html:5
#: netbox/templates/dcim/inc/panels/inventory_items.html:18
@@ -1723,7 +1718,6 @@ msgstr ""
#: netbox/templates/dcim/inventoryitem.html:28
#: netbox/templates/dcim/inventoryitemrole.html:18
#: netbox/templates/dcim/modulebay.html:30
#: netbox/templates/dcim/platform.html:29
#: netbox/templates/dcim/poweroutlet.html:28
#: netbox/templates/dcim/powerport.html:28
#: netbox/templates/dcim/rearport.html:28
@@ -1917,7 +1911,7 @@ msgstr ""
#: netbox/templates/dcim/interface.html:30
#: netbox/templates/dcim/interface.html:231
#: netbox/templates/dcim/inventoryitem.html:20
#: netbox/templates/dcim/module.html:57 netbox/templates/dcim/modulebay.html:20
#: netbox/templates/dcim/modulebay.html:20
#: netbox/templates/dcim/panels/virtual_chassis_members.html:8
#: netbox/templates/dcim/poweroutlet.html:20
#: netbox/templates/dcim/powerport.html:20
@@ -3144,10 +3138,9 @@ msgstr ""
#: netbox/dcim/tables/devices.py:1205 netbox/ipam/forms/bulk_import.py:582
#: netbox/ipam/forms/model_forms.py:758 netbox/ipam/tables/fhrp.py:56
#: netbox/ipam/tables/ip.py:329 netbox/ipam/tables/services.py:42
#: netbox/netbox/tables/tables.py:329 netbox/netbox/ui/panels.py:203
#: netbox/templates/dcim/devicerole.html:34
#: netbox/netbox/tables/tables.py:329 netbox/netbox/ui/panels.py:206
#: netbox/templates/dcim/interface.html:108
#: netbox/templates/dcim/platform.html:37 netbox/templates/ipam/service.html:30
#: netbox/templates/ipam/service.html:30
#: netbox/templates/tenancy/contactgroup.html:29
#: netbox/templates/tenancy/tenantgroup.html:37
#: netbox/templates/wireless/wirelesslangroup.html:37
@@ -4301,9 +4294,8 @@ msgstr ""
#: netbox/dcim/tables/modules.py:47 netbox/dcim/tables/modules.py:90
#: netbox/dcim/tables/racks.py:51 netbox/dcim/tables/racks.py:121
#: netbox/templates/dcim/inventoryitem.html:48
#: netbox/templates/dcim/module.html:95 netbox/templates/dcim/modulebay.html:62
#: netbox/templates/dcim/moduletype.html:31
#: netbox/templates/dcim/platform.html:41
#: netbox/templates/dcim/modulebay.html:62
#: netbox/templates/dcim/panels/module_type.html:7
msgid "Manufacturer"
msgstr ""
@@ -4364,13 +4356,13 @@ msgstr ""
#: netbox/dcim/forms/model_forms.py:237 netbox/dcim/forms/model_forms.py:318
#: netbox/dcim/tables/devicetypes.py:110 netbox/dcim/tables/modules.py:55
#: netbox/dcim/tables/racks.py:71 netbox/dcim/tables/racks.py:162
#: netbox/dcim/views.py:890 netbox/dcim/views.py:1018
#: netbox/dcim/views.py:891 netbox/dcim/views.py:1019
#: netbox/extras/forms/bulk_edit.py:57 netbox/extras/forms/bulk_edit.py:137
#: netbox/extras/forms/bulk_edit.py:191 netbox/extras/forms/bulk_edit.py:219
#: netbox/extras/forms/bulk_edit.py:315 netbox/extras/forms/bulk_edit.py:341
#: netbox/extras/forms/filtersets.py:74 netbox/extras/forms/filtersets.py:170
#: netbox/extras/forms/filtersets.py:266 netbox/extras/forms/filtersets.py:297
#: netbox/ipam/forms/bulk_edit.py:162 netbox/templates/dcim/moduletype.html:51
#: netbox/ipam/forms/bulk_edit.py:162
#: netbox/templates/extras/configcontext.html:17
#: netbox/templates/extras/customlink.html:25
#: netbox/templates/extras/savedfilter.html:33
@@ -4405,7 +4397,7 @@ msgstr ""
#: netbox/dcim/forms/bulk_edit.py:295 netbox/dcim/forms/model_forms.py:238
#: netbox/dcim/forms/model_forms.py:319 netbox/dcim/ui/panels.py:135
#: netbox/dcim/views.py:884 netbox/dcim/views.py:1016
#: netbox/dcim/views.py:885 netbox/dcim/views.py:1017
#: netbox/extras/tables/tables.py:278
#: netbox/templates/dcim/inc/panels/racktype_dimensions.html:3
#: netbox/templates/extras/imageattachment.html:40
@@ -4414,7 +4406,7 @@ msgstr ""
#: netbox/dcim/forms/bulk_edit.py:297 netbox/dcim/forms/filtersets.py:336
#: netbox/dcim/forms/filtersets.py:361 netbox/dcim/forms/model_forms.py:240
#: netbox/dcim/views.py:889 netbox/dcim/views.py:1017
#: netbox/dcim/views.py:890 netbox/dcim/views.py:1018
#: netbox/templates/dcim/inc/panels/racktype_numbering.html:3
msgid "Numbering"
msgstr ""
@@ -4425,8 +4417,7 @@ msgid "Rack type"
msgstr ""
#: netbox/dcim/forms/bulk_edit.py:363 netbox/dcim/forms/bulk_edit.py:708
#: netbox/dcim/forms/bulk_edit.py:763 netbox/templates/dcim/module.html:77
#: netbox/templates/dcim/modulebay.html:70
#: netbox/dcim/forms/bulk_edit.py:763 netbox/templates/dcim/modulebay.html:70
msgid "Serial Number"
msgstr ""
@@ -4441,7 +4432,7 @@ msgstr ""
#: netbox/dcim/forms/bulk_import.py:306 netbox/dcim/forms/bulk_import.py:471
#: netbox/dcim/forms/bulk_import.py:680 netbox/dcim/forms/filtersets.py:420
#: netbox/dcim/forms/filtersets.py:568 netbox/dcim/forms/filtersets.py:750
#: netbox/dcim/forms/filtersets.py:906 netbox/templates/dcim/moduletype.html:47
#: netbox/dcim/forms/filtersets.py:906
msgid "Airflow"
msgstr ""
@@ -4499,12 +4490,12 @@ msgstr ""
#: netbox/dcim/forms/model_forms.py:1140 netbox/dcim/forms/model_forms.py:1180
#: netbox/dcim/forms/model_forms.py:1198 netbox/dcim/forms/object_create.py:119
#: netbox/dcim/tables/devicetypes.py:84 netbox/dcim/ui/panels.py:125
#: netbox/templates/dcim/devicebay.html:52 netbox/templates/dcim/module.html:61
#: netbox/templates/dcim/devicebay.html:52
msgid "Device Type"
msgstr ""
#: netbox/dcim/forms/bulk_edit.py:543 netbox/dcim/forms/model_forms.py:412
#: netbox/dcim/views.py:1589 netbox/extras/forms/model_forms.py:601
#: netbox/dcim/views.py:1590 netbox/extras/forms/model_forms.py:601
msgid "Schema"
msgstr ""
@@ -4515,7 +4506,7 @@ msgstr ""
#: netbox/dcim/forms/model_forms.py:431 netbox/dcim/tables/modules.py:43
#: netbox/extras/forms/filtersets.py:413 netbox/extras/forms/model_forms.py:626
#: netbox/extras/tables/tables.py:627 netbox/templates/account/base.html:7
#: netbox/templates/dcim/cable.html:23 netbox/templates/dcim/moduletype.html:27
#: netbox/templates/dcim/cable.html:23
#: netbox/templates/extras/configcontext.html:21
#: netbox/templates/inc/user_menu.html:38 netbox/vpn/forms/bulk_edit.py:213
#: netbox/vpn/forms/filtersets.py:203 netbox/vpn/forms/model_forms.py:378
@@ -4528,9 +4519,8 @@ msgstr ""
#: netbox/dcim/forms/model_forms.py:1120 netbox/dcim/forms/model_forms.py:1141
#: netbox/dcim/forms/model_forms.py:1181 netbox/dcim/forms/model_forms.py:1199
#: netbox/dcim/forms/object_create.py:120 netbox/dcim/tables/modules.py:52
#: netbox/dcim/tables/modules.py:95 netbox/templates/dcim/module.html:92
#: netbox/dcim/tables/modules.py:95 netbox/dcim/views.py:2848
#: netbox/templates/dcim/modulebay.html:66
#: netbox/templates/dcim/moduletype.html:24
msgid "Module Type"
msgstr ""
@@ -4539,7 +4529,7 @@ msgid "Chassis"
msgstr ""
#: netbox/dcim/forms/bulk_edit.py:611 netbox/dcim/models/devices.py:390
#: netbox/dcim/tables/devices.py:76
#: netbox/dcim/tables/devices.py:76 netbox/dcim/ui/panels.py:142
msgid "VM role"
msgstr ""
@@ -4575,7 +4565,6 @@ msgstr ""
#: netbox/dcim/forms/filtersets.py:789 netbox/dcim/forms/filtersets.py:898
#: netbox/dcim/forms/model_forms.py:557 netbox/dcim/forms/model_forms.py:629
#: netbox/dcim/tables/devices.py:191 netbox/extras/filtersets.py:745
#: netbox/templates/dcim/platform.html:26
#: netbox/virtualization/forms/bulk_edit.py:131
#: netbox/virtualization/forms/bulk_import.py:135
#: netbox/virtualization/forms/filtersets.py:187
@@ -4747,7 +4736,7 @@ msgstr ""
#: netbox/templates/dcim/consoleport.html:24
#: netbox/templates/dcim/consoleserverport.html:24
#: netbox/templates/dcim/frontport.html:24
#: netbox/templates/dcim/interface.html:34 netbox/templates/dcim/module.html:54
#: netbox/templates/dcim/interface.html:34
#: netbox/templates/dcim/modulebay.html:26
#: netbox/templates/dcim/modulebay.html:58
#: netbox/templates/dcim/poweroutlet.html:24
@@ -5540,7 +5529,7 @@ msgid "Function"
msgstr ""
#: netbox/dcim/forms/filtersets.py:461 netbox/dcim/forms/model_forms.py:341
#: netbox/dcim/tables/racks.py:189 netbox/dcim/views.py:1163
#: netbox/dcim/tables/racks.py:189 netbox/dcim/views.py:1164
msgid "Reservation"
msgstr ""
@@ -5568,12 +5557,11 @@ msgid "Module count"
msgstr ""
#: netbox/dcim/forms/filtersets.py:769 netbox/dcim/forms/model_forms.py:522
#: netbox/templates/dcim/devicerole.html:23
msgid "Device Role"
msgstr ""
#: netbox/dcim/forms/filtersets.py:892 netbox/dcim/tables/racks.py:47
#: netbox/templates/dcim/module.html:99
#: netbox/templates/dcim/panels/module_type.html:11
msgid "Model"
msgstr ""
@@ -7629,8 +7617,6 @@ msgstr ""
#: netbox/dcim/tables/devices.py:105 netbox/dcim/tables/devices.py:225
#: netbox/extras/forms/model_forms.py:754
#: netbox/templates/dcim/devicerole.html:48
#: netbox/templates/dcim/platform.html:45
#: netbox/templates/extras/configtemplate.html:10
#: netbox/templates/extras/object_render_config.html:12
#: netbox/templates/extras/object_render_config.html:15
@@ -7695,8 +7681,8 @@ msgid "Power outlets"
msgstr ""
#: netbox/dcim/tables/devices.py:254 netbox/dcim/tables/devices.py:1174
#: netbox/dcim/tables/devicetypes.py:132 netbox/dcim/views.py:1423
#: netbox/dcim/views.py:1760 netbox/dcim/views.py:2590
#: netbox/dcim/tables/devicetypes.py:132 netbox/dcim/views.py:1424
#: netbox/dcim/views.py:1777 netbox/dcim/views.py:2649
#: netbox/netbox/navigation/menu.py:98 netbox/netbox/navigation/menu.py:262
#: netbox/templates/dcim/buttons/bulk_add_components.html:38
#: netbox/templates/dcim/device/base.html:37
@@ -7737,13 +7723,13 @@ msgid "Device Site"
msgstr ""
#: netbox/dcim/tables/devices.py:322 netbox/dcim/tables/modules.py:86
#: netbox/templates/dcim/module.html:65 netbox/templates/dcim/modulebay.html:17
#: netbox/templates/dcim/modulebay.html:17
msgid "Module Bay"
msgstr ""
#: netbox/dcim/tables/devices.py:335 netbox/dcim/tables/devicetypes.py:53
#: netbox/dcim/tables/devicetypes.py:147 netbox/dcim/views.py:1498
#: netbox/dcim/views.py:2676 netbox/netbox/navigation/menu.py:107
#: netbox/dcim/tables/devicetypes.py:147 netbox/dcim/views.py:1499
#: netbox/dcim/views.py:2735 netbox/netbox/navigation/menu.py:107
#: netbox/templates/dcim/buttons/bulk_add_components.html:66
#: netbox/templates/dcim/device/base.html:52
#: netbox/templates/dcim/devicetype/base.html:49
@@ -7884,7 +7870,7 @@ msgstr ""
msgid "Device Types"
msgstr ""
#: netbox/dcim/tables/devicetypes.py:48 netbox/dcim/views.py:1595
#: netbox/dcim/tables/devicetypes.py:48 netbox/dcim/views.py:1596
#: netbox/netbox/navigation/menu.py:90
msgid "Module Types"
msgstr ""
@@ -7907,8 +7893,8 @@ msgstr ""
msgid "Device Count"
msgstr ""
#: netbox/dcim/tables/devicetypes.py:120 netbox/dcim/views.py:1363
#: netbox/dcim/views.py:1700 netbox/dcim/views.py:2525
#: netbox/dcim/tables/devicetypes.py:120 netbox/dcim/views.py:1364
#: netbox/dcim/views.py:1717 netbox/dcim/views.py:2584
#: netbox/netbox/navigation/menu.py:101
#: netbox/templates/dcim/buttons/bulk_add_components.html:10
#: netbox/templates/dcim/device/base.html:25
@@ -7918,8 +7904,8 @@ msgstr ""
msgid "Console Ports"
msgstr ""
#: netbox/dcim/tables/devicetypes.py:123 netbox/dcim/views.py:1378
#: netbox/dcim/views.py:1715 netbox/dcim/views.py:2541
#: netbox/dcim/tables/devicetypes.py:123 netbox/dcim/views.py:1379
#: netbox/dcim/views.py:1732 netbox/dcim/views.py:2600
#: netbox/netbox/navigation/menu.py:102
#: netbox/templates/dcim/buttons/bulk_add_components.html:17
#: netbox/templates/dcim/device/base.html:28
@@ -7929,8 +7915,8 @@ msgstr ""
msgid "Console Server Ports"
msgstr ""
#: netbox/dcim/tables/devicetypes.py:126 netbox/dcim/views.py:1393
#: netbox/dcim/views.py:1730 netbox/dcim/views.py:2557
#: netbox/dcim/tables/devicetypes.py:126 netbox/dcim/views.py:1394
#: netbox/dcim/views.py:1747 netbox/dcim/views.py:2616
#: netbox/netbox/navigation/menu.py:103
#: netbox/templates/dcim/buttons/bulk_add_components.html:24
#: netbox/templates/dcim/device/base.html:31
@@ -7940,8 +7926,8 @@ msgstr ""
msgid "Power Ports"
msgstr ""
#: netbox/dcim/tables/devicetypes.py:129 netbox/dcim/views.py:1408
#: netbox/dcim/views.py:1745 netbox/dcim/views.py:2573
#: netbox/dcim/tables/devicetypes.py:129 netbox/dcim/views.py:1409
#: netbox/dcim/views.py:1762 netbox/dcim/views.py:2632
#: netbox/netbox/navigation/menu.py:104
#: netbox/templates/dcim/buttons/bulk_add_components.html:31
#: netbox/templates/dcim/device/base.html:34
@@ -7951,8 +7937,8 @@ msgstr ""
msgid "Power Outlets"
msgstr ""
#: netbox/dcim/tables/devicetypes.py:135 netbox/dcim/views.py:1438
#: netbox/dcim/views.py:1775 netbox/dcim/views.py:2612
#: netbox/dcim/tables/devicetypes.py:135 netbox/dcim/views.py:1439
#: netbox/dcim/views.py:1792 netbox/dcim/views.py:2671
#: netbox/netbox/navigation/menu.py:99
#: netbox/templates/dcim/device/base.html:40
#: netbox/templates/dcim/devicetype/base.html:37
@@ -7961,8 +7947,8 @@ msgstr ""
msgid "Front Ports"
msgstr ""
#: netbox/dcim/tables/devicetypes.py:138 netbox/dcim/views.py:1453
#: netbox/dcim/views.py:1790 netbox/dcim/views.py:2628
#: netbox/dcim/tables/devicetypes.py:138 netbox/dcim/views.py:1454
#: netbox/dcim/views.py:1807 netbox/dcim/views.py:2687
#: netbox/netbox/navigation/menu.py:100
#: netbox/templates/dcim/buttons/bulk_add_components.html:45
#: netbox/templates/dcim/device/base.html:43
@@ -7972,16 +7958,16 @@ msgstr ""
msgid "Rear Ports"
msgstr ""
#: netbox/dcim/tables/devicetypes.py:141 netbox/dcim/views.py:1483
#: netbox/dcim/views.py:2660 netbox/netbox/navigation/menu.py:106
#: netbox/dcim/tables/devicetypes.py:141 netbox/dcim/views.py:1484
#: netbox/dcim/views.py:2719 netbox/netbox/navigation/menu.py:106
#: netbox/templates/dcim/buttons/bulk_add_components.html:52
#: netbox/templates/dcim/device/base.html:49
#: netbox/templates/dcim/devicetype/base.html:46
msgid "Device Bays"
msgstr ""
#: netbox/dcim/tables/devicetypes.py:144 netbox/dcim/views.py:1468
#: netbox/dcim/views.py:1805 netbox/dcim/views.py:2644
#: netbox/dcim/tables/devicetypes.py:144 netbox/dcim/views.py:1469
#: netbox/dcim/views.py:1822 netbox/dcim/views.py:2703
#: netbox/netbox/navigation/menu.py:105
#: netbox/templates/dcim/buttons/bulk_add_components.html:59
#: netbox/templates/dcim/device/base.html:46
@@ -8068,7 +8054,7 @@ msgid "{} millimeters"
msgstr ""
#: netbox/dcim/ui/panels.py:53 netbox/dcim/ui/panels.py:95
#: netbox/virtualization/forms/filtersets.py:202
#: netbox/dcim/ui/panels.py:168 netbox/virtualization/forms/filtersets.py:202
#: netbox/virtualization/ui/panels.py:23
msgid "Serial number"
msgstr ""
@@ -8086,51 +8072,63 @@ msgstr ""
msgid "Out-of-band IP"
msgstr ""
#: netbox/dcim/ui/panels.py:150
#: netbox/dcim/ui/panels.py:156
msgid "Parent/child"
msgstr ""
#: netbox/dcim/ui/panels.py:166
#: netbox/dcim/ui/panels.py:180
msgid "Model name"
msgstr ""
#: netbox/dcim/ui/panels.py:197
msgid "Virtual Chassis Members"
msgstr ""
#: netbox/dcim/ui/panels.py:185
#: netbox/dcim/ui/panels.py:216
msgid "Power Utilization"
msgstr ""
#: netbox/dcim/views.py:148
#: netbox/dcim/views.py:149
#, python-brace-format
msgid "Disconnected {count} {type}"
msgstr ""
#: netbox/dcim/views.py:256
#: netbox/dcim/views.py:257
msgid "Child Regions"
msgstr ""
#: netbox/dcim/views.py:388 netbox/templates/tenancy/contactgroup.html:47
#: netbox/dcim/views.py:389 netbox/templates/tenancy/contactgroup.html:47
#: netbox/templates/tenancy/tenantgroup.html:56
#: netbox/templates/wireless/wirelesslangroup.html:56
msgid "Child Groups"
msgstr ""
#: netbox/dcim/views.py:546 netbox/dcim/views.py:686 netbox/dcim/views.py:1093
#: netbox/dcim/views.py:547 netbox/dcim/views.py:687 netbox/dcim/views.py:1094
msgid "Non-Racked Devices"
msgstr ""
#: netbox/dcim/views.py:672
#: netbox/dcim/views.py:673
msgid "Child Locations"
msgstr ""
#: netbox/dcim/views.py:1074 netbox/netbox/navigation/menu.py:54
#: netbox/dcim/views.py:1075 netbox/netbox/navigation/menu.py:54
msgid "Reservations"
msgstr ""
#: netbox/dcim/views.py:2470 netbox/netbox/navigation/menu.py:216
#: netbox/dcim/views.py:2339
msgid "Child Device Roles"
msgstr ""
#: netbox/dcim/views.py:2439
msgid "Child Platforms"
msgstr ""
#: netbox/dcim/views.py:2529 netbox/netbox/navigation/menu.py:216
#: netbox/templates/ipam/ipaddress.html:118 netbox/virtualization/views.py:419
msgid "Application Services"
msgstr ""
#: netbox/dcim/views.py:2689 netbox/extras/forms/filtersets.py:402
#: netbox/dcim/views.py:2748 netbox/extras/forms/filtersets.py:402
#: netbox/extras/forms/model_forms.py:701
#: netbox/templates/extras/configcontext.html:10
#: netbox/virtualization/forms/model_forms.py:225
@@ -8138,41 +8136,41 @@ msgstr ""
msgid "Config Context"
msgstr ""
#: netbox/dcim/views.py:2700 netbox/virtualization/views.py:504
#: netbox/dcim/views.py:2759 netbox/virtualization/views.py:504
msgid "Render Config"
msgstr ""
#: netbox/dcim/views.py:2713 netbox/extras/tables/tables.py:725
#: netbox/dcim/views.py:2772 netbox/extras/tables/tables.py:725
#: netbox/netbox/navigation/menu.py:259 netbox/netbox/navigation/menu.py:261
#: netbox/virtualization/views.py:278
msgid "Virtual Machines"
msgstr ""
#: netbox/dcim/views.py:3532
#: netbox/dcim/views.py:3606
#, python-brace-format
msgid "Installed device {device} in bay {device_bay}."
msgstr ""
#: netbox/dcim/views.py:3573
#: netbox/dcim/views.py:3647
#, python-brace-format
msgid "Removed device {device} from bay {device_bay}."
msgstr ""
#: netbox/dcim/views.py:3686 netbox/ipam/tables/ip.py:179
#: netbox/dcim/views.py:3760 netbox/ipam/tables/ip.py:179
msgid "Children"
msgstr ""
#: netbox/dcim/views.py:4147
#: netbox/dcim/views.py:4221
#, python-brace-format
msgid "Added member <a href=\"{url}\">{device}</a>"
msgstr ""
#: netbox/dcim/views.py:4192
#: netbox/dcim/views.py:4266
#, python-brace-format
msgid "Unable to remove master device {device} from the virtual chassis."
msgstr ""
#: netbox/dcim/views.py:4203
#: netbox/dcim/views.py:4277
#, python-brace-format
msgid "Removed {device} from virtual chassis {chassis}"
msgstr ""
@@ -8822,7 +8820,7 @@ msgstr ""
#: netbox/extras/forms/bulk_import.py:305 netbox/extras/tables/tables.py:758
#: netbox/netbox/tables/tables.py:295 netbox/netbox/tables/tables.py:310
#: netbox/netbox/tables/tables.py:333 netbox/netbox/ui/panels.py:216
#: netbox/netbox/tables/tables.py:333 netbox/netbox/ui/panels.py:219
#: netbox/templates/dcim/htmx/cable_edit.html:99
#: netbox/templates/generic/bulk_edit.html:99
#: netbox/templates/inc/panels/comments.html:5
@@ -12815,7 +12813,7 @@ msgstr ""
msgid "GPS coordinates"
msgstr ""
#: netbox/netbox/ui/panels.py:263
#: netbox/netbox/ui/panels.py:266
#: netbox/templates/inc/panels/related_objects.html:5
msgid "Related Objects"
msgstr ""
@@ -13115,7 +13113,7 @@ msgstr ""
#: netbox/templates/dcim/inc/panels/inventory_items.html:45
#: netbox/templates/dcim/interface.html:366
#: netbox/templates/dcim/modulebay.html:80
#: netbox/templates/dcim/moduletype.html:90
#: netbox/templates/dcim/panels/module_type_attributes.html:26
#: netbox/templates/extras/configcontext.html:46
#: netbox/templates/extras/configtemplate.html:81
#: netbox/templates/extras/eventrule.html:66
@@ -13917,18 +13915,6 @@ msgstr ""
msgid "Add Device"
msgstr ""
#: netbox/templates/dcim/devicerole.html:44
msgid "VM Role"
msgstr ""
#: netbox/templates/dcim/devicerole.html:67
msgid "Child Device Roles"
msgstr ""
#: netbox/templates/dcim/devicerole.html:71
msgid "Add a Device Role"
msgstr ""
#: netbox/templates/dcim/frontport.html:50
#: netbox/templates/dcim/rearport.html:50
msgid "Positions"
@@ -14118,7 +14104,7 @@ msgid "Part ID"
msgstr ""
#: netbox/templates/dcim/inventoryitem.html:60
#: netbox/templates/dcim/module.html:81 netbox/templates/dcim/modulebay.html:74
#: netbox/templates/dcim/modulebay.html:74
msgid "Asset Tag"
msgstr ""
@@ -14134,15 +14120,7 @@ msgstr ""
msgid "Add Module Type"
msgstr ""
#: netbox/templates/dcim/moduletype.html:35
msgid "Model Name"
msgstr ""
#: netbox/templates/dcim/moduletype.html:39
msgid "Part Number"
msgstr ""
#: netbox/templates/dcim/moduletype.html:71
#: netbox/templates/dcim/panels/module_type_attributes.html:7
msgid "No profile assigned"
msgstr ""
@@ -14185,14 +14163,6 @@ msgstr ""
msgid "Labels only"
msgstr ""
#: netbox/templates/dcim/platform.html:64
msgid "Child Platforms"
msgstr ""
#: netbox/templates/dcim/platform.html:68
msgid "Add a Platform"
msgstr ""
#: netbox/templates/dcim/powerfeed.html:53
msgid "Connected Device"
msgstr ""

View File

@@ -1,6 +1,6 @@
import logging
from django.contrib.auth.signals import user_login_failed
from django.contrib.auth.signals import user_logged_in, user_login_failed
from django.db.models.signals import post_save
from django.dispatch import receiver
@@ -23,6 +23,18 @@ def log_user_login_failed(sender, credentials, request, **kwargs):
logger.info(f"Failed login attempt for username: {username}")
@receiver(user_logged_in)
def set_language_on_login(sender, user, request, **kwargs):
"""
Store the user's preferred language on the request so that middleware can set the language cookie. This ensures the
language preference is applied even when logging in via an external auth provider (e.g. social-app-django) that
does not go through NetBox's LoginView.
"""
if hasattr(user, 'config'):
if language := user.config.get('locale.language'):
request._language_cookie = language
@receiver(post_save, sender=User)
def create_userconfig(instance, created, raw=False, **kwargs):
"""

View File

@@ -6,7 +6,6 @@ __all__ = (
'AbortScript',
'AbortTransaction',
'PermissionsViolation',
'PreconditionFailed',
'RQWorkerNotRunningException',
)
@@ -41,20 +40,6 @@ class PermissionsViolation(Exception):
message = "Operation failed due to object-level permissions violation"
class PreconditionFailed(APIException):
"""
Raised when an If-Match precondition is not satisfied (HTTP 412).
Optionally carries the current ETag so it can be included in the response.
"""
status_code = status.HTTP_412_PRECONDITION_FAILED
default_detail = 'Precondition failed.'
default_code = 'precondition_failed'
def __init__(self, detail=None, code=None, etag=None):
super().__init__(detail=detail, code=code)
self.etag = etag
class RQWorkerNotRunningException(APIException):
"""
Indicates the temporary inability to enqueue a new task (e.g. custom script execution) because no RQ worker

View File

@@ -114,12 +114,7 @@ class APIViewTestCases:
# Try GET to permitted object
url = self._get_detail_url(instance1)
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
# Verify ETag header is present for objects with timestamps
if issubclass(self.model, ChangeLoggingMixin):
self.assertIn('ETag', response, "ETag header missing from detail response")
self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK)
# Try GET to non-permitted object
url = self._get_detail_url(instance2)
@@ -372,46 +367,6 @@ class APIViewTestCases:
self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(objectchange.message, data['changelog_message'])
def test_update_object_with_etag(self):
"""
PATCH an object using a valid If-Match ETag → expect 200.
PATCH again with the now-stale ETag → expect 412.
"""
if not issubclass(self.model, ChangeLoggingMixin):
self.skipTest("Model does not support ETags")
self.add_permissions(
f'{self.model._meta.app_label}.view_{self.model._meta.model_name}',
f'{self.model._meta.app_label}.change_{self.model._meta.model_name}',
)
instance = self._get_queryset().first()
url = self._get_detail_url(instance)
update_data = self.update_data or getattr(self, 'create_data')[0]
# Fetch current ETag
get_response = self.client.get(url, **self.header)
self.assertHttpStatus(get_response, status.HTTP_200_OK)
etag = get_response.get('ETag')
self.assertIsNotNone(etag, "No ETag returned by GET")
# PATCH with correct ETag → 200
response = self.client.patch(
url, update_data, format='json',
**{**self.header, 'HTTP_IF_MATCH': etag}
)
self.assertHttpStatus(response, status.HTTP_200_OK)
new_etag = response.get('ETag')
self.assertIsNotNone(new_etag)
self.assertNotEqual(etag, new_etag) # ETag must change after update
# PATCH with the old (stale) ETag → 412
with disable_warnings('django.request'):
response = self.client.patch(
url, update_data, format='json',
**{**self.header, 'HTTP_IF_MATCH': etag}
)
self.assertHttpStatus(response, status.HTTP_412_PRECONDITION_FAILED)
def test_bulk_update_objects(self):
"""
PATCH a set of objects in a single request.

View File

@@ -13,7 +13,7 @@ from dcim.tables import DeviceTable
from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import IPAddress, VLANGroup
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from ipam.tables import VLANTranslationRuleTable
from ipam.ui.panels import FHRPGroupAssignmentsPanel
from netbox.object_actions import (
AddObject,
@@ -594,7 +594,11 @@ class VMInterfaceView(generic.ObjectView):
),
],
),
ContextTablePanel('vlan_table', title=_('Assigned VLANs')),
ObjectsTablePanel(
model='ipam.VLAN',
title=_('Assigned VLANs'),
filters={'vminterface_id': lambda ctx: ctx['object'].pk},
),
ContextTablePanel('vlan_translation_table', title=_('VLAN Translation')),
ContextTablePanel('child_interfaces_table', title=_('Child Interfaces')),
],
@@ -620,24 +624,8 @@ class VMInterfaceView(generic.ObjectView):
)
vlan_translation_table.configure(request)
# Get assigned VLANs and annotate whether each is tagged or untagged
vlans = []
if instance.untagged_vlan is not None:
vlans.append(instance.untagged_vlan)
vlans[0].tagged = False
for vlan in instance.tagged_vlans.restrict(request.user).prefetch_related('site', 'group', 'tenant', 'role'):
vlan.tagged = True
vlans.append(vlan)
vlan_table = InterfaceVLANTable(
interface=instance,
data=vlans,
orderable=False
)
vlan_table.configure(request)
return {
'child_interfaces_table': child_interfaces_tables,
'vlan_table': vlan_table,
'vlan_translation_table': vlan_translation_table,
}

View File

@@ -1,5 +1,5 @@
colorama==0.4.6
Django==6.0.3
Django==5.2.11
django-cors-headers==4.9.0
django-debug-toolbar==6.2.0
django-filter==25.2
@@ -7,7 +7,7 @@ django-graphiql-debug-toolbar==0.2.0
django-htmx==1.27.0
django-mptt==0.18.0
django-pglocks==1.0.4
django-prometheus==2.4.0
django-prometheus==2.4.1
django-redis==6.0.0
django-rich==2.2.0
django-rq==3.2.2