Просмотр исходного кода

Refactor DCIM models into separate submodules

Jeremy Stretch 5 лет назад
Родитель
Сommit
e1cf27a3ac

+ 6 - 2319
netbox/dcim/models/__init__.py

@@ -1,42 +1,9 @@
-from collections import OrderedDict
-from itertools import count, groupby
-
-import yaml
-from django.conf import settings
-from django.contrib.auth.models import User
-from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
-from django.contrib.contenttypes.models import ContentType
-from django.contrib.postgres.fields import ArrayField
-from django.core.exceptions import ObjectDoesNotExist, ValidationError
-from django.core.validators import MaxValueValidator, MinValueValidator
-from django.db import models
-from django.db.models import Count, F, ProtectedError, Sum
-from django.urls import reverse
-from django.utils.safestring import mark_safe
-from mptt.models import MPTTModel, TreeForeignKey
-from taggit.managers import TaggableManager
-from timezone_field import TimeZoneField
-
-from dcim.choices import *
-from dcim.constants import *
-from dcim.fields import ASNField
-from dcim.elevations import RackElevationSVG
-from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
-from extras.utils import extras_features
-from utilities.choices import ColorChoices
-from utilities.fields import ColorField, NaturalOrderingField
-from utilities.querysets import RestrictedQuerySet
-from utilities.mptt import TreeManager
-from utilities.utils import serialize_object, to_meters
-from utilities.validators import ExclusionValidator
-from .device_component_templates import (
-    ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
-    PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
-)
-from .device_components import (
-    BaseInterface, CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem,
-    PowerOutlet, PowerPort, RearPort,
-)
+from .device_component_templates import *
+from .device_components import *
+from .devices import *
+from .power import *
+from .racks import *
+from .sites import *
 
 __all__ = (
     'BaseInterface',
@@ -74,2283 +41,3 @@ __all__ = (
     'Site',
     'VirtualChassis',
 )
-
-
-#
-# Regions
-#
-
-@extras_features('export_templates', 'webhooks')
-class Region(MPTTModel, ChangeLoggedModel):
-    """
-    Sites can be grouped within geographic Regions.
-    """
-    parent = TreeForeignKey(
-        to='self',
-        on_delete=models.CASCADE,
-        related_name='children',
-        blank=True,
-        null=True,
-        db_index=True
-    )
-    name = models.CharField(
-        max_length=50,
-        unique=True
-    )
-    slug = models.SlugField(
-        unique=True
-    )
-    description = models.CharField(
-        max_length=200,
-        blank=True
-    )
-
-    objects = TreeManager()
-
-    csv_headers = ['name', 'slug', 'parent', 'description']
-
-    class MPTTMeta:
-        order_insertion_by = ['name']
-
-    def __str__(self):
-        return self.name
-
-    def get_absolute_url(self):
-        return "{}?region={}".format(reverse('dcim:site_list'), self.slug)
-
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.parent.name if self.parent else None,
-            self.description,
-        )
-
-    def get_site_count(self):
-        return Site.objects.filter(
-            Q(region=self) |
-            Q(region__in=self.get_descendants())
-        ).count()
-
-    def to_objectchange(self, action):
-        # Remove MPTT-internal fields
-        return ObjectChange(
-            changed_object=self,
-            object_repr=str(self),
-            action=action,
-            object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
-        )
-
-
-#
-# Sites
-#
-
-@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
-class Site(ChangeLoggedModel, CustomFieldModel):
-    """
-    A Site represents a geographic location within a network; typically a building or campus. The optional facility
-    field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
-    """
-    name = models.CharField(
-        max_length=50,
-        unique=True
-    )
-    _name = NaturalOrderingField(
-        target_field='name',
-        max_length=100,
-        blank=True
-    )
-    slug = models.SlugField(
-        unique=True
-    )
-    status = models.CharField(
-        max_length=50,
-        choices=SiteStatusChoices,
-        default=SiteStatusChoices.STATUS_ACTIVE
-    )
-    region = models.ForeignKey(
-        to='dcim.Region',
-        on_delete=models.SET_NULL,
-        related_name='sites',
-        blank=True,
-        null=True
-    )
-    tenant = models.ForeignKey(
-        to='tenancy.Tenant',
-        on_delete=models.PROTECT,
-        related_name='sites',
-        blank=True,
-        null=True
-    )
-    facility = models.CharField(
-        max_length=50,
-        blank=True,
-        help_text='Local facility ID or description'
-    )
-    asn = ASNField(
-        blank=True,
-        null=True,
-        verbose_name='ASN',
-        help_text='32-bit autonomous system number'
-    )
-    time_zone = TimeZoneField(
-        blank=True
-    )
-    description = models.CharField(
-        max_length=200,
-        blank=True
-    )
-    physical_address = models.CharField(
-        max_length=200,
-        blank=True
-    )
-    shipping_address = models.CharField(
-        max_length=200,
-        blank=True
-    )
-    latitude = models.DecimalField(
-        max_digits=8,
-        decimal_places=6,
-        blank=True,
-        null=True,
-        help_text='GPS coordinate (latitude)'
-    )
-    longitude = models.DecimalField(
-        max_digits=9,
-        decimal_places=6,
-        blank=True,
-        null=True,
-        help_text='GPS coordinate (longitude)'
-    )
-    contact_name = models.CharField(
-        max_length=50,
-        blank=True
-    )
-    contact_phone = models.CharField(
-        max_length=20,
-        blank=True
-    )
-    contact_email = models.EmailField(
-        blank=True,
-        verbose_name='Contact E-mail'
-    )
-    comments = models.TextField(
-        blank=True
-    )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
-    images = GenericRelation(
-        to='extras.ImageAttachment'
-    )
-    tags = TaggableManager(through=TaggedItem)
-
-    objects = RestrictedQuerySet.as_manager()
-
-    csv_headers = [
-        'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
-        'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments',
-    ]
-    clone_fields = [
-        'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
-        'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email',
-    ]
-
-    STATUS_CLASS_MAP = {
-        SiteStatusChoices.STATUS_PLANNED: 'info',
-        SiteStatusChoices.STATUS_STAGING: 'primary',
-        SiteStatusChoices.STATUS_ACTIVE: 'success',
-        SiteStatusChoices.STATUS_DECOMMISSIONING: 'warning',
-        SiteStatusChoices.STATUS_RETIRED: 'danger',
-    }
-
-    class Meta:
-        ordering = ('_name',)
-
-    def __str__(self):
-        return self.name
-
-    def get_absolute_url(self):
-        return reverse('dcim:site', args=[self.slug])
-
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.get_status_display(),
-            self.region.name if self.region else None,
-            self.tenant.name if self.tenant else None,
-            self.facility,
-            self.asn,
-            self.time_zone,
-            self.description,
-            self.physical_address,
-            self.shipping_address,
-            self.latitude,
-            self.longitude,
-            self.contact_name,
-            self.contact_phone,
-            self.contact_email,
-            self.comments,
-        )
-
-    def get_status_class(self):
-        return self.STATUS_CLASS_MAP.get(self.status)
-
-
-#
-# Racks
-#
-
-@extras_features('export_templates')
-class RackGroup(MPTTModel, ChangeLoggedModel):
-    """
-    Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
-    example, if a Site spans a corporate campus, a RackGroup might be defined to represent each building within that
-    campus. If a Site instead represents a single building, a RackGroup might represent a single room or floor.
-    """
-    name = models.CharField(
-        max_length=50
-    )
-    slug = models.SlugField()
-    site = models.ForeignKey(
-        to='dcim.Site',
-        on_delete=models.CASCADE,
-        related_name='rack_groups'
-    )
-    parent = TreeForeignKey(
-        to='self',
-        on_delete=models.CASCADE,
-        related_name='children',
-        blank=True,
-        null=True,
-        db_index=True
-    )
-    description = models.CharField(
-        max_length=200,
-        blank=True
-    )
-
-    objects = TreeManager()
-
-    csv_headers = ['site', 'parent', 'name', 'slug', 'description']
-
-    class Meta:
-        ordering = ['site', 'name']
-        unique_together = [
-            ['site', 'name'],
-            ['site', 'slug'],
-        ]
-
-    class MPTTMeta:
-        order_insertion_by = ['name']
-
-    def __str__(self):
-        return self.name
-
-    def get_absolute_url(self):
-        return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
-
-    def to_csv(self):
-        return (
-            self.site,
-            self.parent.name if self.parent else '',
-            self.name,
-            self.slug,
-            self.description,
-        )
-
-    def to_objectchange(self, action):
-        # Remove MPTT-internal fields
-        return ObjectChange(
-            changed_object=self,
-            object_repr=str(self),
-            action=action,
-            object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
-        )
-
-    def clean(self):
-
-        # Parent RackGroup (if any) must belong to the same Site
-        if self.parent and self.parent.site != self.site:
-            raise ValidationError(f"Parent rack group ({self.parent}) must belong to the same site ({self.site})")
-
-
-class RackRole(ChangeLoggedModel):
-    """
-    Racks can be organized by functional role, similar to Devices.
-    """
-    name = models.CharField(
-        max_length=50,
-        unique=True
-    )
-    slug = models.SlugField(
-        unique=True
-    )
-    color = ColorField(
-        default=ColorChoices.COLOR_GREY
-    )
-    description = models.CharField(
-        max_length=200,
-        blank=True,
-    )
-
-    objects = RestrictedQuerySet.as_manager()
-
-    csv_headers = ['name', 'slug', 'color', 'description']
-
-    class Meta:
-        ordering = ['name']
-
-    def __str__(self):
-        return self.name
-
-    def get_absolute_url(self):
-        return "{}?role={}".format(reverse('dcim:rack_list'), self.slug)
-
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.color,
-            self.description,
-        )
-
-
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
-class Rack(ChangeLoggedModel, CustomFieldModel):
-    """
-    Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
-    Each Rack is assigned to a Site and (optionally) a RackGroup.
-    """
-    name = models.CharField(
-        max_length=50
-    )
-    _name = NaturalOrderingField(
-        target_field='name',
-        max_length=100,
-        blank=True
-    )
-    facility_id = models.CharField(
-        max_length=50,
-        blank=True,
-        null=True,
-        verbose_name='Facility ID',
-        help_text='Locally-assigned identifier'
-    )
-    site = models.ForeignKey(
-        to='dcim.Site',
-        on_delete=models.PROTECT,
-        related_name='racks'
-    )
-    group = models.ForeignKey(
-        to='dcim.RackGroup',
-        on_delete=models.SET_NULL,
-        related_name='racks',
-        blank=True,
-        null=True,
-        help_text='Assigned group'
-    )
-    tenant = models.ForeignKey(
-        to='tenancy.Tenant',
-        on_delete=models.PROTECT,
-        related_name='racks',
-        blank=True,
-        null=True
-    )
-    status = models.CharField(
-        max_length=50,
-        choices=RackStatusChoices,
-        default=RackStatusChoices.STATUS_ACTIVE
-    )
-    role = models.ForeignKey(
-        to='dcim.RackRole',
-        on_delete=models.PROTECT,
-        related_name='racks',
-        blank=True,
-        null=True,
-        help_text='Functional role'
-    )
-    serial = models.CharField(
-        max_length=50,
-        blank=True,
-        verbose_name='Serial number'
-    )
-    asset_tag = models.CharField(
-        max_length=50,
-        blank=True,
-        null=True,
-        unique=True,
-        verbose_name='Asset tag',
-        help_text='A unique tag used to identify this rack'
-    )
-    type = models.CharField(
-        choices=RackTypeChoices,
-        max_length=50,
-        blank=True,
-        verbose_name='Type'
-    )
-    width = models.PositiveSmallIntegerField(
-        choices=RackWidthChoices,
-        default=RackWidthChoices.WIDTH_19IN,
-        verbose_name='Width',
-        help_text='Rail-to-rail width'
-    )
-    u_height = models.PositiveSmallIntegerField(
-        default=RACK_U_HEIGHT_DEFAULT,
-        verbose_name='Height (U)',
-        validators=[MinValueValidator(1), MaxValueValidator(100)],
-        help_text='Height in rack units'
-    )
-    desc_units = models.BooleanField(
-        default=False,
-        verbose_name='Descending units',
-        help_text='Units are numbered top-to-bottom'
-    )
-    outer_width = models.PositiveSmallIntegerField(
-        blank=True,
-        null=True,
-        help_text='Outer dimension of rack (width)'
-    )
-    outer_depth = models.PositiveSmallIntegerField(
-        blank=True,
-        null=True,
-        help_text='Outer dimension of rack (depth)'
-    )
-    outer_unit = models.CharField(
-        max_length=50,
-        choices=RackDimensionUnitChoices,
-        blank=True,
-    )
-    comments = models.TextField(
-        blank=True
-    )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
-    images = GenericRelation(
-        to='extras.ImageAttachment'
-    )
-    tags = TaggableManager(through=TaggedItem)
-
-    objects = RestrictedQuerySet.as_manager()
-
-    csv_headers = [
-        'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
-        'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
-    ]
-    clone_fields = [
-        'site', 'group', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
-        'outer_depth', 'outer_unit',
-    ]
-
-    STATUS_CLASS_MAP = {
-        RackStatusChoices.STATUS_RESERVED: 'warning',
-        RackStatusChoices.STATUS_AVAILABLE: 'success',
-        RackStatusChoices.STATUS_PLANNED: 'info',
-        RackStatusChoices.STATUS_ACTIVE: 'primary',
-        RackStatusChoices.STATUS_DEPRECATED: 'danger',
-    }
-
-    class Meta:
-        ordering = ('site', 'group', '_name', 'pk')  # (site, group, name) may be non-unique
-        unique_together = (
-            # Name and facility_id must be unique *only* within a RackGroup
-            ('group', 'name'),
-            ('group', 'facility_id'),
-        )
-
-    def __str__(self):
-        return self.display_name or super().__str__()
-
-    def get_absolute_url(self):
-        return reverse('dcim:rack', args=[self.pk])
-
-    def clean(self):
-
-        # Validate outer dimensions and unit
-        if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
-            raise ValidationError("Must specify a unit when setting an outer width/depth")
-        elif self.outer_width is None and self.outer_depth is None:
-            self.outer_unit = ''
-
-        if self.pk:
-            # Validate that Rack is tall enough to house the installed Devices
-            top_device = Device.objects.filter(
-                rack=self
-            ).exclude(
-                position__isnull=True
-            ).order_by('-position').first()
-            if top_device:
-                min_height = top_device.position + top_device.device_type.u_height - 1
-                if self.u_height < min_height:
-                    raise ValidationError({
-                        'u_height': "Rack must be at least {}U tall to house currently installed devices.".format(
-                            min_height
-                        )
-                    })
-            # Validate that Rack was assigned a group of its same site, if applicable
-            if self.group:
-                if self.group.site != self.site:
-                    raise ValidationError({
-                        'group': "Rack group must be from the same site, {}.".format(self.site)
-                    })
-
-    def save(self, *args, **kwargs):
-
-        # Record the original site assignment for this rack.
-        _site_id = None
-        if self.pk:
-            _site_id = Rack.objects.get(pk=self.pk).site_id
-
-        super().save(*args, **kwargs)
-
-        # Update racked devices if the assigned Site has been changed.
-        if _site_id is not None and self.site_id != _site_id:
-            devices = Device.objects.filter(rack=self)
-            for device in devices:
-                device.site = self.site
-                device.save()
-
-    def to_csv(self):
-        return (
-            self.site.name,
-            self.group.name if self.group else None,
-            self.name,
-            self.facility_id,
-            self.tenant.name if self.tenant else None,
-            self.get_status_display(),
-            self.role.name if self.role else None,
-            self.get_type_display() if self.type else None,
-            self.serial,
-            self.asset_tag,
-            self.width,
-            self.u_height,
-            self.desc_units,
-            self.outer_width,
-            self.outer_depth,
-            self.outer_unit,
-            self.comments,
-        )
-
-    @property
-    def units(self):
-        if self.desc_units:
-            return range(1, self.u_height + 1)
-        else:
-            return reversed(range(1, self.u_height + 1))
-
-    @property
-    def display_name(self):
-        if self.facility_id:
-            return f'{self.name} ({self.facility_id})'
-        return self.name
-
-    def get_status_class(self):
-        return self.STATUS_CLASS_MAP.get(self.status)
-
-    def get_rack_units(self, user=None, face=DeviceFaceChoices.FACE_FRONT, exclude=None, expand_devices=True):
-        """
-        Return a list of rack units as dictionaries. Example: {'device': None, 'face': 0, 'id': 48, 'name': 'U48'}
-        Each key 'device' is either a Device or None. By default, multi-U devices are repeated for each U they occupy.
-
-        :param face: Rack face (front or rear)
-        :param user: User instance to be used for evaluating device view permissions. If None, all devices
-            will be included.
-        :param exclude: PK of a Device to exclude (optional); helpful when relocating a Device within a Rack
-        :param expand_devices: When True, all units that a device occupies will be listed with each containing a
-            reference to the device. When False, only the bottom most unit for a device is included and that unit
-            contains a height attribute for the device
-        """
-
-        elevation = OrderedDict()
-        for u in self.units:
-            elevation[u] = {
-                'id': u,
-                'name': f'U{u}',
-                'face': face,
-                'device': None,
-                'occupied': False
-            }
-
-        # Add devices to rack units list
-        if self.pk:
-
-            # Retrieve all devices installed within the rack
-            queryset = Device.objects.prefetch_related(
-                'device_type',
-                'device_type__manufacturer',
-                'device_role'
-            ).annotate(
-                devicebay_count=Count('devicebays')
-            ).exclude(
-                pk=exclude
-            ).filter(
-                rack=self,
-                position__gt=0,
-                device_type__u_height__gt=0
-            ).filter(
-                Q(face=face) | Q(device_type__is_full_depth=True)
-            )
-
-            # Determine which devices the user has permission to view
-            permitted_device_ids = []
-            if user is not None:
-                permitted_device_ids = self.devices.restrict(user, 'view').values_list('pk', flat=True)
-
-            for device in queryset:
-                if expand_devices:
-                    for u in range(device.position, device.position + device.device_type.u_height):
-                        if user is None or device.pk in permitted_device_ids:
-                            elevation[u]['device'] = device
-                        elevation[u]['occupied'] = True
-                else:
-                    if user is None or device.pk in permitted_device_ids:
-                        elevation[device.position]['device'] = device
-                    elevation[device.position]['occupied'] = True
-                    elevation[device.position]['height'] = device.device_type.u_height
-                    for u in range(device.position + 1, device.position + device.device_type.u_height):
-                        elevation.pop(u, None)
-
-        return [u for u in elevation.values()]
-
-    def get_available_units(self, u_height=1, rack_face=None, exclude=None):
-        """
-        Return a list of units within the rack available to accommodate a device of a given U height (default 1).
-        Optionally exclude one or more devices when calculating empty units (needed when moving a device from one
-        position to another within a rack).
-
-        :param u_height: Minimum number of contiguous free units required
-        :param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth
-        :param exclude: List of devices IDs to exclude (useful when moving a device within a rack)
-        """
-        # Gather all devices which consume U space within the rack
-        devices = self.devices.prefetch_related('device_type').filter(position__gte=1)
-        if exclude is not None:
-            devices = devices.exclude(pk__in=exclude)
-
-        # Initialize the rack unit skeleton
-        units = list(range(1, self.u_height + 1))
-
-        # Remove units consumed by installed devices
-        for d in devices:
-            if rack_face is None or d.face == rack_face or d.device_type.is_full_depth:
-                for u in range(d.position, d.position + d.device_type.u_height):
-                    try:
-                        units.remove(u)
-                    except ValueError:
-                        # Found overlapping devices in the rack!
-                        pass
-
-        # Remove units without enough space above them to accommodate a device of the specified height
-        available_units = []
-        for u in units:
-            if set(range(u, u + u_height)).issubset(units):
-                available_units.append(u)
-
-        return list(reversed(available_units))
-
-    def get_reserved_units(self):
-        """
-        Return a dictionary mapping all reserved units within the rack to their reservation.
-        """
-        reserved_units = {}
-        for r in self.reservations.all():
-            for u in r.units:
-                reserved_units[u] = r
-        return reserved_units
-
-    def get_elevation_svg(
-            self,
-            face=DeviceFaceChoices.FACE_FRONT,
-            user=None,
-            unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH,
-            unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT,
-            legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
-            include_images=True,
-            base_url=None
-    ):
-        """
-        Return an SVG of the rack elevation
-
-        :param face: Enum of [front, rear] representing the desired side of the rack elevation to render
-        :param user: User instance to be used for evaluating device view permissions. If None, all devices
-            will be included.
-        :param unit_width: Width in pixels for the rendered drawing
-        :param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
-            height of the elevation
-        :param legend_width: Width of the unit legend, in pixels
-        :param include_images: Embed front/rear device images where available
-        :param base_url: Base URL for links and images. If none, URLs will be relative.
-        """
-        elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url)
-
-        return elevation.render(face, unit_width, unit_height, legend_width)
-
-    def get_0u_devices(self):
-        return self.devices.filter(position=0)
-
-    def get_utilization(self):
-        """
-        Determine the utilization rate of the rack and return it as a percentage. Occupied and reserved units both count
-        as utilized.
-        """
-        # Determine unoccupied units
-        available_units = self.get_available_units()
-
-        # Remove reserved units
-        for u in self.get_reserved_units():
-            if u in available_units:
-                available_units.remove(u)
-
-        occupied_unit_count = self.u_height - len(available_units)
-        percentage = int(float(occupied_unit_count) / self.u_height * 100)
-
-        return percentage
-
-    def get_power_utilization(self):
-        """
-        Determine the utilization rate of power in the rack and return it as a percentage.
-        """
-        power_stats = PowerFeed.objects.filter(
-            rack=self
-        ).annotate(
-            allocated_draw_total=Sum('connected_endpoint__poweroutlets__connected_endpoint__allocated_draw'),
-        ).values(
-            'allocated_draw_total',
-            'available_power'
-        )
-
-        if power_stats:
-            allocated_draw_total = sum(x['allocated_draw_total'] or 0 for x in power_stats)
-            available_power_total = sum(x['available_power'] for x in power_stats)
-            return int(allocated_draw_total / available_power_total * 100) or 0
-        return 0
-
-
-@extras_features('custom_links', 'export_templates', 'webhooks')
-class RackReservation(ChangeLoggedModel):
-    """
-    One or more reserved units within a Rack.
-    """
-    rack = models.ForeignKey(
-        to='dcim.Rack',
-        on_delete=models.CASCADE,
-        related_name='reservations'
-    )
-    units = ArrayField(
-        base_field=models.PositiveSmallIntegerField()
-    )
-    tenant = models.ForeignKey(
-        to='tenancy.Tenant',
-        on_delete=models.PROTECT,
-        related_name='rackreservations',
-        blank=True,
-        null=True
-    )
-    user = models.ForeignKey(
-        to=User,
-        on_delete=models.PROTECT
-    )
-    description = models.CharField(
-        max_length=200
-    )
-    tags = TaggableManager(through=TaggedItem)
-
-    objects = RestrictedQuerySet.as_manager()
-
-    csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description']
-
-    class Meta:
-        ordering = ['created']
-
-    def __str__(self):
-        return "Reservation for rack {}".format(self.rack)
-
-    def get_absolute_url(self):
-        return reverse('dcim:rackreservation', args=[self.pk])
-
-    def clean(self):
-
-        if hasattr(self, 'rack') and self.units:
-
-            # Validate that all specified units exist in the Rack.
-            invalid_units = [u for u in self.units if u not in self.rack.units]
-            if invalid_units:
-                raise ValidationError({
-                    'units': "Invalid unit(s) for {}U rack: {}".format(
-                        self.rack.u_height,
-                        ', '.join([str(u) for u in invalid_units]),
-                    ),
-                })
-
-            # Check that none of the units has already been reserved for this Rack.
-            reserved_units = []
-            for resv in self.rack.reservations.exclude(pk=self.pk):
-                reserved_units += resv.units
-            conflicting_units = [u for u in self.units if u in reserved_units]
-            if conflicting_units:
-                raise ValidationError({
-                    'units': 'The following units have already been reserved: {}'.format(
-                        ', '.join([str(u) for u in conflicting_units]),
-                    )
-                })
-
-    def to_csv(self):
-        return (
-            self.rack.site.name,
-            self.rack.group if self.rack.group else None,
-            self.rack.name,
-            ','.join([str(u) for u in self.units]),
-            self.tenant.name if self.tenant else None,
-            self.user.username,
-            self.description
-        )
-
-    @property
-    def unit_list(self):
-        """
-        Express the assigned units as a string of summarized ranges. For example:
-            [0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
-        """
-        group = (list(x) for _, x in groupby(sorted(self.units), lambda x, c=count(): next(c) - x))
-        return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group)
-
-
-#
-# Device Types
-#
-
-@extras_features('export_templates', 'webhooks')
-class Manufacturer(ChangeLoggedModel):
-    """
-    A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
-    """
-    name = models.CharField(
-        max_length=50,
-        unique=True
-    )
-    slug = models.SlugField(
-        unique=True
-    )
-    description = models.CharField(
-        max_length=200,
-        blank=True
-    )
-
-    objects = RestrictedQuerySet.as_manager()
-
-    csv_headers = ['name', 'slug', 'description']
-
-    class Meta:
-        ordering = ['name']
-
-    def __str__(self):
-        return self.name
-
-    def get_absolute_url(self):
-        return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
-
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.description
-        )
-
-
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
-class DeviceType(ChangeLoggedModel, CustomFieldModel):
-    """
-    A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
-    well as high-level functional role(s).
-
-    Each DeviceType can have an arbitrary number of component templates assigned to it, which define console, power, and
-    interface objects. For example, a Juniper EX4300-48T DeviceType would have:
-
-      * 1 ConsolePortTemplate
-      * 2 PowerPortTemplates
-      * 48 InterfaceTemplates
-
-    When a new Device of this type is created, the appropriate console, power, and interface objects (as defined by the
-    DeviceType) are automatically created as well.
-    """
-    manufacturer = models.ForeignKey(
-        to='dcim.Manufacturer',
-        on_delete=models.PROTECT,
-        related_name='device_types'
-    )
-    model = models.CharField(
-        max_length=50
-    )
-    slug = models.SlugField()
-    part_number = models.CharField(
-        max_length=50,
-        blank=True,
-        help_text='Discrete part number (optional)'
-    )
-    u_height = models.PositiveSmallIntegerField(
-        default=1,
-        verbose_name='Height (U)'
-    )
-    is_full_depth = models.BooleanField(
-        default=True,
-        verbose_name='Is full depth',
-        help_text='Device consumes both front and rear rack faces'
-    )
-    subdevice_role = models.CharField(
-        max_length=50,
-        choices=SubdeviceRoleChoices,
-        blank=True,
-        verbose_name='Parent/child status',
-        help_text='Parent devices house child devices in device bays. Leave blank '
-                  'if this device type is neither a parent nor a child.'
-    )
-    front_image = models.ImageField(
-        upload_to='devicetype-images',
-        blank=True
-    )
-    rear_image = models.ImageField(
-        upload_to='devicetype-images',
-        blank=True
-    )
-    comments = models.TextField(
-        blank=True
-    )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
-    tags = TaggableManager(through=TaggedItem)
-
-    objects = RestrictedQuerySet.as_manager()
-
-    clone_fields = [
-        'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role',
-    ]
-
-    class Meta:
-        ordering = ['manufacturer', 'model']
-        unique_together = [
-            ['manufacturer', 'model'],
-            ['manufacturer', 'slug'],
-        ]
-
-    def __str__(self):
-        return self.model
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Save a copy of u_height for validation in clean()
-        self._original_u_height = self.u_height
-
-        # Save references to the original front/rear images
-        self._original_front_image = self.front_image
-        self._original_rear_image = self.rear_image
-
-    def get_absolute_url(self):
-        return reverse('dcim:devicetype', args=[self.pk])
-
-    def to_yaml(self):
-        data = OrderedDict((
-            ('manufacturer', self.manufacturer.name),
-            ('model', self.model),
-            ('slug', self.slug),
-            ('part_number', self.part_number),
-            ('u_height', self.u_height),
-            ('is_full_depth', self.is_full_depth),
-            ('subdevice_role', self.subdevice_role),
-            ('comments', self.comments),
-        ))
-
-        # Component templates
-        if self.consoleporttemplates.exists():
-            data['console-ports'] = [
-                {
-                    'name': c.name,
-                    'type': c.type,
-                }
-                for c in self.consoleporttemplates.all()
-            ]
-        if self.consoleserverporttemplates.exists():
-            data['console-server-ports'] = [
-                {
-                    'name': c.name,
-                    'type': c.type,
-                }
-                for c in self.consoleserverporttemplates.all()
-            ]
-        if self.powerporttemplates.exists():
-            data['power-ports'] = [
-                {
-                    'name': c.name,
-                    'type': c.type,
-                    'maximum_draw': c.maximum_draw,
-                    'allocated_draw': c.allocated_draw,
-                }
-                for c in self.powerporttemplates.all()
-            ]
-        if self.poweroutlettemplates.exists():
-            data['power-outlets'] = [
-                {
-                    'name': c.name,
-                    'type': c.type,
-                    'power_port': c.power_port.name if c.power_port else None,
-                    'feed_leg': c.feed_leg,
-                }
-                for c in self.poweroutlettemplates.all()
-            ]
-        if self.interfacetemplates.exists():
-            data['interfaces'] = [
-                {
-                    'name': c.name,
-                    'type': c.type,
-                    'mgmt_only': c.mgmt_only,
-                }
-                for c in self.interfacetemplates.all()
-            ]
-        if self.frontporttemplates.exists():
-            data['front-ports'] = [
-                {
-                    'name': c.name,
-                    'type': c.type,
-                    'rear_port': c.rear_port.name,
-                    'rear_port_position': c.rear_port_position,
-                }
-                for c in self.frontporttemplates.all()
-            ]
-        if self.rearporttemplates.exists():
-            data['rear-ports'] = [
-                {
-                    'name': c.name,
-                    'type': c.type,
-                    'positions': c.positions,
-                }
-                for c in self.rearporttemplates.all()
-            ]
-        if self.devicebaytemplates.exists():
-            data['device-bays'] = [
-                {
-                    'name': c.name,
-                }
-                for c in self.devicebaytemplates.all()
-            ]
-
-        return yaml.dump(dict(data), sort_keys=False)
-
-    def clean(self):
-
-        # If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have
-        # room to expand within their racks. This validation will impose a very high performance penalty when there are
-        # many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence.
-        if self.pk and self.u_height > self._original_u_height:
-            for d in Device.objects.filter(device_type=self, position__isnull=False):
-                face_required = None if self.is_full_depth else d.face
-                u_available = d.rack.get_available_units(
-                    u_height=self.u_height,
-                    rack_face=face_required,
-                    exclude=[d.pk]
-                )
-                if d.position not in u_available:
-                    raise ValidationError({
-                        'u_height': "Device {} in rack {} does not have sufficient space to accommodate a height of "
-                                    "{}U".format(d, d.rack, self.u_height)
-                    })
-
-        # If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position.
-        elif self.pk and self._original_u_height > 0 and self.u_height == 0:
-            racked_instance_count = Device.objects.filter(
-                device_type=self,
-                position__isnull=False
-            ).count()
-            if racked_instance_count:
-                url = f"{reverse('dcim:device_list')}?manufactuer_id={self.manufacturer_id}&device_type_id={self.pk}"
-                raise ValidationError({
-                    'u_height': mark_safe(
-                        f'Unable to set 0U height: Found <a href="{url}">{racked_instance_count} instances</a> already '
-                        f'mounted within racks.'
-                    )
-                })
-
-        if (
-                self.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT
-        ) and self.devicebaytemplates.count():
-            raise ValidationError({
-                'subdevice_role': "Must delete all device bay templates associated with this device before "
-                                  "declassifying it as a parent device."
-            })
-
-        if self.u_height and self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD:
-            raise ValidationError({
-                'u_height': "Child device types must be 0U."
-            })
-
-    def save(self, *args, **kwargs):
-        ret = super().save(*args, **kwargs)
-
-        # Delete any previously uploaded image files that are no longer in use
-        if self.front_image != self._original_front_image:
-            self._original_front_image.delete(save=False)
-        if self.rear_image != self._original_rear_image:
-            self._original_rear_image.delete(save=False)
-
-        return ret
-
-    def delete(self, *args, **kwargs):
-        super().delete(*args, **kwargs)
-
-        # Delete any uploaded image files
-        if self.front_image:
-            self.front_image.delete(save=False)
-        if self.rear_image:
-            self.rear_image.delete(save=False)
-
-    @property
-    def display_name(self):
-        return f'{self.manufacturer.name} {self.model}'
-
-    @property
-    def is_parent_device(self):
-        return self.subdevice_role == SubdeviceRoleChoices.ROLE_PARENT
-
-    @property
-    def is_child_device(self):
-        return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
-
-
-#
-# Devices
-#
-
-class DeviceRole(ChangeLoggedModel):
-    """
-    Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
-    color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to
-    virtual machines as well.
-    """
-    name = models.CharField(
-        max_length=50,
-        unique=True
-    )
-    slug = models.SlugField(
-        unique=True
-    )
-    color = ColorField(
-        default=ColorChoices.COLOR_GREY
-    )
-    vm_role = models.BooleanField(
-        default=True,
-        verbose_name='VM Role',
-        help_text='Virtual machines may be assigned to this role'
-    )
-    description = models.CharField(
-        max_length=200,
-        blank=True,
-    )
-
-    objects = RestrictedQuerySet.as_manager()
-
-    csv_headers = ['name', 'slug', 'color', 'vm_role', 'description']
-
-    class Meta:
-        ordering = ['name']
-
-    def __str__(self):
-        return self.name
-
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.color,
-            self.vm_role,
-            self.description,
-        )
-
-
-class Platform(ChangeLoggedModel):
-    """
-    Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
-    NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by
-    specifying a NAPALM driver.
-    """
-    name = models.CharField(
-        max_length=100,
-        unique=True
-    )
-    slug = models.SlugField(
-        unique=True,
-        max_length=100
-    )
-    manufacturer = models.ForeignKey(
-        to='dcim.Manufacturer',
-        on_delete=models.PROTECT,
-        related_name='platforms',
-        blank=True,
-        null=True,
-        help_text='Optionally limit this platform to devices of a certain manufacturer'
-    )
-    napalm_driver = models.CharField(
-        max_length=50,
-        blank=True,
-        verbose_name='NAPALM driver',
-        help_text='The name of the NAPALM driver to use when interacting with devices'
-    )
-    napalm_args = models.JSONField(
-        blank=True,
-        null=True,
-        verbose_name='NAPALM arguments',
-        help_text='Additional arguments to pass when initiating the NAPALM driver (JSON format)'
-    )
-    description = models.CharField(
-        max_length=200,
-        blank=True
-    )
-
-    objects = RestrictedQuerySet.as_manager()
-
-    csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description']
-
-    class Meta:
-        ordering = ['name']
-
-    def __str__(self):
-        return self.name
-
-    def get_absolute_url(self):
-        return "{}?platform={}".format(reverse('dcim:device_list'), self.slug)
-
-    def to_csv(self):
-        return (
-            self.name,
-            self.slug,
-            self.manufacturer.name if self.manufacturer else None,
-            self.napalm_driver,
-            self.napalm_args,
-            self.description,
-        )
-
-
-@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
-class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
-    """
-    A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
-    DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.
-
-    Each Device must be assigned to a site, and optionally to a rack within that site. Associating a device with a
-    particular rack face or unit is optional (for example, vertically mounted PDUs do not consume rack units).
-
-    When a new Device is created, console/power/interface/device bay components are created along with it as dictated
-    by the component templates assigned to its DeviceType. Components can also be added, modified, or deleted after the
-    creation of a Device.
-    """
-    device_type = models.ForeignKey(
-        to='dcim.DeviceType',
-        on_delete=models.PROTECT,
-        related_name='instances'
-    )
-    device_role = models.ForeignKey(
-        to='dcim.DeviceRole',
-        on_delete=models.PROTECT,
-        related_name='devices'
-    )
-    tenant = models.ForeignKey(
-        to='tenancy.Tenant',
-        on_delete=models.PROTECT,
-        related_name='devices',
-        blank=True,
-        null=True
-    )
-    platform = models.ForeignKey(
-        to='dcim.Platform',
-        on_delete=models.SET_NULL,
-        related_name='devices',
-        blank=True,
-        null=True
-    )
-    name = models.CharField(
-        max_length=64,
-        blank=True,
-        null=True
-    )
-    _name = NaturalOrderingField(
-        target_field='name',
-        max_length=100,
-        blank=True,
-        null=True
-    )
-    serial = models.CharField(
-        max_length=50,
-        blank=True,
-        verbose_name='Serial number'
-    )
-    asset_tag = models.CharField(
-        max_length=50,
-        blank=True,
-        null=True,
-        unique=True,
-        verbose_name='Asset tag',
-        help_text='A unique tag used to identify this device'
-    )
-    site = models.ForeignKey(
-        to='dcim.Site',
-        on_delete=models.PROTECT,
-        related_name='devices'
-    )
-    rack = models.ForeignKey(
-        to='dcim.Rack',
-        on_delete=models.PROTECT,
-        related_name='devices',
-        blank=True,
-        null=True
-    )
-    position = models.PositiveSmallIntegerField(
-        blank=True,
-        null=True,
-        validators=[MinValueValidator(1)],
-        verbose_name='Position (U)',
-        help_text='The lowest-numbered unit occupied by the device'
-    )
-    face = models.CharField(
-        max_length=50,
-        blank=True,
-        choices=DeviceFaceChoices,
-        verbose_name='Rack face'
-    )
-    status = models.CharField(
-        max_length=50,
-        choices=DeviceStatusChoices,
-        default=DeviceStatusChoices.STATUS_ACTIVE
-    )
-    primary_ip4 = models.OneToOneField(
-        to='ipam.IPAddress',
-        on_delete=models.SET_NULL,
-        related_name='primary_ip4_for',
-        blank=True,
-        null=True,
-        verbose_name='Primary IPv4'
-    )
-    primary_ip6 = models.OneToOneField(
-        to='ipam.IPAddress',
-        on_delete=models.SET_NULL,
-        related_name='primary_ip6_for',
-        blank=True,
-        null=True,
-        verbose_name='Primary IPv6'
-    )
-    cluster = models.ForeignKey(
-        to='virtualization.Cluster',
-        on_delete=models.SET_NULL,
-        related_name='devices',
-        blank=True,
-        null=True
-    )
-    virtual_chassis = models.ForeignKey(
-        to='VirtualChassis',
-        on_delete=models.SET_NULL,
-        related_name='members',
-        blank=True,
-        null=True
-    )
-    vc_position = models.PositiveSmallIntegerField(
-        blank=True,
-        null=True,
-        validators=[MaxValueValidator(255)]
-    )
-    vc_priority = models.PositiveSmallIntegerField(
-        blank=True,
-        null=True,
-        validators=[MaxValueValidator(255)]
-    )
-    comments = models.TextField(
-        blank=True
-    )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
-    images = GenericRelation(
-        to='extras.ImageAttachment'
-    )
-    tags = TaggableManager(through=TaggedItem)
-
-    objects = RestrictedQuerySet.as_manager()
-
-    csv_headers = [
-        'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
-        'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
-    ]
-    clone_fields = [
-        'device_type', 'device_role', 'tenant', 'platform', 'site', 'rack', 'status', 'cluster',
-    ]
-
-    STATUS_CLASS_MAP = {
-        DeviceStatusChoices.STATUS_OFFLINE: 'warning',
-        DeviceStatusChoices.STATUS_ACTIVE: 'success',
-        DeviceStatusChoices.STATUS_PLANNED: 'info',
-        DeviceStatusChoices.STATUS_STAGED: 'primary',
-        DeviceStatusChoices.STATUS_FAILED: 'danger',
-        DeviceStatusChoices.STATUS_INVENTORY: 'default',
-        DeviceStatusChoices.STATUS_DECOMMISSIONING: 'warning',
-    }
-
-    class Meta:
-        ordering = ('_name', 'pk')  # Name may be null
-        unique_together = (
-            ('site', 'tenant', 'name'),  # See validate_unique below
-            ('rack', 'position', 'face'),
-            ('virtual_chassis', 'vc_position'),
-        )
-
-    def __str__(self):
-        return self.display_name or super().__str__()
-
-    def get_absolute_url(self):
-        return reverse('dcim:device', args=[self.pk])
-
-    def validate_unique(self, exclude=None):
-
-        # Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary
-        # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
-        # of the uniqueness constraint without manual intervention.
-        if self.name and self.tenant is None:
-            if Device.objects.exclude(pk=self.pk).filter(
-                    name=self.name,
-                    site=self.site,
-                    tenant__isnull=True
-            ):
-                raise ValidationError({
-                    'name': 'A device with this name already exists.'
-                })
-
-        super().validate_unique(exclude)
-
-    def clean(self):
-
-        super().clean()
-
-        # Validate site/rack combination
-        if self.rack and self.site != self.rack.site:
-            raise ValidationError({
-                'rack': "Rack {} does not belong to site {}.".format(self.rack, self.site),
-            })
-
-        if self.rack is None:
-            if self.face:
-                raise ValidationError({
-                    'face': "Cannot select a rack face without assigning a rack.",
-                })
-            if self.position:
-                raise ValidationError({
-                    'face': "Cannot select a rack position without assigning a rack.",
-                })
-
-        # Validate position/face combination
-        if self.position and not self.face:
-            raise ValidationError({
-                'face': "Must specify rack face when defining rack position.",
-            })
-
-        # Prevent 0U devices from being assigned to a specific position
-        if self.position and self.device_type.u_height == 0:
-            raise ValidationError({
-                'position': "A U0 device type ({}) cannot be assigned to a rack position.".format(self.device_type)
-            })
-
-        if self.rack:
-
-            try:
-                # Child devices cannot be assigned to a rack face/unit
-                if self.device_type.is_child_device and self.face:
-                    raise ValidationError({
-                        'face': "Child device types cannot be assigned to a rack face. This is an attribute of the "
-                                "parent device."
-                    })
-                if self.device_type.is_child_device and self.position:
-                    raise ValidationError({
-                        'position': "Child device types cannot be assigned to a rack position. This is an attribute of "
-                                    "the parent device."
-                    })
-
-                # Validate rack space
-                rack_face = self.face if not self.device_type.is_full_depth else None
-                exclude_list = [self.pk] if self.pk else []
-                try:
-                    available_units = self.rack.get_available_units(
-                        u_height=self.device_type.u_height, rack_face=rack_face, exclude=exclude_list
-                    )
-                    if self.position and self.position not in available_units:
-                        raise ValidationError({
-                            'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) "
-                                        "{} ({}U).".format(self.position, self.device_type, self.device_type.u_height)
-                        })
-                except Rack.DoesNotExist:
-                    pass
-
-            except DeviceType.DoesNotExist:
-                pass
-
-        # Validate primary IP addresses
-        vc_interfaces = self.vc_interfaces.all()
-        if self.primary_ip4:
-            if self.primary_ip4.family != 4:
-                raise ValidationError({
-                    'primary_ip4': f"{self.primary_ip4} is not an IPv4 address."
-                })
-            if self.primary_ip4.assigned_object in vc_interfaces:
-                pass
-            elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.assigned_object in vc_interfaces:
-                pass
-            else:
-                raise ValidationError({
-                    'primary_ip4': f"The specified IP address ({self.primary_ip4}) is not assigned to this device."
-                })
-        if self.primary_ip6:
-            if self.primary_ip6.family != 6:
-                raise ValidationError({
-                    'primary_ip6': f"{self.primary_ip6} is not an IPv6 address."
-                })
-            if self.primary_ip6.assigned_object in vc_interfaces:
-                pass
-            elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.assigned_object in vc_interfaces:
-                pass
-            else:
-                raise ValidationError({
-                    'primary_ip6': f"The specified IP address ({self.primary_ip6}) is not assigned to this device."
-                })
-
-        # Validate manufacturer/platform
-        if hasattr(self, 'device_type') and self.platform:
-            if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer:
-                raise ValidationError({
-                    'platform': "The assigned platform is limited to {} device types, but this device's type belongs "
-                                "to {}.".format(self.platform.manufacturer, self.device_type.manufacturer)
-                })
-
-        # A Device can only be assigned to a Cluster in the same Site (or no Site)
-        if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
-            raise ValidationError({
-                'cluster': "The assigned cluster belongs to a different site ({})".format(self.cluster.site)
-            })
-
-        # Validate virtual chassis assignment
-        if self.virtual_chassis and self.vc_position is None:
-            raise ValidationError({
-                'vc_position': "A device assigned to a virtual chassis must have its position defined."
-            })
-
-    def save(self, *args, **kwargs):
-
-        is_new = not bool(self.pk)
-
-        super().save(*args, **kwargs)
-
-        # If this is a new Device, instantiate all of the related components per the DeviceType definition
-        if is_new:
-            ConsolePort.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.consoleporttemplates.all()]
-            )
-            ConsoleServerPort.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.consoleserverporttemplates.all()]
-            )
-            PowerPort.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.powerporttemplates.all()]
-            )
-            PowerOutlet.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.poweroutlettemplates.all()]
-            )
-            Interface.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.interfacetemplates.all()]
-            )
-            RearPort.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.rearporttemplates.all()]
-            )
-            FrontPort.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.frontporttemplates.all()]
-            )
-            DeviceBay.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.devicebaytemplates.all()]
-            )
-
-        # Update Site and Rack assignment for any child Devices
-        devices = Device.objects.filter(parent_bay__device=self)
-        for device in devices:
-            device.site = self.site
-            device.rack = self.rack
-            device.save()
-
-    def to_csv(self):
-        return (
-            self.name or '',
-            self.device_role.name,
-            self.tenant.name if self.tenant else None,
-            self.device_type.manufacturer.name,
-            self.device_type.model,
-            self.platform.name if self.platform else None,
-            self.serial,
-            self.asset_tag,
-            self.get_status_display(),
-            self.site.name,
-            self.rack.group.name if self.rack and self.rack.group else None,
-            self.rack.name if self.rack else None,
-            self.position,
-            self.get_face_display(),
-            self.comments,
-        )
-
-    @property
-    def display_name(self):
-        if self.name:
-            return self.name
-        elif self.virtual_chassis:
-            return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
-        elif self.device_type:
-            return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
-        else:
-            return ''  # Device has not yet been created
-
-    @property
-    def identifier(self):
-        """
-        Return the device name if set; otherwise return the Device's primary key as {pk}
-        """
-        if self.name is not None:
-            return self.name
-        return '{{{}}}'.format(self.pk)
-
-    @property
-    def primary_ip(self):
-        if settings.PREFER_IPV4 and self.primary_ip4:
-            return self.primary_ip4
-        elif self.primary_ip6:
-            return self.primary_ip6
-        elif self.primary_ip4:
-            return self.primary_ip4
-        else:
-            return None
-
-    def get_vc_master(self):
-        """
-        If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.
-        """
-        return self.virtual_chassis.master if self.virtual_chassis else None
-
-    @property
-    def vc_interfaces(self):
-        """
-        Return a QuerySet matching all Interfaces assigned to this Device or, if this Device is a VC master, to another
-        Device belonging to the same VirtualChassis.
-        """
-        filter = Q(device=self)
-        if self.virtual_chassis and self.virtual_chassis.master == self:
-            filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False)
-        return Interface.objects.filter(filter)
-
-    def get_cables(self, pk_list=False):
-        """
-        Return a QuerySet or PK list matching all Cables connected to a component of this Device.
-        """
-        cable_pks = []
-        for component_model in [
-            ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, FrontPort, RearPort
-        ]:
-            cable_pks += component_model.objects.filter(
-                device=self, cable__isnull=False
-            ).values_list('cable', flat=True)
-        if pk_list:
-            return cable_pks
-        return Cable.objects.filter(pk__in=cable_pks)
-
-    def get_children(self):
-        """
-        Return the set of child Devices installed in DeviceBays within this Device.
-        """
-        return Device.objects.filter(parent_bay__device=self.pk)
-
-    def get_status_class(self):
-        return self.STATUS_CLASS_MAP.get(self.status)
-
-
-#
-# Virtual chassis
-#
-
-@extras_features('custom_links', 'export_templates', 'webhooks')
-class VirtualChassis(ChangeLoggedModel):
-    """
-    A collection of Devices which operate with a shared control plane (e.g. a switch stack).
-    """
-    master = models.OneToOneField(
-        to='Device',
-        on_delete=models.PROTECT,
-        related_name='vc_master_for',
-        blank=True,
-        null=True
-    )
-    name = models.CharField(
-        max_length=64
-    )
-    domain = models.CharField(
-        max_length=30,
-        blank=True
-    )
-    tags = TaggableManager(through=TaggedItem)
-
-    objects = RestrictedQuerySet.as_manager()
-
-    csv_headers = ['name', 'domain', 'master']
-
-    class Meta:
-        ordering = ['name']
-        verbose_name_plural = 'virtual chassis'
-
-    def __str__(self):
-        return self.name
-
-    def get_absolute_url(self):
-        return reverse('dcim:virtualchassis', kwargs={'pk': self.pk})
-
-    def clean(self):
-
-        # Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new
-        # VirtualChassis.)
-        if self.pk and self.master and self.master not in self.members.all():
-            raise ValidationError({
-                'master': f"The selected master ({self.master}) is not assigned to this virtual chassis."
-            })
-
-    def delete(self, *args, **kwargs):
-
-        # Check for LAG interfaces split across member chassis
-        interfaces = Interface.objects.filter(
-            device__in=self.members.all(),
-            lag__isnull=False
-        ).exclude(
-            lag__device=F('device')
-        )
-        if interfaces:
-            raise ProtectedError(
-                f"Unable to delete virtual chassis {self}. There are member interfaces which form a cross-chassis LAG",
-                interfaces
-            )
-
-        return super().delete(*args, **kwargs)
-
-    def to_csv(self):
-        return (
-            self.name,
-            self.domain,
-            self.master.name if self.master else None,
-        )
-
-
-#
-# Power
-#
-
-@extras_features('custom_links', 'export_templates', 'webhooks')
-class PowerPanel(ChangeLoggedModel):
-    """
-    A distribution point for electrical power; e.g. a data center RPP.
-    """
-    site = models.ForeignKey(
-        to='Site',
-        on_delete=models.PROTECT
-    )
-    rack_group = models.ForeignKey(
-        to='RackGroup',
-        on_delete=models.PROTECT,
-        blank=True,
-        null=True
-    )
-    name = models.CharField(
-        max_length=50
-    )
-    tags = TaggableManager(through=TaggedItem)
-
-    objects = RestrictedQuerySet.as_manager()
-
-    csv_headers = ['site', 'rack_group', 'name']
-
-    class Meta:
-        ordering = ['site', 'name']
-        unique_together = ['site', 'name']
-
-    def __str__(self):
-        return self.name
-
-    def get_absolute_url(self):
-        return reverse('dcim:powerpanel', args=[self.pk])
-
-    def to_csv(self):
-        return (
-            self.site.name,
-            self.rack_group.name if self.rack_group else None,
-            self.name,
-        )
-
-    def clean(self):
-
-        # RackGroup must belong to assigned Site
-        if self.rack_group and self.rack_group.site != self.site:
-            raise ValidationError("Rack group {} ({}) is in a different site than {}".format(
-                self.rack_group, self.rack_group.site, self.site
-            ))
-
-
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
-class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
-    """
-    An electrical circuit delivered from a PowerPanel.
-    """
-    power_panel = models.ForeignKey(
-        to='PowerPanel',
-        on_delete=models.PROTECT,
-        related_name='powerfeeds'
-    )
-    rack = models.ForeignKey(
-        to='Rack',
-        on_delete=models.PROTECT,
-        blank=True,
-        null=True
-    )
-    connected_endpoint = models.OneToOneField(
-        to='dcim.PowerPort',
-        on_delete=models.SET_NULL,
-        related_name='+',
-        blank=True,
-        null=True
-    )
-    connection_status = models.BooleanField(
-        choices=CONNECTION_STATUS_CHOICES,
-        blank=True,
-        null=True
-    )
-    name = models.CharField(
-        max_length=50
-    )
-    status = models.CharField(
-        max_length=50,
-        choices=PowerFeedStatusChoices,
-        default=PowerFeedStatusChoices.STATUS_ACTIVE
-    )
-    type = models.CharField(
-        max_length=50,
-        choices=PowerFeedTypeChoices,
-        default=PowerFeedTypeChoices.TYPE_PRIMARY
-    )
-    supply = models.CharField(
-        max_length=50,
-        choices=PowerFeedSupplyChoices,
-        default=PowerFeedSupplyChoices.SUPPLY_AC
-    )
-    phase = models.CharField(
-        max_length=50,
-        choices=PowerFeedPhaseChoices,
-        default=PowerFeedPhaseChoices.PHASE_SINGLE
-    )
-    voltage = models.SmallIntegerField(
-        default=POWERFEED_VOLTAGE_DEFAULT,
-        validators=[ExclusionValidator([0])]
-    )
-    amperage = models.PositiveSmallIntegerField(
-        validators=[MinValueValidator(1)],
-        default=POWERFEED_AMPERAGE_DEFAULT
-    )
-    max_utilization = models.PositiveSmallIntegerField(
-        validators=[MinValueValidator(1), MaxValueValidator(100)],
-        default=POWERFEED_MAX_UTILIZATION_DEFAULT,
-        help_text="Maximum permissible draw (percentage)"
-    )
-    available_power = models.PositiveIntegerField(
-        default=0,
-        editable=False
-    )
-    comments = models.TextField(
-        blank=True
-    )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
-    tags = TaggableManager(through=TaggedItem)
-
-    objects = RestrictedQuerySet.as_manager()
-
-    csv_headers = [
-        'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
-        'amperage', 'max_utilization', 'comments',
-    ]
-    clone_fields = [
-        'power_panel', 'rack', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization',
-        'available_power',
-    ]
-
-    STATUS_CLASS_MAP = {
-        PowerFeedStatusChoices.STATUS_OFFLINE: 'warning',
-        PowerFeedStatusChoices.STATUS_ACTIVE: 'success',
-        PowerFeedStatusChoices.STATUS_PLANNED: 'info',
-        PowerFeedStatusChoices.STATUS_FAILED: 'danger',
-    }
-
-    TYPE_CLASS_MAP = {
-        PowerFeedTypeChoices.TYPE_PRIMARY: 'success',
-        PowerFeedTypeChoices.TYPE_REDUNDANT: 'info',
-    }
-
-    class Meta:
-        ordering = ['power_panel', 'name']
-        unique_together = ['power_panel', 'name']
-
-    def __str__(self):
-        return self.name
-
-    def get_absolute_url(self):
-        return reverse('dcim:powerfeed', args=[self.pk])
-
-    def to_csv(self):
-        return (
-            self.power_panel.site.name,
-            self.power_panel.name,
-            self.rack.group.name if self.rack and self.rack.group else None,
-            self.rack.name if self.rack else None,
-            self.name,
-            self.get_status_display(),
-            self.get_type_display(),
-            self.get_supply_display(),
-            self.get_phase_display(),
-            self.voltage,
-            self.amperage,
-            self.max_utilization,
-            self.comments,
-        )
-
-    def clean(self):
-
-        # Rack must belong to same Site as PowerPanel
-        if self.rack and self.rack.site != self.power_panel.site:
-            raise ValidationError("Rack {} ({}) and power panel {} ({}) are in different sites".format(
-                self.rack, self.rack.site, self.power_panel, self.power_panel.site
-            ))
-
-        # AC voltage cannot be negative
-        if self.voltage < 0 and self.supply == PowerFeedSupplyChoices.SUPPLY_AC:
-            raise ValidationError({
-                "voltage": "Voltage cannot be negative for AC supply"
-            })
-
-    def save(self, *args, **kwargs):
-
-        # Cache the available_power property on the instance
-        kva = abs(self.voltage) * self.amperage * (self.max_utilization / 100)
-        if self.phase == PowerFeedPhaseChoices.PHASE_3PHASE:
-            self.available_power = round(kva * 1.732)
-        else:
-            self.available_power = round(kva)
-
-        super().save(*args, **kwargs)
-
-    @property
-    def parent(self):
-        return self.power_panel
-
-    def get_type_class(self):
-        return self.TYPE_CLASS_MAP.get(self.type)
-
-    def get_status_class(self):
-        return self.STATUS_CLASS_MAP.get(self.status)
-
-
-#
-# Cables
-#
-
-@extras_features('custom_links', 'export_templates', 'webhooks')
-class Cable(ChangeLoggedModel):
-    """
-    A physical connection between two endpoints.
-    """
-    termination_a_type = models.ForeignKey(
-        to=ContentType,
-        limit_choices_to=CABLE_TERMINATION_MODELS,
-        on_delete=models.PROTECT,
-        related_name='+'
-    )
-    termination_a_id = models.PositiveIntegerField()
-    termination_a = GenericForeignKey(
-        ct_field='termination_a_type',
-        fk_field='termination_a_id'
-    )
-    termination_b_type = models.ForeignKey(
-        to=ContentType,
-        limit_choices_to=CABLE_TERMINATION_MODELS,
-        on_delete=models.PROTECT,
-        related_name='+'
-    )
-    termination_b_id = models.PositiveIntegerField()
-    termination_b = GenericForeignKey(
-        ct_field='termination_b_type',
-        fk_field='termination_b_id'
-    )
-    type = models.CharField(
-        max_length=50,
-        choices=CableTypeChoices,
-        blank=True
-    )
-    status = models.CharField(
-        max_length=50,
-        choices=CableStatusChoices,
-        default=CableStatusChoices.STATUS_CONNECTED
-    )
-    label = models.CharField(
-        max_length=100,
-        blank=True
-    )
-    color = ColorField(
-        blank=True
-    )
-    length = models.PositiveSmallIntegerField(
-        blank=True,
-        null=True
-    )
-    length_unit = models.CharField(
-        max_length=50,
-        choices=CableLengthUnitChoices,
-        blank=True,
-    )
-    # Stores the normalized length (in meters) for database ordering
-    _abs_length = models.DecimalField(
-        max_digits=10,
-        decimal_places=4,
-        blank=True,
-        null=True
-    )
-    # Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by
-    # their associated Devices.
-    _termination_a_device = models.ForeignKey(
-        to=Device,
-        on_delete=models.CASCADE,
-        related_name='+',
-        blank=True,
-        null=True
-    )
-    _termination_b_device = models.ForeignKey(
-        to=Device,
-        on_delete=models.CASCADE,
-        related_name='+',
-        blank=True,
-        null=True
-    )
-    tags = TaggableManager(through=TaggedItem)
-
-    objects = RestrictedQuerySet.as_manager()
-
-    csv_headers = [
-        'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label',
-        'color', 'length', 'length_unit',
-    ]
-
-    STATUS_CLASS_MAP = {
-        CableStatusChoices.STATUS_CONNECTED: 'success',
-        CableStatusChoices.STATUS_PLANNED: 'info',
-        CableStatusChoices.STATUS_DECOMMISSIONING: 'warning',
-    }
-
-    class Meta:
-        ordering = ['pk']
-        unique_together = (
-            ('termination_a_type', 'termination_a_id'),
-            ('termination_b_type', 'termination_b_id'),
-        )
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # A copy of the PK to be used by __str__ in case the object is deleted
-        self._pk = self.pk
-
-    @classmethod
-    def from_db(cls, db, field_names, values):
-        """
-        Cache the original A and B terminations of existing Cable instances for later reference inside clean().
-        """
-        instance = super().from_db(db, field_names, values)
-
-        instance._orig_termination_a_type_id = instance.termination_a_type_id
-        instance._orig_termination_a_id = instance.termination_a_id
-        instance._orig_termination_b_type_id = instance.termination_b_type_id
-        instance._orig_termination_b_id = instance.termination_b_id
-
-        return instance
-
-    def __str__(self):
-        return self.label or '#{}'.format(self._pk)
-
-    def get_absolute_url(self):
-        return reverse('dcim:cable', args=[self.pk])
-
-    def clean(self):
-        from circuits.models import CircuitTermination
-
-        # Validate that termination A exists
-        if not hasattr(self, 'termination_a_type'):
-            raise ValidationError('Termination A type has not been specified')
-        try:
-            self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
-        except ObjectDoesNotExist:
-            raise ValidationError({
-                'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
-            })
-
-        # Validate that termination B exists
-        if not hasattr(self, 'termination_b_type'):
-            raise ValidationError('Termination B type has not been specified')
-        try:
-            self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
-        except ObjectDoesNotExist:
-            raise ValidationError({
-                'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
-            })
-
-        # If editing an existing Cable instance, check that neither termination has been modified.
-        if self.pk:
-            err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
-            if (
-                self.termination_a_type_id != self._orig_termination_a_type_id or
-                self.termination_a_id != self._orig_termination_a_id
-            ):
-                raise ValidationError({
-                    'termination_a': err_msg
-                })
-            if (
-                self.termination_b_type_id != self._orig_termination_b_type_id or
-                self.termination_b_id != self._orig_termination_b_id
-            ):
-                raise ValidationError({
-                    'termination_b': err_msg
-                })
-
-        type_a = self.termination_a_type.model
-        type_b = self.termination_b_type.model
-
-        # Validate interface types
-        if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES:
-            raise ValidationError({
-                'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format(
-                    self.termination_a.get_type_display()
-                )
-            })
-        if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES:
-            raise ValidationError({
-                'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format(
-                    self.termination_b.get_type_display()
-                )
-            })
-
-        # Check that termination types are compatible
-        if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
-            raise ValidationError(
-                f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
-            )
-
-        # Check that a RearPort with multiple positions isn't connected to an endpoint
-        # or a RearPort with a different number of positions.
-        for term_a, term_b in [
-            (self.termination_a, self.termination_b),
-            (self.termination_b, self.termination_a)
-        ]:
-            if isinstance(term_a, RearPort) and term_a.positions > 1:
-                if not isinstance(term_b, (FrontPort, RearPort, CircuitTermination)):
-                    raise ValidationError(
-                        "Rear ports with multiple positions may only be connected to other pass-through ports"
-                    )
-                if isinstance(term_b, RearPort) and term_b.positions > 1 and term_a.positions != term_b.positions:
-                    raise ValidationError(
-                        f"{term_a} of {term_a.device} has {term_a.positions} position(s) but "
-                        f"{term_b} of {term_b.device} has {term_b.positions}. "
-                        f"Both terminations must have the same number of positions."
-                    )
-
-        # A termination point cannot be connected to itself
-        if self.termination_a == self.termination_b:
-            raise ValidationError(f"Cannot connect {self.termination_a_type} to itself")
-
-        # A front port cannot be connected to its corresponding rear port
-        if (
-            type_a in ['frontport', 'rearport'] and
-            type_b in ['frontport', 'rearport'] and
-            (
-                getattr(self.termination_a, 'rear_port', None) == self.termination_b or
-                getattr(self.termination_b, 'rear_port', None) == self.termination_a
-            )
-        ):
-            raise ValidationError("A front port cannot be connected to it corresponding rear port")
-
-        # Check for an existing Cable connected to either termination object
-        if self.termination_a.cable not in (None, self):
-            raise ValidationError("{} already has a cable attached (#{})".format(
-                self.termination_a, self.termination_a.cable_id
-            ))
-        if self.termination_b.cable not in (None, self):
-            raise ValidationError("{} already has a cable attached (#{})".format(
-                self.termination_b, self.termination_b.cable_id
-            ))
-
-        # Validate length and length_unit
-        if self.length is not None and not self.length_unit:
-            raise ValidationError("Must specify a unit when setting a cable length")
-        elif self.length is None:
-            self.length_unit = ''
-
-    def save(self, *args, **kwargs):
-
-        # Store the given length (if any) in meters for use in database ordering
-        if self.length and self.length_unit:
-            self._abs_length = to_meters(self.length, self.length_unit)
-        else:
-            self._abs_length = None
-
-        # Store the parent Device for the A and B terminations (if applicable) to enable filtering
-        if hasattr(self.termination_a, 'device'):
-            self._termination_a_device = self.termination_a.device
-        if hasattr(self.termination_b, 'device'):
-            self._termination_b_device = self.termination_b.device
-
-        super().save(*args, **kwargs)
-
-        # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
-        self._pk = self.pk
-
-    def to_csv(self):
-        return (
-            '{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model),
-            self.termination_a_id,
-            '{}.{}'.format(self.termination_b_type.app_label, self.termination_b_type.model),
-            self.termination_b_id,
-            self.get_type_display(),
-            self.get_status_display(),
-            self.label,
-            self.color,
-            self.length,
-            self.length_unit,
-        )
-
-    def get_status_class(self):
-        return self.STATUS_CLASS_MAP.get(self.status)
-
-    def get_compatible_types(self):
-        """
-        Return all termination types compatible with termination A.
-        """
-        if self.termination_a is None:
-            return
-        return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]

