Compare commits

..

2 Commits

Author SHA1 Message Date
Martin Hauser
3509169ccf feat(extras): Allow updates to data_source and data_file via API
Adds support for PATCHing ConfigContext and ConfigContextProfile with
integer IDs for `data_source` and `data_file`.
Adds regression tests to validate assignment and API functionality.

Fixes #20933
2026-01-14 17:58:26 +01:00
Jeremy Stretch
6bd083b7ed Closes #21142: Enable filtering device components by site/location/rack directly via GraphQL API (#21145) 2026-01-14 08:06:55 -06:00
6 changed files with 159 additions and 34 deletions

View File

@@ -44,3 +44,4 @@ class DataFileSerializer(NetBoxModelSerializer):
'id', 'url', 'display_url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
]
brief_fields = ('id', 'url', 'display', 'path')
read_only_fields = ['path', 'last_updated', 'size', 'hash']

View File

@@ -140,6 +140,9 @@ class FrontPortFormMixin(forms.Form):
widget=forms.SelectMultiple(attrs={'size': 8})
)
port_mapping_model = PortMapping
parent_field = 'device'
def clean(self):
super().clean()
@@ -200,22 +203,3 @@ class FrontPortFormMixin(forms.Form):
using=connection,
update_fields=None
)
def _get_rear_port_choices(self, parent_filter, front_port):
"""
Return a list of choices representing each available rear port & position pair on the parent object (identified
by a Q filter), excluding those assigned to the specified instance.
"""
occupied_rear_port_positions = [
f'{mapping.rear_port_id}:{mapping.rear_port_position}'
for mapping in self.port_mapping_model.objects.filter(parent_filter).exclude(front_port=front_port.pk)
]
choices = []
for rear_port in self.rear_port_model.objects.filter(parent_filter):
for i in range(1, rear_port.positions + 1):
pair_id = f'{rear_port.pk}:{i}'
if pair_id not in occupied_rear_port_positions:
pair_label = f'{rear_port.name}:{i}'
choices.append((pair_id, pair_label))
return choices

View File

@@ -1124,8 +1124,9 @@ class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm):
),
)
# Override FrontPortFormMixin attrs
port_mapping_model = PortTemplateMapping
rear_port_model = RearPortTemplate
parent_field = 'device_type'
class Meta:
model = FrontPortTemplate
@@ -1136,14 +1137,13 @@ class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Populate rear port choices based on parent DeviceType or ModuleType
if device_type_id := self.data.get('device_type') or self.initial.get('device_type'):
parent_filter = Q(device_type=device_type_id)
elif module_type_id := self.data.get('module_type') or self.initial.get('module_type'):
parent_filter = Q(module_type=module_type_id)
device_type = DeviceType.objects.get(pk=device_type_id)
else:
return
self.fields['rear_ports'].choices = self._get_rear_port_choices(parent_filter, self.instance)
# Populate rear port choices
self.fields['rear_ports'].choices = self._get_rear_port_choices(device_type, self.instance)
# Set initial rear port mappings
if self.instance.pk:
@@ -1152,6 +1152,27 @@ class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm):
for mapping in PortTemplateMapping.objects.filter(front_port_id=self.instance.pk)
]
def _get_rear_port_choices(self, device_type, front_port):
"""
Return a list of choices representing each available rear port & position pair on the device type, excluding
those assigned to the specified instance.
"""
occupied_rear_port_positions = [
f'{mapping.rear_port_id}:{mapping.rear_port_position}'
for mapping in device_type.port_mappings.exclude(front_port=front_port.pk)
]
choices = []
for rear_port in RearPortTemplate.objects.filter(device_type=device_type):
for i in range(1, rear_port.positions + 1):
pair_id = f'{rear_port.pk}:{i}'
if pair_id not in occupied_rear_port_positions:
pair_label = f'{rear_port.name}:{i}'
choices.append(
(pair_id, pair_label)
)
return choices
class RearPortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
@@ -1598,9 +1619,6 @@ class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm):
),
)
port_mapping_model = PortMapping
rear_port_model = RearPort
class Meta:
model = FrontPort
fields = [
@@ -1611,12 +1629,13 @@ class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Populate rear port choices
if device_id := self.data.get('device') or self.initial.get('device'):
parent_filter = Q(device=device_id)
device = Device.objects.get(pk=device_id)
else:
return
self.fields['rear_ports'].choices = self._get_rear_port_choices(parent_filter, self.instance)
# Populate rear port choices
self.fields['rear_ports'].choices = self._get_rear_port_choices(device, self.instance)
# Set initial rear port mappings
if self.instance.pk:
@@ -1625,6 +1644,27 @@ class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm):
for mapping in PortMapping.objects.filter(front_port_id=self.instance.pk)
]
def _get_rear_port_choices(self, device, front_port):
"""
Return a list of choices representing each available rear port & position pair on the device, excluding those
assigned to the specified instance.
"""
occupied_rear_port_positions = [
f'{mapping.rear_port_id}:{mapping.rear_port_position}'
for mapping in device.port_mappings.exclude(front_port=front_port.pk)
]
choices = []
for rear_port in RearPort.objects.filter(device=device):
for i in range(1, rear_port.positions + 1):
pair_id = f'{rear_port.pk}:{i}'
if pair_id not in occupied_rear_port_positions:
pair_label = f'{rear_port.name}:{i}'
choices.append(
(pair_id, pair_label)
)
return choices
class RearPortForm(ModularDeviceComponentForm):
fieldsets = (

View File

@@ -38,6 +38,15 @@ class ScopedFilterMixin:
@dataclass
class ComponentModelFilterMixin:
_site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='site')
)
_location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='location')
)
_rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='rack')
)
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
device_id: ID | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field()

