I am working on a plugin for the Netbox project. In one of my forms, I get a “this field cannot be blank” validation error and I’m not sure why. It only occurs when the selected objects have a null ‘vrf’ property. Even if I remove the ‘vrf’ and ‘destination_prefix’ fields from my form, the validation error will still occur for objects which have a null ‘vrf’ property.

I don’t understand why this is happening. My suspicions and what I’ve been troubleshooting most are:

  1. My clean methods (I have removed these methods from both models and forms – same issue)
  2. The ‘unique_together’ property in the Meta class of the model (I have now removed the ‘vrf’ property – same issue; I have a lot of suspicion here because the Netbox project itself no longer uses the ‘unique_together’ property in its models, but other Netbox plugins do; it is simple and useful for my plugin too so I don’t want to throw it out unnecessarily)
  3. The _str_ method (I haven’t tried changing this – I want it to remain as is, but does it somehow make the fields in the form required?)

I am a Django (and development in general) novice and this error has been plaguing me for some time now. In my own searching I can find this as a similar symptom in other’s issues posted around the web but the root cause always seems to be inapplicable to my situation. Any thoughts? Is there a way I can determine which field it’s throwing the validation error on?

Model:

from django.urls import reverse
from django.core.exceptions import ValidationError
from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.core.validators import MaxValueValidator, MinValueValidator #, RegexValidator
from netbox.models import NetBoxModel
from .choices import *

class StaticRoute(NetBoxModel):
    '''Static route object'''
    device = models.ForeignKey(
        to='dcim.Device',
        on_delete=models.PROTECT,
        related_name='device_static_routes',
    )
    description = models.CharField(
        max_length=200,
        blank=True,
    )
    vrf = models.ForeignKey(
        to='ipam.VRF',
        on_delete=models.PROTECT,
        null=True,
        related_name='vrf_static_routes',
        verbose_name='VRF',
    )
    destination_prefix = models.ForeignKey(
        to='ipam.Prefix',
        on_delete=models.PROTECT,
        related_name='prefix_static_routes',
    )
    next_hop_addresses = ArrayField(
        base_field=models.GenericIPAddressField(),
        blank=True,
        null=True,
    )
    next_hop_interfaces = models.ManyToManyField(
        to='dcim.Interface',
        blank=True,
        related_name='interface_static_routes',
    )
    next_table = models.ForeignKey(
        to='ipam.VRF',
        on_delete=models.PROTECT,
        blank=True,
        null=True,
    )
    next_hop_action = models.CharField(
        max_length=30,
        choices=StaticRouteNextHopActionChoices,
        blank=True,
        null=True,
    )
    preference = models.PositiveIntegerField(
        validators=[
            MinValueValidator(1),
            MaxValueValidator(255),
        ],
        blank=True,
        null=True,
    )
    metric = models.PositiveBigIntegerField(
        validators=[
            MinValueValidator(0),
            MaxValueValidator(4294967295),
        ],
        blank=True,
        null=True,
    )
    route_tag = models.PositiveBigIntegerField(
        validators=[
            MinValueValidator(0),
            MaxValueValidator(4294967295),
        ],
        blank=True,
        null=True,
    )
    bfd_multiplier = models.PositiveIntegerField(
        validators=[
            MinValueValidator(1),
            MaxValueValidator(255),
        ],
        blank=True,
        null=True,
    )
    bfd_minimum_interval = models.PositiveIntegerField(
        validators=[
            MinValueValidator(1),
            MaxValueValidator(255000),
        ],
        blank=True,
        null=True,
    )
    bfd_local_address = models.ForeignKey(
        to='ipam.IPAddress',
        on_delete=models.PROTECT,
        blank=True,
        null=True,
    )
    bfd_neighbor_address = models.GenericIPAddressField(
        blank=True,
        null=True,
    )
    no_readvertise = models.BooleanField(
        default=False,
        verbose_name='no-readvertise parameter'
    )
    resolve = models.BooleanField(
        default=False,
        verbose_name='Resolve next-hop'
    )
    comments = models.TextField(
        blank=True,
    )

    # clone fields configuration
    clone_fields = (
        'device', 'vrf',
    )

    class Meta:
        ordering = ('device', 'vrf', 'destination_prefix',)
        unique_together = ('device', 'destination_prefix',)
        verbose_name_plural = 'Static Routes'

    def __str__(self):
        if self.vrf == None:
            return f'{self.device}:global:{self.destination_prefix}'
        else:
            return f'{self.device}:{self.vrf.rd}:{self.destination_prefix}'
    
    def get_absolute_url(self):
        return reverse('plugins:netbox_syntrio_static_routes:staticroute', args=[self.pk])
    
    def get_next_hop_action_color(self):
        return StaticRouteNextHopActionChoices.colors.get(self.next_hop_action)

    def clean(self):
        super().clean()

        # Ensure assigned VRF matches the destination address VRF field

        ## if vrf is null, update the vrf to match the vrf of the destination_prefix
        if self.vrf == None:
            self.vrf = self.destination_prefix.vrf
            
        ## if vrf field is not null but does not match vrf field of destination_prefix, raise a ValueError
        if self.vrf != self.destination_prefix.vrf:
            raise ValidationError('The assigned VRF of the destination_prefix field object must match the vrf field.')

        # Ensure bfd_local_address is assigned to the device
        if self.bfd_local_address:
            if self.bfd_local_address.device != self.device:
                raise ValidationError('The assigned device of the bfd_local_address must match the device field.')

