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

1import decimal 

2from functools import cached_property 

3 

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 _ 

12 

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 

24 

25from .device_components import PowerPort 

26from .devices import Device 

27from .modules import Module 

28from .power import PowerFeed 

29 

30__all__ = ( 

31 'Rack', 

32 'RackGroup', 

33 'RackReservation', 

34 'RackRole', 

35 'RackType', 

36) 

37 

38# 

39# Rack Organization 

40# 

41 

42 

43class RackGroup(OrganizationalModel): 

44 """ 

45 Racks can be grouped by physical placement within a Location. 

46 """ 

47 

48 class Meta: 

49 ordering = ('name',) 

50 verbose_name = _('rack group') 

51 verbose_name_plural = _('rack groups') 

52 

53 

54# 

55# Rack Base 

56# 

57 

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 ) 

68 

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 ) 

87 

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 ) 

123 

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 ) 

137 

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 ) 

155 

156 class Meta: 

157 abstract = True 

158 

159 def get_cooling_capability_color(self): 

160 return RackCoolingCapabilityChoices.colors.get(self.cooling_capability) 

161 

162 

163# 

164# Rack Types 

165# 

166 

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 ) 

195 

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 ) 

204 

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') 

219 

220 def __str__(self): 

221 return self.model 

222 

223 @property 

224 def full_name(self): 

225 return f"{self.manufacturer} {self.model}" 

226 

227 def clean(self): 

228 super().clean() 

229 

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")) 

233 

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")) 

237 

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 

244 

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 

248 

249 super().save(*args, **kwargs) 

250 

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() 

256 

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) 

265 

266 

267# 

268# Racks 

269# 

270 

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 ) 

279 

280 class Meta: 

281 ordering = ('name',) 

282 verbose_name = _('rack role') 

283 verbose_name_plural = _('rack roles') 

284 

285 

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 ) 

297 

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 ) 

385 

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 ) 

393 

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 ) 

402 

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') 

421 

422 def __str__(self): 

423 if self.facility_id: 

424 return f'{self.name} ({self.facility_id})' 

425 return self.name 

426 

427 def clean(self): 

428 super().clean() 

429 

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)) 

433 

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")) 

437 

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")) 

441 

442 if not self._state.adding: 

443 mounted_devices = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('position') 

444 

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 

447 

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 }) 

458 

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 }) 

467 

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 }) 

474 

475 def save(self, *args, **kwargs): 

476 self.copy_racktype_attrs() 

477 

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 

483 

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 

487 

488 super().save(*args, **kwargs) 

489 

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)) 

497 

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) 

506 

507 def get_status_color(self): 

508 return RackStatusChoices.colors.get(self.status) 

509 

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. 

514 

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 } 

533 

534 # Add devices to rack units list 

535 if not self._state.adding: 

536 

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 ) 

553 

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) 

558 

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 

570 

571 return [u for u in elevation.values()] 

572 

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). 

578 

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) 

588 

589 if exclude is not None: 

590 devices = devices.exclude(pk__in=exclude) 

591 

592 # Initialize the rack unit skeleton 

593 units = list(self.units) 

594 

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 

604 

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) 

610 

611 return list(reversed(available_units)) 

612 

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 

622 

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 

637 

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 ) 

661 

662 return elevation.render(face) 

663 

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) 

672 

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) 

678 

679 occupied_unit_count = total_units - len(available_units) 

680 percentage = float(occupied_unit_count) / total_units * 100 

681 

682 return percentage 

683 

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 

692 

693 powerports = [] 

694 for powerfeed in powerfeeds: 

695 powerports.extend([ 

696 peer for peer in powerfeed.link_peers if isinstance(peer, PowerPort) 

697 ]) 

698 

699 allocated_draw = sum([ 

700 powerport.get_power_draw()['allocated'] for powerport in powerports 

701 ]) 

702 

703 return round(allocated_draw / available_power_total * 100, 1) 

704 

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) 

720 

721 

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 ) 

756 

757 clone_fields = ('rack', 'user', 'tenant') 

758 prerequisite_models = ( 

759 'dcim.Rack', 

760 ) 

761 

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') 

769 

770 def __str__(self): 

771 return "Reservation for rack {}".format(self.rack) 

772 

773 def clean(self): 

774 super().clean() 

775 

776 if hasattr(self, 'rack') and self.units: 

777 

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 }) 

787 

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 }) 

799 

800 @property 

801 def unit_list(self): 

802 return array_to_string(self.units) 

803 

804 def get_status_color(self): 

805 return RackReservationStatusChoices.colors.get(self.status) 

806 

807 def to_objectchange(self, action): 

808 objectchange = super().to_objectchange(action) 

809 objectchange.related_object = self.rack 

810 return objectchange