Coverage for dcim/models/racks.py: 35%
279 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import decimal
2from functools import cached_property
4from django.conf import settings
5from django.contrib.contenttypes.fields import GenericRelation
6from django.contrib.postgres.fields import ArrayField
7from django.core.exceptions import ValidationError
8from django.core.validators import MaxValueValidator, MinValueValidator
9from django.db import models
10from django.db.models import Count
11from django.utils.translation import gettext_lazy as _
13from dcim.choices import *
14from dcim.constants import *
15from dcim.svg import RackElevationSVG
16from netbox.choices import ColorChoices
17from netbox.models import OrganizationalModel, PrimaryModel
18from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
19from netbox.models.mixins import WeightMixin
20from utilities.conversion import to_grams
21from utilities.data import array_to_string, drange
22from utilities.fields import ColorField, CounterCacheField
23from utilities.tracking import TrackingModelMixin
25from .device_components import PowerPort
26from .devices import Device
27from .modules import Module
28from .power import PowerFeed
30__all__ = (
31 'Rack',
32 'RackGroup',
33 'RackReservation',
34 'RackRole',
35 'RackType',
36)
38#
39# Rack Organization
40#
43class RackGroup(OrganizationalModel):
44 """
45 Racks can be grouped by physical placement within a Location.
46 """
48 class Meta:
49 ordering = ('name',)
50 verbose_name = _('rack group')
51 verbose_name_plural = _('rack groups')
54#
55# Rack Base
56#
58class RackBase(WeightMixin, PrimaryModel):
59 """
60 Base class for RackType & Rack. Holds
61 """
62 width = models.PositiveSmallIntegerField(
63 choices=RackWidthChoices,
64 default=RackWidthChoices.WIDTH_19IN,
65 verbose_name=_('width'),
66 help_text=_('Rail-to-rail width')
67 )
69 # Numbering
70 u_height = models.PositiveSmallIntegerField(
71 default=RACK_U_HEIGHT_DEFAULT,
72 verbose_name=_('height (U)'),
73 validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)],
74 help_text=_('Height in rack units')
75 )
76 starting_unit = models.PositiveSmallIntegerField(
77 default=RACK_STARTING_UNIT_DEFAULT,
78 verbose_name=_('starting unit'),
79 validators=[MinValueValidator(1)],
80 help_text=_('Starting unit for rack')
81 )
82 desc_units = models.BooleanField(
83 default=False,
84 verbose_name=_('descending units'),
85 help_text=_('Units are numbered top-to-bottom')
86 )
88 # Dimensions
89 outer_width = models.PositiveSmallIntegerField(
90 verbose_name=_('outer width'),
91 blank=True,
92 null=True,
93 help_text=_('Outer dimension of rack (width)')
94 )
95 outer_height = models.PositiveSmallIntegerField(
96 verbose_name=_('outer height'),
97 blank=True,
98 null=True,
99 help_text=_('Outer dimension of rack (height)')
100 )
101 outer_depth = models.PositiveSmallIntegerField(
102 verbose_name=_('outer depth'),
103 blank=True,
104 null=True,
105 help_text=_('Outer dimension of rack (depth)')
106 )
107 outer_unit = models.CharField(
108 verbose_name=_('outer unit'),
109 max_length=50,
110 choices=RackDimensionUnitChoices,
111 blank=True,
112 null=True
113 )
114 mounting_depth = models.PositiveSmallIntegerField(
115 verbose_name=_('mounting depth'),
116 blank=True,
117 null=True,
118 help_text=(_(
119 'Maximum depth of a mounted device, in millimeters. For four-post racks, this is the distance between the '
120 'front and rear rails.'
121 ))
122 )
124 # Weight
125 # WeightMixin provides weight, weight_unit, and _abs_weight
126 max_weight = models.PositiveIntegerField(
127 verbose_name=_('max weight'),
128 blank=True,
129 null=True,
130 help_text=_('Maximum load capacity for the rack')
131 )
132 # Stores the normalized max weight (in grams) for database ordering
133 _abs_max_weight = models.PositiveBigIntegerField(
134 blank=True,
135 null=True
136 )
138 # Cooling
139 cooling_capability = models.CharField(
140 verbose_name=_('cooling capability'),
141 max_length=50,
142 choices=RackCoolingCapabilityChoices,
143 blank=True,
144 null=True
145 )
146 cooling_capacity = models.DecimalField(
147 verbose_name=_('cooling capacity'),
148 max_digits=10,
149 decimal_places=2,
150 blank=True,
151 null=True,
152 validators=[MinValueValidator(0)],
153 help_text=_('Cooling capacity (kW)')
154 )
156 class Meta:
157 abstract = True
159 def get_cooling_capability_color(self):
160 return RackCoolingCapabilityChoices.colors.get(self.cooling_capability)
163#
164# Rack Types
165#
167class RackType(ImageAttachmentsMixin, RackBase):
168 """
169 Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
170 Each Rack is assigned to a Site and (optionally) a Location.
171 """
172 form_factor = models.CharField(
173 choices=RackFormFactorChoices,
174 max_length=50,
175 verbose_name=_('form factor')
176 )
177 manufacturer = models.ForeignKey(
178 to='dcim.Manufacturer',
179 on_delete=models.PROTECT,
180 related_name='rack_types'
181 )
182 model = models.CharField(
183 verbose_name=_('model'),
184 max_length=100
185 )
186 slug = models.SlugField(
187 verbose_name=_('slug'),
188 max_length=100,
189 unique=True
190 )
191 rack_count = CounterCacheField(
192 to_model='dcim.Rack',
193 to_field='rack_type'
194 )
196 clone_fields = (
197 'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
198 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'cooling_capability',
199 'cooling_capacity',
200 )
201 prerequisite_models = (
202 'dcim.Manufacturer',
203 )
205 class Meta:
206 ordering = ('manufacturer', 'model')
207 constraints = (
208 models.UniqueConstraint(
209 fields=('manufacturer', 'model'),
210 name='%(app_label)s_%(class)s_unique_manufacturer_model'
211 ),
212 models.UniqueConstraint(
213 fields=('manufacturer', 'slug'),
214 name='%(app_label)s_%(class)s_unique_manufacturer_slug'
215 ),
216 )
217 verbose_name = _('rack type')
218 verbose_name_plural = _('rack types')
220 def __str__(self):
221 return self.model
223 @property
224 def full_name(self):
225 return f"{self.manufacturer} {self.model}"
227 def clean(self):
228 super().clean()
230 # Validate outer dimensions and unit
231 if any([self.outer_width, self.outer_depth, self.outer_height]) and not self.outer_unit:
232 raise ValidationError(_("Must specify a unit when setting an outer dimension"))
234 # Validate max_weight and weight_unit
235 if self.max_weight and not self.weight_unit:
236 raise ValidationError(_("Must specify a unit when setting a maximum weight"))
238 def save(self, *args, **kwargs):
239 # Store the given max weight (if any) in grams for use in database ordering
240 if self.max_weight and self.weight_unit:
241 self._abs_max_weight = to_grams(self.max_weight, self.weight_unit)
242 else:
243 self._abs_max_weight = None
245 # Clear unit if outer width & depth are not set
246 if not any([self.outer_width, self.outer_depth, self.outer_height]):
247 self.outer_unit = None
249 super().save(*args, **kwargs)
251 # Update all Racks associated with this RackType
252 for rack in self.racks.all():
253 rack.snapshot()
254 rack.copy_racktype_attrs()
255 rack.save()
257 @property
258 def units(self):
259 """
260 Return a list of unit numbers, top to bottom.
261 """
262 if self.desc_units:
263 return drange(decimal.Decimal(self.starting_unit), self.u_height + self.starting_unit, 0.5)
264 return drange(self.u_height + decimal.Decimal(0.5) + self.starting_unit - 1, 0.5 + self.starting_unit - 1, -0.5)
267#
268# Racks
269#
271class RackRole(OrganizationalModel):
272 """
273 Racks can be organized by functional role, similar to Devices.
274 """
275 color = ColorField(
276 verbose_name=_('color'),
277 default=ColorChoices.COLOR_GREY
278 )
280 class Meta:
281 ordering = ('name',)
282 verbose_name = _('rack role')
283 verbose_name_plural = _('rack roles')
286class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
287 """
288 Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
289 Each Rack is assigned to a Site and (optionally) a Location.
290 """
291 # Fields which cannot be set locally if a RackType is assigned
292 RACKTYPE_FIELDS = (
293 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height',
294 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'max_weight', 'cooling_capability',
295 'cooling_capacity',
296 )
298 form_factor = models.CharField(
299 choices=RackFormFactorChoices,
300 max_length=50,
301 blank=True,
302 null=True,
303 verbose_name=_('form factor')
304 )
305 rack_type = models.ForeignKey(
306 to='dcim.RackType',
307 on_delete=models.PROTECT,
308 related_name='racks',
309 blank=True,
310 null=True,
311 )
312 name = models.CharField(
313 verbose_name=_('name'),
314 max_length=100,
315 db_collation="natural_sort"
316 )
317 facility_id = models.CharField(
318 max_length=50,
319 blank=True,
320 null=True,
321 verbose_name=_('facility ID'),
322 help_text=_("Locally-assigned identifier")
323 )
324 site = models.ForeignKey(
325 to='dcim.Site',
326 on_delete=models.PROTECT,
327 related_name='racks'
328 )
329 location = models.ForeignKey(
330 to='dcim.Location',
331 on_delete=models.SET_NULL,
332 related_name='racks',
333 blank=True,
334 null=True
335 )
336 group = models.ForeignKey(
337 to='dcim.RackGroup',
338 on_delete=models.PROTECT,
339 related_name='racks',
340 blank=True,
341 null=True,
342 help_text=_('physical grouping')
343 )
344 tenant = models.ForeignKey(
345 to='tenancy.Tenant',
346 on_delete=models.PROTECT,
347 related_name='racks',
348 blank=True,
349 null=True
350 )
351 status = models.CharField(
352 verbose_name=_('status'),
353 max_length=50,
354 choices=RackStatusChoices,
355 default=RackStatusChoices.STATUS_ACTIVE
356 )
357 role = models.ForeignKey(
358 to='dcim.RackRole',
359 on_delete=models.PROTECT,
360 related_name='racks',
361 blank=True,
362 null=True,
363 help_text=_('Functional role')
364 )
365 serial = models.CharField(
366 max_length=50,
367 blank=True,
368 verbose_name=_('serial number')
369 )
370 asset_tag = models.CharField(
371 max_length=50,
372 blank=True,
373 null=True,
374 unique=True,
375 verbose_name=_('asset tag'),
376 help_text=_('A unique tag used to identify this rack')
377 )
378 airflow = models.CharField(
379 verbose_name=_('airflow'),
380 max_length=50,
381 choices=RackAirflowChoices,
382 blank=True,
383 null=True
384 )
386 # Generic relations
387 vlan_groups = GenericRelation(
388 to='ipam.VLANGroup',
389 content_type_field='scope_type',
390 object_id_field='scope_id',
391 related_query_name='rack'
392 )
394 clone_fields = (
395 'site', 'location', 'tenant', 'status', 'role', 'form_factor', 'width', 'airflow', 'cooling_capability',
396 'cooling_capacity', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
397 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
398 )
399 prerequisite_models = (
400 'dcim.Site',
401 )
403 class Meta:
404 ordering = ('site', 'location', 'name', 'pk') # (site, location, name) may be non-unique
405 constraints = (
406 # Name and facility_id must be unique *only* within a Location
407 models.UniqueConstraint(
408 fields=('location', 'name'),
409 name='%(app_label)s_%(class)s_unique_location_name'
410 ),
411 models.UniqueConstraint(
412 fields=('location', 'facility_id'),
413 name='%(app_label)s_%(class)s_unique_location_facility_id'
414 ),
415 )
416 indexes = (
417 models.Index(fields=('site', 'location', 'name', 'id')), # Default ordering
418 )
419 verbose_name = _('rack')
420 verbose_name_plural = _('racks')
422 def __str__(self):
423 if self.facility_id:
424 return f'{self.name} ({self.facility_id})'
425 return self.name
427 def clean(self):
428 super().clean()
430 # Validate location/site assignment
431 if self.site_id and self.location_id and self.location.site_id != self.site_id:
432 raise ValidationError(_("Assigned location must belong to parent site ({site}).").format(site=self.site))
434 # Validate outer dimensions and unit
435 if any([self.outer_width, self.outer_depth, self.outer_height]) and not self.outer_unit:
436 raise ValidationError(_("Must specify a unit when setting an outer dimension"))
438 # Validate max_weight and weight_unit
439 if self.max_weight and not self.weight_unit:
440 raise ValidationError(_("Must specify a unit when setting a maximum weight"))
442 if not self._state.adding:
443 mounted_devices = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('position')
445 effective_u_height = self.rack_type.u_height if self.rack_type else self.u_height
446 effective_starting_unit = self.rack_type.starting_unit if self.rack_type else self.starting_unit
448 # Validate that Rack is tall enough to house the highest mounted Device
449 if top_device := mounted_devices.last():
450 min_height = top_device.position + top_device.device_type.u_height - effective_starting_unit
451 if effective_u_height < min_height:
452 field = 'rack_type' if self.rack_type else 'u_height'
453 raise ValidationError({
454 field: _(
455 "Rack must be at least {min_height}U tall to house currently installed devices."
456 ).format(min_height=min_height)
457 })
459 # Validate that the Rack's starting unit is less than or equal to the position of the lowest mounted Device
460 if last_device := mounted_devices.first():
461 if effective_starting_unit > last_device.position:
462 field = 'rack_type' if self.rack_type else 'starting_unit'
463 raise ValidationError({
464 field: _("Rack unit numbering must begin at {position} or less to house "
465 "currently installed devices.").format(position=last_device.position)
466 })
468 # Validate that Rack was assigned a Location of its same site, if applicable
469 if self.location:
470 if self.location.site != self.site:
471 raise ValidationError({
472 'location': _("Location must be from the same site, {site}.").format(site=self.site)
473 })
475 def save(self, *args, **kwargs):
476 self.copy_racktype_attrs()
478 # Store the given max weight (if any) in grams for use in database ordering
479 if self.max_weight and self.weight_unit:
480 self._abs_max_weight = to_grams(self.max_weight, self.weight_unit)
481 else:
482 self._abs_max_weight = None
484 # Clear unit if outer width & depth are not set
485 if not any([self.outer_width, self.outer_depth, self.outer_height]):
486 self.outer_unit = None
488 super().save(*args, **kwargs)
490 def copy_racktype_attrs(self):
491 """
492 Copy physical attributes from the assigned RackType (if any).
493 """
494 if self.rack_type:
495 for field_name in self.RACKTYPE_FIELDS:
496 setattr(self, field_name, getattr(self.rack_type, field_name))
498 @property
499 def units(self):
500 """
501 Return a list of unit numbers, top to bottom.
502 """
503 if self.desc_units:
504 return drange(decimal.Decimal(self.starting_unit), self.u_height + self.starting_unit, 0.5)
505 return drange(self.u_height + decimal.Decimal(0.5) + self.starting_unit - 1, 0.5 + self.starting_unit - 1, -0.5)
507 def get_status_color(self):
508 return RackStatusChoices.colors.get(self.status)
510 def get_rack_units(self, user=None, face=DeviceFaceChoices.FACE_FRONT, exclude=None, expand_devices=True):
511 """
512 Return a list of rack units as dictionaries. Example: {'device': None, 'face': 0, 'id': 48, 'name': 'U48'}
513 Each key 'device' is either a Device or None. By default, multi-U devices are repeated for each U they occupy.
515 :param face: Rack face (front or rear)
516 :param user: User instance to be used for evaluating device view permissions. If None, all devices
517 will be included.
518 :param exclude: PK of a Device to exclude (optional); helpful when relocating a Device within a Rack
519 :param expand_devices: When True, all units that a device occupies will be listed with each containing a
520 reference to the device. When False, only the bottom most unit for a device is included and that unit
521 contains a height attribute for the device
522 """
523 elevation = {}
524 for u in self.units:
525 u_name = f'U{u}'.split('.')[0] if not u % 1 else f'U{u}'
526 elevation[u] = {
527 'id': u,
528 'name': u_name,
529 'face': face,
530 'device': None,
531 'occupied': False
532 }
534 # Add devices to rack units list
535 if not self._state.adding:
537 # Retrieve all devices installed within the rack
538 devices = Device.objects.prefetch_related(
539 'device_type',
540 'device_type__manufacturer',
541 'role'
542 ).annotate(
543 devicebay_count=Count('devicebays')
544 ).exclude(
545 pk=exclude
546 ).filter(
547 rack=self,
548 position__gt=0,
549 device_type__u_height__gt=0
550 ).filter(
551 Q(face=face) | Q(device_type__is_full_depth=True)
552 )
554 # Determine which devices the user has permission to view
555 permitted_device_ids = []
556 if user is not None:
557 permitted_device_ids = self.devices.restrict(user, 'view').values_list('pk', flat=True)
559 for device in devices:
560 if expand_devices:
561 for u in drange(device.position, device.position + device.device_type.u_height, 0.5):
562 if user is None or device.pk in permitted_device_ids:
563 elevation[u]['device'] = device
564 elevation[u]['occupied'] = True
565 else:
566 if user is None or device.pk in permitted_device_ids:
567 elevation[device.position]['device'] = device
568 elevation[device.position]['occupied'] = True
569 elevation[device.position]['height'] = device.device_type.u_height
571 return [u for u in elevation.values()]
573 def get_available_units(self, u_height=1.0, rack_face=None, exclude=None, ignore_excluded_devices=False):
574 """
575 Return a list of units within the rack available to accommodate a device of a given U height (default 1).
576 Optionally exclude one or more devices when calculating empty units (needed when moving a device from one
577 position to another within a rack).
579 :param u_height: Minimum number of contiguous free units required
580 :param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth
581 :param exclude: List of devices IDs to exclude (useful when moving a device within a rack)
582 :param ignore_excluded_devices: Ignore devices that are marked to exclude from utilization calculations
583 """
584 # Gather all devices which consume U space within the rack
585 devices = self.devices.prefetch_related('device_type').filter(position__gte=1)
586 if ignore_excluded_devices:
587 devices = devices.exclude(device_type__exclude_from_utilization=True)
589 if exclude is not None:
590 devices = devices.exclude(pk__in=exclude)
592 # Initialize the rack unit skeleton
593 units = list(self.units)
595 # Remove units consumed by installed devices
596 for d in devices:
597 if rack_face is None or d.face == rack_face or d.device_type.is_full_depth:
598 for u in drange(d.position, d.position + d.device_type.u_height, 0.5):
599 try:
600 units.remove(u)
601 except ValueError:
602 # Found overlapping devices in the rack!
603 pass
605 # Remove units without enough space above them to accommodate a device of the specified height
606 available_units = []
607 for u in units:
608 if set(drange(u, u + decimal.Decimal(u_height), 0.5)).issubset(units):
609 available_units.append(u)
611 return list(reversed(available_units))
613 def get_reserved_units(self):
614 """
615 Return a dictionary mapping all reserved units within the rack to their reservation.
616 """
617 reserved_units = {}
618 for reservation in self.reservations.all():
619 for u in reservation.units:
620 reserved_units[u] = reservation
621 return reserved_units
623 def get_elevation_svg(
624 self,
625 face=DeviceFaceChoices.FACE_FRONT,
626 user=None,
627 unit_width=None,
628 unit_height=None,
629 legend_width=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH,
630 margin_width=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH,
631 include_images=True,
632 base_url=None,
633 highlight_params=None
634 ):
635 """
636 Return an SVG of the rack elevation
638 :param face: Enum of [front, rear] representing the desired side of the rack elevation to render
639 :param user: User instance to be used for evaluating device view permissions. If None, all devices
640 will be included.
641 :param unit_width: Width in pixels for the rendered drawing
642 :param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
643 height of the elevation
644 :param legend_width: Width of the unit legend, in pixels
645 :param margin_width: Width of the right-hand margin, in pixels
646 :param include_images: Embed front/rear device images where available
647 :param base_url: Base URL for links and images. If none, URLs will be relative.
648 :param highlight_params: Dictionary of parameters to be passed to the RackElevationSVG.render_highlight() method
649 """
650 elevation = RackElevationSVG(
651 self,
652 unit_width=unit_width,
653 unit_height=unit_height,
654 legend_width=legend_width,
655 margin_width=margin_width,
656 user=user,
657 include_images=include_images,
658 base_url=base_url,
659 highlight_params=highlight_params
660 )
662 return elevation.render(face)
664 def get_utilization(self):
665 """
666 Determine the utilization rate of the rack and return it as a percentage. Occupied and reserved units both count
667 as utilized.
668 """
669 # Determine unoccupied units
670 total_units = len(list(self.units))
671 available_units = self.get_available_units(u_height=0.5, ignore_excluded_devices=True)
673 # Remove reserved units
674 for ru in self.get_reserved_units():
675 for u in drange(ru, ru + 1, 0.5):
676 if u in available_units:
677 available_units.remove(u)
679 occupied_unit_count = total_units - len(available_units)
680 percentage = float(occupied_unit_count) / total_units * 100
682 return percentage
684 def get_power_utilization(self):
685 """
686 Determine the utilization rate of power in the rack and return it as a percentage.
687 """
688 powerfeeds = PowerFeed.objects.filter(rack=self)
689 available_power_total = sum(pf.available_power for pf in powerfeeds)
690 if not available_power_total:
691 return 0
693 powerports = []
694 for powerfeed in powerfeeds:
695 powerports.extend([
696 peer for peer in powerfeed.link_peers if isinstance(peer, PowerPort)
697 ])
699 allocated_draw = sum([
700 powerport.get_power_draw()['allocated'] for powerport in powerports
701 ])
703 return round(allocated_draw / available_power_total * 100, 1)
705 @cached_property
706 def total_weight(self):
707 total_weight = sum(
708 device.device_type._abs_weight
709 for device in self.devices.exclude(device_type___abs_weight__isnull=True).prefetch_related('device_type')
710 )
711 total_weight += sum(
712 module.module_type._abs_weight
713 for module in Module.objects.filter(device__rack=self)
714 .exclude(module_type___abs_weight__isnull=True)
715 .prefetch_related('module_type')
716 )
717 if self._abs_weight:
718 total_weight += self._abs_weight
719 return round(total_weight / 1000, 2)
722class RackReservation(PrimaryModel):
723 """
724 One or more reserved units within a Rack.
725 """
726 rack = models.ForeignKey(
727 to='dcim.Rack',
728 on_delete=models.CASCADE,
729 related_name='reservations'
730 )
731 units = ArrayField(
732 verbose_name=_('units'),
733 base_field=models.PositiveSmallIntegerField()
734 )
735 status = models.CharField(
736 verbose_name=_('status'),
737 max_length=50,
738 choices=RackReservationStatusChoices,
739 default=RackReservationStatusChoices.STATUS_ACTIVE
740 )
741 tenant = models.ForeignKey(
742 to='tenancy.Tenant',
743 on_delete=models.PROTECT,
744 related_name='rackreservations',
745 blank=True,
746 null=True
747 )
748 user = models.ForeignKey(
749 to=settings.AUTH_USER_MODEL,
750 on_delete=models.PROTECT
751 )
752 description = models.CharField(
753 verbose_name=_('description'),
754 max_length=200
755 )
757 clone_fields = ('rack', 'user', 'tenant')
758 prerequisite_models = (
759 'dcim.Rack',
760 )
762 class Meta:
763 ordering = ['created', 'pk']
764 indexes = (
765 models.Index(fields=('created', 'id')), # Default ordering
766 )
767 verbose_name = _('rack reservation')
768 verbose_name_plural = _('rack reservations')
770 def __str__(self):
771 return "Reservation for rack {}".format(self.rack)
773 def clean(self):
774 super().clean()
776 if hasattr(self, 'rack') and self.units:
778 # Validate that all specified units exist in the Rack.
779 invalid_units = [u for u in self.units if u not in self.rack.units]
780 if invalid_units:
781 raise ValidationError({
782 'units': _("Invalid unit(s) for {height}U rack: {unit_list}").format(
783 height=self.rack.u_height,
784 unit_list=', '.join([str(u) for u in invalid_units])
785 ),
786 })
788 # Check that none of the units has already been reserved for this Rack.
789 reserved_units = []
790 for resv in self.rack.reservations.exclude(pk=self.pk):
791 reserved_units += resv.units
792 conflicting_units = [u for u in self.units if u in reserved_units]
793 if conflicting_units:
794 raise ValidationError({
795 'units': _('The following units have already been reserved: {unit_list}').format(
796 unit_list=', '.join([str(u) for u in conflicting_units])
797 )
798 })
800 @property
801 def unit_list(self):
802 return array_to_string(self.units)
804 def get_status_color(self):
805 return RackReservationStatusChoices.colors.get(self.status)
807 def to_objectchange(self, action):
808 objectchange = super().to_objectchange(action)
809 objectchange.related_object = self.rack
810 return objectchange