View File

@@ -28,7 +28,7 @@ class ConfigContextProfileSerializer(PrimaryModelSerializer):
)
data_file = DataFileSerializer(
nested=True,
read_only=True
required=False
)
class Meta:
@@ -143,7 +143,7 @@ class ConfigContextSerializer(OwnerMixin, ChangeLogMessageSerializer, ValidatedM
)
data_file = DataFileSerializer(
nested=True,
read_only=True
required=False
)
class Meta:

View File

@@ -1,4 +1,5 @@
import datetime
import hashlib
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
@@ -7,7 +8,7 @@ from rest_framework import status
from core.choices import ManagedFileRootPathChoices
from core.events import *
from core.models import ObjectType
from core.models import DataFile, DataSource, ObjectType
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
from extras.choices import *
from extras.models import *
@@ -731,6 +732,51 @@ class ConfigContextProfileTest(APIViewTestCases.APIViewTestCase):
)
ConfigContextProfile.objects.bulk_create(profiles)
def test_update_data_source_and_data_file(self):
"""
Regression test: Ensure data_source and data_file can be assigned via the API.
This specifically covers PATCHing a ConfigContext with integer IDs for both fields.
"""
self.add_permissions(
'core.view_datafile',
'core.view_datasource',
'extras.view_configcontextprofile',
'extras.change_configcontextprofile',
)
config_context_profile = ConfigContextProfile.objects.first()
# Create a data source and file
datasource = DataSource.objects.create(
name='Data Source 1',
type='local',
source_url='file:///tmp/netbox-datasource/',
)
# Generate a valid dummy YAML file
file_data = b'profile: configcontext\n'
datafile = DataFile.objects.create(
source=datasource,
path='dir1/file1.yml',
last_updated=now(),
size=len(file_data),
hash=hashlib.sha256(file_data).hexdigest(),
data=file_data,
)
url = self._get_detail_url(config_context_profile)
payload = {
'data_source': datasource.pk,
'data_file': datafile.pk,
}
response = self.client.patch(url, payload, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
config_context_profile.refresh_from_db()
self.assertEqual(config_context_profile.data_source_id, datasource.pk)
self.assertEqual(config_context_profile.data_file_id, datafile.pk)
self.assertEqual(response.data['data_source']['id'], datasource.pk)
self.assertEqual(response.data['data_file']['id'], datafile.pk)
class ConfigContextTest(APIViewTestCases.APIViewTestCase):
model = ConfigContext
@@ -812,6 +858,51 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
rendered_context = device.get_config_context()
self.assertEqual(rendered_context['bar'], 456)
def test_update_data_source_and_data_file(self):
"""
Regression test: Ensure data_source and data_file can be assigned via the API.
This specifically covers PATCHing a ConfigContext with integer IDs for both fields.
"""
self.add_permissions(
'core.view_datafile',
'core.view_datasource',
'extras.view_configcontext',
'extras.change_configcontext',
)
config_context = ConfigContext.objects.first()
# Create a data source and file
datasource = DataSource.objects.create(
name='Data Source 1',
type='local',
source_url='file:///tmp/netbox-datasource/',
)
# Generate a valid dummy YAML file
file_data = b'context: config\n'
datafile = DataFile.objects.create(
source=datasource,
path='dir1/file1.yml',
last_updated=now(),
size=len(file_data),
hash=hashlib.sha256(file_data).hexdigest(),
data=file_data,
)
url = self._get_detail_url(config_context)
payload = {
'data_source': datasource.pk,
'data_file': datafile.pk,
}
response = self.client.patch(url, payload, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
config_context.refresh_from_db()
self.assertEqual(config_context.data_source_id, datasource.pk)
self.assertEqual(config_context.data_file_id, datafile.pk)
self.assertEqual(response.data['data_source']['id'], datasource.pk)
self.assertEqual(response.data['data_file']['id'], datafile.pk)
class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
model = ConfigTemplate