+ 1 - 0
netbox/dcim/models/device_components.py

@@ -22,6 +22,7 @@ from utilities.utils import serialize_object
 
 
 __all__ = (
+    'BaseInterface',
     'CableTermination',
     'ConsolePort',
     'ConsoleServerPort',

+ 1247 - 0
netbox/dcim/models/devices.py

@@ -0,0 +1,1247 @@
+from collections import OrderedDict
+
+import yaml
+from django.conf import settings
+from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.core.validators import MaxValueValidator, MinValueValidator
+from django.db import models
+from django.db.models import F, ProtectedError
+from django.urls import reverse
+from django.utils.safestring import mark_safe
+from taggit.managers import TaggableManager
+
+from dcim.choices import *
+from dcim.constants import *
+from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, TaggedItem
+from extras.utils import extras_features
+from utilities.choices import ColorChoices
+from utilities.fields import ColorField, NaturalOrderingField
+from utilities.querysets import RestrictedQuerySet
+from utilities.utils import to_meters
+from .device_components import *
+
+
+__all__ = (
+    'Cable',
+    'Device',
+    'DeviceRole',
+    'DeviceType',
+    'Manufacturer',
+    'Platform',
+    'VirtualChassis',
+)
+
+
+#
+# Device Types
+#
+
+@extras_features('export_templates', 'webhooks')
+class Manufacturer(ChangeLoggedModel):
+    """
+    A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
+    """
+    name = models.CharField(
+        max_length=50,
+        unique=True
+    )
+    slug = models.SlugField(
+        unique=True
+    )
+    description = models.CharField(
+        max_length=200,
+        blank=True
+    )
+
+    objects = RestrictedQuerySet.as_manager()
+
+    csv_headers = ['name', 'slug', 'description']
+
+    class Meta:
+        ordering = ['name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
+
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+            self.description
+        )
+
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+class DeviceType(ChangeLoggedModel, CustomFieldModel):
+    """
+    A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
+    well as high-level functional role(s).
+
+    Each DeviceType can have an arbitrary number of component templates assigned to it, which define console, power, and
+    interface objects. For example, a Juniper EX4300-48T DeviceType would have:
+
+      * 1 ConsolePortTemplate
+      * 2 PowerPortTemplates
+      * 48 InterfaceTemplates
+
+    When a new Device of this type is created, the appropriate console, power, and interface objects (as defined by the
+    DeviceType) are automatically created as well.
+    """
+    manufacturer = models.ForeignKey(
+        to='dcim.Manufacturer',
+        on_delete=models.PROTECT,
+        related_name='device_types'
+    )
+    model = models.CharField(
+        max_length=50
+    )
+    slug = models.SlugField()
+    part_number = models.CharField(
+        max_length=50,
+        blank=True,
+        help_text='Discrete part number (optional)'
+    )
+    u_height = models.PositiveSmallIntegerField(
+        default=1,
+        verbose_name='Height (U)'
+    )
+    is_full_depth = models.BooleanField(
+        default=True,
+        verbose_name='Is full depth',
+        help_text='Device consumes both front and rear rack faces'
+    )
+    subdevice_role = models.CharField(
+        max_length=50,
+        choices=SubdeviceRoleChoices,
+        blank=True,
+        verbose_name='Parent/child status',
+        help_text='Parent devices house child devices in device bays. Leave blank '
+                  'if this device type is neither a parent nor a child.'
+    )
+    front_image = models.ImageField(
+        upload_to='devicetype-images',
+        blank=True
+    )
+    rear_image = models.ImageField(
+        upload_to='devicetype-images',
+        blank=True
+    )
+    comments = models.TextField(
+        blank=True
+    )
+    custom_field_values = GenericRelation(
+        to='extras.CustomFieldValue',
+        content_type_field='obj_type',
+        object_id_field='obj_id'
+    )
+    tags = TaggableManager(through=TaggedItem)
+
+    objects = RestrictedQuerySet.as_manager()
+
+    clone_fields = [
+        'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role',
+    ]
+
+    class Meta:
+        ordering = ['manufacturer', 'model']
+        unique_together = [
+            ['manufacturer', 'model'],
+            ['manufacturer', 'slug'],
+        ]
+
+    def __str__(self):
+        return self.model
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Save a copy of u_height for validation in clean()
+        self._original_u_height = self.u_height
+
+        # Save references to the original front/rear images
+        self._original_front_image = self.front_image
+        self._original_rear_image = self.rear_image
+
+    def get_absolute_url(self):
+        return reverse('dcim:devicetype', args=[self.pk])
+
+    def to_yaml(self):
+        data = OrderedDict((
+            ('manufacturer', self.manufacturer.name),
+            ('model', self.model),
+            ('slug', self.slug),
+            ('part_number', self.part_number),
+            ('u_height', self.u_height),
+            ('is_full_depth', self.is_full_depth),
+            ('subdevice_role', self.subdevice_role),
+            ('comments', self.comments),
+        ))
+
+        # Component templates
+        if self.consoleporttemplates.exists():
+            data['console-ports'] = [
+                {
+                    'name': c.name,
+                    'type': c.type,
+                }
+                for c in self.consoleporttemplates.all()
+            ]
+        if self.consoleserverporttemplates.exists():
+            data['console-server-ports'] = [
+                {
+                    'name': c.name,
+                    'type': c.type,
+                }
+                for c in self.consoleserverporttemplates.all()
+            ]
+        if self.powerporttemplates.exists():
+            data['power-ports'] = [
+                {
+                    'name': c.name,
+                    'type': c.type,
+                    'maximum_draw': c.maximum_draw,
+                    'allocated_draw': c.allocated_draw,
+                }
+                for c in self.powerporttemplates.all()
+            ]
+        if self.poweroutlettemplates.exists():
+            data['power-outlets'] = [
+                {
+                    'name': c.name,
+                    'type': c.type,
+                    'power_port': c.power_port.name if c.power_port else None,
+                    'feed_leg': c.feed_leg,
+                }
+                for c in self.poweroutlettemplates.all()
+            ]
+        if self.interfacetemplates.exists():
+            data['interfaces'] = [
+                {
+                    'name': c.name,
+                    'type': c.type,
+                    'mgmt_only': c.mgmt_only,
+                }
+                for c in self.interfacetemplates.all()
+            ]
+        if self.frontporttemplates.exists():
+            data['front-ports'] = [
+                {
+                    'name': c.name,
+                    'type': c.type,
+                    'rear_port': c.rear_port.name,
+                    'rear_port_position': c.rear_port_position,
+                }
+                for c in self.frontporttemplates.all()
+            ]
+        if self.rearporttemplates.exists():
+            data['rear-ports'] = [
+                {
+                    'name': c.name,
+                    'type': c.type,
+                    'positions': c.positions,
+                }
+                for c in self.rearporttemplates.all()
+            ]
+        if self.devicebaytemplates.exists():
+            data['device-bays'] = [
+                {
+                    'name': c.name,
+                }
+                for c in self.devicebaytemplates.all()
+            ]
+
+        return yaml.dump(dict(data), sort_keys=False)
+
+    def clean(self):
+
+        # If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have
+        # room to expand within their racks. This validation will impose a very high performance penalty when there are
+        # many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence.
+        if self.pk and self.u_height > self._original_u_height:
+            for d in Device.objects.filter(device_type=self, position__isnull=False):
+                face_required = None if self.is_full_depth else d.face
+                u_available = d.rack.get_available_units(
+                    u_height=self.u_height,
+                    rack_face=face_required,
+                    exclude=[d.pk]
+                )
+                if d.position not in u_available:
+                    raise ValidationError({
+                        'u_height': "Device {} in rack {} does not have sufficient space to accommodate a height of "
+                                    "{}U".format(d, d.rack, self.u_height)
+                    })
+
+        # If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position.
+        elif self.pk and self._original_u_height > 0 and self.u_height == 0:
+            racked_instance_count = Device.objects.filter(
+                device_type=self,
+                position__isnull=False
+            ).count()
+            if racked_instance_count:
+                url = f"{reverse('dcim:device_list')}?manufactuer_id={self.manufacturer_id}&device_type_id={self.pk}"
+                raise ValidationError({
+                    'u_height': mark_safe(
+                        f'Unable to set 0U height: Found <a href="{url}">{racked_instance_count} instances</a> already '
+                        f'mounted within racks.'
+                    )
+                })
+
+        if (
+                self.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT
+        ) and self.devicebaytemplates.count():
+            raise ValidationError({
+                'subdevice_role': "Must delete all device bay templates associated with this device before "
+                                  "declassifying it as a parent device."
+            })
+
+        if self.u_height and self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD:
+            raise ValidationError({
+                'u_height': "Child device types must be 0U."
+            })
+
+    def save(self, *args, **kwargs):
+        ret = super().save(*args, **kwargs)
+
+        # Delete any previously uploaded image files that are no longer in use
+        if self.front_image != self._original_front_image:
+            self._original_front_image.delete(save=False)
+        if self.rear_image != self._original_rear_image:
+            self._original_rear_image.delete(save=False)
+
+        return ret
+
+    def delete(self, *args, **kwargs):
+        super().delete(*args, **kwargs)
+
+        # Delete any uploaded image files
+        if self.front_image:
+            self.front_image.delete(save=False)
+        if self.rear_image:
+            self.rear_image.delete(save=False)
+
+    @property
+    def display_name(self):
+        return f'{self.manufacturer.name} {self.model}'
+
+    @property
+    def is_parent_device(self):
+        return self.subdevice_role == SubdeviceRoleChoices.ROLE_PARENT
+
+    @property
+    def is_child_device(self):
+        return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
+
+
+#
+# Devices
+#
+
+class DeviceRole(ChangeLoggedModel):
+    """
+    Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
+    color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to
+    virtual machines as well.
+    """
+    name = models.CharField(
+        max_length=50,
+        unique=True
+    )
+    slug = models.SlugField(
+        unique=True
+    )
+    color = ColorField(
+        default=ColorChoices.COLOR_GREY
+    )
+    vm_role = models.BooleanField(
+        default=True,
+        verbose_name='VM Role',
+        help_text='Virtual machines may be assigned to this role'
+    )
+    description = models.CharField(
+        max_length=200,
+        blank=True,
+    )
+
+    objects = RestrictedQuerySet.as_manager()
+
+    csv_headers = ['name', 'slug', 'color', 'vm_role', 'description']
+
+    class Meta:
+        ordering = ['name']
+
+    def __str__(self):
+        return self.name
+
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+            self.color,
+            self.vm_role,
+            self.description,
+        )
+
+
+class Platform(ChangeLoggedModel):
+    """
+    Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
+    NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by
+    specifying a NAPALM driver.
+    """
+    name = models.CharField(
+        max_length=100,
+        unique=True
+    )
+    slug = models.SlugField(
+        unique=True,
+        max_length=100
+    )
+    manufacturer = models.ForeignKey(
+        to='dcim.Manufacturer',
+        on_delete=models.PROTECT,
+        related_name='platforms',
+        blank=True,
+        null=True,
+        help_text='Optionally limit this platform to devices of a certain manufacturer'
+    )
+    napalm_driver = models.CharField(
+        max_length=50,
+        blank=True,
+        verbose_name='NAPALM driver',
+        help_text='The name of the NAPALM driver to use when interacting with devices'
+    )
+    napalm_args = models.JSONField(
+        blank=True,
+        null=True,
+        verbose_name='NAPALM arguments',
+        help_text='Additional arguments to pass when initiating the NAPALM driver (JSON format)'
+    )
+    description = models.CharField(
+        max_length=200,
+        blank=True
+    )
+
+    objects = RestrictedQuerySet.as_manager()
+
+    csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description']
+
+    class Meta:
+        ordering = ['name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return "{}?platform={}".format(reverse('dcim:device_list'), self.slug)
+
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+            self.manufacturer.name if self.manufacturer else None,
+            self.napalm_driver,
+            self.napalm_args,
+            self.description,
+        )
+
+
+@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
+class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
+    """
+    A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
+    DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.
+
+    Each Device must be assigned to a site, and optionally to a rack within that site. Associating a device with a
+    particular rack face or unit is optional (for example, vertically mounted PDUs do not consume rack units).
+
+    When a new Device is created, console/power/interface/device bay components are created along with it as dictated
+    by the component templates assigned to its DeviceType. Components can also be added, modified, or deleted after the
+    creation of a Device.
+    """
+    device_type = models.ForeignKey(
+        to='dcim.DeviceType',
+        on_delete=models.PROTECT,
+        related_name='instances'
+    )
+    device_role = models.ForeignKey(
+        to='dcim.DeviceRole',
+        on_delete=models.PROTECT,
+        related_name='devices'
+    )
+    tenant = models.ForeignKey(
+        to='tenancy.Tenant',
+        on_delete=models.PROTECT,
+        related_name='devices',
+        blank=True,
+        null=True
+    )
+    platform = models.ForeignKey(
+        to='dcim.Platform',
+        on_delete=models.SET_NULL,
+        related_name='devices',
+        blank=True,
+        null=True
+    )
+    name = models.CharField(
+        max_length=64,
+        blank=True,
+        null=True
+    )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True,
+        null=True
+    )
+    serial = models.CharField(
+        max_length=50,
+        blank=True,
+        verbose_name='Serial number'
+    )
+    asset_tag = models.CharField(
+        max_length=50,
+        blank=True,
+        null=True,
+        unique=True,
+        verbose_name='Asset tag',
+        help_text='A unique tag used to identify this device'
+    )
+    site = models.ForeignKey(
+        to='dcim.Site',
+        on_delete=models.PROTECT,
+        related_name='devices'
+    )
+    rack = models.ForeignKey(
+        to='dcim.Rack',
+        on_delete=models.PROTECT,
+        related_name='devices',
+        blank=True,
+        null=True
+    )
+    position = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(1)],
+        verbose_name='Position (U)',
+        help_text='The lowest-numbered unit occupied by the device'
+    )
+    face = models.CharField(
+        max_length=50,
+        blank=True,
+        choices=DeviceFaceChoices,
+        verbose_name='Rack face'
+    )
+    status = models.CharField(
+        max_length=50,
+        choices=DeviceStatusChoices,
+        default=DeviceStatusChoices.STATUS_ACTIVE
+    )
+    primary_ip4 = models.OneToOneField(
+        to='ipam.IPAddress',
+        on_delete=models.SET_NULL,
+        related_name='primary_ip4_for',
+        blank=True,
+        null=True,
+        verbose_name='Primary IPv4'
+    )
+    primary_ip6 = models.OneToOneField(
+        to='ipam.IPAddress',
+        on_delete=models.SET_NULL,
+        related_name='primary_ip6_for',
+        blank=True,
+        null=True,
+        verbose_name='Primary IPv6'
+    )
+    cluster = models.ForeignKey(
+        to='virtualization.Cluster',
+        on_delete=models.SET_NULL,
+        related_name='devices',
+        blank=True,
+        null=True
+    )
+    virtual_chassis = models.ForeignKey(
+        to='VirtualChassis',
+        on_delete=models.SET_NULL,
+        related_name='members',
+        blank=True,
+        null=True
+    )
+    vc_position = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True,
+        validators=[MaxValueValidator(255)]
+    )
+    vc_priority = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True,
+        validators=[MaxValueValidator(255)]
+    )
+    comments = models.TextField(
+        blank=True
+    )
+    custom_field_values = GenericRelation(
+        to='extras.CustomFieldValue',
+        content_type_field='obj_type',
+        object_id_field='obj_id'
+    )
+    images = GenericRelation(
+        to='extras.ImageAttachment'
+    )
+    tags = TaggableManager(through=TaggedItem)
+
+    objects = RestrictedQuerySet.as_manager()
+
+    csv_headers = [
+        'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
+        'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
+    ]
+    clone_fields = [
+        'device_type', 'device_role', 'tenant', 'platform', 'site', 'rack', 'status', 'cluster',
+    ]
+
+    STATUS_CLASS_MAP = {
+        DeviceStatusChoices.STATUS_OFFLINE: 'warning',
+        DeviceStatusChoices.STATUS_ACTIVE: 'success',
+        DeviceStatusChoices.STATUS_PLANNED: 'info',
+        DeviceStatusChoices.STATUS_STAGED: 'primary',
+        DeviceStatusChoices.STATUS_FAILED: 'danger',
+        DeviceStatusChoices.STATUS_INVENTORY: 'default',
+        DeviceStatusChoices.STATUS_DECOMMISSIONING: 'warning',
+    }
+
+    class Meta:
+        ordering = ('_name', 'pk')  # Name may be null
+        unique_together = (
+            ('site', 'tenant', 'name'),  # See validate_unique below
+            ('rack', 'position', 'face'),
+            ('virtual_chassis', 'vc_position'),
+        )
+
+    def __str__(self):
+        return self.display_name or super().__str__()
+
+    def get_absolute_url(self):
+        return reverse('dcim:device', args=[self.pk])
+
+    def validate_unique(self, exclude=None):
+
+        # Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary
+        # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
+        # of the uniqueness constraint without manual intervention.
+        if self.name and self.tenant is None:
+            if Device.objects.exclude(pk=self.pk).filter(
+                    name=self.name,
+                    site=self.site,
+                    tenant__isnull=True
+            ):
+                raise ValidationError({
+                    'name': 'A device with this name already exists.'
+                })
+
+        super().validate_unique(exclude)
+
+    def clean(self):
+
+        super().clean()
+
+        # Validate site/rack combination
+        if self.rack and self.site != self.rack.site:
+            raise ValidationError({
+                'rack': "Rack {} does not belong to site {}.".format(self.rack, self.site),
+            })
+
+        if self.rack is None:
+            if self.face:
+                raise ValidationError({
+                    'face': "Cannot select a rack face without assigning a rack.",
+                })
+            if self.position:
+                raise ValidationError({
+                    'face': "Cannot select a rack position without assigning a rack.",
+                })
+
+        # Validate position/face combination
+        if self.position and not self.face:
+            raise ValidationError({
+                'face': "Must specify rack face when defining rack position.",
+            })
+
+        # Prevent 0U devices from being assigned to a specific position
+        if self.position and self.device_type.u_height == 0:
+            raise ValidationError({
+                'position': "A U0 device type ({}) cannot be assigned to a rack position.".format(self.device_type)
+            })
+
+        if self.rack:
+
+            try:
+                # Child devices cannot be assigned to a rack face/unit
+                if self.device_type.is_child_device and self.face:
+                    raise ValidationError({
+                        'face': "Child device types cannot be assigned to a rack face. This is an attribute of the "
+                                "parent device."
+                    })
+                if self.device_type.is_child_device and self.position:
+                    raise ValidationError({
+                        'position': "Child device types cannot be assigned to a rack position. This is an attribute of "
+                                    "the parent device."
+                    })
+
+                # Validate rack space
+                rack_face = self.face if not self.device_type.is_full_depth else None
+                exclude_list = [self.pk] if self.pk else []
+                available_units = self.rack.get_available_units(
+                    u_height=self.device_type.u_height, rack_face=rack_face, exclude=exclude_list
+                )
+                if self.position and self.position not in available_units:
+                    raise ValidationError({
+                        'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) "
+                                    "{} ({}U).".format(self.position, self.device_type, self.device_type.u_height)
+                    })
+
+            except DeviceType.DoesNotExist:
+                pass
+
+        # Validate primary IP addresses
+        vc_interfaces = self.vc_interfaces.all()
+        if self.primary_ip4:
+            if self.primary_ip4.family != 4:
+                raise ValidationError({
+                    'primary_ip4': f"{self.primary_ip4} is not an IPv4 address."
+                })
+            if self.primary_ip4.assigned_object in vc_interfaces:
+                pass
+            elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.assigned_object in vc_interfaces:
+                pass
+            else:
+                raise ValidationError({
+                    'primary_ip4': f"The specified IP address ({self.primary_ip4}) is not assigned to this device."
+                })
+        if self.primary_ip6:
+            if self.primary_ip6.family != 6:
+                raise ValidationError({
+                    'primary_ip6': f"{self.primary_ip6} is not an IPv6 address."
+                })
+            if self.primary_ip6.assigned_object in vc_interfaces:
+                pass
+            elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.assigned_object in vc_interfaces:
+                pass
+            else:
+                raise ValidationError({
+                    'primary_ip6': f"The specified IP address ({self.primary_ip6}) is not assigned to this device."
+                })
+
+        # Validate manufacturer/platform
+        if hasattr(self, 'device_type') and self.platform:
+            if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer:
+                raise ValidationError({
+                    'platform': "The assigned platform is limited to {} device types, but this device's type belongs "
+                                "to {}.".format(self.platform.manufacturer, self.device_type.manufacturer)
+                })
+
+        # A Device can only be assigned to a Cluster in the same Site (or no Site)
+        if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
+            raise ValidationError({
+                'cluster': "The assigned cluster belongs to a different site ({})".format(self.cluster.site)
+            })
+
+        # Validate virtual chassis assignment
+        if self.virtual_chassis and self.vc_position is None:
+            raise ValidationError({
+                'vc_position': "A device assigned to a virtual chassis must have its position defined."
+            })
+
+    def save(self, *args, **kwargs):
+
+        is_new = not bool(self.pk)
+
+        super().save(*args, **kwargs)
+
+        # If this is a new Device, instantiate all of the related components per the DeviceType definition
+        if is_new:
+            ConsolePort.objects.bulk_create(
+                [x.instantiate(self) for x in self.device_type.consoleporttemplates.all()]
+            )
+            ConsoleServerPort.objects.bulk_create(
+                [x.instantiate(self) for x in self.device_type.consoleserverporttemplates.all()]
+            )
+            PowerPort.objects.bulk_create(
+                [x.instantiate(self) for x in self.device_type.powerporttemplates.all()]
+            )
+            PowerOutlet.objects.bulk_create(
+                [x.instantiate(self) for x in self.device_type.poweroutlettemplates.all()]
+            )
+            Interface.objects.bulk_create(
+                [x.instantiate(self) for x in self.device_type.interfacetemplates.all()]
+            )
+            RearPort.objects.bulk_create(
+                [x.instantiate(self) for x in self.device_type.rearporttemplates.all()]
+            )
+            FrontPort.objects.bulk_create(
+                [x.instantiate(self) for x in self.device_type.frontporttemplates.all()]
+            )
+            DeviceBay.objects.bulk_create(
+                [x.instantiate(self) for x in self.device_type.devicebaytemplates.all()]
+            )
+
+        # Update Site and Rack assignment for any child Devices
+        devices = Device.objects.filter(parent_bay__device=self)
+        for device in devices:
+            device.site = self.site
+            device.rack = self.rack
+            device.save()
+
+    def to_csv(self):
+        return (
+            self.name or '',
+            self.device_role.name,
+            self.tenant.name if self.tenant else None,
+            self.device_type.manufacturer.name,
+            self.device_type.model,
+            self.platform.name if self.platform else None,
+            self.serial,
+            self.asset_tag,
+            self.get_status_display(),
+            self.site.name,
+            self.rack.group.name if self.rack and self.rack.group else None,
+            self.rack.name if self.rack else None,
+            self.position,
+            self.get_face_display(),
+            self.comments,
+        )
+
+    @property
+    def display_name(self):
+        if self.name:
+            return self.name
+        elif self.virtual_chassis:
+            return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
+        elif self.device_type:
+            return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
+        else:
+            return ''  # Device has not yet been created
+
+    @property
+    def identifier(self):
+        """
+        Return the device name if set; otherwise return the Device's primary key as {pk}
+        """
+        if self.name is not None:
+            return self.name
+        return '{{{}}}'.format(self.pk)
+
+    @property
+    def primary_ip(self):
+        if settings.PREFER_IPV4 and self.primary_ip4:
+            return self.primary_ip4
+        elif self.primary_ip6:
+            return self.primary_ip6
+        elif self.primary_ip4:
+            return self.primary_ip4
+        else:
+            return None
+
+    def get_vc_master(self):
+        """
+        If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.
+        """
+        return self.virtual_chassis.master if self.virtual_chassis else None
+
+    @property
+    def vc_interfaces(self):
+        """
+        Return a QuerySet matching all Interfaces assigned to this Device or, if this Device is a VC master, to another
+        Device belonging to the same VirtualChassis.
+        """
+        filter = Q(device=self)
+        if self.virtual_chassis and self.virtual_chassis.master == self:
+            filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False)
+        return Interface.objects.filter(filter)
+
+    def get_cables(self, pk_list=False):
+        """
+        Return a QuerySet or PK list matching all Cables connected to a component of this Device.
+        """
+        cable_pks = []
+        for component_model in [
+            ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, FrontPort, RearPort
+        ]:
+            cable_pks += component_model.objects.filter(
+                device=self, cable__isnull=False
+            ).values_list('cable', flat=True)
+        if pk_list:
+            return cable_pks
+        return Cable.objects.filter(pk__in=cable_pks)
+
+    def get_children(self):
+        """
+        Return the set of child Devices installed in DeviceBays within this Device.
+        """
+        return Device.objects.filter(parent_bay__device=self.pk)
+
+    def get_status_class(self):
+        return self.STATUS_CLASS_MAP.get(self.status)
+
+
+#
+# Cables
+#
+
+@extras_features('custom_links', 'export_templates', 'webhooks')
+class Cable(ChangeLoggedModel):
+    """
+    A physical connection between two endpoints.
+    """
+    termination_a_type = models.ForeignKey(
+        to=ContentType,
+        limit_choices_to=CABLE_TERMINATION_MODELS,
+        on_delete=models.PROTECT,
+        related_name='+'
+    )
+    termination_a_id = models.PositiveIntegerField()
+    termination_a = GenericForeignKey(
+        ct_field='termination_a_type',
+        fk_field='termination_a_id'
+    )
+    termination_b_type = models.ForeignKey(
+        to=ContentType,
+        limit_choices_to=CABLE_TERMINATION_MODELS,
+        on_delete=models.PROTECT,
+        related_name='+'
+    )
+    termination_b_id = models.PositiveIntegerField()
+    termination_b = GenericForeignKey(
+        ct_field='termination_b_type',
+        fk_field='termination_b_id'
+    )
+    type = models.CharField(
+        max_length=50,
+        choices=CableTypeChoices,
+        blank=True
+    )
+    status = models.CharField(
+        max_length=50,
+        choices=CableStatusChoices,
+        default=CableStatusChoices.STATUS_CONNECTED
+    )
+    label = models.CharField(
+        max_length=100,
+        blank=True
+    )
+    color = ColorField(
+        blank=True
+    )
+    length = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True
+    )
+    length_unit = models.CharField(
+        max_length=50,
+        choices=CableLengthUnitChoices,
+        blank=True,
+    )
+    # Stores the normalized length (in meters) for database ordering
+    _abs_length = models.DecimalField(
+        max_digits=10,
+        decimal_places=4,
+        blank=True,
+        null=True
+    )
+    # Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by
+    # their associated Devices.
+    _termination_a_device = models.ForeignKey(
+        to=Device,
+        on_delete=models.CASCADE,
+        related_name='+',
+        blank=True,
+        null=True
+    )
+    _termination_b_device = models.ForeignKey(
+        to=Device,
+        on_delete=models.CASCADE,
+        related_name='+',
+        blank=True,
+        null=True
+    )
+    tags = TaggableManager(through=TaggedItem)
+
+    objects = RestrictedQuerySet.as_manager()
+
+    csv_headers = [
+        'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label',
+        'color', 'length', 'length_unit',
+    ]
+
+    STATUS_CLASS_MAP = {
+        CableStatusChoices.STATUS_CONNECTED: 'success',
+        CableStatusChoices.STATUS_PLANNED: 'info',
+        CableStatusChoices.STATUS_DECOMMISSIONING: 'warning',
+    }
+
+    class Meta:
+        ordering = ['pk']
+        unique_together = (
+            ('termination_a_type', 'termination_a_id'),
+            ('termination_b_type', 'termination_b_id'),
+        )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # A copy of the PK to be used by __str__ in case the object is deleted
+        self._pk = self.pk
+
+    @classmethod
+    def from_db(cls, db, field_names, values):
+        """
+        Cache the original A and B terminations of existing Cable instances for later reference inside clean().
+        """
+        instance = super().from_db(db, field_names, values)
+
+        instance._orig_termination_a_type_id = instance.termination_a_type_id
+        instance._orig_termination_a_id = instance.termination_a_id
+        instance._orig_termination_b_type_id = instance.termination_b_type_id
+        instance._orig_termination_b_id = instance.termination_b_id
+
+        return instance
+
+    def __str__(self):
+        return self.label or '#{}'.format(self._pk)
+
+    def get_absolute_url(self):
+        return reverse('dcim:cable', args=[self.pk])
+
+    def clean(self):
+        from circuits.models import CircuitTermination
+
+        # Validate that termination A exists
+        if not hasattr(self, 'termination_a_type'):
+            raise ValidationError('Termination A type has not been specified')
+        try:
+            self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
+        except ObjectDoesNotExist:
+            raise ValidationError({
+                'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
+            })
+
+        # Validate that termination B exists
+        if not hasattr(self, 'termination_b_type'):
+            raise ValidationError('Termination B type has not been specified')
+        try:
+            self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
+        except ObjectDoesNotExist:
+            raise ValidationError({
+                'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
+            })
+
+        # If editing an existing Cable instance, check that neither termination has been modified.
+        if self.pk:
+            err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
+            if (
+                self.termination_a_type_id != self._orig_termination_a_type_id or
+                self.termination_a_id != self._orig_termination_a_id
+            ):
+                raise ValidationError({
+                    'termination_a': err_msg
+                })
+            if (
+                self.termination_b_type_id != self._orig_termination_b_type_id or
+                self.termination_b_id != self._orig_termination_b_id
+            ):
+                raise ValidationError({
+                    'termination_b': err_msg
+                })
+
+        type_a = self.termination_a_type.model
+        type_b = self.termination_b_type.model
+
+        # Validate interface types
+        if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES:
+            raise ValidationError({
+                'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format(
+                    self.termination_a.get_type_display()
+                )
+            })
+        if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES:
+            raise ValidationError({
+                'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format(
+                    self.termination_b.get_type_display()
+                )
+            })
+
+        # Check that termination types are compatible
+        if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
+            raise ValidationError(
+                f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
+            )
+
+        # Check that a RearPort with multiple positions isn't connected to an endpoint
+        # or a RearPort with a different number of positions.
+        for term_a, term_b in [
+            (self.termination_a, self.termination_b),
+            (self.termination_b, self.termination_a)
+        ]:
+            if isinstance(term_a, RearPort) and term_a.positions > 1:
+                if not isinstance(term_b, (FrontPort, RearPort, CircuitTermination)):
+                    raise ValidationError(
+                        "Rear ports with multiple positions may only be connected to other pass-through ports"
+                    )
+                if isinstance(term_b, RearPort) and term_b.positions > 1 and term_a.positions != term_b.positions:
+                    raise ValidationError(
+                        f"{term_a} of {term_a.device} has {term_a.positions} position(s) but "
+                        f"{term_b} of {term_b.device} has {term_b.positions}. "
+                        f"Both terminations must have the same number of positions."
+                    )
+
+        # A termination point cannot be connected to itself
+        if self.termination_a == self.termination_b:
+            raise ValidationError(f"Cannot connect {self.termination_a_type} to itself")
+
+        # A front port cannot be connected to its corresponding rear port
+        if (
+            type_a in ['frontport', 'rearport'] and
+            type_b in ['frontport', 'rearport'] and
+            (
+                getattr(self.termination_a, 'rear_port', None) == self.termination_b or
+                getattr(self.termination_b, 'rear_port', None) == self.termination_a
+            )
+        ):
+            raise ValidationError("A front port cannot be connected to it corresponding rear port")
+
+        # Check for an existing Cable connected to either termination object
+        if self.termination_a.cable not in (None, self):
+            raise ValidationError("{} already has a cable attached (#{})".format(
+                self.termination_a, self.termination_a.cable_id
+            ))
+        if self.termination_b.cable not in (None, self):
+            raise ValidationError("{} already has a cable attached (#{})".format(
+                self.termination_b, self.termination_b.cable_id
+            ))
+
+        # Validate length and length_unit
+        if self.length is not None and not self.length_unit:
+            raise ValidationError("Must specify a unit when setting a cable length")
+        elif self.length is None:
+            self.length_unit = ''
+
+    def save(self, *args, **kwargs):
+
+        # Store the given length (if any) in meters for use in database ordering
+        if self.length and self.length_unit:
+            self._abs_length = to_meters(self.length, self.length_unit)
+        else:
+            self._abs_length = None
+
+        # Store the parent Device for the A and B terminations (if applicable) to enable filtering
+        if hasattr(self.termination_a, 'device'):
+            self._termination_a_device = self.termination_a.device
+        if hasattr(self.termination_b, 'device'):
+            self._termination_b_device = self.termination_b.device
+
+        super().save(*args, **kwargs)
+
+        # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
+        self._pk = self.pk
+
+    def to_csv(self):
+        return (
+            '{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model),
+            self.termination_a_id,
+            '{}.{}'.format(self.termination_b_type.app_label, self.termination_b_type.model),
+            self.termination_b_id,
+            self.get_type_display(),
+            self.get_status_display(),
+            self.label,
+            self.color,
+            self.length,
+            self.length_unit,
+        )
+
+    def get_status_class(self):
+        return self.STATUS_CLASS_MAP.get(self.status)
+
+    def get_compatible_types(self):
+        """
+        Return all termination types compatible with termination A.
+        """
+        if self.termination_a is None:
+            return
+        return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
+
+
+#
+# Virtual chassis
+#
+
+@extras_features('custom_links', 'export_templates', 'webhooks')
+class VirtualChassis(ChangeLoggedModel):
+    """
+    A collection of Devices which operate with a shared control plane (e.g. a switch stack).
+    """
+    master = models.OneToOneField(
+        to='Device',
+        on_delete=models.PROTECT,
+        related_name='vc_master_for',
+        blank=True,
+        null=True
+    )
+    name = models.CharField(
+        max_length=64
+    )
+    domain = models.CharField(
+        max_length=30,
+        blank=True
+    )
+    tags = TaggableManager(through=TaggedItem)
+
+    objects = RestrictedQuerySet.as_manager()
+
+    csv_headers = ['name', 'domain', 'master']
+
+    class Meta:
+        ordering = ['name']
+        verbose_name_plural = 'virtual chassis'
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('dcim:virtualchassis', kwargs={'pk': self.pk})
+
+    def clean(self):
+
+        # Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new
+        # VirtualChassis.)
+        if self.pk and self.master and self.master not in self.members.all():
+            raise ValidationError({
+                'master': f"The selected master ({self.master}) is not assigned to this virtual chassis."
+            })
+
+    def delete(self, *args, **kwargs):
+
+        # Check for LAG interfaces split across member chassis
+        interfaces = Interface.objects.filter(
+            device__in=self.members.all(),
+            lag__isnull=False
+        ).exclude(
+            lag__device=F('device')
+        )
+        if interfaces:
+            raise ProtectedError(
+                f"Unable to delete virtual chassis {self}. There are member interfaces which form a cross-chassis LAG",
+                interfaces
+            )
+
+        return super().delete(*args, **kwargs)
+
+    def to_csv(self):
+        return (
+            self.name,
+            self.domain,
+            self.master.name if self.master else None,
+        )

