Compare commits

..

7 Commits

Author SHA1 Message Date
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
Jeremy Stretch
fa5f9430fc Fixes #20468: Fix range lookups for numeric GraphQL filters (#21589)
* Fixes #20468: Fix range lookups for numeric GraphQL filters

* Update netbox/netbox/tests/test_graphql.py

---------

Co-authored-by: Martin Hauser <mhauser@netboxlabs.com>
2026-03-05 17:10:49 +01:00
Jeremy Stretch
351066c73f Limit auto-review workflow to GitHub org members (#21570) 2026-03-05 08:06:43 -08:00
bctiemann
e6db3f75ea Merge pull request #21588 from netbox-community/19867-preserve-per_page-param
Fixes #19867: Retain the `per_page` URL parameter after editing an object
2026-03-05 09:56:32 -05:00
Jeremy Stretch
04244e188f #20923: Migrate DCIM view templates (#21372)
* Permit passing template_name to Panel instance

* Define UI layout for ModuleType view

* Define UI layout for DeviceRole view

* Define UI layout for Platform view

* Define UI layout for Module view

* Misc cleanup

* Linkify module bay
2026-03-05 08:43:46 -05:00
Jeremy Stretch
eaad5cc26f Fixes #19867: Retain the per_page URL parameter after editing an object 2026-03-05 08:26:47 -05:00
20 changed files with 244 additions and 438 deletions

View File

@@ -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

View File

@@ -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.

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 (
@@ -25,6 +25,7 @@ from netbox.ui.panels import (
NestedGroupObjectPanel,
ObjectsTablePanel,
OrganizationalObjectPanel,
Panel,
RelatedObjectsPanel,
TemplatePanel,
)
@@ -388,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}),
],
),
]
@@ -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 {
@@ -3156,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:
@@ -3186,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,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

@@ -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)

View File

@@ -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')

View File

@@ -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

View File

@@ -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 }}">&nbsp;</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 %}

View File

@@ -411,7 +411,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

@@ -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 %}

View File

@@ -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 %}

View 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 %}

View 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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -38,7 +38,6 @@ FILTER_TREENODE_NEGATION_LOOKUP_MAP = dict(
# HTTP Request META safe copy
#
# Non-HTTP_ META keys to include when copying a request (whitelist)
HTTP_REQUEST_META_SAFE_COPY = [
'CONTENT_LENGTH',
'CONTENT_TYPE',
@@ -62,13 +61,6 @@ HTTP_REQUEST_META_SAFE_COPY = [
'SERVER_PORT',
]
# HTTP_ META keys known to carry sensitive data; excluded when copying a request (denylist)
HTTP_REQUEST_META_SENSITIVE = {
'HTTP_AUTHORIZATION',
'HTTP_COOKIE',
'HTTP_PROXY_AUTHORIZATION',
}
#
# CSV-style format delimiters

View File

@@ -8,7 +8,7 @@ from netaddr import AddrFormatError, IPAddress
from netbox.registry import registry
from .constants import HTTP_REQUEST_META_SAFE_COPY, HTTP_REQUEST_META_SENSITIVE
from .constants import HTTP_REQUEST_META_SAFE_COPY
__all__ = (
'NetBoxFakeRequest',
@@ -45,14 +45,11 @@ def copy_safe_request(request, include_files=True):
request: The original request object
include_files: Whether to include request.FILES.
"""
meta = {}
for k, v in request.META.items():
if not isinstance(v, str):
continue
if k in HTTP_REQUEST_META_SAFE_COPY:
meta[k] = v
elif k.startswith('HTTP_') and k not in HTTP_REQUEST_META_SENSITIVE:
meta[k] = v
meta = {
k: request.META[k]
for k in HTTP_REQUEST_META_SAFE_COPY
if k in request.META and isinstance(request.META[k], str)
}
data = {
'META': meta,
'COOKIES': request.COOKIES,

View File

@@ -1,42 +1,7 @@
from django.contrib.auth.models import AnonymousUser
from django.test import RequestFactory, TestCase
from netaddr import IPAddress
from utilities.request import copy_safe_request, get_client_ip
class CopySafeRequestTests(TestCase):
def setUp(self):
self.factory = RequestFactory()
def _make_request(self, **kwargs):
request = self.factory.get('/', **kwargs)
request.user = AnonymousUser()
return request
def test_standard_meta_keys_copied(self):
request = self._make_request(HTTP_USER_AGENT='TestAgent/1.0')
fake = copy_safe_request(request)
self.assertEqual(fake.META.get('HTTP_USER_AGENT'), 'TestAgent/1.0')
def test_arbitrary_http_headers_copied(self):
"""Arbitrary HTTP_ headers (e.g. X-NetBox-*) should be included."""
request = self._make_request(HTTP_X_NETBOX_BRANCH='my-branch')
fake = copy_safe_request(request)
self.assertEqual(fake.META.get('HTTP_X_NETBOX_BRANCH'), 'my-branch')
def test_sensitive_headers_excluded(self):
"""Authorization and Cookie headers must not be copied."""
request = self._make_request(HTTP_AUTHORIZATION='Bearer secret')
fake = copy_safe_request(request)
self.assertNotIn('HTTP_AUTHORIZATION', fake.META)
def test_non_string_meta_values_excluded(self):
"""Non-string META values must not be copied."""
request = self._make_request()
request.META['HTTP_X_CUSTOM_INT'] = 42
fake = copy_safe_request(request)
self.assertNotIn('HTTP_X_CUSTOM_INT', fake.META)
from utilities.request import get_client_ip
class GetClientIPTests(TestCase):

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,
}