fix(circuits): Require termination object for selected type

Require a Circuit Termination target when a termination type has been
selected, so blank dynamic target fields surface an inline form error
instead of falling through to generic GFK validation for termination_id.

Add a model-level guard for the same invalid state before generic
GenericForeignKey validation runs.

Fixes #22163
This commit is contained in:
Martin Hauser 2026-05-20 15:47:00 +02:00 committed by Jeremy Stretch
parent 64d3b114bc
commit d25e2d43d0
4 changed files with 87 additions and 6 deletions

View File

@ -1,6 +1,6 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.utils.translation import gettext_lazy as _
from circuits.choices import (
@ -24,7 +24,7 @@ from utilities.forms.fields import (
from utilities.forms.mixins import DistanceValidationMixin
from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields
from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
from utilities.templatetags.builtins.filters import bettertitle
from utilities.string import title
__all__ = (
'CircuitForm',
@ -195,13 +195,11 @@ class CircuitTerminationForm(NetBoxModelForm):
termination_type = ContentTypeChoiceField(
queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
widget=HTMXSelect(),
required=False,
label=_('Termination type')
)
termination = DynamicModelChoiceField(
label=_('Termination'),
queryset=Site.objects.none(), # Initial queryset
required=False,
disabled=True,
selector=True
)
@ -247,16 +245,28 @@ class CircuitTerminationForm(NetBoxModelForm):
self.fields['termination'].queryset = model.objects.all()
self.fields['termination'].widget.attrs['selector'] = model._meta.label_lower
self.fields['termination'].disabled = False
self.fields['termination'].label = _(bettertitle(model._meta.verbose_name))
self.fields['termination'].label = _(title(model._meta.verbose_name))
except ObjectDoesNotExist:
pass
if self.instance and termination_type_id != self.instance.termination_type_id:
self.initial['termination'] = None
else:
# Clear the initial termination value if termination_type is not set
self.initial['termination'] = None
def clean(self):
super().clean()
termination = self.cleaned_data.get('termination')
termination_type = self.cleaned_data.get('termination_type')
if termination_type and not termination:
raise ValidationError({
'termination': _('Please select a {termination_type}.').format(
termination_type=_(title(termination_type.model_class()._meta.verbose_name))
)
})
# Assign the selected termination (if any)
self.instance.termination = self.cleaned_data.get('termination')
@ -319,7 +329,7 @@ class CircuitGroupAssignmentForm(NetBoxModelForm):
self.fields['member'].queryset = model.objects.all()
self.fields['member'].widget.attrs['selector'] = model._meta.label_lower
self.fields['member'].disabled = False
self.fields['member'].label = _(bettertitle(model._meta.verbose_name))
self.fields['member'].label = _(title(model._meta.verbose_name))
except ObjectDoesNotExist:
pass

View File

@ -17,6 +17,7 @@ from netbox.models.features import (
TagsMixin,
)
from netbox.models.mixins import DistanceMixin
from utilities.string import title
from .base import BaseCircuitType
@ -367,6 +368,13 @@ class CircuitTermination(
return reverse('circuits:circuittermination', args=[self.pk])
def clean(self):
if self.termination_type and not (self.termination or self.termination_id):
termination_type = self.termination_type.model_class()
raise ValidationError(
_("Please select a {termination_type}.").format(
termination_type=_(title(termination_type._meta.verbose_name))
)
)
super().clean()
if self.termination is None:

View File

@ -0,0 +1,43 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from circuits.forms import CircuitTerminationForm
from circuits.models import Circuit, CircuitType, Provider, ProviderNetwork
class CircuitTerminationFormTestCase(TestCase):
@classmethod
def setUpTestData(cls):
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
cls.circuit = Circuit.objects.create(
cid='Circuit 1',
provider=provider,
type=circuit_type,
)
cls.provider_network = ProviderNetwork.objects.create(
name='Provider Network 1',
provider=provider,
)
def test_termination_required_when_termination_type_is_selected(self):
"""
Selecting a termination type without a target object should report a
validation error against the visible form field.
"""
provider_network_type = ContentType.objects.get_for_model(ProviderNetwork)
form = CircuitTerminationForm(
data={
'circuit': self.circuit.pk,
'term_side': 'A',
'termination_type': provider_network_type.pk,
'termination': '',
}
)
self.assertFalse(form.is_valid())
self.assertIn('termination', form.errors)
self.assertIn('Please select a Provider Network.', form.errors['termination'])
self.assertNotIn('termination_id', form.errors)

View File

@ -1,3 +1,5 @@
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
from django.test import TestCase
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider, ProviderNetwork
@ -146,3 +148,21 @@ class CircuitTerminationTestCase(TestCase):
# Cache should be cleared (SET_NULL behavior)
self.assertIsNone(self.circuits[0].termination_a)
def test_termination_required_when_termination_type_is_selected(self):
"""Model rejects type-without-target before generic GFK validation hits termination_id."""
provider_network_type = ContentType.objects.get_for_model(ProviderNetwork)
termination = CircuitTermination(
circuit=self.circuits[0],
term_side='A',
termination_type=provider_network_type,
)
with self.assertRaises(ValidationError) as cm:
termination.full_clean()
errors = cm.exception.message_dict
self.assertIn(NON_FIELD_ERRORS, errors)
self.assertIn('Please select a Provider Network.', errors[NON_FIELD_ERRORS])
self.assertNotIn('termination_id', errors)