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

Closes #3092: Split DCIM models into separate files for easier management

Jeremy Stretch 6 лет назад
Родитель
Сommit
ca13045515

+ 469 - 1788
netbox/dcim/models.py → netbox/dcim/models/__init__.py

@@ -17,173 +17,59 @@ from mptt.models import MPTTModel, TreeForeignKey
 from taggit.managers import TaggableManager
 from timezone_field import TimeZoneField
 
-from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
+from dcim.choices import *
+from dcim.constants import *
+from dcim.fields import ASNField
+from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
 from utilities.fields import ColorField
 from utilities.managers import NaturalOrderingManager
 from utilities.models import ChangeLoggedModel
-from utilities.utils import foreground_color, serialize_object, to_meters
-from virtualization.choices import VMInterfaceTypeChoices
-
-from .choices import *
-from .constants import *
-from .exceptions import LoopDetected
-from .fields import ASNField, MACAddressField
-from .managers import InterfaceManager
-
-
-class ComponentTemplateModel(models.Model):
-
-    class Meta:
-        abstract = True
-
-    def instantiate(self, device):
-        """
-        Instantiate a new component on the specified Device.
-        """
-        raise NotImplementedError()
-
-    def to_objectchange(self, action):
-        return ObjectChange(
-            changed_object=self,
-            object_repr=str(self),
-            action=action,
-            related_object=self.device_type,
-            object_data=serialize_object(self)
-        )
-
-
-class ComponentModel(models.Model):
-    description = models.CharField(
-        max_length=100,
-        blank=True
-    )
-
-    class Meta:
-        abstract = True
-
-    def to_objectchange(self, action):
-        # Annotate the parent Device/VM
-        try:
-            parent = getattr(self, 'device', None) or getattr(self, 'virtual_machine', None)
-        except ObjectDoesNotExist:
-            # The parent device/VM has already been deleted
-            parent = None
-
-        return ObjectChange(
-            changed_object=self,
-            object_repr=str(self),
-            action=action,
-            related_object=parent,
-            object_data=serialize_object(self)
-        )
-
-    @property
-    def parent(self):
-        return getattr(self, 'device', None)
-
-
-class CableTermination(models.Model):
-    cable = models.ForeignKey(
-        to='dcim.Cable',
-        on_delete=models.SET_NULL,
-        related_name='+',
-        blank=True,
-        null=True
-    )
-
-    # Generic relations to Cable. These ensure that an attached Cable is deleted if the terminated object is deleted.
-    _cabled_as_a = GenericRelation(
-        to='dcim.Cable',
-        content_type_field='termination_a_type',
-        object_id_field='termination_a_id'
-    )
-    _cabled_as_b = GenericRelation(
-        to='dcim.Cable',
-        content_type_field='termination_b_type',
-        object_id_field='termination_b_id'
-    )
-
-    is_path_endpoint = True
-
-    class Meta:
-        abstract = True
-
-    def trace(self, position=1, follow_circuits=False, cable_history=None):
-        """
-        Return a list representing a complete cable path, with each individual segment represented as a three-tuple:
-            [
-                (termination A, cable, termination B),
-                (termination C, cable, termination D),
-                (termination E, cable, termination F)
-            ]
-        """
-        def get_peer_port(termination, position=1, follow_circuits=False):
-            from circuits.models import CircuitTermination
-
-            # Map a front port to its corresponding rear port
-            if isinstance(termination, FrontPort):
-                return termination.rear_port, termination.rear_port_position
-
-            # Map a rear port/position to its corresponding front port
-            elif isinstance(termination, RearPort):
-                if position not in range(1, termination.positions + 1):
-                    raise Exception("Invalid position for {} ({} positions): {})".format(
-                        termination, termination.positions, position
-                    ))
-                try:
-                    peer_port = FrontPort.objects.get(
-                        rear_port=termination,
-                        rear_port_position=position,
-                    )
-                    return peer_port, 1
-                except ObjectDoesNotExist:
-                    return None, None
-
-            # Follow a circuit to its other termination
-            elif isinstance(termination, CircuitTermination) and follow_circuits:
-                peer_termination = termination.get_peer_termination()
-                if peer_termination is None:
-                    return None, None
-                return peer_termination, position
-
-            # Termination is not a pass-through port
-            else:
-                return None, None
-
-        if not self.cable:
-            return [(self, None, None)]
-
-        # Record cable history to detect loops
-        if cable_history is None:
-            cable_history = []
-        elif self.cable in cable_history:
-            raise LoopDetected()
-        cable_history.append(self.cable)
-
-        far_end = self.cable.termination_b if self.cable.termination_a == self else self.cable.termination_a
-        path = [(self, self.cable, far_end)]
-
-        peer_port, position = get_peer_port(far_end, position, follow_circuits)
-        if peer_port is None:
-            return path
-
-        try:
-            next_segment = peer_port.trace(position, follow_circuits, cable_history)
-        except LoopDetected:
-            return path
-
-        if next_segment is None:
-            return path + [(peer_port, None, None)]
-
-        return path + next_segment
-
-    def get_cable_peer(self):
-        if self.cable is None:
-            return None
-        if self._cabled_as_a.exists():
-            return self.cable.termination_b
-        if self._cabled_as_b.exists():
-            return self.cable.termination_a
+from utilities.utils import foreground_color, to_meters
+from .device_components import (
+    CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, PowerOutlet,
+    PowerPort, RearPort,
+)
+from .device_component_templates import (
+    ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
+    PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
+)
+
+
+__all__ = (
+    'Cable',
+    'CableTermination',
+    'ConsolePort',
+    'ConsolePortTemplate',
+    'ConsoleServerPort',
+    'ConsoleServerPortTemplate',
+    'Device',
+    'DeviceBay',
+    'DeviceBayTemplate',
+    'DeviceRole',
+    'DeviceType',
+    'FrontPort',
+    'FrontPortTemplate',
+    'Interface',
+    'InterfaceTemplate',
+    'InventoryItem',
+    'Manufacturer',
+    'Platform',
+    'PowerFeed',
+    'PowerOutlet',
+    'PowerOutletTemplate',
+    'PowerPanel',
+    'PowerPort',
+    'PowerPortTemplate',
+    'Rack',
+    'RackGroup',
+    'RackReservation',
+    'RackRole',
+    'RearPort',
+    'RearPortTemplate',
+    'Region',
+    'Site',
+    'VirtualChassis',
+)
 
 
 #
@@ -1171,1737 +1057,532 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
         return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
 
 
-class ConsolePortTemplate(ComponentTemplateModel):
+#
+# Devices
+#
+
+class DeviceRole(ChangeLoggedModel):
     """
-    A template for a ConsolePort to be created for a new Device.
+    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.
     """
-    device_type = models.ForeignKey(
-        to='dcim.DeviceType',
-        on_delete=models.CASCADE,
-        related_name='consoleport_templates'
-    )
     name = models.CharField(
-        max_length=50
-    )
-    type = models.CharField(
         max_length=50,
-        choices=ConsolePortTypeChoices,
-        blank=True
+        unique=True
     )