+ 237 - 0
netbox/dcim/models/power.py

@@ -0,0 +1,237 @@
+from django.contrib.contenttypes.fields import GenericRelation
+from django.core.exceptions import ValidationError
+from django.core.validators import MaxValueValidator, MinValueValidator
+from django.db import models
+from django.urls import reverse
+from taggit.managers import TaggableManager
+
+from dcim.choices import *
+from dcim.constants import *
+from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem
+from extras.utils import extras_features
+from utilities.querysets import RestrictedQuerySet
+from utilities.validators import ExclusionValidator
+from .device_components import CableTermination
+
+__all__ = (
+    'PowerFeed',
+    'PowerPanel',
+)
+
+
+#
+# Power
+#
+
+@extras_features('custom_links', 'export_templates', 'webhooks')
+class PowerPanel(ChangeLoggedModel):
+    """
+    A distribution point for electrical power; e.g. a data center RPP.
+    """
+    site = models.ForeignKey(
+        to='Site',
+        on_delete=models.PROTECT
+    )
+    rack_group = models.ForeignKey(
+        to='RackGroup',
+        on_delete=models.PROTECT,
+        blank=True,
+        null=True
+    )
+    name = models.CharField(
+        max_length=50
+    )
+    tags = TaggableManager(through=TaggedItem)
+
+    objects = RestrictedQuerySet.as_manager()
+
+    csv_headers = ['site', 'rack_group', 'name']
+
+    class Meta:
+        ordering = ['site', 'name']
+        unique_together = ['site', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('dcim:powerpanel', args=[self.pk])
+
+    def to_csv(self):
+        return (
+            self.site.name,
+            self.rack_group.name if self.rack_group else None,
+            self.name,
+        )
+
+    def clean(self):
+
+        # RackGroup must belong to assigned Site
+        if self.rack_group and self.rack_group.site != self.site:
+            raise ValidationError("Rack group {} ({}) is in a different site than {}".format(
+                self.rack_group, self.rack_group.site, self.site
+            ))
+
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
+    """
+    An electrical circuit delivered from a PowerPanel.
+    """
+    power_panel = models.ForeignKey(
+        to='PowerPanel',
+        on_delete=models.PROTECT,
+        related_name='powerfeeds'
+    )
+    rack = models.ForeignKey(
+        to='Rack',
+        on_delete=models.PROTECT,
+        blank=True,
+        null=True
+    )
+    connected_endpoint = models.OneToOneField(
+        to='dcim.PowerPort',
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True
+    )
+    connection_status = models.BooleanField(
+        choices=CONNECTION_STATUS_CHOICES,
+        blank=True,
+        null=True
+    )
+    name = models.CharField(
+        max_length=50
+    )
+    status = models.CharField(
+        max_length=50,
+        choices=PowerFeedStatusChoices,
+        default=PowerFeedStatusChoices.STATUS_ACTIVE
+    )
+    type = models.CharField(
+        max_length=50,
+        choices=PowerFeedTypeChoices,
+        default=PowerFeedTypeChoices.TYPE_PRIMARY
+    )
+    supply = models.CharField(
+        max_length=50,
+        choices=PowerFeedSupplyChoices,
+        default=PowerFeedSupplyChoices.SUPPLY_AC
+    )
+    phase = models.CharField(
+        max_length=50,
+        choices=PowerFeedPhaseChoices,
+        default=PowerFeedPhaseChoices.PHASE_SINGLE
+    )
+    voltage = models.SmallIntegerField(
+        default=POWERFEED_VOLTAGE_DEFAULT,
+        validators=[ExclusionValidator([0])]
+    )
+    amperage = models.PositiveSmallIntegerField(
+        validators=[MinValueValidator(1)],
+        default=POWERFEED_AMPERAGE_DEFAULT
+    )
+    max_utilization = models.PositiveSmallIntegerField(
+        validators=[MinValueValidator(1), MaxValueValidator(100)],
+        default=POWERFEED_MAX_UTILIZATION_DEFAULT,
+        help_text="Maximum permissible draw (percentage)"
+    )
+    available_power = models.PositiveIntegerField(
+        default=0,
+        editable=False
+    )
+    comments = models.TextField(
+        blank=True
+    )
+    custom_field_values = GenericRelation(
+        to='extras.CustomFieldValue',
+        content_type_field='obj_type',
+        object_id_field='obj_id'
+    )
+    tags = TaggableManager(through=TaggedItem)
+
+    objects = RestrictedQuerySet.as_manager()
+
+    csv_headers = [
+        'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
+        'amperage', 'max_utilization', 'comments',
+    ]
+    clone_fields = [
+        'power_panel', 'rack', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization',
+        'available_power',
+    ]
+
+    STATUS_CLASS_MAP = {
+        PowerFeedStatusChoices.STATUS_OFFLINE: 'warning',
+        PowerFeedStatusChoices.STATUS_ACTIVE: 'success',
+        PowerFeedStatusChoices.STATUS_PLANNED: 'info',
+        PowerFeedStatusChoices.STATUS_FAILED: 'danger',
+    }
+
+    TYPE_CLASS_MAP = {
+        PowerFeedTypeChoices.TYPE_PRIMARY: 'success',
+        PowerFeedTypeChoices.TYPE_REDUNDANT: 'info',
+    }
+
+    class Meta:
+        ordering = ['power_panel', 'name']
+        unique_together = ['power_panel', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('dcim:powerfeed', args=[self.pk])
+
+    def to_csv(self):
+        return (
+            self.power_panel.site.name,
+            self.power_panel.name,
+            self.rack.group.name if self.rack and self.rack.group else None,
+            self.rack.name if self.rack else None,
+            self.name,
+            self.get_status_display(),
+            self.get_type_display(),
+            self.get_supply_display(),
+            self.get_phase_display(),
+            self.voltage,
+            self.amperage,
+            self.max_utilization,
+            self.comments,
+        )
+
+    def clean(self):
+
+        # Rack must belong to same Site as PowerPanel
+        if self.rack and self.rack.site != self.power_panel.site:
+            raise ValidationError("Rack {} ({}) and power panel {} ({}) are in different sites".format(
+                self.rack, self.rack.site, self.power_panel, self.power_panel.site
+            ))
+
+        # AC voltage cannot be negative
+        if self.voltage < 0 and self.supply == PowerFeedSupplyChoices.SUPPLY_AC:
+            raise ValidationError({
+                "voltage": "Voltage cannot be negative for AC supply"
+            })
+
+    def save(self, *args, **kwargs):
+
+        # Cache the available_power property on the instance
+        kva = abs(self.voltage) * self.amperage * (self.max_utilization / 100)
+        if self.phase == PowerFeedPhaseChoices.PHASE_3PHASE:
+            self.available_power = round(kva * 1.732)
+        else:
+            self.available_power = round(kva)
+
+        super().save(*args, **kwargs)
+
+    @property
+    def parent(self):
+        return self.power_panel
+
+    def get_type_class(self):
+        return self.TYPE_CLASS_MAP.get(self.type)
+
+    def get_status_class(self):
+        return self.STATUS_CLASS_MAP.get(self.status)

+ 655 - 0
netbox/dcim/models/racks.py

@@ -0,0 +1,655 @@
+from collections import OrderedDict
+from itertools import count, groupby
+
+from django.conf import settings
+from django.contrib.auth.models import User
+from django.contrib.contenttypes.fields import GenericRelation
+from django.contrib.postgres.fields import ArrayField
+from django.core.exceptions import ValidationError
+from django.core.validators import MaxValueValidator, MinValueValidator
+from django.db import models
+from django.db.models import Count, Sum
+from django.urls import reverse
+from mptt.models import MPTTModel, TreeForeignKey
+from taggit.managers import TaggableManager
+
+from dcim.choices import *
+from dcim.constants import *
+from dcim.elevations import RackElevationSVG
+from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem
+from extras.utils import extras_features
+from utilities.choices import ColorChoices
+from utilities.fields import ColorField, NaturalOrderingField
+from utilities.querysets import RestrictedQuerySet
+from utilities.mptt import TreeManager
+from utilities.utils import serialize_object
+from .devices import Device
+from .power import PowerFeed
+
+__all__ = (
+    'Rack',
+    'RackGroup',
+    'RackReservation',
+    'RackRole',
+)
+
+
+#
+# Racks
+#
+
+@extras_features('export_templates')
+class RackGroup(MPTTModel, ChangeLoggedModel):
+    """
+    Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
+    example, if a Site spans a corporate campus, a RackGroup might be defined to represent each building within that
+    campus. If a Site instead represents a single building, a RackGroup might represent a single room or floor.
+    """
+    name = models.CharField(
+        max_length=50
+    )
+    slug = models.SlugField()
+    site = models.ForeignKey(
+        to='dcim.Site',
+        on_delete=models.CASCADE,
+        related_name='rack_groups'
+    )
+    parent = TreeForeignKey(
+        to='self',
+        on_delete=models.CASCADE,
+        related_name='children',
+        blank=True,
+        null=True,
+        db_index=True
+    )
+    description = models.CharField(
+        max_length=200,
+        blank=True
+    )
+
+    objects = TreeManager()
+
+    csv_headers = ['site', 'parent', 'name', 'slug', 'description']
+
+    class Meta:
+        ordering = ['site', 'name']
+        unique_together = [
+            ['site', 'name'],
+            ['site', 'slug'],
+        ]
+
+    class MPTTMeta:
+        order_insertion_by = ['name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
+
+    def to_csv(self):
+        return (
+            self.site,
+            self.parent.name if self.parent else '',
+            self.name,
+            self.slug,
+            self.description,
+        )
+
+    def to_objectchange(self, action):
+        # Remove MPTT-internal fields
+        return ObjectChange(
+            changed_object=self,
+            object_repr=str(self),
+            action=action,
+            object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
+        )
+
+    def clean(self):
+
+        # Parent RackGroup (if any) must belong to the same Site
+        if self.parent and self.parent.site != self.site:
+            raise ValidationError(f"Parent rack group ({self.parent}) must belong to the same site ({self.site})")
+
+
+class RackRole(ChangeLoggedModel):
+    """
+    Racks can be organized by functional role, similar to Devices.
+    """
+    name = models.CharField(
+        max_length=50,
+        unique=True
+    )
+    slug = models.SlugField(
+        unique=True
+    )
+    color = ColorField(
+        default=ColorChoices.COLOR_GREY
+    )
+    description = models.CharField(
+        max_length=200,
+        blank=True,
+    )
+
+    objects = RestrictedQuerySet.as_manager()
+
+    csv_headers = ['name', 'slug', 'color', 'description']
+
+    class Meta:
+        ordering = ['name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return "{}?role={}".format(reverse('dcim:rack_list'), self.slug)
+
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+            self.color,
+            self.description,
+        )
+
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+class Rack(ChangeLoggedModel, CustomFieldModel):
+    """
+    Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
+    Each Rack is assigned to a Site and (optionally) a RackGroup.
+    """
+    name = models.CharField(
+        max_length=50
+    )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
+    facility_id = models.CharField(
+        max_length=50,
+        blank=True,
+        null=True,
+        verbose_name='Facility ID',
+        help_text='Locally-assigned identifier'
+    )
+    site = models.ForeignKey(
+        to='dcim.Site',
+        on_delete=models.PROTECT,
+        related_name='racks'
+    )
+    group = models.ForeignKey(
+        to='dcim.RackGroup',
+        on_delete=models.SET_NULL,
+        related_name='racks',
+        blank=True,
+        null=True,
+        help_text='Assigned group'
+    )
+    tenant = models.ForeignKey(
+        to='tenancy.Tenant',
+        on_delete=models.PROTECT,
+        related_name='racks',
+        blank=True,
+        null=True
+    )
+    status = models.CharField(
+        max_length=50,
+        choices=RackStatusChoices,
+        default=RackStatusChoices.STATUS_ACTIVE
+    )
+    role = models.ForeignKey(
+        to='dcim.RackRole',
+        on_delete=models.PROTECT,
+        related_name='racks',
+        blank=True,
+        null=True,
+        help_text='Functional role'
+    )
+    serial = models.CharField(
+        max_length=50,
+        blank=True,
+        verbose_name='Serial number'
+    )
+    asset_tag = models.CharField(
+        max_length=50,
+        blank=True,
+        null=True,
+        unique=True,
+        verbose_name='Asset tag',
+        help_text='A unique tag used to identify this rack'
+    )
+    type = models.CharField(
+        choices=RackTypeChoices,
+        max_length=50,
+        blank=True,
+        verbose_name='Type'
+    )
+    width = models.PositiveSmallIntegerField(
+        choices=RackWidthChoices,
+        default=RackWidthChoices.WIDTH_19IN,
+        verbose_name='Width',
+        help_text='Rail-to-rail width'
+    )
+    u_height = models.PositiveSmallIntegerField(
+        default=RACK_U_HEIGHT_DEFAULT,
+        verbose_name='Height (U)',
+        validators=[MinValueValidator(1), MaxValueValidator(100)],
+        help_text='Height in rack units'
+    )
+    desc_units = models.BooleanField(
+        default=False,
+        verbose_name='Descending units',
+        help_text='Units are numbered top-to-bottom'
+    )
+    outer_width = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True,
+        help_text='Outer dimension of rack (width)'
+    )
+    outer_depth = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True,
+        help_text='Outer dimension of rack (depth)'
+    )
+    outer_unit = models.CharField(
+        max_length=50,
+        choices=RackDimensionUnitChoices,
+        blank=True,
+    )
+    comments = models.TextField(
+        blank=True
+    )
+    custom_field_values = GenericRelation(
+        to='extras.CustomFieldValue',
+        content_type_field='obj_type',
+        object_id_field='obj_id'
+    )
+    images = GenericRelation(
+        to='extras.ImageAttachment'
+    )
+    tags = TaggableManager(through=TaggedItem)
+
+    objects = RestrictedQuerySet.as_manager()
+
+    csv_headers = [
+        'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
+        'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
+    ]
+    clone_fields = [
+        'site', 'group', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
+        'outer_depth', 'outer_unit',
+    ]
+
+    STATUS_CLASS_MAP = {
+        RackStatusChoices.STATUS_RESERVED: 'warning',
+        RackStatusChoices.STATUS_AVAILABLE: 'success',
+        RackStatusChoices.STATUS_PLANNED: 'info',
+        RackStatusChoices.STATUS_ACTIVE: 'primary',
+        RackStatusChoices.STATUS_DEPRECATED: 'danger',
+    }
+
+    class Meta:
+        ordering = ('site', 'group', '_name', 'pk')  # (site, group, name) may be non-unique
+        unique_together = (
+            # Name and facility_id must be unique *only* within a RackGroup
+            ('group', 'name'),
+            ('group', 'facility_id'),
+        )
+
+    def __str__(self):
+        return self.display_name or super().__str__()
+
+    def get_absolute_url(self):
+        return reverse('dcim:rack', args=[self.pk])
+
+    def clean(self):
+
+        # Validate outer dimensions and unit
+        if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
+            raise ValidationError("Must specify a unit when setting an outer width/depth")
+        elif self.outer_width is None and self.outer_depth is None:
+            self.outer_unit = ''
+
+        if self.pk:
+            # Validate that Rack is tall enough to house the installed Devices
+            top_device = Device.objects.filter(
+                rack=self
+            ).exclude(
+                position__isnull=True
+            ).order_by('-position').first()
+            if top_device:
+                min_height = top_device.position + top_device.device_type.u_height - 1
+                if self.u_height < min_height:
+                    raise ValidationError({
+                        'u_height': "Rack must be at least {}U tall to house currently installed devices.".format(
+                            min_height
+                        )
+                    })
+            # Validate that Rack was assigned a group of its same site, if applicable
+            if self.group:
+                if self.group.site != self.site:
+                    raise ValidationError({
+                        'group': "Rack group must be from the same site, {}.".format(self.site)
+                    })
+
+    def save(self, *args, **kwargs):
+
+        # Record the original site assignment for this rack.
+        _site_id = None
+        if self.pk:
+            _site_id = Rack.objects.get(pk=self.pk).site_id
+
+        super().save(*args, **kwargs)
+
+        # Update racked devices if the assigned Site has been changed.
+        if _site_id is not None and self.site_id != _site_id:
+            devices = Device.objects.filter(rack=self)
+            for device in devices:
+                device.site = self.site
+                device.save()
+
+    def to_csv(self):
+        return (
+            self.site.name,
+            self.group.name if self.group else None,
+            self.name,
+            self.facility_id,
+            self.tenant.name if self.tenant else None,
+            self.get_status_display(),
+            self.role.name if self.role else None,
+            self.get_type_display() if self.type else None,
+            self.serial,
+            self.asset_tag,
+            self.width,
+            self.u_height,
+            self.desc_units,
+            self.outer_width,
+            self.outer_depth,
+            self.outer_unit,
+            self.comments,
+        )
+
+    @property
+    def units(self):
+        if self.desc_units:
+            return range(1, self.u_height + 1)
+        else:
+            return reversed(range(1, self.u_height + 1))
+
+    @property
+    def display_name(self):
+        if self.facility_id:
+            return f'{self.name} ({self.facility_id})'
+        return self.name
+
+    def get_status_class(self):
+        return self.STATUS_CLASS_MAP.get(self.status)
+
+    def get_rack_units(self, user=None, face=DeviceFaceChoices.FACE_FRONT, exclude=None, expand_devices=True):
+        """
+        Return a list of rack units as dictionaries. Example: {'device': None, 'face': 0, 'id': 48, 'name': 'U48'}
+        Each key 'device' is either a Device or None. By default, multi-U devices are repeated for each U they occupy.
+
+        :param face: Rack face (front or rear)
+        :param user: User instance to be used for evaluating device view permissions. If None, all devices
+            will be included.
+        :param exclude: PK of a Device to exclude (optional); helpful when relocating a Device within a Rack
+        :param expand_devices: When True, all units that a device occupies will be listed with each containing a
+            reference to the device. When False, only the bottom most unit for a device is included and that unit
+            contains a height attribute for the device
+        """
+
+        elevation = OrderedDict()
+        for u in self.units:
+            elevation[u] = {
+                'id': u,
+                'name': f'U{u}',
+                'face': face,
+                'device': None,
+                'occupied': False
+            }
+
+        # Add devices to rack units list
+        if self.pk:
+
+            # Retrieve all devices installed within the rack
+            queryset = Device.objects.prefetch_related(
+                'device_type',
+                'device_type__manufacturer',
+                'device_role'
+            ).annotate(
+                devicebay_count=Count('devicebays')
+            ).exclude(
+                pk=exclude
+            ).filter(
+                rack=self,
+                position__gt=0,
+                device_type__u_height__gt=0
+            ).filter(
+                Q(face=face) | Q(device_type__is_full_depth=True)
+            )
+
+            # Determine which devices the user has permission to view
+            permitted_device_ids = []
+            if user is not None:
+                permitted_device_ids = self.devices.restrict(user, 'view').values_list('pk', flat=True)
+
+            for device in queryset:
+                if expand_devices:
+                    for u in range(device.position, device.position + device.device_type.u_height):
+                        if user is None or device.pk in permitted_device_ids:
+                            elevation[u]['device'] = device
+                        elevation[u]['occupied'] = True
+                else:
+                    if user is None or device.pk in permitted_device_ids:
+                        elevation[device.position]['device'] = device
+                    elevation[device.position]['occupied'] = True
+                    elevation[device.position]['height'] = device.device_type.u_height
+                    for u in range(device.position + 1, device.position + device.device_type.u_height):
+                        elevation.pop(u, None)
+
+        return [u for u in elevation.values()]
+
+    def get_available_units(self, u_height=1, rack_face=None, exclude=None):
+        """
+        Return a list of units within the rack available to accommodate a device of a given U height (default 1).
+        Optionally exclude one or more devices when calculating empty units (needed when moving a device from one
+        position to another within a rack).
+
+        :param u_height: Minimum number of contiguous free units required
+        :param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth
+        :param exclude: List of devices IDs to exclude (useful when moving a device within a rack)
+        """
+        # Gather all devices which consume U space within the rack
+        devices = self.devices.prefetch_related('device_type').filter(position__gte=1)
+        if exclude is not None:
+            devices = devices.exclude(pk__in=exclude)
+
+        # Initialize the rack unit skeleton
+        units = list(range(1, self.u_height + 1))
+
+        # Remove units consumed by installed devices
+        for d in devices:
+            if rack_face is None or d.face == rack_face or d.device_type.is_full_depth:
+                for u in range(d.position, d.position + d.device_type.u_height):
+                    try:
+                        units.remove(u)
+                    except ValueError:
+                        # Found overlapping devices in the rack!
+                        pass
+
+        # Remove units without enough space above them to accommodate a device of the specified height
+        available_units = []
+        for u in units:
+            if set(range(u, u + u_height)).issubset(units):
+                available_units.append(u)
+
+        return list(reversed(available_units))
+
+    def get_reserved_units(self):
+        """
+        Return a dictionary mapping all reserved units within the rack to their reservation.
+        """
+        reserved_units = {}
+        for r in self.reservations.all():
+            for u in r.units:
+                reserved_units[u] = r
+        return reserved_units
+
+    def get_elevation_svg(
+            self,
+            face=DeviceFaceChoices.FACE_FRONT,
+            user=None,
+            unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH,
+            unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT,
+            legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
+            include_images=True,
+            base_url=None
+    ):
+        """
+        Return an SVG of the rack elevation
+
+        :param face: Enum of [front, rear] representing the desired side of the rack elevation to render
+        :param user: User instance to be used for evaluating device view permissions. If None, all devices
+            will be included.
+        :param unit_width: Width in pixels for the rendered drawing
+        :param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
+            height of the elevation
+        :param legend_width: Width of the unit legend, in pixels
+        :param include_images: Embed front/rear device images where available
+        :param base_url: Base URL for links and images. If none, URLs will be relative.
+        """
+        elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url)
+
+        return elevation.render(face, unit_width, unit_height, legend_width)
+
+    def get_0u_devices(self):
+        return self.devices.filter(position=0)
+
+    def get_utilization(self):
+        """
+        Determine the utilization rate of the rack and return it as a percentage. Occupied and reserved units both count
+        as utilized.
+        """
+        # Determine unoccupied units
+        available_units = self.get_available_units()
+
+        # Remove reserved units
+        for u in self.get_reserved_units():
+            if u in available_units:
+                available_units.remove(u)
+
+        occupied_unit_count = self.u_height - len(available_units)
+        percentage = int(float(occupied_unit_count) / self.u_height * 100)
+
+        return percentage
+
+    def get_power_utilization(self):
+        """
+        Determine the utilization rate of power in the rack and return it as a percentage.
+        """
+        power_stats = PowerFeed.objects.filter(
+            rack=self
+        ).annotate(
+            allocated_draw_total=Sum('connected_endpoint__poweroutlets__connected_endpoint__allocated_draw'),
+        ).values(
+            'allocated_draw_total',
+            'available_power'
+        )
+
+        if power_stats:
+            allocated_draw_total = sum(x['allocated_draw_total'] or 0 for x in power_stats)
+            available_power_total = sum(x['available_power'] for x in power_stats)
+            return int(allocated_draw_total / available_power_total * 100) or 0
+        return 0
+
+
+@extras_features('custom_links', 'export_templates', 'webhooks')
+class RackReservation(ChangeLoggedModel):
+    """
+    One or more reserved units within a Rack.
+    """
+    rack = models.ForeignKey(
+        to='dcim.Rack',
+        on_delete=models.CASCADE,
+        related_name='reservations'
+    )
+    units = ArrayField(
+        base_field=models.PositiveSmallIntegerField()
+    )
+    tenant = models.ForeignKey(
+        to='tenancy.Tenant',
+        on_delete=models.PROTECT,
+        related_name='rackreservations',
+        blank=True,
+        null=True
+    )
+    user = models.ForeignKey(
+        to=User,
+        on_delete=models.PROTECT
+    )
+    description = models.CharField(
+        max_length=200
+    )
+    tags = TaggableManager(through=TaggedItem)
+
+    objects = RestrictedQuerySet.as_manager()
+
+    csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description']
+
+    class Meta:
+        ordering = ['created']
+
+    def __str__(self):
+        return "Reservation for rack {}".format(self.rack)
+
+    def get_absolute_url(self):
+        return reverse('dcim:rackreservation', args=[self.pk])
+
+    def clean(self):
+
+        if hasattr(self, 'rack') and self.units:
+
+            # Validate that all specified units exist in the Rack.
+            invalid_units = [u for u in self.units if u not in self.rack.units]
+            if invalid_units:
+                raise ValidationError({
+                    'units': "Invalid unit(s) for {}U rack: {}".format(
+                        self.rack.u_height,
+                        ', '.join([str(u) for u in invalid_units]),
+                    ),
+                })
+
+            # Check that none of the units has already been reserved for this Rack.
+            reserved_units = []
+            for resv in self.rack.reservations.exclude(pk=self.pk):
+                reserved_units += resv.units
+            conflicting_units = [u for u in self.units if u in reserved_units]
+            if conflicting_units:
+                raise ValidationError({
+                    'units': 'The following units have already been reserved: {}'.format(
+                        ', '.join([str(u) for u in conflicting_units]),
+                    )
+                })
+
+    def to_csv(self):
+        return (
+            self.rack.site.name,
+            self.rack.group if self.rack.group else None,
+            self.rack.name,
+            ','.join([str(u) for u in self.units]),
+            self.tenant.name if self.tenant else None,
+            self.user.username,
+            self.description
+        )
+
+    @property
+    def unit_list(self):
+        """
+        Express the assigned units as a string of summarized ranges. For example:
+            [0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
+        """
+        group = (list(x) for _, x in groupby(sorted(self.units), lambda x, c=count(): next(c) - x))
+        return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group)

