diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py new file mode 100644 index 000000000..d846ae55b --- /dev/null +++ b/netbox/dcim/models/__init__.py @@ -0,0 +1,3 @@ +from .models import * +from .component_models import * +from .component_template_models import * diff --git a/netbox/dcim/models/component_models.py b/netbox/dcim/models/component_models.py new file mode 100644 index 000000000..03aee6ce0 --- /dev/null +++ b/netbox/dcim/models/component_models.py @@ -0,0 +1,876 @@ +from collections import OrderedDict +from itertools import count, groupby + +from django.conf import settings +from django.contrib.auth.models import User +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.fields import ArrayField, JSONField +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.db.models import Count, Q +from django.urls import reverse +from mptt.models import MPTTModel, TreeForeignKey +from taggit.managers import TaggableManager +from timezone_field import TimeZoneField + +from dcim.constants import * +from dcim.exceptions import LoopDetected +from dcim.fields import ASNField, MACAddressField +from dcim.managers import InterfaceManager +from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange +from utilities.fields import ColorField +from utilities.managers import NaturalOrderingManager +from utilities.models import ChangeLoggedModel +from utilities.utils import serialize_object, to_meters + + +class ComponentModel(models.Model): + + class Meta: + abstract = True + + def log_change(self, user, request_id, action): + """ + Log an ObjectChange including 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 + ObjectChange( + user=user, + request_id=request_id, + changed_object=self, + related_object=parent, + action=action, + object_data=serialize_object(self) + ).save() + + @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' + ) + + 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 + ) + 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() + + csv_headers = ['device', 'name'] + + 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, + ) + + +# +# 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 + ) + connection_status = models.NullBooleanField( + choices=CONNECTION_STATUS_CHOICES, + blank=True + ) + + objects = NaturalOrderingManager() + tags = TaggableManager() + + csv_headers = ['device', 'name'] + + 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, + ) + + +# +# 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 + ) + connected_endpoint = models.OneToOneField( + to='dcim.PowerOutlet', + 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() + + csv_headers = ['device', 'name'] + + 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, + ) + + +# +# 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 + ) + connection_status = models.NullBooleanField( + choices=CONNECTION_STATUS_CHOICES, + blank=True + ) + + objects = NaturalOrderingManager() + tags = TaggableManager() + + csv_headers = ['device', 'name'] + + 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, + ) + + +# +# 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' + ) + form_factor = models.PositiveSmallIntegerField( + choices=IFACE_FF_CHOICES, + default=IFACE_FF_10GE_SFP_PLUS + ) + 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' + ) + description = models.CharField( + max_length=100, + blank=True + ) + mode = models.PositiveSmallIntegerField( + choices=IFACE_MODE_CHOICES, + blank=True, + null=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() + + csv_headers = [ + 'device', 'virtual_machine', 'name', 'lag', 'form_factor', '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_form_factor_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.form_factor is not IFACE_FF_VIRTUAL: + raise ValidationError({ + 'form_factor': "Virtual machines can only have virtual interfaces." + }) + + # Virtual interfaces cannot be connected + if self.form_factor in NONCONNECTABLE_IFACE_TYPES and ( + self.cable or getattr(self, 'circuit_termination', False) + ): + raise ValidationError({ + 'form_factor': "Virtual and wireless interfaces cannot be connected to another interface or circuit. " + "Disconnect the interface or choose a suitable form factor." + }) + + # 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.form_factor in NONCONNECTABLE_IFACE_TYPES and self.lag is not None: + raise ValidationError({ + 'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display()) + }) + + # Only a LAG can have LAG members + if self.form_factor != IFACE_FF_LAG and self.member_interfaces.exists(): + raise ValidationError({ + 'form_factor': "Cannot change interface form factor; 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 IFACE_MODE_TAGGED: + self.tagged_vlans.clear() + + return super().save(*args, **kwargs) + + def log_change(self, user, request_id, action): + """ + Include the connected Interface (if any). + """ + + # It's possible that an Interface can be deleted _after_ its parent Device/VM, in which case trying to resolve + # the component parent will raise DoesNotExist. For more discussion, see + # https://github.com/digitalocean/netbox/issues/2323 + try: + parent_obj = self.device or self.virtual_machine + except ObjectDoesNotExist: + parent_obj = None + + ObjectChange( + user=user, + request_id=request_id, + changed_object=self, + related_object=parent_obj, + action=action, + object_data=serialize_object(self) + ).save() + + @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.form_factor not in NONCONNECTABLE_IFACE_TYPES + + @property + def is_virtual(self): + return self.form_factor in VIRTUAL_IFACE_TYPES + + @property + def is_wireless(self): + return self.form_factor in WIRELESS_IFACE_TYPES + + @property + def is_lag(self): + return self.form_factor == IFACE_FF_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.PositiveSmallIntegerField( + choices=PORT_TYPE_CHOICES + ) + 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)] + ) + description = models.CharField( + max_length=100, + blank=True + ) + + objects = NaturalOrderingManager() + tags = TaggableManager() + + 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.PositiveSmallIntegerField( + choices=PORT_TYPE_CHOICES + ) + positions = models.PositiveSmallIntegerField( + default=1, + validators=[MinValueValidator(1), MaxValueValidator(64)] + ) + description = models.CharField( + max_length=100, + blank=True + ) + + objects = NaturalOrderingManager() + tags = TaggableManager() + + 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() + + csv_headers = ['device', 'name', 'installed_device'] + + 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, + ) + + 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.") + + +# +# 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' + ) + description = models.CharField( + max_length=100, + blank=True + ) + + tags = TaggableManager() + + 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, + ) diff --git a/netbox/dcim/models/component_template_models.py b/netbox/dcim/models/component_template_models.py new file mode 100644 index 000000000..440f6ba3b --- /dev/null +++ b/netbox/dcim/models/component_template_models.py @@ -0,0 +1,258 @@ +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models + +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 + + +class ComponentTemplateModel(models.Model): + + class Meta: + abstract = True + + def log_change(self, user, request_id, action): + """ + Log an ObjectChange including the parent DeviceType. + """ + ObjectChange( + user=user, + request_id=request_id, + changed_object=self, + related_object=self.device_type, + action=action, + object_data=serialize_object(self) + ).save() + + +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 + ) + + objects = NaturalOrderingManager() + + class Meta: + ordering = ['device_type', 'name'] + unique_together = ['device_type', 'name'] + + def __str__(self): + return self.name + + +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 + ) + + objects = NaturalOrderingManager() + + class Meta: + ordering = ['device_type', 'name'] + unique_together = ['device_type', 'name'] + + def __str__(self): + return self.name + + +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 + ) + + objects = NaturalOrderingManager() + + class Meta: + ordering = ['device_type', 'name'] + unique_together = ['device_type', 'name'] + + def __str__(self): + return self.name + + +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 + ) + + objects = NaturalOrderingManager() + + class Meta: + ordering = ['device_type', 'name'] + unique_together = ['device_type', 'name'] + + def __str__(self): + return self.name + + +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 + ) + form_factor = models.PositiveSmallIntegerField( + choices=IFACE_FF_CHOICES, + default=IFACE_FF_10GE_SFP_PLUS + ) + 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 + + +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.PositiveSmallIntegerField( + choices=PORT_TYPE_CHOICES + ) + 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 + ) + ) + + +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.PositiveSmallIntegerField( + choices=PORT_TYPE_CHOICES + ) + 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 + + +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 diff --git a/netbox/dcim/models.py b/netbox/dcim/models/models.py similarity index 62% rename from netbox/dcim/models.py rename to netbox/dcim/models/models.py index f8e8a028e..63e1226bf 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models/models.py @@ -6,7 +6,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField, JSONField -from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Count, Q @@ -15,164 +15,15 @@ from mptt.models import MPTTModel, TreeForeignKey from taggit.managers import TaggableManager from timezone_field import TimeZoneField -from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange +from dcim.constants import * +from dcim.fields import ASNField +from extras.models import ConfigContextModel, CustomFieldModel from utilities.fields import ColorField from utilities.managers import NaturalOrderingManager from utilities.models import ChangeLoggedModel -from utilities.utils import serialize_object, to_meters -from .constants import * -from .exceptions import LoopDetected -from .fields import ASNField, MACAddressField -from .managers import InterfaceManager +from utilities.utils import to_meters - -class ComponentTemplateModel(models.Model): - - class Meta: - abstract = True - - def log_change(self, user, request_id, action): - """ - Log an ObjectChange including the parent DeviceType. - """ - ObjectChange( - user=user, - request_id=request_id, - changed_object=self, - related_object=self.device_type, - action=action, - object_data=serialize_object(self) - ).save() - - -class ComponentModel(models.Model): - - class Meta: - abstract = True - - def log_change(self, user, request_id, action): - """ - Log an ObjectChange including 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 - ObjectChange( - user=user, - request_id=request_id, - changed_object=self, - related_object=parent, - action=action, - object_data=serialize_object(self) - ).save() - - @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' - ) - - 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 .component_models import * # @@ -991,234 +842,6 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): return bool(self.subdevice_role is False) -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 - ) - - objects = NaturalOrderingManager() - - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] - - def __str__(self): - return self.name - - -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 - ) - - objects = NaturalOrderingManager() - - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] - - def __str__(self): - return self.name - - -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 - ) - - objects = NaturalOrderingManager() - - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] - - def __str__(self): - return self.name - - -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 - ) - - objects = NaturalOrderingManager() - - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] - - def __str__(self): - return self.name - - -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 - ) - form_factor = models.PositiveSmallIntegerField( - choices=IFACE_FF_CHOICES, - default=IFACE_FF_10GE_SFP_PLUS - ) - 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 - - -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.PositiveSmallIntegerField( - choices=PORT_TYPE_CHOICES - ) - 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 - ) - ) - - -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.PositiveSmallIntegerField( - choices=PORT_TYPE_CHOICES - ) - 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 - - -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 # @@ -1729,726 +1352,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): return STATUS_CLASSES[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 - ) - 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() - - csv_headers = ['device', 'name'] - - 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, - ) - - -# -# 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 - ) - connection_status = models.NullBooleanField( - choices=CONNECTION_STATUS_CHOICES, - blank=True - ) - - objects = NaturalOrderingManager() - tags = TaggableManager() - - csv_headers = ['device', 'name'] - - 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, - ) - - -# -# 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 - ) - connected_endpoint = models.OneToOneField( - to='dcim.PowerOutlet', - 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() - - csv_headers = ['device', 'name'] - - 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, - ) - - -# -# 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 - ) - connection_status = models.NullBooleanField( - choices=CONNECTION_STATUS_CHOICES, - blank=True - ) - - objects = NaturalOrderingManager() - tags = TaggableManager() - - csv_headers = ['device', 'name'] - - 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, - ) - - -# -# 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' - ) - form_factor = models.PositiveSmallIntegerField( - choices=IFACE_FF_CHOICES, - default=IFACE_FF_10GE_SFP_PLUS - ) - 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' - ) - description = models.CharField( - max_length=100, - blank=True - ) - mode = models.PositiveSmallIntegerField( - choices=IFACE_MODE_CHOICES, - blank=True, - null=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() - - csv_headers = [ - 'device', 'virtual_machine', 'name', 'lag', 'form_factor', '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_form_factor_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.form_factor is not IFACE_FF_VIRTUAL: - raise ValidationError({ - 'form_factor': "Virtual machines can only have virtual interfaces." - }) - - # Virtual interfaces cannot be connected - if self.form_factor in NONCONNECTABLE_IFACE_TYPES and ( - self.cable or getattr(self, 'circuit_termination', False) - ): - raise ValidationError({ - 'form_factor': "Virtual and wireless interfaces cannot be connected to another interface or circuit. " - "Disconnect the interface or choose a suitable form factor." - }) - - # 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.form_factor in NONCONNECTABLE_IFACE_TYPES and self.lag is not None: - raise ValidationError({ - 'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display()) - }) - - # Only a LAG can have LAG members - if self.form_factor != IFACE_FF_LAG and self.member_interfaces.exists(): - raise ValidationError({ - 'form_factor': "Cannot change interface form factor; 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 IFACE_MODE_TAGGED: - self.tagged_vlans.clear() - - return super().save(*args, **kwargs) - - def log_change(self, user, request_id, action): - """ - Include the connected Interface (if any). - """ - - # It's possible that an Interface can be deleted _after_ its parent Device/VM, in which case trying to resolve - # the component parent will raise DoesNotExist. For more discussion, see - # https://github.com/digitalocean/netbox/issues/2323 - try: - parent_obj = self.device or self.virtual_machine - except ObjectDoesNotExist: - parent_obj = None - - ObjectChange( - user=user, - request_id=request_id, - changed_object=self, - related_object=parent_obj, - action=action, - object_data=serialize_object(self) - ).save() - - @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.form_factor not in NONCONNECTABLE_IFACE_TYPES - - @property - def is_virtual(self): - return self.form_factor in VIRTUAL_IFACE_TYPES - - @property - def is_wireless(self): - return self.form_factor in WIRELESS_IFACE_TYPES - - @property - def is_lag(self): - return self.form_factor == IFACE_FF_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.PositiveSmallIntegerField( - choices=PORT_TYPE_CHOICES - ) - 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)] - ) - description = models.CharField( - max_length=100, - blank=True - ) - - objects = NaturalOrderingManager() - tags = TaggableManager() - - 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.PositiveSmallIntegerField( - choices=PORT_TYPE_CHOICES - ) - positions = models.PositiveSmallIntegerField( - default=1, - validators=[MinValueValidator(1), MaxValueValidator(64)] - ) - description = models.CharField( - max_length=100, - blank=True - ) - - objects = NaturalOrderingManager() - tags = TaggableManager() - - 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() - - csv_headers = ['device', 'name', 'installed_device'] - - 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, - ) - - 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.") - - -# -# 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' - ) - description = models.CharField( - max_length=100, - blank=True - ) - - tags = TaggableManager() - - 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, - ) - - # # Virtual chassis #