Merge branch 'main' into 18904-config-context-table

This commit is contained in:
Arthur 2025-03-24 09:29:30 -07:00
commit ba03dd7dfd
59 changed files with 14810 additions and 13476 deletions

View File

@ -15,7 +15,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v4.2.5
placeholder: v4.2.6
validations:
required: true
- type: dropdown

View File

@ -27,7 +27,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v4.2.5
placeholder: v4.2.6
validations:
required: true
- type: dropdown

View File

@ -8,7 +8,10 @@ django-cors-headers
# Runtime UI tool for debugging Django
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
django-debug-toolbar
# See: https://django-debug-toolbar.readthedocs.io/en/latest/changes.html#id1
# "Wrap SHOW_TOOLBAR_CALLBACK function with sync_to_async or async_to_sync to allow sync/async
# compatibility." breaks stawberry-graphql-django at version 0.52.0 (current)
django-debug-toolbar==5.0.1
# Library for writing reusable URL query filters
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
@ -88,8 +91,7 @@ mkdocs-material
# Introspection for embedded code
# https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md
# See #18568
mkdocstrings[python-legacy]==0.27.0
mkdocstrings[python]
# Library for manipulating IP prefixes and addresses
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst

View File

@ -500,6 +500,9 @@
"n",
"mrj21",
"fc",
"fc-pc",
"fc-upc",
"fc-apc",
"lc",
"lc-pc",
"lc-upc",
@ -565,6 +568,9 @@
"n",
"mrj21",
"fc",
"fc-pc",
"fc-upc",
"fc-apc",
"lc",
"lc-pc",
"lc-upc",

View File

