""" The four copy paths: rack -> concept, concept -> concept, concept -> rack(s), and concept + concept -> one merged concept. """ from django.conf import settings from django.core.exceptions import ValidationError from django.db import transaction from django.utils.translation import gettext as _ from dcim.models import Device, Rack from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices from .integrations import apply_partial_width, read_partial_width from .models import ConceptDeployment, ConceptDevice, RackConcept __all__ = ( 'clone_concept', 'copy_rack_to_concept', 'deploy_concept', 'merge_concepts', 'validate_deployment', ) GEOMETRY_FIELDS = ( 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'max_weight', ) def _config(): return settings.PLUGINS_CONFIG.get('netbox_rack_concept', {}) # # dcim.Rack -> RackConcept # @transaction.atomic def copy_rack_to_concept(rack, name, status=ConceptStatusChoices.STATUS_DRAFT, tenant=None, tenant_group=None, description='', include_devices=True, include_device_names=True, override_width=None, override_u_height=None, override_starting_unit=None): """ Snapshot a real rack, and optionally its contents, into a new blueprint. Passing an override lets the concept end up a different size than the source rack (e.g. to plan "what if we had a taller one"); devices that no longer fit are skipped and reported back as warnings rather than aborting the whole copy. Returns (concept, warnings). """ concept = RackConcept( name=name, status=status, tenant=tenant, tenant_group=tenant_group, description=description, rack_type=rack.rack_type, default_site=rack.site, default_location=rack.location, rack_role=rack.role, source_rack=rack, ) for field in GEOMETRY_FIELDS: value = getattr(rack, field, None) # A rack built from a rack type may leave its own dimension fields empty. if value in (None, '') and rack.rack_type: value = getattr(rack.rack_type, field, None) if value not in (None, ''): setattr(concept, field, value) has_override = any(v is not None for v in (override_width, override_u_height, override_starting_unit)) if has_override: # The concept's own geometry fields are ignored on deploy as long as rack_type # is set (NetBox derives geometry from the type). Since the override is the # whole point here, it must not be silently overruled by the source's type. concept.rack_type = None if override_width: concept.width = override_width if override_u_height: concept.u_height = override_u_height if override_starting_unit: concept.starting_unit = override_starting_unit concept.full_clean() concept.save() warnings = [] if include_devices: devices = Device.objects.filter(rack=rack, position__isnull=False).select_related( 'device_type', 'role', 'platform', 'tenant' ) for device in devices: fraction, horizontal = read_partial_width(device) concept_device = ConceptDevice( concept=concept, name=(device.name or '') if include_device_names else '', status=ConceptDeviceStatusChoices.STATUS_EXISTING, device_type=device.device_type, role=device.role, platform=device.platform, tenant=device.tenant, position=device.position, face=device.face, width_fraction=fraction, horizontal_position=horizontal, serial=device.serial if include_device_names else '', source_device=device, ) if has_override: try: concept_device.full_clean() except ValidationError: warnings.append(_( '%(device)s (U%(position)s) does not fit in the chosen size and was left out.' ) % {'device': device, 'position': device.position}) continue concept_device.save() return concept, warnings # # RackConcept + RackConcept -> RackConcept (stacked) # @transaction.atomic def merge_concepts(concept_bottom, concept_top, name, status=ConceptStatusChoices.STATUS_DRAFT, tenant=None, tenant_group=None, width=None, u_height=None, starting_unit=1, description=''): """ Stack two blueprints into one taller one - e.g. two half-height racks combined into a full-height rack. concept_bottom's devices keep their position; concept_top's are shifted up by concept_bottom's height. Neither source concept is modified. Returns (merged_concept, warnings). """ offset = concept_bottom.u_height total_height = u_height or (concept_bottom.u_height + concept_top.u_height) merged = RackConcept( name=name, status=status, tenant=tenant, tenant_group=tenant_group, description=description, width=width or concept_bottom.width, u_height=total_height, starting_unit=starting_unit, form_factor=concept_bottom.form_factor, airflow=concept_bottom.airflow, default_site=concept_bottom.default_site or concept_top.default_site, default_location=concept_bottom.default_location or concept_top.default_location, rack_role=concept_bottom.rack_role or concept_top.rack_role, ) merged.full_clean() merged.save() warnings = [] def _copy_devices(source, shift): for device in source.devices.all(): concept_device = ConceptDevice( concept=merged, name=device.name, label=device.label, status=device.status, device_type=device.device_type, role=device.role, platform=device.platform, tenant=device.tenant, position=(device.position + shift) if device.position is not None else None, face=device.face, width_fraction=device.width_fraction, horizontal_position=device.horizontal_position, u_height=device.u_height, is_full_depth=device.is_full_depth, serial=device.serial, description=device.description, comments=device.comments, source_device=device.source_device, ) try: concept_device.full_clean() except ValidationError: warnings.append(_( '%(device)s from %(concept)s does not fit at its shifted position and was left out.' ) % {'device': device, 'concept': source}) continue concept_device.save() _copy_devices(concept_bottom, 0) _copy_devices(concept_top, offset) return merged, warnings # # RackConcept -> RackConcept # @transaction.atomic def clone_concept(concept, name, status=ConceptStatusChoices.STATUS_DRAFT, tenant=None, include_devices=True): """Duplicate a blueprint so variants can be built without touching the original.""" clone = RackConcept( name=name, status=status, tenant=tenant or concept.tenant, tenant_group=concept.tenant_group, description=concept.description, comments=concept.comments, rack_type=concept.rack_type, default_site=concept.default_site, default_location=concept.default_location, rack_role=concept.rack_role, source_rack=concept.source_rack, ) for field in GEOMETRY_FIELDS: setattr(clone, field, getattr(concept, field)) clone.full_clean() clone.save() if include_devices: for device in concept.devices.all(): ConceptDevice.objects.create( concept=clone, name=device.name, label=device.label, status=device.status, device_type=device.device_type, role=device.role, platform=device.platform, tenant=device.tenant, position=device.position, face=device.face, width_fraction=device.width_fraction, horizontal_position=device.horizontal_position, u_height=device.u_height, is_full_depth=device.is_full_depth, serial=device.serial, description=device.description, comments=device.comments, source_device=device.source_device, ) return clone # # RackConcept -> dcim.Rack # def validate_deployment(concept, create_devices=True, skip_unnamed_devices=True): """ Report what a deployment would and would not be able to create. Returned as (deployable_devices, warnings) so the confirmation page can show it before anything is written. """ warnings = [] deployable = [] if create_devices: for device in concept.devices.select_related('device_type', 'role'): if device.position is None: warnings.append(_('%(device)s has no position and will not be created.') % {'device': device}) continue if device.device_type is None: warnings.append( _('%(device)s is reserved space without a device type and will not be created.') % {'device': device} ) continue if device.role is None: warnings.append( _('%(device)s has no role. NetBox requires one, so it will not be created.') % {'device': device} ) continue if not device.name and skip_unnamed_devices: warnings.append(_('%(device)s has no name and will be skipped.') % {'device': device}) continue if device.status == ConceptDeviceStatusChoices.STATUS_OPTIONAL: warnings.append( _('%(device)s is marked optional but will be created.') % {'device': device} ) deployable.append(device) return deployable, warnings @transaction.atomic def deploy_concept(concept, rack_names, site, location=None, tenant=None, create_devices=True, device_name_pattern='', skip_unnamed_devices=True, user=None): """ Materialise the blueprint as one real rack per name in `rack_names`. Returns (racks, devices, warnings). """ config = _config() rack_status = config.get('deployed_rack_status', 'planned') device_status = config.get('deployed_device_status', 'planned') planned, warnings = validate_deployment(concept, create_devices, skip_unnamed_devices) racks = [] created_devices = [] for rack_name in rack_names: rack = Rack( name=rack_name, site=site, location=location, tenant=tenant or concept.tenant, status=rack_status, role=concept.rack_role, description=concept.description, comments=concept.comments, ) if concept.rack_type: # Let NetBox derive the geometry from the rack type rather than fighting it. rack.rack_type = concept.rack_type else: for field in GEOMETRY_FIELDS: setattr(rack, field, getattr(concept, field)) rack.full_clean() rack.save() racks.append(rack) rack_device_count = 0 for planned_device in planned: name = planned_device.name if name and device_name_pattern: name = device_name_pattern.format(rack=rack_name, name=planned_device.name) device = Device( name=name or None, device_type=planned_device.device_type, role=planned_device.role, platform=planned_device.platform, site=site, location=location, rack=rack, position=planned_device.position, face=planned_device.face, status=device_status, tenant=planned_device.tenant or tenant or concept.tenant, serial=planned_device.serial, description=planned_device.description, ) device.full_clean() device.save() apply_partial_width(device, planned_device) created_devices.append(device) rack_device_count += 1 ConceptDeployment.objects.create( concept=concept, rack=rack, rack_name=rack_name, device_count=rack_device_count, user=user if user and user.is_authenticated else None, ) return racks, created_devices, warnings