Form:

from django import forms
from django.contrib.postgres.forms import SimpleArrayField
from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelBulkEditForm, NetBoxModelImportForm
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField, CSVModelChoiceField, CSVModelMultipleChoiceField
from dcim.models import Device, Interface
from ipam.models import VRF, IPAddress, Prefix
from .models import *
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES
from django.core.exceptions import ValidationError
from utilities.querysets import RestrictedQuerySet
from utilities.forms.widgets import BulkEditNullBooleanSelect


# 
# Functions
# 

def item_not_null_blank_empty(item):
    '''Returns true if the supplied item is not None, blank, or empty'''
    if type(item) == type(RestrictedQuerySet()):
        if item.exists() == False:
            return False
        else:
            return True
    elif type(item) == list:
        if len(item) == 0:
            return False
        else:
            return True
    elif item == '' or item == None:
        return False
    else:
        return True


# 
# Static Route forms
# 

class StaticRouteBulkEditForm(NetBoxModelBulkEditForm):
    '''Bulk edit form for static routes'''
    device = DynamicModelChoiceField(
        queryset=Device.objects.all(),
        required=False,
    )
    description = forms.CharField(
        max_length=200,
        required=False,
    )
    vrf = DynamicModelChoiceField(
        queryset=VRF.objects.all(),
        required=False,
        label='VRF',
    )
    destination_prefix = DynamicModelChoiceField(
        queryset=Prefix.objects.all(),
        query_params={
            'vrf_id': '$vrf',
        },
        required=False,
        label='Destination Prefix',
    )
    next_hop_addresses = SimpleArrayField(
        forms.GenericIPAddressField(),
        required=False,
        label='Next-hop addresses',
    )
    next_hop_interfaces = DynamicModelMultipleChoiceField(
        queryset=Interface.objects.all(),
        query_params={
            'device_id': '$device',
        },
        required=False,
        label='Next-hop interfaces',
    )
    next_table = DynamicModelChoiceField(
        queryset=VRF.objects.all(),
        required=False,
        label='Next table',
    )
    next_hop_action = forms.ChoiceField(
        choices=StaticRouteNextHopActionChoices,
        required=False,
        label='Next-hop action',
    )
    preference = forms.IntegerField(
        required=False,
        label='Route preference',
    )
    metric = forms.IntegerField(
        required=False,
        label='Route metric',
    )
    route_tag = forms.IntegerField(
        required=False,
        label='Route tag',
    )
    bfd_multiplier = forms.IntegerField(
        required=False,
        label='BFD multiplier',
    )
    bfd_minimum_interval = forms.IntegerField(
        required=False,
        label='BFD minimum interval',
    )
    bfd_local_address = DynamicModelChoiceField(
        queryset=IPAddress.objects.all(),
        query_params={
            'device_id': '$device'
        },
        required=False,
        label='BFD local address',
    )
    bfd_neighbor_address = forms.GenericIPAddressField(
        required=False,
        label='BFD neighbor address',
    )
    no_readvertise = forms.NullBooleanField(
        required=False,
        widget=BulkEditNullBooleanSelect,
        label='No readvertisement',
    )
    resolve = forms.NullBooleanField(
        required=False,
        widget=BulkEditNullBooleanSelect,
        label='Resolve next-hop',
    )

    model = StaticRoute
    fieldsets = (
        ('Static Route', ('device', 'description', 'vrf', 'destination_prefix',)),
        ('Next-Hop Action', ('next_hop_addresses', 'next_hop_interfaces', 'next_table', 'next_hop_action',)),
        ('Route Attributes', ('preference', 'metric', 'route_tag',)),
        ('Bidirectional Forwarding Detection', ('bfd_multiplier', 'bfd_minimum_interval', 'bfd_local_address', 'bfd_neighbor_address',)),
        ('Route Parameters', ('no_readvertise', 'resolve',)),
    )
    nullable_fields = [
        'description', 'vrf',
        'next_hop_addresses', 'next_hop_interfaces', 'next_table', 'next_hop_action',
        'preference', 'metric', 'route_tag',
        'bfd_multiplier', 'bfd_minimum_interval', 'bfd_neighbor_address',
    ]

    def clean(self):
        super().clean()

        # Ensure next_hop_interfaces represents valid device interfaces
        device = self.cleaned_data.get('device')
        next_hop_interfaces = self.cleaned_data.get('next_hop_interfaces')

        for interface in next_hop_interfaces:
            if interface.device != device:
                raise ValidationError('The assigned device of each interface in next_hop_interfaces must match the device field.')