+ 246 - 0
netbox/dcim/models/sites.py

@@ -0,0 +1,246 @@
+from django.contrib.contenttypes.fields import GenericRelation
+from django.db import models
+from django.urls import reverse
+from mptt.models import MPTTModel, TreeForeignKey
+from taggit.managers import TaggableManager
+from timezone_field import TimeZoneField
+
+from dcim.choices import *
+from dcim.constants import *
+from dcim.fields import ASNField
+from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem
+from extras.utils import extras_features
+from utilities.fields import NaturalOrderingField
+from utilities.querysets import RestrictedQuerySet
+from utilities.mptt import TreeManager
+from utilities.utils import serialize_object
+
+__all__ = (
+    'Region',
+    'Site',
+)
+
+
+#
+# Regions
+#
+
+@extras_features('export_templates', 'webhooks')
+class Region(MPTTModel, ChangeLoggedModel):
+    """
+    Sites can be grouped within geographic Regions.
+    """
+    parent = TreeForeignKey(
+        to='self',
+        on_delete=models.CASCADE,
+        related_name='children',
+        blank=True,
+        null=True,
+        db_index=True
+    )
+    name = models.CharField(
+        max_length=50,
+        unique=True
+    )
+    slug = models.SlugField(
+        unique=True
+    )
+    description = models.CharField(
+        max_length=200,
+        blank=True
+    )
+
+    objects = TreeManager()
+
+    csv_headers = ['name', 'slug', 'parent', 'description']
+
+    class MPTTMeta:
+        order_insertion_by = ['name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return "{}?region={}".format(reverse('dcim:site_list'), self.slug)
+
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+            self.parent.name if self.parent else None,
+            self.description,
+        )
+
+    def get_site_count(self):
+        return Site.objects.filter(
+            Q(region=self) |
+            Q(region__in=self.get_descendants())
+        ).count()
+
+    def to_objectchange(self, action):
+        # Remove MPTT-internal fields
+        return ObjectChange(
+            changed_object=self,
+            object_repr=str(self),
+            action=action,
+            object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
+        )
+
+
+#
+# Sites
+#
+
+@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
+class Site(ChangeLoggedModel, CustomFieldModel):
+    """
+    A Site represents a geographic location within a network; typically a building or campus. The optional facility
+    field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
+    """
+    name = models.CharField(
+        max_length=50,
+        unique=True
+    )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
+    slug = models.SlugField(
+        unique=True
+    )
+    status = models.CharField(
+        max_length=50,
+        choices=SiteStatusChoices,
+        default=SiteStatusChoices.STATUS_ACTIVE
+    )
+    region = models.ForeignKey(
+        to='dcim.Region',
+        on_delete=models.SET_NULL,
+        related_name='sites',
+        blank=True,
+        null=True
+    )
+    tenant = models.ForeignKey(
+        to='tenancy.Tenant',
+        on_delete=models.PROTECT,
+        related_name='sites',
+        blank=True,
+        null=True
+    )
+    facility = models.CharField(
+        max_length=50,
+        blank=True,
+        help_text='Local facility ID or description'
+    )
+    asn = ASNField(
+        blank=True,
+        null=True,
+        verbose_name='ASN',
+        help_text='32-bit autonomous system number'
+    )
+    time_zone = TimeZoneField(
+        blank=True
+    )
+    description = models.CharField(
+        max_length=200,
+        blank=True
+    )
+    physical_address = models.CharField(
+        max_length=200,
+        blank=True
+    )
+    shipping_address = models.CharField(
+        max_length=200,
+        blank=True
+    )
+    latitude = models.DecimalField(
+        max_digits=8,
+        decimal_places=6,
+        blank=True,
+        null=True,
+        help_text='GPS coordinate (latitude)'
+    )
+    longitude = models.DecimalField(
+        max_digits=9,
+        decimal_places=6,
+        blank=True,
+        null=True,
+        help_text='GPS coordinate (longitude)'
+    )
+    contact_name = models.CharField(
+        max_length=50,
+        blank=True
+    )
+    contact_phone = models.CharField(
+        max_length=20,
+        blank=True
+    )
+    contact_email = models.EmailField(
+        blank=True,
+        verbose_name='Contact E-mail'
+    )
+    comments = models.TextField(
+        blank=True
+    )
+    custom_field_values = GenericRelation(
+        to='extras.CustomFieldValue',
+        content_type_field='obj_type',
+        object_id_field='obj_id'
+    )
+    images = GenericRelation(
+        to='extras.ImageAttachment'
+    )
+    tags = TaggableManager(through=TaggedItem)
+
+    objects = RestrictedQuerySet.as_manager()
+
+    csv_headers = [
+        'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
+        'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments',
+    ]
+    clone_fields = [
+        'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
+        'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email',
+    ]
+
+    STATUS_CLASS_MAP = {
+        SiteStatusChoices.STATUS_PLANNED: 'info',
+        SiteStatusChoices.STATUS_STAGING: 'primary',
+        SiteStatusChoices.STATUS_ACTIVE: 'success',
+        SiteStatusChoices.STATUS_DECOMMISSIONING: 'warning',
+        SiteStatusChoices.STATUS_RETIRED: 'danger',
+    }
+
+    class Meta:
+        ordering = ('_name',)
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('dcim:site', args=[self.slug])
+
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+            self.get_status_display(),
+            self.region.name if self.region else None,
+            self.tenant.name if self.tenant else None,
+            self.facility,
+            self.asn,
+            self.time_zone,
+            self.description,
+            self.physical_address,
+            self.shipping_address,
+            self.latitude,
+            self.longitude,
+            self.contact_name,
+            self.contact_phone,
+            self.contact_email,
+            self.comments,
+        )
+
+    def get_status_class(self):
+        return self.STATUS_CLASS_MAP.get(self.status)