From 88180658408017a80359c33ce5de51585abe25cd Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 16 Sep 2026 10:45:16 +0200 Subject: [PATCH] Fix IntegrityError merging real racks: airflow/face can be NULL on a Rack/Device merge_racks_to_concept() copied rack_bottom.airflow and device.face straight from the real objects. Both are nullable on dcim.Rack/Device (airflow when not set, face on full-depth devices), but the corresponding RackConcept.airflow and ConceptDevice.face fields are not nullable. Model.full_clean() doesn't catch this: for a blank=True field, Django's clean_fields() skips validation entirely when the raw value is already an "empty" value (including None), so the None sailed straight through to the database as a raw IntegrityError instead of a clean form error. Normalize both: airflow falls back to '' (matching the existing form_factor fallback in the same function), face falls back to DeviceFaceChoices.FACE_FRONT (matching ConceptDevice.face's own default). Applied the face fallback everywhere a ConceptDevice is built from another device's .face, including the two copy paths that read from an already-validated ConceptDevice, as a harmless defense in depth. Co-Authored-By: Claude Sonnet 5 --- netbox_rack_concept/deploy.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/netbox_rack_concept/deploy.py b/netbox_rack_concept/deploy.py index 58de894..adf0c64 100644 --- a/netbox_rack_concept/deploy.py +++ b/netbox_rack_concept/deploy.py @@ -7,7 +7,7 @@ from django.core.exceptions import ValidationError from django.db import transaction from django.utils.translation import gettext as _ -from dcim.choices import RackWidthChoices +from dcim.choices import DeviceFaceChoices, RackWidthChoices from dcim.models import Device, Rack from . import components @@ -113,7 +113,7 @@ def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, t platform=device.platform, tenant=device.tenant, position=device.position, - face=device.face, + face=device.face or DeviceFaceChoices.FACE_FRONT, width_fraction=fraction, horizontal_position=horizontal, serial=device.serial if include_device_names else '', @@ -187,7 +187,7 @@ def merge_concepts(concept_bottom, concept_top, name, status=ConceptStatusChoice platform=device.platform, tenant=device.tenant, position=(device.position + shift) if device.position is not None else None, - face=device.face, + face=device.face or DeviceFaceChoices.FACE_FRONT, width_fraction=device.width_fraction, horizontal_position=device.horizontal_position, u_height=device.u_height, @@ -245,7 +245,7 @@ def merge_racks_to_concept(rack_bottom, rack_top, name, status=ConceptStatusChoi u_height=total_height, starting_unit=starting_unit, form_factor=_rack_geometry(rack_bottom, 'form_factor') or '', - airflow=rack_bottom.airflow, + airflow=rack_bottom.airflow or '', default_site=rack_bottom.site, default_location=rack_bottom.location, rack_role=rack_bottom.role, @@ -274,7 +274,7 @@ def merge_racks_to_concept(rack_bottom, rack_top, name, status=ConceptStatusChoi platform=device.platform, tenant=device.tenant, position=device.position + shift, - face=device.face, + face=device.face or DeviceFaceChoices.FACE_FRONT, width_fraction=fraction, horizontal_position=horizontal, serial=device.serial if include_device_names else '', @@ -338,7 +338,7 @@ def clone_concept(concept, name, status=ConceptStatusChoices.STATUS_DRAFT, tenan platform=device.platform, tenant=device.tenant, position=device.position, - face=device.face, + face=device.face or DeviceFaceChoices.FACE_FRONT, width_fraction=device.width_fraction, horizontal_position=device.horizontal_position, u_height=device.u_height,