mirror of
https://github.com/netbox-community/netbox.git
synced 2026-03-06 06:20:04 +01:00
Compare commits
22 Commits
17654-asn
...
20123-expo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c1742ca68 | ||
|
|
28f532e59a | ||
|
|
2a176df28a | ||
|
|
cd5d88ff8a | ||
|
|
6e3fd9d4b2 | ||
|
|
53ae164c75 | ||
|
|
fa5f9430fc | ||
|
|
351066c73f | ||
|
|
e6db3f75ea | ||
|
|
04244e188f | ||
|
|
eaad5cc26f | ||
|
|
c40640af81 | ||
|
|
3c6596de8f | ||
|
|
b3de0b9bee | ||
|
|
ec0fe62df5 | ||
|
|
d3a0566ee3 | ||
|
|
694e3765dd | ||
|
|
303199dc8f | ||
|
|
6eafffb497 | ||
|
|
53ea48efa9 | ||
|
|
1a404f5c0f | ||
|
|
3320e07b70 |
19
.github/workflows/claude-code-review.yml
vendored
19
.github/workflows/claude-code-review.yml
vendored
@@ -3,20 +3,14 @@ name: Claude Code Review
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
# Only run for PRs submitted by organization members or owners
|
||||
if: |
|
||||
github.repository == 'netbox-community/netbox' &&
|
||||
(github.event.pull_request.author_association == 'MEMBER' ||
|
||||
github.event.pull_request.author_association == 'OWNER')
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -33,7 +27,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
uses: anthropics/claude-code-action@e763fe78de2db7389e04818a00b5ff8ba13d1360 # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
@@ -41,4 +35,3 @@ jobs:
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ colorama
|
||||
|
||||
# The Python web framework on which NetBox is built
|
||||
# https://docs.djangoproject.com/en/stable/releases/
|
||||
Django==5.2.*
|
||||
Django==6.0.*
|
||||
|
||||
# Django middleware which permits cross-domain API requests
|
||||
# https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst
|
||||
@@ -35,7 +35,9 @@ django-pglocks
|
||||
|
||||
# Prometheus metrics library for Django
|
||||
# https://github.com/korfuri/django-prometheus/blob/master/CHANGELOG.md
|
||||
django-prometheus
|
||||
# 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 caching backend using Redis
|
||||
# https://github.com/jazzband/django-redis/blob/master/CHANGELOG.rst
|
||||
|
||||
@@ -31,6 +31,11 @@ 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.
|
||||
|
||||
!!! 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:
|
||||
|
||||
@@ -88,3 +88,8 @@ The following context variables are available in to the text and link templates.
|
||||
| `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.
|
||||
|
||||
@@ -43,6 +43,11 @@ 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:
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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
|
||||
@@ -82,3 +84,9 @@ 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
|
||||
|
||||
@@ -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
|
||||
from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS, MODULE_TOKEN
|
||||
from dcim.models import Device, DeviceBay, MACAddress, Module, VirtualDeviceContext
|
||||
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
|
||||
from ipam.api.serializers_.ip import IPAddressSerializer
|
||||
@@ -150,15 +150,128 @@ 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)
|
||||
data['replicate_components'] = replicate_components
|
||||
data['adopt_components'] = adopt_components
|
||||
|
||||
# Only check for component conflicts when creating a new module with replication or adoption enabled
|
||||
if self.instance or (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')
|
||||
|
||||
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.")
|
||||
)
|
||||
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 manage manually
|
||||
tags = validated_data.pop('tags', None)
|
||||
|
||||
# Build the instance without saving so we can set private attributes
|
||||
# that control component replication behaviour in Module.save()
|
||||
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:
|
||||
instance.tags.set([t.name for t in tags])
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class MACAddressSerializer(PrimaryModelSerializer):
|
||||
assigned_object_type = ContentTypeField(
|
||||
|
||||
@@ -1699,6 +1699,101 @@ 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)
|
||||
|
||||
|
||||
class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
model = ConsolePort
|
||||
|
||||
@@ -137,6 +137,12 @@ class DeviceDimensionsPanel(panels.ObjectAttributesPanel):
|
||||
total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/device/attrs/total_weight.html')
|
||||
|
||||
|
||||
class DeviceRolePanel(panels.NestedGroupObjectPanel):
|
||||
color = attrs.ColorAttr('color')
|
||||
vm_role = attrs.BooleanAttr('vm_role', label=_('VM role'))
|
||||
config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
|
||||
|
||||
|
||||
class DeviceTypePanel(panels.ObjectAttributesPanel):
|
||||
manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
|
||||
model = attrs.TextAttr('model')
|
||||
@@ -153,11 +159,36 @@ class DeviceTypePanel(panels.ObjectAttributesPanel):
|
||||
rear_image = attrs.ImageAttr('rear_image')
|
||||
|
||||
|
||||
class ModulePanel(panels.ObjectAttributesPanel):
|
||||
device = attrs.RelatedObjectAttr('device', linkify=True)
|
||||
device_type = attrs.RelatedObjectAttr('device.device_type', linkify=True, grouped_by='manufacturer')
|
||||
module_bay = attrs.NestedObjectAttr('module_bay', linkify=True)
|
||||
status = attrs.ChoiceAttr('status')
|
||||
description = attrs.TextAttr('description')
|
||||
serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
|
||||
asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True)
|
||||
|
||||
|
||||
class ModuleTypeProfilePanel(panels.ObjectAttributesPanel):
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class ModuleTypePanel(panels.ObjectAttributesPanel):
|
||||
profile = attrs.RelatedObjectAttr('profile', linkify=True)
|
||||
manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
|
||||
model = attrs.TextAttr('model', label=_('Model name'))
|
||||
part_number = attrs.TextAttr('part_number')
|
||||
description = attrs.TextAttr('description')
|
||||
airflow = attrs.ChoiceAttr('airflow')
|
||||
weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display')
|
||||
|
||||
|
||||
class PlatformPanel(panels.NestedGroupObjectPanel):
|
||||
manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
|
||||
config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
|
||||
|
||||
|
||||
class VirtualChassisMembersPanel(panels.ObjectPanel):
|
||||
"""
|
||||
A panel which lists all members of a virtual chassis.
|
||||
|
||||
@@ -25,6 +25,7 @@ from netbox.ui.panels import (
|
||||
NestedGroupObjectPanel,
|
||||
ObjectsTablePanel,
|
||||
OrganizationalObjectPanel,
|
||||
Panel,
|
||||
RelatedObjectsPanel,
|
||||
TemplatePanel,
|
||||
)
|
||||
@@ -1667,6 +1668,22 @@ class ModuleTypeListView(generic.ObjectListView):
|
||||
@register_model_view(ModuleType)
|
||||
class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = ModuleType.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ModuleTypePanel(),
|
||||
TagsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
Panel(
|
||||
title=_('Attributes'),
|
||||
template_name='dcim/panels/module_type_attributes.html',
|
||||
),
|
||||
RelatedObjectsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
ImageAttachmentsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -2306,6 +2323,27 @@ class DeviceRoleListView(generic.ObjectListView):
|
||||
@register_model_view(DeviceRole)
|
||||
class DeviceRoleView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = DeviceRole.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.DeviceRolePanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
RelatedObjectsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
ObjectsTablePanel(
|
||||
model='dcim.DeviceRole',
|
||||
title=_('Child Device Roles'),
|
||||
filters={'parent_id': lambda ctx: ctx['object'].pk},
|
||||
actions=[
|
||||
actions.AddObject('dcim.DeviceRole', url_params={'parent': lambda ctx: ctx['object'].pk}),
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -2385,6 +2423,27 @@ class PlatformListView(generic.ObjectListView):
|
||||
@register_model_view(Platform)
|
||||
class PlatformView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = Platform.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.PlatformPanel(),
|
||||
TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
RelatedObjectsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
ObjectsTablePanel(
|
||||
model='dcim.Platform',
|
||||
title=_('Child Platforms'),
|
||||
filters={'parent_id': lambda ctx: ctx['object'].pk},
|
||||
actions=[
|
||||
actions.AddObject('dcim.Platform', url_params={'parent': lambda ctx: ctx['object'].pk}),
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -2778,6 +2837,21 @@ class ModuleListView(generic.ObjectListView):
|
||||
@register_model_view(Module)
|
||||
class ModuleView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = Module.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ModulePanel(),
|
||||
TagsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
Panel(
|
||||
title=_('Module Type'),
|
||||
template_name='dcim/panels/module_type.html',
|
||||
),
|
||||
RelatedObjectsPanel(),
|
||||
CustomFieldsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import warnings
|
||||
from datetime import timedelta
|
||||
from importlib import import_module
|
||||
|
||||
@@ -17,11 +18,12 @@ class Command(BaseCommand):
|
||||
help = "Perform nightly housekeeping tasks [DEPRECATED]"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write(
|
||||
warnings.warn(
|
||||
"\n\nDEPRECATION WARNING\n"
|
||||
"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.",
|
||||
self.style.WARNING
|
||||
"will be removed in a future release.\n",
|
||||
category=FutureWarning,
|
||||
)
|
||||
|
||||
config = Config()
|
||||
|
||||
@@ -677,15 +677,19 @@ class ConfigContextTest(TestCase):
|
||||
if hasattr(node, 'children'):
|
||||
for child in node.children:
|
||||
try:
|
||||
if child.rhs.query.model is TaggedItem:
|
||||
subqueries.append(child.rhs.query)
|
||||
# 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)
|
||||
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(config_annotation.query.where)
|
||||
tag_subqueries = find_tag_subqueries(annotation_query.where)
|
||||
distinct_subqueries = [sq for sq in tag_subqueries if sq.distinct]
|
||||
|
||||
# Verify we found at least one DISTINCT subquery for tags
|
||||
|
||||
@@ -94,9 +94,11 @@ 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 = lhs_params + rhs_params
|
||||
params = list(lhs_params) + rhs_params
|
||||
return f'HOST({lhs}) = {rhs}', params
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
from utilities.exceptions import AbortRequest, PreconditionFailed
|
||||
from utilities.query import reapply_model_ordering
|
||||
|
||||
from . import mixins
|
||||
@@ -34,6 +34,50 @@ 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.
|
||||
@@ -95,6 +139,7 @@ class BaseViewSet(GenericViewSet):
|
||||
|
||||
|
||||
class NetBoxReadOnlyModelViewSet(
|
||||
ETagMixin,
|
||||
mixins.CustomFieldsMixin,
|
||||
mixins.ExportTemplatesMixin,
|
||||
drf_mixins.RetrieveModelMixin,
|
||||
@@ -105,6 +150,7 @@ class NetBoxReadOnlyModelViewSet(
|
||||
|
||||
|
||||
class NetBoxModelViewSet(
|
||||
ETagMixin,
|
||||
mixins.BulkUpdateModelMixin,
|
||||
mixins.BulkDestroyModelMixin,
|
||||
mixins.ObjectValidationMixin,
|
||||
@@ -191,7 +237,14 @@ class NetBoxModelViewSet(
|
||||
serializer = self.get_serializer(qs, many=bulk_create)
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
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
|
||||
|
||||
def perform_create(self, serializer):
|
||||
model = self.queryset.model
|
||||
@@ -211,6 +264,10 @@ 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)
|
||||
@@ -221,8 +278,12 @@ class NetBoxModelViewSet(
|
||||
|
||||
# Re-serialize the instance(s) with prefetched data
|
||||
serializer = self.get_serializer(qs)
|
||||
response = Response(serializer.data)
|
||||
|
||||
return Response(serializer.data)
|
||||
if etag := self._get_etag(qs):
|
||||
response['ETag'] = etag
|
||||
|
||||
return response
|
||||
|
||||
def perform_update(self, serializer):
|
||||
model = self.queryset.model
|
||||
@@ -232,6 +293,11 @@ 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:
|
||||
@@ -242,6 +308,9 @@ 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)
|
||||
@@ -256,7 +325,16 @@ class NetBoxModelViewSet(
|
||||
logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
|
||||
logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
|
||||
|
||||
return super().perform_destroy(instance)
|
||||
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()
|
||||
|
||||
|
||||
class MPTTLockedMixin:
|
||||
|
||||
@@ -79,6 +79,9 @@ class IntegerLookup:
|
||||
if not filters:
|
||||
return queryset, Q()
|
||||
|
||||
if isinstance(filters, RangeLookup):
|
||||
prefix = f'{prefix}range__'
|
||||
|
||||
return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)
|
||||
|
||||
|
||||
@@ -102,6 +105,9 @@ class BigIntegerLookup:
|
||||
if not filters:
|
||||
return queryset, Q()
|
||||
|
||||
if isinstance(filters, RangeLookup):
|
||||
prefix = f'{prefix}range__'
|
||||
|
||||
return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)
|
||||
|
||||
|
||||
@@ -125,6 +131,9 @@ class FloatLookup:
|
||||
if not filters:
|
||||
return queryset, Q()
|
||||
|
||||
if isinstance(filters, RangeLookup):
|
||||
prefix = f'{prefix}range__'
|
||||
|
||||
return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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
|
||||
@@ -21,6 +24,11 @@ __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):
|
||||
"""
|
||||
@@ -107,6 +115,13 @@ 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)
|
||||
|
||||
@@ -435,6 +435,7 @@ INSTALLED_APPS = [
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.humanize',
|
||||
'django.contrib.postgres',
|
||||
'django.forms',
|
||||
'corsheaders',
|
||||
'debug_toolbar',
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from dcim.choices import LocationStatusChoices
|
||||
from dcim.models import Location, Site
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Site, VirtualChassis
|
||||
from utilities.testing import APITestCase, TestCase, disable_warnings
|
||||
|
||||
|
||||
@@ -138,6 +138,40 @@ class GraphQLAPITestCase(APITestCase):
|
||||
self.assertNotIn('errors', data)
|
||||
self.assertEqual(len(data['data']['site']['locations']), 0)
|
||||
|
||||
def test_graphql_integer_range_lookup(self):
|
||||
"""
|
||||
Test that range_lookup works for integer fields (e.g. vc_position). Regression test for #20468.
|
||||
"""
|
||||
self.add_permissions('dcim.view_device')
|
||||
url = reverse('graphql')
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Test Manufacturer', slug='test-manufacturer')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device', slug='test-device')
|
||||
device_role = DeviceRole.objects.create(name='Test Role', slug='test-role')
|
||||
site = Site.objects.first()
|
||||
vc = VirtualChassis.objects.create(name='Test VC')
|
||||
|
||||
devices = [
|
||||
Device(name=f'Device {i}', device_type=device_type, role=device_role, site=site,
|
||||
virtual_chassis=vc, vc_position=i)
|
||||
for i in range(1, 6)
|
||||
]
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
# range_lookup should return devices with vc_position between 2 and 4 inclusive
|
||||
query = """
|
||||
{
|
||||
device_list(filters: {vc_position: {range_lookup: {start: 2, end: 4}}}) {
|
||||
id name
|
||||
}
|
||||
}
|
||||
"""
|
||||
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']['device_list']), 3)
|
||||
|
||||
def test_offset_pagination(self):
|
||||
self.add_permissions('dcim.view_site')
|
||||
url = reverse('graphql')
|
||||
|
||||
@@ -10,6 +10,7 @@ from core.models import DataSource, Job
|
||||
from utilities.testing import disable_warnings
|
||||
|
||||
from ..jobs import *
|
||||
from ..jobs import _INSTALL_ROOT
|
||||
|
||||
|
||||
class TestJobRunner(JobRunner):
|
||||
@@ -83,6 +84,12 @@ 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):
|
||||
|
||||
@@ -44,15 +44,18 @@ class Panel:
|
||||
Parameters:
|
||||
title (str): The human-friendly title of the panel
|
||||
actions (list): An iterable of PanelActions to include in the panel header
|
||||
template_name (str): Overrides the default template name, if defined
|
||||
"""
|
||||
template_name = None
|
||||
title = None
|
||||
actions = None
|
||||
|
||||
def __init__(self, title=None, actions=None):
|
||||
def __init__(self, title=None, actions=None, template_name=None):
|
||||
if title is not None:
|
||||
self.title = title
|
||||
self.actions = actions or self.actions or []
|
||||
if template_name is not None:
|
||||
self.template_name = template_name
|
||||
|
||||
def get_context(self, context):
|
||||
"""
|
||||
@@ -317,9 +320,8 @@ class TemplatePanel(Panel):
|
||||
Parameters:
|
||||
template_name (str): The name of the template to render
|
||||
"""
|
||||
def __init__(self, template_name, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.template_name = template_name
|
||||
def __init__(self, template_name):
|
||||
super().__init__(template_name=template_name)
|
||||
|
||||
def render(self, context):
|
||||
# Pass the entire context to the template
|
||||
|
||||
@@ -15,67 +15,3 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endblock extra_controls %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Device Role" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Parent" %}</th>
|
||||
<td>{{ object.parent|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Color" %}</th>
|
||||
<td>
|
||||
<span class="badge color-label" style="background-color: #{{ object.color }}"> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "VM Role" %}</th>
|
||||
<td>{% checkmark object.vm_role %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Config Template" %}</th>
|
||||
<td>{{ object.config_template|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h2 class="card-header">
|
||||
{% trans "Child Device Roles" %}
|
||||
{% if perms.dcim.add_devicerole %}
|
||||
<div class="card-actions">
|
||||
<a href="{% url 'dcim:devicerole_add' %}?parent={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Device Role" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% htmx_table 'dcim:devicerole_list' parent_id=object.pk %}
|
||||
</div>
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -46,75 +46,3 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Module" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Device" %}</th>
|
||||
<td>{{ object.device|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Device Type" %}</th>
|
||||
<td>{{ object.device.device_type|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Module Bay" %}</th>
|
||||
<td>{% nested_tree object.module_bay %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Status" %}</th>
|
||||
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Serial Number" %}</th>
|
||||
<td class="font-monospace">{{ object.serial|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Asset Tag" %}</th>
|
||||
<td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Module Type" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Manufacturer" %}</th>
|
||||
<td>{{ object.module_type.manufacturer|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Model" %}</th>
|
||||
<td>{{ object.module_type|linkify }}</td>
|
||||
</tr>
|
||||
{% for k, v in object.module_type.attributes.items %}
|
||||
<tr>
|
||||
<th scope="row">{{ k }}</th>
|
||||
<td>{{ v|placeholder }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load buttons %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %}
|
||||
@@ -14,92 +11,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_controls %}
|
||||
{% include 'dcim/inc/moduletype_buttons.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Module Type" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Profile" %}</th>
|
||||
<td>{{ object.profile|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Manufacturer" %}</th>
|
||||
<td>{{ object.manufacturer|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Model Name" %}</th>
|
||||
<td>{{ object.model }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Part Number" %}</th>
|
||||
<td>{{ object.part_number|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Airflow" %}</th>
|
||||
<td>{{ object.get_airflow_display|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Weight" %}</th>
|
||||
<td>
|
||||
{% if object.weight %}
|
||||
{{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Attributes" %}</h2>
|
||||
{% if not object.profile %}
|
||||
<div class="card-body text-muted">
|
||||
{% trans "No profile assigned" %}
|
||||
</div>
|
||||
{% elif object.attributes %}
|
||||
<table class="table table-hover attr-table">
|
||||
{% for k, v in object.attributes.items %}
|
||||
<tr>
|
||||
<th scope="row">{{ k }}</th>
|
||||
<td>
|
||||
{% if v is True or v is False %}
|
||||
{% checkmark v %}
|
||||
{% else %}
|
||||
{{ v|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-body text-muted">
|
||||
{% trans "None" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/image_attachments.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% include 'dcim/inc/moduletype_buttons.html' %}
|
||||
{% endblock %}
|
||||
|
||||
27
netbox/templates/dcim/panels/module_type.html
Normal file
27
netbox/templates/dcim/panels/module_type.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "ui/panels/_base.html" %}
|
||||
{% load helpers i18n %}
|
||||
|
||||
{% block panel_content %}
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Manufacturer" %}</th>
|
||||
<td>{{ object.module_type.manufacturer|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Model" %}</th>
|
||||
<td>{{ object.module_type|linkify }}</td>
|
||||
</tr>
|
||||
{% for k, v in object.module_type.attributes.items %}
|
||||
<tr>
|
||||
<th scope="row">{{ k }}</th>
|
||||
<td>
|
||||
{% if v is True or v is False %}
|
||||
{% checkmark v %}
|
||||
{% else %}
|
||||
{{ v|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock panel_content %}
|
||||
29
netbox/templates/dcim/panels/module_type_attributes.html
Normal file
29
netbox/templates/dcim/panels/module_type_attributes.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{% extends "ui/panels/_base.html" %}
|
||||
{% load helpers i18n %}
|
||||
|
||||
{% block panel_content %}
|
||||
{% if not object.profile %}
|
||||
<div class="card-body text-muted">
|
||||
{% trans "No profile assigned" %}
|
||||
</div>
|
||||
{% elif object.attributes %}
|
||||
<table class="table table-hover attr-table">
|
||||
{% for k, v in object.attributes.items %}
|
||||
<tr>
|
||||
<th scope="row">{{ k }}</th>
|
||||
<td>
|
||||
{% if v is True or v is False %}
|
||||
{% checkmark v %}
|
||||
{% else %}
|
||||
{{ v|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-body text-muted">
|
||||
{% trans "None" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock panel_content %}
|
||||
@@ -18,61 +18,3 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endblock extra_controls %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Platform" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Parent" %}</th>
|
||||
<td>{{ object.parent|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Manufacturer" %}</th>
|
||||
<td>{{ object.manufacturer|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Config Template" %}</th>
|
||||
<td>{{ object.config_template|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h2 class="card-header">
|
||||
{% trans "Child Platforms" %}
|
||||
{% if perms.dcim.add_platform %}
|
||||
<div class="card-actions">
|
||||
<a href="{% url 'dcim:platform_add' %}?parent={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Platform" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% htmx_table 'dcim:platform_list' parent_id=object.pk %}
|
||||
</div>
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -92,7 +92,7 @@ Context:
|
||||
|
||||
<div class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
|
||||
<input type="hidden" id="object-list-return-url" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
|
||||
|
||||
{# Warn of any missing prerequisite objects #}
|
||||
{% if prerequisite_model %}
|
||||
|
||||
@@ -32,4 +32,9 @@
|
||||
{% action_buttons actions model multi=True %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Update the return_url to reflect any changed query parameters (e.g. per_page) #}
|
||||
{% if not table.embedded %}
|
||||
<input type="hidden" id="object-list-return-url" name="return_url" value="{{ request.get_full_path }}" hx-swap-oob="outerHTML:#object-list-return-url" />
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -6,6 +6,7 @@ __all__ = (
|
||||
'AbortScript',
|
||||
'AbortTransaction',
|
||||
'PermissionsViolation',
|
||||
'PreconditionFailed',
|
||||
'RQWorkerNotRunningException',
|
||||
)
|
||||
|
||||
@@ -40,6 +41,20 @@ 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
|
||||
|
||||
@@ -114,7 +114,12 @@ class APIViewTestCases:
|
||||
|
||||
# Try GET to permitted object
|
||||
url = self._get_detail_url(instance1)
|
||||
self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK)
|
||||
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")
|
||||
|
||||
# Try GET to non-permitted object
|
||||
url = self._get_detail_url(instance2)
|
||||
@@ -367,6 +372,46 @@ 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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
colorama==0.4.6
|
||||
Django==5.2.11
|
||||
Django==6.0.3
|
||||
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.1
|
||||
django-prometheus==2.4.0
|
||||
django-redis==6.0.0
|
||||
django-rich==2.2.0
|
||||
django-rq==3.2.2
|
||||
|
||||
Reference in New Issue
Block a user