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 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 10:45:16 +02:00
co-authored by Claude Sonnet 5
parent b289bc5aeb
commit 8818065840
+6 -6
View File
@@ -7,7 +7,7 @@ from django.core.exceptions import ValidationError
from django.db import transaction from django.db import transaction
from django.utils.translation import gettext as _ 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 dcim.models import Device, Rack
from . import components from . import components
@@ -113,7 +113,7 @@ def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, t
platform=device.platform, platform=device.platform,
tenant=device.tenant, tenant=device.tenant,
position=device.position, position=device.position,
face=device.face, face=device.face or DeviceFaceChoices.FACE_FRONT,
width_fraction=fraction, width_fraction=fraction,
horizontal_position=horizontal, horizontal_position=horizontal,
serial=device.serial if include_device_names else '', 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, platform=device.platform,
tenant=device.tenant, tenant=device.tenant,
position=(device.position + shift) if device.position is not None else None, 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, width_fraction=device.width_fraction,
horizontal_position=device.horizontal_position, horizontal_position=device.horizontal_position,
u_height=device.u_height, 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, u_height=total_height,
starting_unit=starting_unit, starting_unit=starting_unit,
form_factor=_rack_geometry(rack_bottom, 'form_factor') or '', form_factor=_rack_geometry(rack_bottom, 'form_factor') or '',
airflow=rack_bottom.airflow, airflow=rack_bottom.airflow or '',
default_site=rack_bottom.site, default_site=rack_bottom.site,
default_location=rack_bottom.location, default_location=rack_bottom.location,
rack_role=rack_bottom.role, rack_role=rack_bottom.role,
@@ -274,7 +274,7 @@ def merge_racks_to_concept(rack_bottom, rack_top, name, status=ConceptStatusChoi
platform=device.platform, platform=device.platform,
tenant=device.tenant, tenant=device.tenant,
position=device.position + shift, position=device.position + shift,
face=device.face, face=device.face or DeviceFaceChoices.FACE_FRONT,
width_fraction=fraction, width_fraction=fraction,
horizontal_position=horizontal, horizontal_position=horizontal,
serial=device.serial if include_device_names else '', 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, platform=device.platform,
tenant=device.tenant, tenant=device.tenant,
position=device.position, position=device.position,
face=device.face, face=device.face or DeviceFaceChoices.FACE_FRONT,
width_fraction=device.width_fraction, width_fraction=device.width_fraction,
horizontal_position=device.horizontal_position, horizontal_position=device.horizontal_position,
u_height=device.u_height, u_height=device.u_height,