-
-    objects = NaturalOrderingManager()
-
-    class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = ['device_type', 'name']
-
-    def __str__(self):
-        return self.name
-
-    def instantiate(self, device):
-        return ConsolePort(
-            device=device,
-            name=self.name,
-            type=self.type
-        )
-
-
-class ConsoleServerPortTemplate(ComponentTemplateModel):
-    """
-    A template for a ConsoleServerPort to be created for a new Device.
-    """
-    device_type = models.ForeignKey(
-        to='dcim.DeviceType',
-        on_delete=models.CASCADE,
-        related_name='consoleserverport_templates'
+    slug = models.SlugField(
+        unique=True
     )
-    name = models.CharField(
-        max_length=50
+    color = ColorField()
+    vm_role = models.BooleanField(
+        default=True,
+        verbose_name='VM Role',
+        help_text='Virtual machines may be assigned to this role'
     )
-    type = models.CharField(
-        max_length=50,
-        choices=ConsolePortTypeChoices,
-        blank=True
+    description = models.CharField(
+        max_length=100,
+        blank=True,
     )
 
-    objects = NaturalOrderingManager()
+    csv_headers = ['name', 'slug', 'color', 'vm_role', 'description']
 
     class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = ['device_type', 'name']
+        ordering = ['name']
 
     def __str__(self):
         return self.name
 
-    def instantiate(self, device):
-        return ConsoleServerPort(
-            device=device,
-            name=self.name,
-            type=self.type
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+            self.color,
+            self.vm_role,
+            self.description,
         )
 
 
-class PowerPortTemplate(ComponentTemplateModel):
+class Platform(ChangeLoggedModel):
     """
-    A template for a PowerPort to be created for a new Device.
+    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.
     """
-    device_type = models.ForeignKey(
-        to='dcim.DeviceType',
-        on_delete=models.CASCADE,
-        related_name='powerport_templates'
-    )
     name = models.CharField(
-        max_length=50
+        max_length=100,
+        unique=True
     )
-    type = models.CharField(
-        max_length=50,
-        choices=PowerPortTypeChoices,
-        blank=True
+    slug = models.SlugField(
+        unique=True,
+        max_length=100
     )
-    maximum_draw = models.PositiveSmallIntegerField(
+    manufacturer = models.ForeignKey(
+        to='dcim.Manufacturer',
+        on_delete=models.PROTECT,
+        related_name='platforms',
         blank=True,
         null=True,
-        validators=[MinValueValidator(1)],
-        help_text="Maximum power draw (watts)"
+        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'
     )
-    allocated_draw = models.PositiveSmallIntegerField(
+    napalm_args = JSONField(
         blank=True,
         null=True,
-        validators=[MinValueValidator(1)],
-        help_text="Allocated power draw (watts)"
+        verbose_name='NAPALM arguments',
+        help_text='Additional arguments to pass when initiating the NAPALM driver (JSON format)'
     )
 
-    objects = NaturalOrderingManager()
+    csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args']
 
     class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = ['device_type', 'name']
+        ordering = ['name']
 
     def __str__(self):
         return self.name
 
-    def instantiate(self, device):
-        return PowerPort(
-            device=device,
-            name=self.name,
-            maximum_draw=self.maximum_draw,
-            allocated_draw=self.allocated_draw
+    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,
         )
 
 
-class PowerOutletTemplate(ComponentTemplateModel):
+class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     """
-    A template for a PowerOutlet to be created for a new Device.
+    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.CASCADE,
-        related_name='poweroutlet_templates'
+        on_delete=models.PROTECT,
+        related_name='instances'
     )
-    name = models.CharField(
-        max_length=50
+    device_role = models.ForeignKey(
+        to='dcim.DeviceRole',
+        on_delete=models.PROTECT,
+        related_name='devices'
     )
-    type = models.CharField(
-        max_length=50,
-        choices=PowerOutletTypeChoices,
-        blank=True
+    tenant = models.ForeignKey(
+        to='tenancy.Tenant',
+        on_delete=models.PROTECT,
+        related_name='devices',
+        blank=True,
+        null=True
     )
-    power_port = models.ForeignKey(
-        to='dcim.PowerPortTemplate',
+    platform = models.ForeignKey(
+        to='dcim.Platform',
         on_delete=models.SET_NULL,
+        related_name='devices',
         blank=True,
-        null=True,
-        related_name='poweroutlet_templates'
+        null=True
+    )
+    name = models.CharField(
+        max_length=64,
+        blank=True,
+        null=True
     )
-    feed_leg = models.CharField(
+    serial = models.CharField(
         max_length=50,
-        choices=PowerOutletFeedLegChoices,
         blank=True,
-        help_text="Phase (for three-phase feeds)"
+        verbose_name='Serial number'
     )
-
-    objects = NaturalOrderingManager()
-
-    class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = ['device_type', 'name']
-
-    def __str__(self):
-        return self.name
-
-    def clean(self):
-
-        # Validate power port assignment
-        if self.power_port and self.power_port.device_type != self.device_type:
-            raise ValidationError(
-                "Parent power port ({}) must belong to the same device type".format(self.power_port)
-            )
-
-    def instantiate(self, device):
-        if self.power_port:
-            power_port = PowerPort.objects.get(device=device, name=self.power_port.name)
-        else:
-            power_port = None
-        return PowerOutlet(
-            device=device,
-            name=self.name,
-            power_port=power_port,
-            feed_leg=self.feed_leg
-        )
-
-
-class InterfaceTemplate(ComponentTemplateModel):
-    """
-    A template for a physical data interface on a new Device.
-    """
-    device_type = models.ForeignKey(
-        to='dcim.DeviceType',
-        on_delete=models.CASCADE,
-        related_name='interface_templates'
+    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'
     )
-    name = models.CharField(
-        max_length=64
+    site = models.ForeignKey(
+        to='dcim.Site',
+        on_delete=models.PROTECT,
+        related_name='devices'
     )
-    type = models.CharField(
+    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,
-        choices=InterfaceTypeChoices
+        blank=True,
+        choices=DeviceFaceChoices,
+        verbose_name='Rack face'
     )
-    mgmt_only = models.BooleanField(
-        default=False,
-        verbose_name='Management only'
+    status = models.CharField(
+        max_length=50,
+        choices=DeviceStatusChoices,
+        default=DeviceStatusChoices.STATUS_ACTIVE
     )
-
-    objects = InterfaceManager()
-
-    class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = ['device_type', 'name']
-
-    def __str__(self):
-        return self.name
-
-    def instantiate(self, device):
-        return Interface(
-            device=device,
-            name=self.name,
-            type=self.type,
-            mgmt_only=self.mgmt_only
-        )
-
-
-class FrontPortTemplate(ComponentTemplateModel):
-    """
-    Template for a pass-through port on the front of a new Device.
-    """
-    device_type = models.ForeignKey(
-        to='dcim.DeviceType',
-        on_delete=models.CASCADE,
-        related_name='frontport_templates'
+    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'
     )
-    name = models.CharField(
-        max_length=64
+    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'
     )
-    type = models.CharField(
-        max_length=50,
-        choices=PortTypeChoices
+    cluster = models.ForeignKey(
+        to='virtualization.Cluster',
+        on_delete=models.SET_NULL,
+        related_name='devices',
+        blank=True,
+        null=True
     )
-    rear_port = models.ForeignKey(
-        to='dcim.RearPortTemplate',
-        on_delete=models.CASCADE,
-        related_name='frontport_templates'
+    virtual_chassis = models.ForeignKey(
+        to='VirtualChassis',
+        on_delete=models.SET_NULL,
+        related_name='members',
+        blank=True,
+        null=True
     )
-    rear_port_position = models.PositiveSmallIntegerField(
-        default=1,
-        validators=[MinValueValidator(1), MaxValueValidator(64)]
+    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'
     )
 
     objects = NaturalOrderingManager()
+    tags = TaggableManager(through=TaggedItem)
+
+    csv_headers = [
+        'name', 'device_role', 'tenant', 'manufacturer', 'model_name', '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 = ['device_type', 'name']
+        ordering = ['name']
         unique_together = [
-            ['device_type', 'name'],
-            ['rear_port', 'rear_port_position'],
+            ['site', 'tenant', 'name'],  # See validate_unique below
+            ['rack', 'position', 'face'],
+            ['virtual_chassis', 'vc_position'],
         ]
+        permissions = (
+            ('napalm_read', 'Read-only access to devices via NAPALM'),
+            ('napalm_write', 'Read/write access to devices via NAPALM'),
+        )
 
     def __str__(self):
-        return self.name
+        return self.display_name or super().__str__()
 
-    def clean(self):
+    def get_absolute_url(self):
+        return reverse('dcim:device', args=[self.pk])
 
-        # Validate rear port assignment
-        if self.rear_port.device_type != self.device_type:
-            raise ValidationError(
-                "Rear port ({}) must belong to the same device type".format(self.rear_port)
-            )
+    def validate_unique(self, exclude=None):
 
-        # Validate rear port position assignment
-        if self.rear_port_position > self.rear_port.positions:
-            raise ValidationError(
-                "Invalid rear port position ({}); rear port {} has only {} positions".format(
-                    self.rear_port_position, self.rear_port.name, self.rear_port.positions
-                )
-            )
+        # 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.tenant is None and Device.objects.exclude(pk=self.pk).filter(name=self.name, tenant__isnull=True):
+            raise ValidationError({
+                'name': 'A device with this name already exists.'
+            })
 
-    def instantiate(self, device):
-        if self.rear_port:
-            rear_port = RearPort.objects.get(device=device, name=self.rear_port.name)
-        else:
-            rear_port = None
-        return FrontPort(
-            device=device,
-            name=self.name,
-            type=self.type,
-            rear_port=rear_port,
-            rear_port_position=self.rear_port_position
-        )
+        super().validate_unique(exclude)
 
+    def clean(self):
 
-class RearPortTemplate(ComponentTemplateModel):
-    """
-    Template for a pass-through port on the rear of a new Device.
-    """
-    device_type = models.ForeignKey(
-        to='dcim.DeviceType',
-        on_delete=models.CASCADE,
-        related_name='rearport_templates'
-    )
-    name = models.CharField(
-        max_length=64
-    )
-    type = models.CharField(
-        max_length=50,
-        choices=PortTypeChoices
-    )
-    positions = models.PositiveSmallIntegerField(
-        default=1,
-        validators=[MinValueValidator(1), MaxValueValidator(64)]
-    )
+        super().clean()
 
-    objects = NaturalOrderingManager()
+        # 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),
+            })
 
-    class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = ['device_type', 'name']
+        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.",
+                })
 
-    def __str__(self):
-        return self.name
+        # Validate position/face combination
+        if self.position and not self.face:
+            raise ValidationError({
+                'face': "Must specify rack face when defining rack position.",
+            })
 
-    def instantiate(self, device):
-        return RearPort(
-            device=device,
-            name=self.name,
-            type=self.type,
-            positions=self.positions
-        )
+        # 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:
 
-class DeviceBayTemplate(ComponentTemplateModel):
-    """
-    A template for a DeviceBay to be created for a new parent Device.
-    """
-    device_type = models.ForeignKey(
-        to='dcim.DeviceType',
-        on_delete=models.CASCADE,
-        related_name='device_bay_templates'
-    )
-    name = models.CharField(
-        max_length=50
-    )
+            try:
+                # Child devices cannot be assigned to a rack face/unit
+                if self.device_type.is_child_device and self.face is not None:
+                    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."
+                    })
 
-    objects = NaturalOrderingManager()
+                # 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
 
-    class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = ['device_type', 'name']
+            except DeviceType.DoesNotExist:
+                pass
 
-    def __str__(self):
-        return self.name
+        # Validate primary IP addresses
+        vc_interfaces = self.vc_interfaces.all()
+        if self.primary_ip4:
+            if self.primary_ip4.interface in vc_interfaces:
+                pass
+            elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in vc_interfaces:
+                pass
+            else:
+                raise ValidationError({
+                    'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(
+                        self.primary_ip4),
+                })
+        if self.primary_ip6:
+            if self.primary_ip6.interface in vc_interfaces:
+                pass
+            elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.interface in vc_interfaces:
+                pass
+            else:
+                raise ValidationError({
+                    'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(
+                        self.primary_ip6),
+                })
 
-    def instantiate(self, device):
-        return DeviceBay(
-            device=device,
-            name=self.name
-        )
+        # 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)
+            })
 
-#
-# Devices
-#
+        # 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."
+            })
 
-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()
-    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=100,
-        blank=True,
-    )
-
-    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 = JSONField(
-        blank=True,
-        null=True,
-        verbose_name='NAPALM arguments',
-        help_text='Additional arguments to pass when initiating the NAPALM driver (JSON format)'
-    )
-
-    csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args']
-
-    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,
-        )
-
-
-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
-    )
-    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'
-    )
-
-    objects = NaturalOrderingManager()
-    tags = TaggableManager(through=TaggedItem)
-
-    csv_headers = [
-        'name', 'device_role', 'tenant', 'manufacturer', 'model_name', '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']
-        unique_together = [
-            ['site', 'tenant', 'name'],  # See validate_unique below
-            ['rack', 'position', 'face'],
-            ['virtual_chassis', 'vc_position'],
-        ]
-        permissions = (
-            ('napalm_read', 'Read-only access to devices via NAPALM'),
-            ('napalm_write', 'Read/write access to devices via NAPALM'),
-        )
-
-    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.tenant is None and Device.objects.exclude(pk=self.pk).filter(name=self.name, 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 is not None:
-                    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.interface in vc_interfaces:
-                pass
-            elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in vc_interfaces:
-                pass
-            else:
-                raise ValidationError({
-                    'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(
-                        self.primary_ip4),
-                })
-        if self.primary_ip6:
-            if self.primary_ip6.interface in vc_interfaces:
-                pass
-            elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.interface in vc_interfaces:
-                pass
-            else:
-                raise ValidationError({
-                    'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(
-                        self.primary_ip6),
-                })
-
-        # 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.consoleport_templates.all()]
-            )
-            ConsoleServerPort.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.consoleserverport_templates.all()]
-            )
-            PowerPort.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.powerport_templates.all()]
-            )
-            PowerOutlet.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.poweroutlet_templates.all()]
-            )
-            Interface.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.interface_templates.all()]
-            )
-            RearPort.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.rearport_templates.all()]
-            )
-            FrontPort.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.frontport_templates.all()]
-            )
-            DeviceBay.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.device_bay_templates.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 and self.virtual_chassis.master.name:
-            return "{}:{}".format(self.virtual_chassis.master, self.vc_position)
-        elif hasattr(self, 'device_type'):
-            return "{}".format(self.device_type)
-        return ""
-
-    @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)
-
-
-#
-# Console ports
-#
-
-class ConsolePort(CableTermination, ComponentModel):
-    """
-    A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
-    """
-    device = models.ForeignKey(
-        to='dcim.Device',
-        on_delete=models.CASCADE,
-        related_name='consoleports'
-    )
-    name = models.CharField(
-        max_length=50
-    )
-    type = models.CharField(
-        max_length=50,
-        choices=ConsolePortTypeChoices,
-        blank=True
-    )
-    connected_endpoint = models.OneToOneField(
-        to='dcim.ConsoleServerPort',
-        on_delete=models.SET_NULL,
-        related_name='connected_endpoint',
-        blank=True,
-        null=True
-    )
-    connection_status = models.NullBooleanField(
-        choices=CONNECTION_STATUS_CHOICES,
-        blank=True
-    )
-
-    objects = NaturalOrderingManager()
-    tags = TaggableManager(through=TaggedItem)
-
-    csv_headers = ['device', 'name', 'type', 'description']
-
-    class Meta:
-        ordering = ['device', 'name']
-        unique_together = ['device', 'name']
-
-    def __str__(self):
-        return self.name
-
-    def get_absolute_url(self):
-        return self.device.get_absolute_url()
-
-    def to_csv(self):
-        return (
-            self.device.identifier,
-            self.name,
-            self.type,
-            self.description,
-        )
-
-
-#
-# Console server ports
-#
-
-class ConsoleServerPort(CableTermination, ComponentModel):
-    """
-    A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
-    """
-    device = models.ForeignKey(
-        to='dcim.Device',
-        on_delete=models.CASCADE,
-        related_name='consoleserverports'
-    )
-    name = models.CharField(
-        max_length=50
-    )
-    type = models.CharField(
-        max_length=50,
-        choices=ConsolePortTypeChoices,
-        blank=True
-    )
-    connection_status = models.NullBooleanField(
-        choices=CONNECTION_STATUS_CHOICES,
-        blank=True
-    )
-
-    objects = NaturalOrderingManager()
-    tags = TaggableManager(through=TaggedItem)
-
-    csv_headers = ['device', 'name', 'type', 'description']
-
-    class Meta:
-        unique_together = ['device', 'name']
-
-    def __str__(self):
-        return self.name
-
-    def get_absolute_url(self):
-        return self.device.get_absolute_url()
-
-    def to_csv(self):
-        return (
-            self.device.identifier,
-            self.name,
-            self.type,
-            self.description,
-        )
-
-
-#
-# Power ports
-#
-
-class PowerPort(CableTermination, ComponentModel):
-    """
-    A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
-    """
-    device = models.ForeignKey(
-        to='dcim.Device',
-        on_delete=models.CASCADE,
-        related_name='powerports'
-    )
-    name = models.CharField(
-        max_length=50
-    )
-    type = models.CharField(
-        max_length=50,
-        choices=PowerPortTypeChoices,
-        blank=True
-    )
-    maximum_draw = models.PositiveSmallIntegerField(
-        blank=True,
-        null=True,
-        validators=[MinValueValidator(1)],
-        help_text="Maximum power draw (watts)"
-    )
-    allocated_draw = models.PositiveSmallIntegerField(
-        blank=True,
-        null=True,
-        validators=[MinValueValidator(1)],
-        help_text="Allocated power draw (watts)"
-    )
-    _connected_poweroutlet = models.OneToOneField(
-        to='dcim.PowerOutlet',
-        on_delete=models.SET_NULL,
-        related_name='connected_endpoint',
-        blank=True,
-        null=True
-    )
-    _connected_powerfeed = models.OneToOneField(
-        to='dcim.PowerFeed',
-        on_delete=models.SET_NULL,
-        related_name='+',
-        blank=True,
-        null=True
-    )
-    connection_status = models.NullBooleanField(
-        choices=CONNECTION_STATUS_CHOICES,
-        blank=True
-    )
-
-    objects = NaturalOrderingManager()
-    tags = TaggableManager(through=TaggedItem)
-
-    csv_headers = ['device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description']
-
-    class Meta:
-        ordering = ['device', 'name']
-        unique_together = ['device', 'name']
-
-    def __str__(self):
-        return self.name
-
-    def get_absolute_url(self):
-        return self.device.get_absolute_url()
-
-    def to_csv(self):
-        return (
-            self.device.identifier,
-            self.name,
-            self.get_type_display(),
-            self.maximum_draw,
-            self.allocated_draw,
-            self.description,
-        )
-
-    @property
-    def connected_endpoint(self):
-        if self._connected_poweroutlet:
-            return self._connected_poweroutlet
-        return self._connected_powerfeed
-
-    @connected_endpoint.setter
-    def connected_endpoint(self, value):
-        if value is None:
-            self._connected_poweroutlet = None
-            self._connected_powerfeed = None
-        elif isinstance(value, PowerOutlet):
-            self._connected_poweroutlet = value
-            self._connected_powerfeed = None
-        elif isinstance(value, PowerFeed):
-            self._connected_poweroutlet = None
-            self._connected_powerfeed = value
-        else:
-            raise ValueError(
-                "Connected endpoint must be a PowerOutlet or PowerFeed, not {}.".format(type(value))
-            )
-
-    def get_power_draw(self):
-        """
-        Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort.
-        """
-        # Calculate aggregate draw of all child power outlets if no numbers have been defined manually
-        if self.allocated_draw is None and self.maximum_draw is None:
-            outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True)
-            utilization = PowerPort.objects.filter(_connected_poweroutlet_id__in=outlet_ids).aggregate(
-                maximum_draw_total=Sum('maximum_draw'),
-                allocated_draw_total=Sum('allocated_draw'),
-            )
-            ret = {
-                'allocated': utilization['allocated_draw_total'] or 0,
-                'maximum': utilization['maximum_draw_total'] or 0,
-                'outlet_count': len(outlet_ids),
-                'legs': [],
-            }
-
-            # Calculate per-leg aggregates for three-phase feeds
-            if self._connected_powerfeed and self._connected_powerfeed.phase == PowerFeedPhaseChoices.PHASE_3PHASE:
-                for leg, leg_name in PowerOutletFeedLegChoices:
-                    outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True)
-                    utilization = PowerPort.objects.filter(_connected_poweroutlet_id__in=outlet_ids).aggregate(
-                        maximum_draw_total=Sum('maximum_draw'),
-                        allocated_draw_total=Sum('allocated_draw'),
-                    )
-                    ret['legs'].append({
-                        'name': leg_name,
-                        'allocated': utilization['allocated_draw_total'] or 0,
-                        'maximum': utilization['maximum_draw_total'] or 0,
-                        'outlet_count': len(outlet_ids),
-                    })
-
-            return ret
-
-        # Default to administratively defined values
-        return {
-            'allocated': self.allocated_draw or 0,
-            'maximum': self.maximum_draw or 0,
-            'outlet_count': PowerOutlet.objects.filter(power_port=self).count(),
-            'legs': [],
-        }
-
-
-#
-# Power outlets
-#
-
-class PowerOutlet(CableTermination, ComponentModel):
-    """
-    A physical power outlet (output) within a Device which provides power to a PowerPort.
-    """
-    device = models.ForeignKey(
-        to='dcim.Device',
-        on_delete=models.CASCADE,
-        related_name='poweroutlets'
-    )
-    name = models.CharField(
-        max_length=50
-    )
-    type = models.CharField(
-        max_length=50,
-        choices=PowerOutletTypeChoices,
-        blank=True
-    )
-    power_port = models.ForeignKey(
-        to='dcim.PowerPort',
-        on_delete=models.SET_NULL,
-        blank=True,
-        null=True,
-        related_name='poweroutlets'
-    )
-    feed_leg = models.CharField(
-        max_length=50,
-        choices=PowerOutletFeedLegChoices,
-        blank=True,
-        help_text="Phase (for three-phase feeds)"
-    )
-    connection_status = models.NullBooleanField(
-        choices=CONNECTION_STATUS_CHOICES,
-        blank=True
-    )
-
-    objects = NaturalOrderingManager()
-    tags = TaggableManager(through=TaggedItem)
-
-    csv_headers = ['device', 'name', 'type', 'power_port', 'feed_leg', 'description']
-
-    class Meta:
-        unique_together = ['device', 'name']
-
-    def __str__(self):
-        return self.name
-
-    def get_absolute_url(self):
-        return self.device.get_absolute_url()
-
-    def to_csv(self):
-        return (
-            self.device.identifier,
-            self.name,
-            self.get_type_display(),
-            self.power_port.name if self.power_port else None,
-            self.get_feed_leg_display(),
-            self.description,
-        )
-
-    def clean(self):
-
-        # Validate power port assignment
-        if self.power_port and self.power_port.device != self.device:
-            raise ValidationError(
-                "Parent power port ({}) must belong to the same device".format(self.power_port)
-            )
-
-
-#
-# Interfaces
-#
-
-class Interface(CableTermination, ComponentModel):
-    """
-    A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
-    Interface.
-    """
-    device = models.ForeignKey(
-        to='Device',
-        on_delete=models.CASCADE,
-        related_name='interfaces',
-        null=True,
-        blank=True
-    )
-    virtual_machine = models.ForeignKey(
-        to='virtualization.VirtualMachine',
-        on_delete=models.CASCADE,
-        related_name='interfaces',
-        null=True,
-        blank=True
-    )
-    name = models.CharField(
-        max_length=64
-    )
-    _connected_interface = models.OneToOneField(
-        to='self',
-        on_delete=models.SET_NULL,
-        related_name='+',
-        blank=True,
-        null=True
-    )
-    _connected_circuittermination = models.OneToOneField(
-        to='circuits.CircuitTermination',
-        on_delete=models.SET_NULL,
-        related_name='+',
-        blank=True,
-        null=True
-    )
-    connection_status = models.NullBooleanField(
-        choices=CONNECTION_STATUS_CHOICES,
-        blank=True
-    )
-    lag = models.ForeignKey(
-        to='self',
-        on_delete=models.SET_NULL,
-        related_name='member_interfaces',
-        null=True,
-        blank=True,
-        verbose_name='Parent LAG'
-    )
-    type = models.CharField(
-        max_length=50,
-        choices=InterfaceTypeChoices
-    )
-    enabled = models.BooleanField(
-        default=True
-    )
-    mac_address = MACAddressField(
-        null=True,
-        blank=True,
-        verbose_name='MAC Address'
-    )
-    mtu = models.PositiveIntegerField(
-        blank=True,
-        null=True,
-        validators=[MinValueValidator(1), MaxValueValidator(65536)],
-        verbose_name='MTU'
-    )
-    mgmt_only = models.BooleanField(
-        default=False,
-        verbose_name='OOB Management',
-        help_text='This interface is used only for out-of-band management'
-    )
-    mode = models.CharField(
-        max_length=50,
-        choices=InterfaceModeChoices,
-        blank=True,
-    )
-    untagged_vlan = models.ForeignKey(
-        to='ipam.VLAN',
-        on_delete=models.SET_NULL,
-        related_name='interfaces_as_untagged',
-        null=True,
-        blank=True,
-        verbose_name='Untagged VLAN'
-    )
-    tagged_vlans = models.ManyToManyField(
-        to='ipam.VLAN',
-        related_name='interfaces_as_tagged',
-        blank=True,
-        verbose_name='Tagged VLANs'
-    )
-
-    objects = InterfaceManager()
-    tags = TaggableManager(through=TaggedItem)
-
-    csv_headers = [
-        'device', 'virtual_machine', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only',
-        'description', 'mode',
-    ]
-
-    class Meta:
-        ordering = ['device', 'name']
-        unique_together = ['device', 'name']
-
-    def __str__(self):
-        return self.name
-
-    def get_absolute_url(self):
-        return reverse('dcim:interface', kwargs={'pk': self.pk})
-
-    def to_csv(self):
-        return (
-            self.device.identifier if self.device else None,
-            self.virtual_machine.name if self.virtual_machine else None,
-            self.name,
-            self.lag.name if self.lag else None,
-            self.get_type_display(),
-            self.enabled,
-            self.mac_address,
-            self.mtu,
-            self.mgmt_only,
-            self.description,
-            self.get_mode_display(),
-        )
-
-    def clean(self):
-
-        # An Interface must belong to a Device *or* to a VirtualMachine
-        if self.device and self.virtual_machine:
-            raise ValidationError("An interface cannot belong to both a device and a virtual machine.")
-        if not self.device and not self.virtual_machine:
-            raise ValidationError("An interface must belong to either a device or a virtual machine.")
-
-        # VM interfaces must be virtual
-        if self.virtual_machine and self.type not in VMInterfaceTypeChoices.values():
-            raise ValidationError({
-                'type': "Invalid interface type for a virtual machine: {}".format(self.type)
-            })
-
-        # Virtual interfaces cannot be connected
-        if self.type in NONCONNECTABLE_IFACE_TYPES and (
-                self.cable or getattr(self, 'circuit_termination', False)
-        ):
-            raise ValidationError({
-                'type': "Virtual and wireless interfaces cannot be connected to another interface or circuit. "
-                        "Disconnect the interface or choose a suitable type."
-            })
-
-        # An interface's LAG must belong to the same device (or VC master)
-        if self.lag and self.lag.device not in [self.device, self.device.get_vc_master()]:
-            raise ValidationError({
-                'lag': "The selected LAG interface ({}) belongs to a different device ({}).".format(
-                    self.lag.name, self.lag.device.name
-                )
-            })
-
-        # A virtual interface cannot have a parent LAG
-        if self.type in NONCONNECTABLE_IFACE_TYPES and self.lag is not None:
-            raise ValidationError({
-                'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_type_display())
-            })
-
-        # Only a LAG can have LAG members
-        if self.type != InterfaceTypeChoices.TYPE_LAG and self.member_interfaces.exists():
-            raise ValidationError({
-                'type': "Cannot change interface type; it has LAG members ({}).".format(
-                    ", ".join([iface.name for iface in self.member_interfaces.all()])
-                )
-            })
-
-        # Validate untagged VLAN
-        if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
-            raise ValidationError({
-                'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
-                                 "device/VM, or it must be global".format(self.untagged_vlan)
-            })
-
-    def save(self, *args, **kwargs):
-
-        # Remove untagged VLAN assignment for non-802.1Q interfaces
-        if self.mode is None:
-            self.untagged_vlan = None
-
-        # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
-        if self.pk and self.mode is not InterfaceModeChoices.MODE_TAGGED:
-            self.tagged_vlans.clear()
-
-        return super().save(*args, **kwargs)
-
-    def to_objectchange(self, action):
-        # Annotate the parent Device/VM
-        try:
-            parent_obj = self.device or self.virtual_machine
-        except ObjectDoesNotExist:
-            parent_obj = None
-
-        return ObjectChange(
-            changed_object=self,
-            object_repr=str(self),
-            action=action,
-            related_object=parent_obj,
-            object_data=serialize_object(self)
-        )
-
-    @property
-    def connected_endpoint(self):
-        if self._connected_interface:
-            return self._connected_interface
-        return self._connected_circuittermination
-
-    @connected_endpoint.setter
-    def connected_endpoint(self, value):
-        from circuits.models import CircuitTermination
-
-        if value is None:
-            self._connected_interface = None
-            self._connected_circuittermination = None
-        elif isinstance(value, Interface):
-            self._connected_interface = value
-            self._connected_circuittermination = None
-        elif isinstance(value, CircuitTermination):
-            self._connected_interface = None
-            self._connected_circuittermination = value
-        else:
-            raise ValueError(
-                "Connected endpoint must be an Interface or CircuitTermination, not {}.".format(type(value))
-            )
-
-    @property
-    def parent(self):
-        return self.device or self.virtual_machine
-
-    @property
-    def is_connectable(self):
-        return self.type not in NONCONNECTABLE_IFACE_TYPES
-
-    @property
-    def is_virtual(self):
-        return self.type in VIRTUAL_IFACE_TYPES
-
-    @property
-    def is_wireless(self):
-        return self.type in WIRELESS_IFACE_TYPES
-
-    @property
-    def is_lag(self):
-        return self.type == InterfaceTypeChoices.TYPE_LAG
-
-    @property
-    def count_ipaddresses(self):
-        return self.ip_addresses.count()
-
-
-#
-# Pass-through ports
-#
-
-class FrontPort(CableTermination, ComponentModel):
-    """
-    A pass-through port on the front of a Device.
-    """
-    device = models.ForeignKey(
-        to='dcim.Device',
-        on_delete=models.CASCADE,
-        related_name='frontports'
-    )
-    name = models.CharField(
-        max_length=64
-    )
-    type = models.CharField(
-        max_length=50,
-        choices=PortTypeChoices
-    )
-    rear_port = models.ForeignKey(
-        to='dcim.RearPort',
-        on_delete=models.CASCADE,
-        related_name='frontports'
-    )
-    rear_port_position = models.PositiveSmallIntegerField(
-        default=1,
-        validators=[MinValueValidator(1), MaxValueValidator(64)]
-    )
-
-    is_path_endpoint = False
-
-    objects = NaturalOrderingManager()
-    tags = TaggableManager(through=TaggedItem)
-
-    csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
-
-    class Meta:
-        ordering = ['device', 'name']
-        unique_together = [
-            ['device', 'name'],
-            ['rear_port', 'rear_port_position'],
-        ]
-
-    def __str__(self):
-        return self.name
+    def save(self, *args, **kwargs):
 
-    def to_csv(self):
-        return (
-            self.device.identifier,
-            self.name,
-            self.get_type_display(),
-            self.rear_port.name,
-            self.rear_port_position,
-            self.description,
-        )
+        is_new = not bool(self.pk)
 
-    def clean(self):
+        super().save(*args, **kwargs)
 
-        # Validate rear port assignment
-        if self.rear_port.device != self.device:
-            raise ValidationError(
-                "Rear port ({}) must belong to the same device".format(self.rear_port)
+        # 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.consoleport_templates.all()]
             )
-
-        # Validate rear port position assignment
-        if self.rear_port_position > self.rear_port.positions:
-            raise ValidationError(
-                "Invalid rear port position ({}); rear port {} has only {} positions".format(
-                    self.rear_port_position, self.rear_port.name, self.rear_port.positions
-                )
+            ConsoleServerPort.objects.bulk_create(
+                [x.instantiate(self) for x in self.device_type.consoleserverport_templates.all()]
+            )
+            PowerPort.objects.bulk_create(
+                [x.instantiate(self) for x in self.device_type.powerport_templates.all()]
+            )
+            PowerOutlet.objects.bulk_create(
+                [x.instantiate(self) for x in self.device_type.poweroutlet_templates.all()]
+            )
+            Interface.objects.bulk_create(
+                [x.instantiate(self) for x in self.device_type.interface_templates.all()]
+            )
+            RearPort.objects.bulk_create(
+                [x.instantiate(self) for x in self.device_type.rearport_templates.all()]
+            )
+            FrontPort.objects.bulk_create(
+                [x.instantiate(self) for x in self.device_type.frontport_templates.all()]
+            )
+            DeviceBay.objects.bulk_create(
+                [x.instantiate(self) for x in self.device_type.device_bay_templates.all()]
             )
 
-
-class RearPort(CableTermination, ComponentModel):
-    """
-    A pass-through port on the rear of a Device.
-    """
-    device = models.ForeignKey(
-        to='dcim.Device',
-        on_delete=models.CASCADE,
-        related_name='rearports'
-    )
-    name = models.CharField(
-        max_length=64
-    )
-    type = models.CharField(
-        max_length=50,
-        choices=PortTypeChoices
-    )
-    positions = models.PositiveSmallIntegerField(
-        default=1,
-        validators=[MinValueValidator(1), MaxValueValidator(64)]
-    )
-
-    is_path_endpoint = False
-
-    objects = NaturalOrderingManager()
-    tags = TaggableManager(through=TaggedItem)
-
-    csv_headers = ['device', 'name', 'type', 'positions', 'description']
-
-    class Meta:
-        ordering = ['device', 'name']
-        unique_together = ['device', 'name']
-
-    def __str__(self):
-        return self.name
-
-    def to_csv(self):
-        return (
-            self.device.identifier,
-            self.name,
-            self.get_type_display(),
-            self.positions,
-            self.description,
-        )
-
-
-#
-# Device bays
-#
-
-class DeviceBay(ComponentModel):
-    """
-    An empty space within a Device which can house a child device
-    """
-    device = models.ForeignKey(
-        to='dcim.Device',
-        on_delete=models.CASCADE,
-        related_name='device_bays'
-    )
-    name = models.CharField(
-        max_length=50,
-        verbose_name='Name'
-    )
-    installed_device = models.OneToOneField(
-        to='dcim.Device',
-        on_delete=models.SET_NULL,
-        related_name='parent_bay',
-        blank=True,
-        null=True
-    )
-
-    objects = NaturalOrderingManager()
-    tags = TaggableManager(through=TaggedItem)
-
-    csv_headers = ['device', 'name', 'installed_device', 'description']
-
-    class Meta:
-        ordering = ['device', 'name']
-        unique_together = ['device', 'name']
-
-    def __str__(self):
-        return '{} - {}'.format(self.device.name, self.name)
-
-    def get_absolute_url(self):
-        return self.device.get_absolute_url()
+        # 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.device.identifier,
-            self.name,
-            self.installed_device.identifier if self.installed_device else None,
-            self.description,
+            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,
         )
 
-    def clean(self):
-
-        # Validate that the parent Device can have DeviceBays
-        if not self.device.device_type.is_parent_device:
-            raise ValidationError("This type of device ({}) does not support device bays.".format(
-                self.device.device_type
-            ))
-
-        # Cannot install a device into itself, obviously
-        if self.device == self.installed_device:
-            raise ValidationError("Cannot install a device into itself.")
-
-        # Check that the installed device is not already installed elsewhere
-        if self.installed_device:
-            current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first()
-            if current_bay and current_bay != self:
-                raise ValidationError({
-                    'installed_device': "Cannot install the specified device; device is already installed in {}".format(
-                        current_bay
-                    )
-                })
-
-
-#
-# Inventory items
-#
+    @property
+    def display_name(self):
+        if self.name:
+            return self.name
+        elif self.virtual_chassis and self.virtual_chassis.master.name:
+            return "{}:{}".format(self.virtual_chassis.master, self.vc_position)
+        elif hasattr(self, 'device_type'):
+            return "{}".format(self.device_type)
+        return ""
 
-class InventoryItem(ComponentModel):
-    """
-    An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
-    InventoryItems are used only for inventory purposes.
-    """
-    device = models.ForeignKey(
-        to='dcim.Device',
-        on_delete=models.CASCADE,
-        related_name='inventory_items'
-    )
-    parent = models.ForeignKey(
-        to='self',
-        on_delete=models.CASCADE,
-        related_name='child_items',
-        blank=True,
-        null=True
-    )
-    name = models.CharField(
-        max_length=50,
-        verbose_name='Name'
-    )
-    manufacturer = models.ForeignKey(
-        to='dcim.Manufacturer',
-        on_delete=models.PROTECT,
-        related_name='inventory_items',
-        blank=True,
-        null=True
-    )
-    part_id = models.CharField(
-        max_length=50,
-        verbose_name='Part ID',
-        blank=True
-    )
-    serial = models.CharField(
-        max_length=50,
-        verbose_name='Serial number',
-        blank=True
-    )
-    asset_tag = models.CharField(
-        max_length=50,
-        unique=True,
-        blank=True,
-        null=True,
-        verbose_name='Asset tag',
-        help_text='A unique tag used to identify this item'
-    )
-    discovered = models.BooleanField(
-        default=False,
-        verbose_name='Discovered'
-    )
+    @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)
 
-    tags = TaggableManager(through=TaggedItem)
+    @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
 
-    csv_headers = [
-        'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
-    ]
+    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
 
-    class Meta:
-        ordering = ['device__id', 'parent__id', 'name']
-        unique_together = ['device', 'parent', 'name']
+    @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 __str__(self):
-        return self.name
+    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_absolute_url(self):
-        return self.device.get_absolute_url()
+    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 to_csv(self):
-        return (
-            self.device.name or '{{{}}}'.format(self.device.pk),
-            self.name,
-            self.manufacturer.name if self.manufacturer else None,
-            self.part_id,
-            self.serial,
-            self.asset_tag,
-            self.discovered,
-            self.description,
-        )
+    def get_status_class(self):
+        return self.STATUS_CLASS_MAP.get(self.status)
 
 
 #

+ 400 - 0
netbox/dcim/models/device_component_templates.py

@@ -0,0 +1,400 @@
+from django.core.exceptions import ValidationError
+from django.core.validators import MaxValueValidator, MinValueValidator
+from django.db import models
+
+from dcim.choices import *
+from dcim.constants import *
+from dcim.managers import InterfaceManager
+from extras.models import ObjectChange
+from utilities.managers import NaturalOrderingManager
+from utilities.utils import serialize_object
+from .device_components import (
+    ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort,
+)
+
+
+__all__ = (
+    'ConsolePortTemplate',
+    'ConsoleServerPortTemplate',
+    'DeviceBayTemplate',
+    'FrontPortTemplate',
+    'InterfaceTemplate',
+    'PowerOutletTemplate',
+    'PowerPortTemplate',
+    'RearPortTemplate',
+)
+
+
+class ComponentTemplateModel(models.Model):
+
+    class Meta:
+        abstract = True
+
+    def instantiate(self, device):
+        """
+        Instantiate a new component on the specified Device.
+        """
+        raise NotImplementedError()
+
+    def to_objectchange(self, action):
+        return ObjectChange(
+            changed_object=self,
+            object_repr=str(self),
+            action=action,
+            related_object=self.device_type,
+            object_data=serialize_object(self)
+        )
+
+
+class ConsolePortTemplate(ComponentTemplateModel):
+    """
+    A template for a ConsolePort to be created for a new Device.
+    """
+    device_type = models.ForeignKey(
+        to='dcim.DeviceType',
+        on_delete=models.CASCADE,
+        related_name='consoleport_templates'
+    )
+    name = models.CharField(
+        max_length=50
+    )
+    type = models.CharField(
+        max_length=50,
+        choices=ConsolePortTypeChoices,
+        blank=True
+    )
+
+    objects = NaturalOrderingManager()
+
+    class Meta:
+        ordering = ['device_type', 'name']
+        unique_together = ['device_type', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def instantiate(self, device):
+        return ConsolePort(
+            device=device,
+            name=self.name,
+            type=self.type
+        )
+
+
+class ConsoleServerPortTemplate(ComponentTemplateModel):
+    """
+    A template for a ConsoleServerPort to be created for a new Device.
+    """
+    device_type = models.ForeignKey(
+        to='dcim.DeviceType',
+        on_delete=models.CASCADE,
+        related_name='consoleserverport_templates'
+    )
+    name = models.CharField(
+        max_length=50
+    )
+    type = models.CharField(
+        max_length=50,
+        choices=ConsolePortTypeChoices,
+        blank=True
+    )
+
+    objects = NaturalOrderingManager()
+
+    class Meta:
+        ordering = ['device_type', 'name']
+        unique_together = ['device_type', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def instantiate(self, device):
+        return ConsoleServerPort(
+            device=device,
+            name=self.name,
+            type=self.type
+        )
+
+
+class PowerPortTemplate(ComponentTemplateModel):
+    """
+    A template for a PowerPort to be created for a new Device.
+    """
+    device_type = models.ForeignKey(
+        to='dcim.DeviceType',
+        on_delete=models.CASCADE,
+        related_name='powerport_templates'
+    )
+    name = models.CharField(
+        max_length=50
+    )
+    type = models.CharField(
+        max_length=50,
+        choices=PowerPortTypeChoices,
+        blank=True
+    )
+    maximum_draw = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(1)],
+        help_text="Maximum power draw (watts)"
+    )
+    allocated_draw = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(1)],
+        help_text="Allocated power draw (watts)"
+    )
+
+    objects = NaturalOrderingManager()
+
+    class Meta:
+        ordering = ['device_type', 'name']
+        unique_together = ['device_type', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def instantiate(self, device):
+        return PowerPort(
+            device=device,
+            name=self.name,
+            maximum_draw=self.maximum_draw,
+            allocated_draw=self.allocated_draw
+        )
+
+
+class PowerOutletTemplate(ComponentTemplateModel):
+    """
+    A template for a PowerOutlet to be created for a new Device.
+    """
+    device_type = models.ForeignKey(
+        to='dcim.DeviceType',
+        on_delete=models.CASCADE,
+        related_name='poweroutlet_templates'
+    )
+    name = models.CharField(
+        max_length=50
+    )
+    type = models.CharField(
+        max_length=50,
+        choices=PowerOutletTypeChoices,
+        blank=True
+    )
+    power_port = models.ForeignKey(
+        to='dcim.PowerPortTemplate',
+        on_delete=models.SET_NULL,
+        blank=True,
+        null=True,
+        related_name='poweroutlet_templates'
+    )
+    feed_leg = models.CharField(
+        max_length=50,
+        choices=PowerOutletFeedLegChoices,
+        blank=True,
+        help_text="Phase (for three-phase feeds)"
+    )
+
+    objects = NaturalOrderingManager()
+
+    class Meta:
+        ordering = ['device_type', 'name']
+        unique_together = ['device_type', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def clean(self):
+
+        # Validate power port assignment
+        if self.power_port and self.power_port.device_type != self.device_type:
+            raise ValidationError(
+                "Parent power port ({}) must belong to the same device type".format(self.power_port)
+            )
+
+    def instantiate(self, device):
+        if self.power_port:
+            power_port = PowerPort.objects.get(device=device, name=self.power_port.name)
+        else:
+            power_port = None
+        return PowerOutlet(
+            device=device,
+            name=self.name,
+            power_port=power_port,
+            feed_leg=self.feed_leg
+        )
+
+
+class InterfaceTemplate(ComponentTemplateModel):
+    """
+    A template for a physical data interface on a new Device.
+    """
+    device_type = models.ForeignKey(
+        to='dcim.DeviceType',
+        on_delete=models.CASCADE,
+        related_name='interface_templates'
+    )
+    name = models.CharField(
+        max_length=64
+    )
+    type = models.CharField(
+        max_length=50,
+        choices=InterfaceTypeChoices
+    )
+    mgmt_only = models.BooleanField(
+        default=False,
+        verbose_name='Management only'
+    )
+
+    objects = InterfaceManager()
+
+    class Meta:
+        ordering = ['device_type', 'name']
+        unique_together = ['device_type', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def instantiate(self, device):
+        return Interface(
+            device=device,
+            name=self.name,
+            type=self.type,
+            mgmt_only=self.mgmt_only
+        )
+
+
+class FrontPortTemplate(ComponentTemplateModel):
+    """
+    Template for a pass-through port on the front of a new Device.
+    """
+    device_type = models.ForeignKey(
+        to='dcim.DeviceType',
+        on_delete=models.CASCADE,
+        related_name='frontport_templates'
+    )
+    name = models.CharField(
+        max_length=64
+    )
+    type = models.CharField(
+        max_length=50,
+        choices=PortTypeChoices
+    )
+    rear_port = models.ForeignKey(
+        to='dcim.RearPortTemplate',
+        on_delete=models.CASCADE,
+        related_name='frontport_templates'
+    )
+    rear_port_position = models.PositiveSmallIntegerField(
+        default=1,
+        validators=[MinValueValidator(1), MaxValueValidator(64)]
+    )
+
+    objects = NaturalOrderingManager()
+
+    class Meta:
+        ordering = ['device_type', 'name']
+        unique_together = [
+            ['device_type', 'name'],
+            ['rear_port', 'rear_port_position'],
+        ]
+
+    def __str__(self):
+        return self.name
+
+    def clean(self):
+
+        # Validate rear port assignment
+        if self.rear_port.device_type != self.device_type:
+            raise ValidationError(
+                "Rear port ({}) must belong to the same device type".format(self.rear_port)
+            )
+
+        # Validate rear port position assignment
+        if self.rear_port_position > self.rear_port.positions:
+            raise ValidationError(
+                "Invalid rear port position ({}); rear port {} has only {} positions".format(
+                    self.rear_port_position, self.rear_port.name, self.rear_port.positions
+                )
+            )
+
+    def instantiate(self, device):
+        if self.rear_port:
+            rear_port = RearPort.objects.get(device=device, name=self.rear_port.name)
+        else:
+            rear_port = None
+        return FrontPort(
+            device=device,
+            name=self.name,
+            type=self.type,
+            rear_port=rear_port,
+            rear_port_position=self.rear_port_position
+        )
+
+
+class RearPortTemplate(ComponentTemplateModel):
+    """
+    Template for a pass-through port on the rear of a new Device.
+    """
+    device_type = models.ForeignKey(
+        to='dcim.DeviceType',
+        on_delete=models.CASCADE,
+        related_name='rearport_templates'
+    )
+    name = models.CharField(
+        max_length=64
+    )
+    type = models.CharField(
+        max_length=50,
+        choices=PortTypeChoices
+    )
+    positions = models.PositiveSmallIntegerField(
+        default=1,
+        validators=[MinValueValidator(1), MaxValueValidator(64)]
+    )
+
+    objects = NaturalOrderingManager()
+
+    class Meta:
+        ordering = ['device_type', 'name']
+        unique_together = ['device_type', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def instantiate(self, device):
+        return RearPort(
+            device=device,
+            name=self.name,
+            type=self.type,
+            positions=self.positions
+        )
+
+
+class DeviceBayTemplate(ComponentTemplateModel):
+    """
+    A template for a DeviceBay to be created for a new parent Device.
+    """
+    device_type = models.ForeignKey(
+        to='dcim.DeviceType',
+        on_delete=models.CASCADE,
+        related_name='device_bay_templates'
+    )
+    name = models.CharField(
+        max_length=50
+    )
+
+    objects = NaturalOrderingManager()
+
+    class Meta:
+        ordering = ['device_type', 'name']
+        unique_together = ['device_type', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def instantiate(self, device):
+        return DeviceBay(
+            device=device,
+            name=self.name
+        )

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

@@ -0,0 +1,1019 @@
+from django.contrib.contenttypes.fields import GenericRelation
+from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.core.validators import MaxValueValidator, MinValueValidator
+from django.db import models
+from django.db.models import Sum
+from django.urls import reverse
+from taggit.managers import TaggableManager
+
+from dcim.choices import *
+from dcim.constants import *
+from dcim.exceptions import LoopDetected
+from dcim.fields import MACAddressField
+from dcim.managers import InterfaceManager
+from extras.models import ObjectChange, TaggedItem
+from utilities.managers import NaturalOrderingManager
+from utilities.utils import serialize_object
+from virtualization.choices import VMInterfaceTypeChoices
+
+
+__all__ = (
+    'CableTermination',
+    'ConsolePort',
+    'ConsoleServerPort',
+    'DeviceBay',
+    'FrontPort',
+    'Interface',
+    'InventoryItem',
+    'PowerOutlet',
+    'PowerPort',
+    'RearPort',
+)
+
+
+class ComponentModel(models.Model):
+    description = models.CharField(
+        max_length=100,
+        blank=True
+    )
+
+    class Meta:
+        abstract = True
+
+    def to_objectchange(self, action):
+        # Annotate the parent Device/VM
+        try:
+            parent = getattr(self, 'device', None) or getattr(self, 'virtual_machine', None)
+        except ObjectDoesNotExist:
+            # The parent device/VM has already been deleted
+            parent = None
+
+        return ObjectChange(
+            changed_object=self,
+            object_repr=str(self),
+            action=action,
+            related_object=parent,
+            object_data=serialize_object(self)
+        )
+
+    @property
+    def parent(self):
+        return getattr(self, 'device', None)
+
+
+class CableTermination(models.Model):
+    cable = models.ForeignKey(
+        to='dcim.Cable',
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True
+    )
+
+    # Generic relations to Cable. These ensure that an attached Cable is deleted if the terminated object is deleted.
+    _cabled_as_a = GenericRelation(
+        to='dcim.Cable',
+        content_type_field='termination_a_type',
+        object_id_field='termination_a_id'
+    )
+    _cabled_as_b = GenericRelation(
+        to='dcim.Cable',
+        content_type_field='termination_b_type',
+        object_id_field='termination_b_id'
+    )
+
+    is_path_endpoint = True
+
+    class Meta:
+        abstract = True
+
+    def trace(self, position=1, follow_circuits=False, cable_history=None):
+        """
+        Return a list representing a complete cable path, with each individual segment represented as a three-tuple:
+            [
+                (termination A, cable, termination B),
+                (termination C, cable, termination D),
+                (termination E, cable, termination F)
+            ]
+        """
+        def get_peer_port(termination, position=1, follow_circuits=False):
+            from circuits.models import CircuitTermination
+
+            # Map a front port to its corresponding rear port
+            if isinstance(termination, FrontPort):
+                return termination.rear_port, termination.rear_port_position
+
+            # Map a rear port/position to its corresponding front port
+            elif isinstance(termination, RearPort):
+                if position not in range(1, termination.positions + 1):
+                    raise Exception("Invalid position for {} ({} positions): {})".format(
+                        termination, termination.positions, position
+                    ))
+                try:
+                    peer_port = FrontPort.objects.get(
+                        rear_port=termination,
+                        rear_port_position=position,
+                    )
+                    return peer_port, 1
+                except ObjectDoesNotExist:
+                    return None, None
+
+            # Follow a circuit to its other termination
+            elif isinstance(termination, CircuitTermination) and follow_circuits:
+                peer_termination = termination.get_peer_termination()
+                if peer_termination is None:
+                    return None, None
+                return peer_termination, position
+
+            # Termination is not a pass-through port
+            else:
+                return None, None
+
+        if not self.cable:
+            return [(self, None, None)]
+
+        # Record cable history to detect loops
+        if cable_history is None:
+            cable_history = []
+        elif self.cable in cable_history:
+            raise LoopDetected()
+        cable_history.append(self.cable)
+
+        far_end = self.cable.termination_b if self.cable.termination_a == self else self.cable.termination_a
+        path = [(self, self.cable, far_end)]
+
+        peer_port, position = get_peer_port(far_end, position, follow_circuits)
+        if peer_port is None:
+            return path
+
+        try:
+            next_segment = peer_port.trace(position, follow_circuits, cable_history)
+        except LoopDetected:
+            return path
+
+        if next_segment is None:
+            return path + [(peer_port, None, None)]
+
+        return path + next_segment
+
+    def get_cable_peer(self):
+        if self.cable is None:
+            return None
+        if self._cabled_as_a.exists():
+            return self.cable.termination_b
+        if self._cabled_as_b.exists():
+            return self.cable.termination_a
+
+
+#
+# Console ports
+#
+
+class ConsolePort(CableTermination, ComponentModel):
+    """
+    A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
+    """
+    device = models.ForeignKey(
+        to='dcim.Device',
+        on_delete=models.CASCADE,
+        related_name='consoleports'
+    )
+    name = models.CharField(
+        max_length=50
+    )
+    type = models.CharField(
+        max_length=50,
+        choices=ConsolePortTypeChoices,
+        blank=True
+    )
+    connected_endpoint = models.OneToOneField(
+        to='dcim.ConsoleServerPort',
+        on_delete=models.SET_NULL,
+        related_name='connected_endpoint',
+        blank=True,
+        null=True
+    )
+    connection_status = models.NullBooleanField(
+        choices=CONNECTION_STATUS_CHOICES,
+        blank=True
+    )
+
+    objects = NaturalOrderingManager()
+    tags = TaggableManager(through=TaggedItem)
+
+    csv_headers = ['device', 'name', 'type', 'description']
+
+    class Meta:
+        ordering = ['device', 'name']
+        unique_together = ['device', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return self.device.get_absolute_url()
+
+    def to_csv(self):
+        return (
+            self.device.identifier,
+            self.name,
+            self.type,
+            self.description,
+        )
+
+
+#
+# Console server ports
+#
+
+class ConsoleServerPort(CableTermination, ComponentModel):
+    """
+    A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
+    """
+    device = models.ForeignKey(
+        to='dcim.Device',
+        on_delete=models.CASCADE,
+        related_name='consoleserverports'
+    )
+    name = models.CharField(
+        max_length=50
+    )
+    type = models.CharField(
+        max_length=50,
+        choices=ConsolePortTypeChoices,
+        blank=True
+    )
+    connection_status = models.NullBooleanField(
+        choices=CONNECTION_STATUS_CHOICES,
+        blank=True
+    )
+
+    objects = NaturalOrderingManager()
+    tags = TaggableManager(through=TaggedItem)
+
+    csv_headers = ['device', 'name', 'type', 'description']
+
+    class Meta:
+        unique_together = ['device', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return self.device.get_absolute_url()
+
+    def to_csv(self):
+        return (
+            self.device.identifier,
+            self.name,
+            self.type,
+            self.description,
+        )
+
+
+#
+# Power ports
+#
+
+class PowerPort(CableTermination, ComponentModel):
+    """
+    A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
+    """
+    device = models.ForeignKey(
+        to='dcim.Device',
+        on_delete=models.CASCADE,
+        related_name='powerports'
+    )
+    name = models.CharField(
+        max_length=50
+    )
+    type = models.CharField(
+        max_length=50,
+        choices=PowerPortTypeChoices,
+        blank=True
+    )
+    maximum_draw = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(1)],
+        help_text="Maximum power draw (watts)"
+    )
+    allocated_draw = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(1)],
+        help_text="Allocated power draw (watts)"
+    )
+    _connected_poweroutlet = models.OneToOneField(
+        to='dcim.PowerOutlet',
+        on_delete=models.SET_NULL,
+        related_name='connected_endpoint',
+        blank=True,
+        null=True
+    )
+    _connected_powerfeed = models.OneToOneField(
+        to='dcim.PowerFeed',
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True
+    )
+    connection_status = models.NullBooleanField(
+        choices=CONNECTION_STATUS_CHOICES,
+        blank=True
+    )
+
+    objects = NaturalOrderingManager()
+    tags = TaggableManager(through=TaggedItem)
+
+    csv_headers = ['device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description']
+
+    class Meta:
+        ordering = ['device', 'name']
+        unique_together = ['device', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return self.device.get_absolute_url()
+
+    def to_csv(self):
+        return (
+            self.device.identifier,
+            self.name,
+            self.get_type_display(),
+            self.maximum_draw,
+            self.allocated_draw,
+            self.description,
+        )
+
+    @property
+    def connected_endpoint(self):
+        if self._connected_poweroutlet:
+            return self._connected_poweroutlet
+        return self._connected_powerfeed
+
+    @connected_endpoint.setter
+    def connected_endpoint(self, value):
+        # TODO: Fix circular import
+        from . import PowerFeed
+
+        if value is None:
+            self._connected_poweroutlet = None
+            self._connected_powerfeed = None
+        elif isinstance(value, PowerOutlet):
+            self._connected_poweroutlet = value
+            self._connected_powerfeed = None
+        elif isinstance(value, PowerFeed):
+            self._connected_poweroutlet = None
+            self._connected_powerfeed = value
+        else:
+            raise ValueError(
+                "Connected endpoint must be a PowerOutlet or PowerFeed, not {}.".format(type(value))
+            )
+
+    def get_power_draw(self):
+        """
+        Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort.
+        """
+        # Calculate aggregate draw of all child power outlets if no numbers have been defined manually
+        if self.allocated_draw is None and self.maximum_draw is None:
+            outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True)
+            utilization = PowerPort.objects.filter(_connected_poweroutlet_id__in=outlet_ids).aggregate(
+                maximum_draw_total=Sum('maximum_draw'),
+                allocated_draw_total=Sum('allocated_draw'),
+            )
+            ret = {
+                'allocated': utilization['allocated_draw_total'] or 0,
+                'maximum': utilization['maximum_draw_total'] or 0,
+                'outlet_count': len(outlet_ids),
+                'legs': [],
+            }
+
+            # Calculate per-leg aggregates for three-phase feeds
+            if self._connected_powerfeed and self._connected_powerfeed.phase == PowerFeedPhaseChoices.PHASE_3PHASE:
+                for leg, leg_name in PowerOutletFeedLegChoices:
+                    outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True)
+                    utilization = PowerPort.objects.filter(_connected_poweroutlet_id__in=outlet_ids).aggregate(
+                        maximum_draw_total=Sum('maximum_draw'),
+                        allocated_draw_total=Sum('allocated_draw'),
+                    )
+                    ret['legs'].append({
+                        'name': leg_name,
+                        'allocated': utilization['allocated_draw_total'] or 0,
+                        'maximum': utilization['maximum_draw_total'] or 0,
+                        'outlet_count': len(outlet_ids),
+                    })
+
+            return ret
+
+        # Default to administratively defined values
+        return {
+            'allocated': self.allocated_draw or 0,
+            'maximum': self.maximum_draw or 0,
+            'outlet_count': PowerOutlet.objects.filter(power_port=self).count(),
+            'legs': [],
+        }
+
+
+#
+# Power outlets
+#
+
+class PowerOutlet(CableTermination, ComponentModel):
+    """
+    A physical power outlet (output) within a Device which provides power to a PowerPort.
+    """
+    device = models.ForeignKey(
+        to='dcim.Device',
+        on_delete=models.CASCADE,
+        related_name='poweroutlets'
+    )
+    name = models.CharField(
+        max_length=50
+    )
+    type = models.CharField(
+        max_length=50,
+        choices=PowerOutletTypeChoices,
+        blank=True
+    )
+    power_port = models.ForeignKey(
+        to='dcim.PowerPort',
+        on_delete=models.SET_NULL,
+        blank=True,
+        null=True,
+        related_name='poweroutlets'
+    )
+    feed_leg = models.CharField(
+        max_length=50,
+        choices=PowerOutletFeedLegChoices,
+        blank=True,
+        help_text="Phase (for three-phase feeds)"
+    )
+    connection_status = models.NullBooleanField(
+        choices=CONNECTION_STATUS_CHOICES,
+        blank=True
+    )
+
+    objects = NaturalOrderingManager()
+    tags = TaggableManager(through=TaggedItem)
+
+    csv_headers = ['device', 'name', 'type', 'power_port', 'feed_leg', 'description']
+
+    class Meta:
+        unique_together = ['device', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return self.device.get_absolute_url()
+
+    def to_csv(self):
+        return (
+            self.device.identifier,
+            self.name,
+            self.get_type_display(),
+            self.power_port.name if self.power_port else None,
+            self.get_feed_leg_display(),
+            self.description,
+        )
+
+    def clean(self):
+
+        # Validate power port assignment
+        if self.power_port and self.power_port.device != self.device:
+            raise ValidationError(
+                "Parent power port ({}) must belong to the same device".format(self.power_port)
+            )
+
+
+#
+# Interfaces
+#
+
+class Interface(CableTermination, ComponentModel):
+    """
+    A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
+    Interface.
+    """
+    device = models.ForeignKey(
+        to='Device',
+        on_delete=models.CASCADE,
+        related_name='interfaces',
+        null=True,
+        blank=True
+    )
+    virtual_machine = models.ForeignKey(
+        to='virtualization.VirtualMachine',
+        on_delete=models.CASCADE,
+        related_name='interfaces',
+        null=True,
+        blank=True
+    )
+    name = models.CharField(
+        max_length=64
+    )
+    _connected_interface = models.OneToOneField(
+        to='self',
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True
+    )
+    _connected_circuittermination = models.OneToOneField(
+        to='circuits.CircuitTermination',
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True
+    )
+    connection_status = models.NullBooleanField(
+        choices=CONNECTION_STATUS_CHOICES,
+        blank=True
+    )
+    lag = models.ForeignKey(
+        to='self',
+        on_delete=models.SET_NULL,
+        related_name='member_interfaces',
+        null=True,
+        blank=True,
+        verbose_name='Parent LAG'
+    )
+    type = models.CharField(
+        max_length=50,
+        choices=InterfaceTypeChoices
+    )
+    enabled = models.BooleanField(
+        default=True
+    )
+    mac_address = MACAddressField(
+        null=True,
+        blank=True,
+        verbose_name='MAC Address'
+    )
+    mtu = models.PositiveIntegerField(
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(1), MaxValueValidator(65536)],
+        verbose_name='MTU'
+    )
+    mgmt_only = models.BooleanField(
+        default=False,
+        verbose_name='OOB Management',
+        help_text='This interface is used only for out-of-band management'
+    )
+    mode = models.CharField(
+        max_length=50,
+        choices=InterfaceModeChoices,
+        blank=True,
+    )
+    untagged_vlan = models.ForeignKey(
+        to='ipam.VLAN',
+        on_delete=models.SET_NULL,
+        related_name='interfaces_as_untagged',
+        null=True,
+        blank=True,
+        verbose_name='Untagged VLAN'
+    )
+    tagged_vlans = models.ManyToManyField(
+        to='ipam.VLAN',
+        related_name='interfaces_as_tagged',
+        blank=True,
+        verbose_name='Tagged VLANs'
+    )
+
+    objects = InterfaceManager()
+    tags = TaggableManager(through=TaggedItem)
+
+    csv_headers = [
+        'device', 'virtual_machine', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only',
+        'description', 'mode',
+    ]
+
+    class Meta:
+        ordering = ['device', 'name']
+        unique_together = ['device', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('dcim:interface', kwargs={'pk': self.pk})
+
+    def to_csv(self):
+        return (
+            self.device.identifier if self.device else None,
+            self.virtual_machine.name if self.virtual_machine else None,
+            self.name,
+            self.lag.name if self.lag else None,
+            self.get_type_display(),
+            self.enabled,
+            self.mac_address,
+            self.mtu,
+            self.mgmt_only,
+            self.description,
+            self.get_mode_display(),
+        )
+
+    def clean(self):
+
+        # An Interface must belong to a Device *or* to a VirtualMachine
+        if self.device and self.virtual_machine:
+            raise ValidationError("An interface cannot belong to both a device and a virtual machine.")
+        if not self.device and not self.virtual_machine:
+            raise ValidationError("An interface must belong to either a device or a virtual machine.")
+
+        # VM interfaces must be virtual
+        if self.virtual_machine and self.type not in VMInterfaceTypeChoices.values():
+            raise ValidationError({
+                'type': "Invalid interface type for a virtual machine: {}".format(self.type)
+            })
+
+        # Virtual interfaces cannot be connected
+        if self.type in NONCONNECTABLE_IFACE_TYPES and (
+                self.cable or getattr(self, 'circuit_termination', False)
+        ):
+            raise ValidationError({
+                'type': "Virtual and wireless interfaces cannot be connected to another interface or circuit. "
+                        "Disconnect the interface or choose a suitable type."
+            })
+
+        # An interface's LAG must belong to the same device (or VC master)
+        if self.lag and self.lag.device not in [self.device, self.device.get_vc_master()]:
+            raise ValidationError({
+                'lag': "The selected LAG interface ({}) belongs to a different device ({}).".format(
+                    self.lag.name, self.lag.device.name
+                )
+            })
+
+        # A virtual interface cannot have a parent LAG
+        if self.type in NONCONNECTABLE_IFACE_TYPES and self.lag is not None:
+            raise ValidationError({
+                'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_type_display())
+            })
+
+        # Only a LAG can have LAG members
+        if self.type != InterfaceTypeChoices.TYPE_LAG and self.member_interfaces.exists():
+            raise ValidationError({
+                'type': "Cannot change interface type; it has LAG members ({}).".format(
+                    ", ".join([iface.name for iface in self.member_interfaces.all()])
+                )
+            })
+
+        # Validate untagged VLAN
+        if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
+            raise ValidationError({
+                'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
+                                 "device/VM, or it must be global".format(self.untagged_vlan)
+            })
+
+    def save(self, *args, **kwargs):
+
+        # Remove untagged VLAN assignment for non-802.1Q interfaces
+        if self.mode is None:
+            self.untagged_vlan = None
+
+        # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
+        if self.pk and self.mode is not InterfaceModeChoices.MODE_TAGGED:
+            self.tagged_vlans.clear()
+
+        return super().save(*args, **kwargs)
+
+    def to_objectchange(self, action):
+        # Annotate the parent Device/VM
+        try:
+            parent_obj = self.device or self.virtual_machine
+        except ObjectDoesNotExist:
+            parent_obj = None
+
+        return ObjectChange(
+            changed_object=self,
+            object_repr=str(self),
+            action=action,
+            related_object=parent_obj,
+            object_data=serialize_object(self)
+        )
+
+    @property
+    def connected_endpoint(self):
+        if self._connected_interface:
+            return self._connected_interface
+        return self._connected_circuittermination
+
+    @connected_endpoint.setter
+    def connected_endpoint(self, value):
+        from circuits.models import CircuitTermination
+
+        if value is None:
+            self._connected_interface = None
+            self._connected_circuittermination = None
+        elif isinstance(value, Interface):
+            self._connected_interface = value
+            self._connected_circuittermination = None
+        elif isinstance(value, CircuitTermination):
+            self._connected_interface = None
+            self._connected_circuittermination = value
+        else:
+            raise ValueError(
+                "Connected endpoint must be an Interface or CircuitTermination, not {}.".format(type(value))
+            )
+
+    @property
+    def parent(self):
+        return self.device or self.virtual_machine
+
+    @property
+    def is_connectable(self):
+        return self.type not in NONCONNECTABLE_IFACE_TYPES
+
+    @property
+    def is_virtual(self):
+        return self.type in VIRTUAL_IFACE_TYPES
+
+    @property
+    def is_wireless(self):
+        return self.type in WIRELESS_IFACE_TYPES
+
+    @property
+    def is_lag(self):
+        return self.type == InterfaceTypeChoices.TYPE_LAG
+
+    @property
+    def count_ipaddresses(self):
+        return self.ip_addresses.count()
+
+
+#
+# Pass-through ports
+#
+
+class FrontPort(CableTermination, ComponentModel):
+    """
+    A pass-through port on the front of a Device.
+    """
+    device = models.ForeignKey(
+        to='dcim.Device',
+        on_delete=models.CASCADE,
+        related_name='frontports'
+    )
+    name = models.CharField(
+        max_length=64
+    )
+    type = models.CharField(
+        max_length=50,
+        choices=PortTypeChoices
+    )
+    rear_port = models.ForeignKey(
+        to='dcim.RearPort',
+        on_delete=models.CASCADE,
+        related_name='frontports'
+    )
+    rear_port_position = models.PositiveSmallIntegerField(
+        default=1,
+        validators=[MinValueValidator(1), MaxValueValidator(64)]
+    )
+
+    is_path_endpoint = False
+
+    objects = NaturalOrderingManager()
+    tags = TaggableManager(through=TaggedItem)
+
+    csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
+
+    class Meta:
+        ordering = ['device', 'name']
+        unique_together = [
+            ['device', 'name'],
+            ['rear_port', 'rear_port_position'],
+        ]
+
+    def __str__(self):
+        return self.name
+
+    def to_csv(self):
+        return (
+            self.device.identifier,
+            self.name,
+            self.get_type_display(),
+            self.rear_port.name,
+            self.rear_port_position,
+            self.description,
+        )
+
+    def clean(self):
+
+        # Validate rear port assignment
+        if self.rear_port.device != self.device:
+            raise ValidationError(
+                "Rear port ({}) must belong to the same device".format(self.rear_port)
+            )
+
+        # Validate rear port position assignment
+        if self.rear_port_position > self.rear_port.positions:
+            raise ValidationError(
+                "Invalid rear port position ({}); rear port {} has only {} positions".format(
+                    self.rear_port_position, self.rear_port.name, self.rear_port.positions
+                )
+            )
+
+
+class RearPort(CableTermination, ComponentModel):
+    """
+    A pass-through port on the rear of a Device.
+    """
+    device = models.ForeignKey(
+        to='dcim.Device',
+        on_delete=models.CASCADE,
+        related_name='rearports'
+    )
+    name = models.CharField(
+        max_length=64
+    )
+    type = models.CharField(
+        max_length=50,
+        choices=PortTypeChoices
+    )
+    positions = models.PositiveSmallIntegerField(
+        default=1,
+        validators=[MinValueValidator(1), MaxValueValidator(64)]
+    )
+
+    is_path_endpoint = False
+
+    objects = NaturalOrderingManager()
+    tags = TaggableManager(through=TaggedItem)
+
+    csv_headers = ['device', 'name', 'type', 'positions', 'description']
+
+    class Meta:
+        ordering = ['device', 'name']
+        unique_together = ['device', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def to_csv(self):
+        return (
+            self.device.identifier,
+            self.name,
+            self.get_type_display(),
+            self.positions,
+            self.description,
+        )
+
+
+#
+# Device bays
+#
+
+class DeviceBay(ComponentModel):
+    """
+    An empty space within a Device which can house a child device
+    """
+    device = models.ForeignKey(
+        to='dcim.Device',
+        on_delete=models.CASCADE,
+        related_name='device_bays'
+    )
+    name = models.CharField(
+        max_length=50,
+        verbose_name='Name'
+    )
+    installed_device = models.OneToOneField(
+        to='dcim.Device',
+        on_delete=models.SET_NULL,
+        related_name='parent_bay',
+        blank=True,
+        null=True
+    )
+
+    objects = NaturalOrderingManager()
+    tags = TaggableManager(through=TaggedItem)
+
+    csv_headers = ['device', 'name', 'installed_device', 'description']
+
+    class Meta:
+        ordering = ['device', 'name']
+        unique_together = ['device', 'name']
+
+    def __str__(self):
+        return '{} - {}'.format(self.device.name, self.name)
+
+    def get_absolute_url(self):
+        return self.device.get_absolute_url()
+
+    def to_csv(self):
+        return (
+            self.device.identifier,
+            self.name,
+            self.installed_device.identifier if self.installed_device else None,
+            self.description,
+        )
+
+    def clean(self):
+
+        # Validate that the parent Device can have DeviceBays
+        if not self.device.device_type.is_parent_device:
+            raise ValidationError("This type of device ({}) does not support device bays.".format(
+                self.device.device_type
+            ))
+
+        # Cannot install a device into itself, obviously
+        if self.device == self.installed_device:
+            raise ValidationError("Cannot install a device into itself.")
+
+        # Check that the installed device is not already installed elsewhere
+        if self.installed_device:
+            current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first()
+            if current_bay and current_bay != self:
+                raise ValidationError({
+                    'installed_device': "Cannot install the specified device; device is already installed in {}".format(
+                        current_bay
+                    )
+                })
+
+
+#
+# Inventory items
+#
+
+class InventoryItem(ComponentModel):
+    """
+    An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
+    InventoryItems are used only for inventory purposes.
+    """
+    device = models.ForeignKey(
+        to='dcim.Device',
+        on_delete=models.CASCADE,
+        related_name='inventory_items'
+    )
+    parent = models.ForeignKey(
+        to='self',
+        on_delete=models.CASCADE,
+        related_name='child_items',
+        blank=True,
+        null=True
+    )
+    name = models.CharField(
+        max_length=50,
+        verbose_name='Name'
+    )
+    manufacturer = models.ForeignKey(
+        to='dcim.Manufacturer',
+        on_delete=models.PROTECT,
+        related_name='inventory_items',
+        blank=True,
+        null=True
+    )
+    part_id = models.CharField(
+        max_length=50,
+        verbose_name='Part ID',
+        blank=True
+    )
+    serial = models.CharField(
+        max_length=50,
+        verbose_name='Serial number',
+        blank=True
+    )
+    asset_tag = models.CharField(
+        max_length=50,
+        unique=True,
+        blank=True,
+        null=True,
+        verbose_name='Asset tag',
+        help_text='A unique tag used to identify this item'
+    )
+    discovered = models.BooleanField(
+        default=False,
+        verbose_name='Discovered'
+    )
+
+    tags = TaggableManager(through=TaggedItem)
+
+    csv_headers = [
+        'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
+    ]
+
+    class Meta:
+        ordering = ['device__id', 'parent__id', 'name']
+        unique_together = ['device', 'parent', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return self.device.get_absolute_url()
+
+    def to_csv(self):
+        return (
+            self.device.name or '{{{}}}'.format(self.device.pk),
+            self.name,
+            self.manufacturer.name if self.manufacturer else None,
+            self.part_id,
+            self.serial,
+            self.asset_tag,
+            self.discovered,
+            self.description,
+        )

+ 4 - 1
netbox/dcim/tests/test_models.py

@@ -1,5 +1,8 @@
+from django.core.exceptions import ValidationError
 from django.test import TestCase
 
+from dcim.choices import *
+from dcim.constants import CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_PLANNED
 from dcim.models import *
 from tenancy.models import Tenant
 
@@ -498,7 +501,7 @@ class CablePathTestCase(TestCase):
         self.assertEqual(interface1.connection_status, CONNECTION_STATUS_PLANNED)
 
         # Switch third segment from planned to connected
-        cable3.status = CONNECTION_STATUS_CONNECTED
+        cable3.status = CableStatusChoices.STATUS_CONNECTED
         cable3.save()
         interface1 = Interface.objects.get(pk=self.interface1.pk)
         self.assertEqual(interface1.connected_endpoint, self.interface2)