@ -1,5 +1,40 @@
# NetBox v4.2
## v4.2.6 (2025-03-21)
### Enhancements
* [#17503](https://github.com/netbox-community/netbox/issues/17503) - Add rack title above rack on rack detail view
* [#17686](https://github.com/netbox-community/netbox/issues/17686) - Add config option for disk space divisor
* [#18579](https://github.com/netbox-community/netbox/issues/18579) - Update filtersets and filter forms to include contact filters where missing
* [#18744](https://github.com/netbox-community/netbox/issues/18744) - Ensure contact link in tables is hyperlinked
* [#18816](https://github.com/netbox-community/netbox/issues/18816) - Add FC/UPC, FC/APC and FC/PC port types
* [#18880](https://github.com/netbox-community/netbox/issues/18880) - Delay enqueuing background tasks until DB transaction is committed to avoid race condition
* [#18939](https://github.com/netbox-community/netbox/issues/18939) - Support site group search for ASNs
### Bug Fixes
* [#18409](https://github.com/netbox-community/netbox/issues/18409) - Eliminate N+1 issue by adding generic prefetch operation to Interface API endpoint
* [#18557](https://github.com/netbox-community/netbox/issues/18557) - Update JSONField to enclose bare string values in quotes
* [#18582](https://github.com/netbox-community/netbox/issues/18582) - Fix prefix bulk import with associated VLAN and conflicting VLAN IDs
* [#18742](https://github.com/netbox-community/netbox/issues/18742) - Ensure location list and detail views show related VLAN group information
* [#18782](https://github.com/netbox-community/netbox/issues/18782) - Ensure misconfigured object list widgets on the dashboard now degrade gracefully
* [#18833](https://github.com/netbox-community/netbox/issues/18833) - Fix inventory item bulk edit to ensure that component name and type are both validated Ensure
* [#18838](https://github.com/netbox-community/netbox/issues/18838) - Ensure that local context data correctly rejects falsy values
* [#18845](https://github.com/netbox-community/netbox/issues/18845) - Restore default sort behavior of name column on devices list view
* [#18863](https://github.com/netbox-community/netbox/issues/18863) - Exempt MPTT-based models from ordering fix introduced in #18279
* [#18869](https://github.com/netbox-community/netbox/issues/18869) - Ensure numeric conversion helper always return a clean decimal value
* [#18872](https://github.com/netbox-community/netbox/issues/18872) - Ensure that `kind` is a required field when making journal entries
* [#18884](https://github.com/netbox-community/netbox/issues/18884) - Ensure tag deserialization is handled correctly
* [#18887](https://github.com/netbox-community/netbox/issues/18887) - Allow VM interface objects to be set on prefix object-type custom field
* [#18926](https://github.com/netbox-community/netbox/issues/18926) - Fix icon displayed for GitHub authentication on login page
* [#18928](https://github.com/netbox-community/netbox/issues/18928) - Support cascading deletions when cleaning up expired changelog records
* [#18933](https://github.com/netbox-community/netbox/issues/18933) - Allow filtering VLAN groups by associated site groups
* [#18944](https://github.com/netbox-community/netbox/issues/18944) - Ensure clearing "Widget type" field when adding widgets to dashboard does not cause a "ValueError: Unregistered widget class" error
* [#18949](https://github.com/netbox-community/netbox/issues/18949) - Add missing contacts property to GraphQL types where the associated model has a connection to a contact
---
## v4.2.5 (2025-03-06)
### Enhancements

View File

@ -43,7 +43,7 @@ class ProviderType(NetBoxObjectType, ContactsMixin):
fields='__all__',
filters=ProviderAccountFilter
)
class ProviderAccountType(NetBoxObjectType):
class ProviderAccountType(ContactsMixin, NetBoxObjectType):
provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')]
circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]

View File

@ -1161,27 +1161,45 @@ class InventoryItemImportForm(NetBoxModelImportForm):
else:
self.fields['parent'].queryset = InventoryItem.objects.none()
def clean_component_name(self):
content_type = self.cleaned_data.get('component_type')
component_name = self.cleaned_data.get('component_name')
def clean(self):
super().clean()
cleaned_data = self.cleaned_data
component_type = cleaned_data.get('component_type')
component_name = cleaned_data.get('component_name')
device = self.cleaned_data.get("device")
if not device and hasattr(self, 'instance') and hasattr(self.instance, 'device'):
device = self.instance.device
if not all([device, content_type, component_name]):
return None
model = content_type.model_class()
try:
component = model.objects.get(device=device, name=component_name)
self.instance.component = component
except ObjectDoesNotExist:
raise forms.ValidationError(
_("Component not found: {device} - {component_name}").format(
device=device, component_name=component_name
if component_type:
if device is None:
cleaned_data.pop('component_type', None)
if component_name is None:
cleaned_data.pop('component_type', None)
raise forms.ValidationError(
_("Component name must be specified when component type is specified")
)
)
if all([device, component_name]):
try:
model = component_type.model_class()
self.instance.component = model.objects.get(device=device, name=component_name)
except ObjectDoesNotExist:
cleaned_data.pop('component_type', None)
cleaned_data.pop('component_name', None)
raise forms.ValidationError(
_("Component not found: {device} - {component_name}").format(
device=device, component_name=component_name
)
)
else:
cleaned_data.pop('component_type', None)
if not component_name:
raise forms.ValidationError(
_("Component name must be specified when component type is specified")
)
else:
if component_name:
raise forms.ValidationError(
_("Component type must be specified when component name is specified")
)
return cleaned_data
#

View File

@ -9,6 +9,7 @@ import requests
from django import forms
from django.conf import settings
from django.core.cache import cache
from django.db.models import Model
from django.template.loader import render_to_string
from django.urls import NoReverseMatch, resolve, reverse
from django.utils.translation import gettext as _
@ -42,6 +43,27 @@ def get_object_type_choices():
]
def object_list_widget_supports_model(model: Model) -> bool:
"""Test whether a model is supported by the ObjectListWidget
In theory there could be more than one reason why a model isn't supported by the
ObjectListWidget, although we've only identified one so far--there's no resolve-able 'list' URL
for the model. Add more tests if more conditions arise.
"""
def can_resolve_model_list_view(model: Model) -> bool:
try:
reverse(get_viewname(model, action='list'))
return True
except Exception:
return False
tests = [
can_resolve_model_list_view,
]
return all(test(model) for test in tests)
def get_bookmarks_object_type_choices():
return [
(object_type_identifier(ot), object_type_name(ot))
@ -234,6 +256,17 @@ class ObjectListWidget(DashboardWidget):
raise forms.ValidationError(_("Invalid format. URL parameters must be passed as a dictionary."))
return data
def clean_model(self):
if model_info := self.cleaned_data['model']:
app_label, model_name = model_info.split('.')
model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class()
if not object_list_widget_supports_model(model):
raise forms.ValidationError(
_(f"Invalid model selection: {self['model'].data} is not supported.")
)
return model_info
def render(self, request):
app_label, model_name = self.config['model'].split('.')
model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class()
@ -257,7 +290,7 @@ class ObjectListWidget(DashboardWidget):
parameters['per_page'] = page_size
parameters['embedded'] = True
if parameters:
if parameters and htmx_url is not None:
try:
htmx_url = f'{htmx_url}?{urlencode(parameters, doseq=True)}'
except ValueError:

View File

@ -14,7 +14,7 @@ from netbox.events import get_event_type_choices
from netbox.forms import NetBoxModelForm
from tenancy.models import Tenant, TenantGroup
from users.models import Group, User
from utilities.forms import add_blank_choice, get_field_value
from utilities.forms import get_field_value
from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, JSONField, SlugField,
@ -687,8 +687,7 @@ class ImageAttachmentForm(forms.ModelForm):
class JournalEntryForm(NetBoxModelForm):
kind = forms.ChoiceField(
label=_('Kind'),
choices=add_blank_choice(JournalEntryKindChoices),
required=False
choices=JournalEntryKindChoices
)
comments = CommentField()

View File

@ -5,7 +5,6 @@ import requests
from django.conf import settings
from django.core.cache import cache
from django.core.management.base import BaseCommand
from django.db import DEFAULT_DB_ALIAS
from django.utils import timezone
from packaging import version
@ -53,7 +52,7 @@ class Command(BaseCommand):
ending=""
)
self.stdout.flush()
ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
ObjectChange.objects.filter(time__lt=cutoff).delete()
if options['verbosity']:
self.stdout.write("Done.", self.style.SUCCESS)
elif options['verbosity']:

View File

@ -0,0 +1,25 @@
from django.db import migrations
from extras.choices import JournalEntryKindChoices
def set_kind_default(apps, schema_editor):
"""
Set kind to "info" on any entries with no kind assigned.
"""
JournalEntry = apps.get_model('extras', 'JournalEntry')
JournalEntry.objects.filter(kind='').update(kind=JournalEntryKindChoices.KIND_INFO)
class Migration(migrations.Migration):
dependencies = [
('extras', '0122_charfield_null_choices'),
]
operations = [
migrations.RunPython(
code=set_kind_default,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -0,0 +1,48 @@
from django.test import tag, TestCase
from extras.dashboard.widgets import ObjectListWidget
class ObjectListWidgetTests(TestCase):
def test_widget_config_form_validates_model(self):
model_info = 'extras.notification'
form = ObjectListWidget.ConfigForm({'model': model_info})
self.assertFalse(form.is_valid())
@tag('regression')
def test_widget_fails_gracefully(self):
"""
Example:
'2829fd9b-5dee-4c9a-81f2-5bd84c350a27': {
'class': 'extras.ObjectListWidget',
'color': 'indigo',
'title': 'Object List',
'config': {
'model': 'extras.notification',
'page_size': None,
'url_params': None
}
}
"""
config = {
# 'class': 'extras.ObjectListWidget', # normally popped off, left for clarity
'color': 'yellow',
'title': 'this should fail',
'config': {
'model': 'extras.notification',
'page_size': None,
'url_params': None,
},
}
class Request:
class User:
def has_perm(self, *args, **kwargs):
return True
user = User()
mock_request = Request()
widget = ObjectListWidget(id='2829fd9b-5dee-4c9a-81f2-5bd84c350a27', **config)
rendered = widget.render(mock_request)
self.assertTrue('Unable to load content. Invalid view name:' in rendered)

View File

@ -1098,8 +1098,8 @@ class DashboardWidgetAddView(LoginRequiredMixin, View):
if not request.htmx:
return redirect('home')
initial = request.GET or {
'widget_class': 'extras.NoteWidget',
initial = {
'widget_class': request.GET.get('widget_class') or 'extras.NoteWidget',
}
widget_form = DashboardWidgetAddForm(initial=initial)
widget_name = get_field_value(widget_form, 'widget_class')

View File

@ -232,6 +232,19 @@ class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
to_field_name='slug',
label=_('RIR (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='sites__group',
lookup_expr='in',
label=_('Site group (ID)'),
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='sites__group',
lookup_expr='in',
to_field_name='slug',
label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='sites',
queryset=Site.objects.all(),

View File

@ -327,6 +327,13 @@ class IPAddressImportForm(NetBoxModelImportForm):
to_field_name='name',
help_text=_('Assigned interface')
)
fhrp_group = CSVModelChoiceField(
label=_('FHRP Group'),
queryset=FHRPGroup.objects.all(),
required=False,
to_field_name='name',
help_text=_('Assigned FHRP Group name')
)
is_primary = forms.BooleanField(
label=_('Is primary'),
help_text=_('Make this the primary IP for the assigned device'),
@ -341,8 +348,8 @@ class IPAddressImportForm(NetBoxModelImportForm):
class Meta:
model = IPAddress
fields = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
'is_oob', 'dns_name', 'description', 'comments', 'tags',
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'fhrp_group',
'is_primary', 'is_oob', 'dns_name', 'description', 'comments', 'tags',
]
def __init__(self, data=None, *args, **kwargs):
@ -398,6 +405,8 @@ class IPAddressImportForm(NetBoxModelImportForm):
# Set interface assignment
if self.cleaned_data.get('interface'):
self.instance.assigned_object = self.cleaned_data['interface']
if self.cleaned_data.get('fhrp_group'):
self.instance.assigned_object = self.cleaned_data['fhrp_group']
ipaddress = super().save(*args, **kwargs)

View File

@ -142,7 +142,7 @@ class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = ASN
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('rir_id', 'site_id', name=_('Assignment')),
FieldSet('rir_id', 'site_group_id', 'site_id', name=_('Assignment')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
)
rir_id = DynamicModelMultipleChoiceField(
@ -150,6 +150,11 @@ class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
required=False,
label=_('RIR')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
@ -418,7 +423,7 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
class VLANGroupFilterForm(NetBoxModelFilterSetForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')),
FieldSet('region', 'site_group', 'site', 'location', 'rack', name=_('Location')),
FieldSet('cluster_group', 'cluster', name=_('Cluster')),
FieldSet('contains_vid', name=_('VLANs')),
)
@ -428,7 +433,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('Region')
)
sitegroup = DynamicModelMultipleChoiceField(
site_group = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')

View File

@ -5,6 +5,7 @@ import strawberry_django
from circuits.graphql.types import ProviderType
from dcim.graphql.types import SiteType
from extras.graphql.mixins import ContactsMixin
from ipam import models
from netbox.graphql.scalars import BigInt
from netbox.graphql.types import BaseObjectType, NetBoxObjectType, OrganizationalObjectType
@ -83,7 +84,7 @@ class ASNRangeType(NetBoxObjectType):
fields='__all__',
filters=AggregateFilter
)
class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType):
class AggregateType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType):
prefix: str
rir: Annotated["RIRType", strawberry.lazy('ipam.graphql.types')] | None
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
@ -120,7 +121,7 @@ class FHRPGroupAssignmentType(BaseObjectType):
exclude=('assigned_object_type', 'assigned_object_id', 'address'),
filters=IPAddressFilter
)
class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
class IPAddressType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType):
address: str
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
@ -144,7 +145,7 @@ class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
fields='__all__',
filters=IPRangeFilter
)
class IPRangeType(NetBoxObjectType):
class IPRangeType(NetBoxObjectType, ContactsMixin):
start_address: str
end_address: str
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
@ -157,7 +158,7 @@ class IPRangeType(NetBoxObjectType):
exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'),
filters=PrefixFilter
)
class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):
class PrefixType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType):
prefix: str
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
@ -217,7 +218,7 @@ class RouteTargetType(NetBoxObjectType):
fields='__all__',
filters=ServiceFilter
)
class ServiceType(NetBoxObjectType):
class ServiceType(NetBoxObjectType, ContactsMixin):
ports: List[int]
device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None
virtual_machine: Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')] | None

View File

@ -133,10 +133,18 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
)
ASN.objects.bulk_create(asns)
site_groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for site_group in site_groups:
site_group.save()
sites = [
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3')
Site(name='Site 1', slug='site-1', group=site_groups[0]),
Site(name='Site 2', slug='site-2', group=site_groups[1]),
Site(name='Site 3', slug='site-3', group=site_groups[2]),
]
Site.objects.bulk_create(sites)
asns[0].sites.set([sites[0]])
@ -178,6 +186,13 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'rir': [rirs[0].slug, rirs[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}

View File

@ -666,6 +666,24 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
tags = create_tags('Alpha', 'Bravo', 'Charlie')
fhrp_groups = (
FHRPGroup(
name='FHRP Group 1',
protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP,
group_id=10
),
FHRPGroup(
name='FHRP Group 2',
protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP,
group_id=20
),
FHRPGroup(
name='FHRP Group 3',
protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP,
group_id=30
),
)
FHRPGroup.objects.bulk_create(fhrp_groups)
cls.form_data = {
'vrf': vrfs[1].pk,
'address': IPNetwork('192.0.2.99/24'),
@ -679,10 +697,10 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
"vrf,address,status",
"VRF 1,192.0.2.4/24,active",
"VRF 1,192.0.2.5/24,active",
"VRF 1,192.0.2.6/24,active",
"vrf,address,status,fhrp_group",
"VRF 1,192.0.2.4/24,active,FHRP Group 1",
"VRF 1,192.0.2.5/24,active,FHRP Group 2",
"VRF 1,192.0.2.6/24,active,FHRP Group 3",
)
cls.csv_update_data = (

Binary file not shown.

Binary file not shown.

View File

@ -27,10 +27,10 @@
"bootstrap": "5.3.3",
"clipboard": "2.0.11",
"flatpickr": "4.6.13",
"gridstack": "11.3.0",
"gridstack": "11.5.0",
"htmx.org": "1.9.12",
"query-string": "9.1.1",
"sass": "1.85.0",
"sass": "1.86.0",
"tom-select": "2.4.3",
"typeface-inter": "3.18.1",
"typeface-roboto-mono": "1.1.13"

View File

@ -769,9 +769,9 @@
bootstrap "5.3.3"
"@tabler/icons@^3.14.0":
version "3.16.0"
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-3.16.0.tgz#d618670b80163925a31a6c2290e8775f6058d81a"
integrity sha512-GU7MSx4uQEr55BmyON6hD/QYTl6k1v0YlRhM91gBWDoKAbyCt6QIYw7rpJ/ecdh5zrHaTOJKPenZ4+luoutwFA==
version "3.31.0"
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-3.31.0.tgz#92d39dc336f2e3e312170420b00ffe9ca474925e"
integrity sha512-dblAdeKY3+GA1U+Q9eziZ0ooVlZMHsE8dqP0RkwvRtEsAULoKOYaCUOcJ4oW1DjWegdxk++UAt2SlQVnmeHv+g==
"@tanstack/react-virtual@^3.0.0-beta.60":
version "3.5.0"
@ -1911,10 +1911,10 @@ graphql@16.10.0:
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.10.0.tgz#24c01ae0af6b11ea87bf55694429198aaa8e220c"
integrity sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ==
gridstack@11.3.0:
version "11.3.0"
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-11.3.0.tgz#b110c66bafc64c920fc54933e2c9df4f7b2cfffe"
integrity sha512-Z0eRovKcZTRTs3zetJwjO6CNwrgIy845WfOeZGk8ybpeMCE8fMA8tScyKU72Y2M6uGHkjgwnjflglvPiv+RcBQ==
gridstack@11.5.0:
version "11.5.0"
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-11.5.0.tgz#ecd507776db857f3308d37a8fd67d6a24c7fdd74"
integrity sha512-SE1a/aC2K8VKQr5cqV7gSJ+r/xIYghijIjHzkZ3Xo3aS1/4dvwIgPYT7QqgV1z+d7XjKYUPEizcgVQ5HhdFTng==
has-bigints@^1.0.1, has-bigints@^1.0.2:
version "1.0.2"
@ -2673,10 +2673,10 @@ safe-regex-test@^1.0.3:
es-errors "^1.3.0"
is-regex "^1.1.4"
sass@1.85.0:
version "1.85.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.85.0.tgz#0127ef697d83144496401553f0a0e87be83df45d"
integrity sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==
sass@1.86.0:
version "1.86.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.86.0.tgz#f49464fb6237a903a93f4e8760ef6e37a5030114"
integrity sha512-zV8vGUld/+mP4KbMLJMX7TyGCuUp7hnkOScgCMsWuHtns8CWBoz+vmEhoGMXsaJrbUP8gj+F1dLvVe79sK8UdA==
dependencies:
chokidar "^4.0.0"
immutable "^5.0.2"

View File

@ -1,3 +1,3 @@
version: "4.2.5"
version: "4.2.6"
edition: "Community"
published: "2025-03-06"
published: "2025-03-21"

View File

@ -3,7 +3,7 @@ from typing import Annotated, List
import strawberry
import strawberry_django
from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
from extras.graphql.mixins import CustomFieldsMixin, TagsMixin, ContactsMixin
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
from tenancy import models
from .mixins import ContactAssignmentsMixin
@ -28,7 +28,7 @@ __all__ = (
fields='__all__',
filters=TenantFilter
)
class TenantType(NetBoxObjectType):
class TenantType(ContactsMixin, NetBoxObjectType):
group: Annotated["TenantGroupType", strawberry.lazy('tenancy.graphql.types')] | None
asns: List[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -112,15 +112,32 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description')
def validate(self, data):
# Validate many-to-many VLAN assignments
virtual_machine = self.instance.virtual_machine if self.instance else data.get('virtual_machine')
for vlan in data.get('tagged_vlans', []):
if vlan.site not in [virtual_machine.site, None]:
raise serializers.ValidationError({
'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent virtual "
f"machine, or it must be global."
})
virtual_machine = None
tagged_vlans = []
# #18887
# There seem to be multiple code paths coming through here. Previously, we might either get
# the VirtualMachine instance from self.instance or from incoming data. However, #18887
# illustrated that this is also being called when a custom field pointing to an object_type
# of VMInterface is on the right side of a custom-field assignment coming in from an API
# request. As such, we need to check a third way to access the VirtualMachine
# instance--where `data` is the VMInterface instance itself and we can get the associated
# VirtualMachine via attribute access.
if isinstance(data, dict):
virtual_machine = self.instance.virtual_machine if self.instance else data.get('virtual_machine')
tagged_vlans = data.get('tagged_vlans', [])
elif isinstance(data, VMInterface):
virtual_machine = data.virtual_machine
tagged_vlans = data.tagged_vlans.all()
if virtual_machine:
for vlan in tagged_vlans:
if vlan.site not in [virtual_machine.site, None]:
raise serializers.ValidationError({
'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent virtual "
f"machine, or it must be global."
})
return super().validate(data)

View File

@ -33,7 +33,7 @@ class ComponentType(NetBoxObjectType):
exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'),
filters=ClusterFilter
)
class ClusterType(VLANGroupsMixin, NetBoxObjectType):
class ClusterType(ContactsMixin, VLANGroupsMixin, NetBoxObjectType):
type: Annotated["ClusterTypeType", strawberry.lazy('virtualization.graphql.types')] | None
group: Annotated["ClusterGroupType", strawberry.lazy('virtualization.graphql.types')] | None
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
@ -55,7 +55,7 @@ class ClusterType(VLANGroupsMixin, NetBoxObjectType):
fields='__all__',
filters=ClusterGroupFilter
)
class ClusterGroupType(VLANGroupsMixin, OrganizationalObjectType):
class ClusterGroupType(ContactsMixin, VLANGroupsMixin, OrganizationalObjectType):
clusters: List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]

View File

@ -1,11 +1,15 @@
from django.test import tag
from django.urls import reverse
from netaddr import IPNetwork
from rest_framework import status
from core.models import ObjectType
from dcim.choices import InterfaceModeChoices
from dcim.models import Site
from extras.models import ConfigTemplate
from extras.choices import CustomFieldTypeChoices
from extras.models import ConfigTemplate, CustomField
from ipam.choices import VLANQinQRoleChoices
from ipam.models import VLAN, VRF
from ipam.models import Prefix, VLAN, VRF
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine
from virtualization.choices import *
from virtualization.models import *
@ -350,6 +354,39 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
},
]
@tag('regression')
def test_set_vminterface_as_object_in_custom_field(self):
cf = CustomField.objects.create(
name='associated_interface',
type=CustomFieldTypeChoices.TYPE_OBJECT,
related_object_type=ObjectType.objects.get_for_model(VMInterface),
required=False
)
cf.object_types.set([ObjectType.objects.get_for_model(Prefix)])
cf.save()
prefix = Prefix.objects.create(prefix=IPNetwork('10.0.0.0/12'))
vmi = VMInterface.objects.first()
url = reverse('ipam-api:prefix-detail', kwargs={'pk': prefix.pk})
data = {
'custom_fields': {
'associated_interface': vmi.id,
},
}
self.add_permissions('ipam.change_prefix')
response = self.client.patch(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 200)
prefix_data = response.json()
self.assertEqual(prefix_data['custom_fields']['associated_interface']['id'], vmi.id)
reloaded_prefix = Prefix.objects.get(pk=prefix.pk)
self.assertEqual(prefix.pk, reloaded_prefix.pk)
self.assertNotEqual(reloaded_prefix.cf['associated_interface'], None)
def test_bulk_delete_child_interfaces(self):
interface1 = VMInterface.objects.get(name='Interface 1')
virtual_machine = interface1.virtual_machine

View File

@ -27,7 +27,7 @@ __all__ = (
fields='__all__',
filters=TunnelGroupFilter
)
class TunnelGroupType(OrganizationalObjectType):
class TunnelGroupType(ContactsMixin, OrganizationalObjectType):
tunnels: List[Annotated["TunnelType", strawberry.lazy('vpn.graphql.types')]]
@ -48,7 +48,7 @@ class TunnelTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
fields='__all__',
filters=TunnelFilter
)
class TunnelType(NetBoxObjectType):
class TunnelType(ContactsMixin, NetBoxObjectType):
group: Annotated["TunnelGroupType", strawberry.lazy('vpn.graphql.types')] | None
ipsec_profile: Annotated["IPSecProfileType", strawberry.lazy('vpn.graphql.types')] | None
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None

View File

@ -2,7 +2,7 @@ Django==5.1.7
django-cors-headers==4.7.0
django-debug-toolbar==5.0.1
django-filter==25.1
django-htmx==1.22.0
django-htmx==1.23.0
django-graphiql-debug-toolbar==0.2.0
django-mptt==0.16.0
django-pglocks==1.0.4
@ -20,18 +20,18 @@ feedparser==6.0.11
gunicorn==23.0.0
Jinja2==3.1.6
Markdown==3.7
mkdocs-material==9.6.7
mkdocstrings[python]==0.28.2
mkdocs-material==9.6.9
mkdocstrings[python]==0.29.0
netaddr==1.3.0
nh3==0.2.21
Pillow==11.1.0
psycopg[c,pool]==3.2.5
psycopg[c,pool]==3.2.6
PyYAML==6.0.2
requests==2.32.3
rq==2.1.0
social-auth-app-django==5.4.3
social-auth-core==4.5.6
strawberry-graphql==0.262.0
strawberry-graphql==0.262.5
strawberry-graphql-django==0.52.0
svgwrite==1.4.3
tablib==3.8.0