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