View:

from netbox.views import generic
from . import filtersets, forms, models, tables


class StaticRouteBulkEditView(generic.BulkEditView):
    queryset = models.StaticRoute.objects.all()
    filterset = filtersets.StaticRouteFilterSet
    table = tables.StaticRouteTable
    form = forms.StaticRouteBulkEditForm

And my serializer:

from rest_framework import serializers

from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
from dcim.api.nested_serializers import NestedDeviceSerializer, NestedInterfaceSerializer
from ipam.api.nested_serializers import NestedVRFSerializer, NestedIPAddressSerializer
from dcim.models import Interface
from netbox.api.fields import SerializedPKRelatedField
from ..models import StaticRoute, QualifiedNextHop, AggregateRoute


class StaticRouteSerializer(NetBoxModelSerializer):
    '''Serializer for StaticRoute model'''
    url = serializers.HyperlinkedIdentityField(
        view_name='plugins-api:netbox_syntrio_static_routes-api:staticroute-detail'
    )

    device = NestedDeviceSerializer(
        required=False,
        allow_null=True,
    )
    vrf = NestedVRFSerializer(
        required=False,
        allow_null=True,
    )
    next_hop_addresses = serializers.ListField(
        child=serializers.IPAddressField(),
        required=False,
    )
    next_hop_interfaces = SerializedPKRelatedField(
        queryset=Interface.objects.all(),
        serializer=NestedInterfaceSerializer,
        required=False,
        allow_null=True,
        many=True,
    )
    next_table = NestedVRFSerializer(
        required=False,
        allow_null=True,
    )
    qualified_next_hops = NestedQualifiedNextHopSerializer(
        many=True,
        required=False,
    )
    bfd_local_address = NestedIPAddressSerializer(
        required=False,
        allow_null=True,
    )

    class Meta:
        model = StaticRoute
        fields = (
            'id', 'url', 'display',
            'device', 'description', 'vrf', 'destination_prefix',
            'next_hop_addresses', 'next_hop_interfaces', 'next_table', 'next_hop_action', 'qualified_next_hops',
            'preference', 'metric', 'route_tag',
            'bfd_multiplier', 'bfd_minimum_interval', 'bfd_local_address', 'bfd_neighbor_address',
            'no_readvertise', 'resolve',
            'tags', 'custom_fields', 'created', 'last_updated',
        )
        #validators = []