Coverage for dcim/models/devices.py: 52%

512 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 

4import yaml 

5from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation 

6from django.contrib.contenttypes.models import ContentType 

7from django.contrib.postgres.indexes import GistIndex 

8from django.core.exceptions import ValidationError 

9from django.core.files.storage import default_storage 

10from django.core.validators import MaxValueValidator, MinValueValidator 

11from django.db import models 

12from django.db.models import F, ProtectedError, prefetch_related_objects 

13from django.db.models.functions import Lower 

14from django.db.models.signals import post_save 

15from django.urls import reverse 

16from django.utils.safestring import mark_safe 

17from django.utils.translation import gettext_lazy as _ 

18 

19from dcim.choices import * 

20from dcim.constants import * 

21from dcim.fields import MACAddressField 

22from dcim.utils import create_port_mappings, update_interface_bridges, update_interface_parents 

23from extras.models import ConfigContextModel, CustomField 

24from extras.querysets import ConfigContextModelQuerySet 

25from netbox.choices import ColorChoices 

26from netbox.config import ConfigItem 

27from netbox.models import NestedLtreeGroupModel, OrganizationalModel, PrimaryModel 

28from netbox.models.features import ContactsMixin, ImageAttachmentsMixin 

29from netbox.models.mixins import WeightMixin 

30from utilities.exceptions import AbortRequest 

31from utilities.fields import ColorField, CounterCacheField 

32from utilities.prefetch import get_prefetchable_fields 

33from utilities.tracking import TrackingModelMixin 

34 

35from .device_components import * 

36from .mixins import RenderConfigMixin 

37from .modules import Module 

38 

39__all__ = ( 

40 'Device', 

41 'DeviceRole', 

42 'DeviceType', 

43 'MACAddress', 

44 'Manufacturer', 

45 'Platform', 

46 'VirtualChassis', 

47 'VirtualDeviceContext', 

48) 

49 

50 

51# 

52# Device Types 

53# 

54 

55class Manufacturer(ContactsMixin, OrganizationalModel): 

56 """ 

57 A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. 

58 """ 

59 class Meta: 

60 ordering = ('name',) 

61 verbose_name = _('manufacturer') 

62 verbose_name_plural = _('manufacturers') 

63 

64 

65class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): 

66 """ 

67 A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as 

68 well as high-level functional role(s). 

69 

70 Each DeviceType can have an arbitrary number of component templates assigned to it, which define console, power, and 

71 interface objects. For example, a Juniper EX4300-48T DeviceType would have: 

72 

73 * 1 ConsolePortTemplate 

74 * 2 PowerPortTemplates 

75 * 48 InterfaceTemplates 

76 

77 When a new Device of this type is created, the appropriate console, power, and interface objects (as defined by the 

78 DeviceType) are automatically created as well. 

79 """ 

80 manufacturer = models.ForeignKey( 

81 to='dcim.Manufacturer', 

82 on_delete=models.PROTECT, 

83 related_name='device_types' 

84 ) 

85 model = models.CharField( 

86 verbose_name=_('model'), 

87 max_length=100 

88 ) 

89 slug = models.SlugField( 

90 verbose_name=_('slug'), 

91 max_length=100 

92 ) 

93 default_platform = models.ForeignKey( 

94 to='dcim.Platform', 

95 on_delete=models.SET_NULL, 

96 related_name='+', 

97 blank=True, 

98 null=True, 

99 verbose_name=_('default platform') 

100 ) 

101 part_number = models.CharField( 

102 verbose_name=_('part number'), 

103 max_length=50, 

104 blank=True, 

105 help_text=_('Discrete part number (optional)') 

106 ) 

107 u_height = models.DecimalField( 

108 max_digits=4, 

109 decimal_places=1, 

110 default=1.0, 

111 verbose_name=_('height (U)') 

112 ) 

113 exclude_from_utilization = models.BooleanField( 

114 default=False, 

115 verbose_name=_('exclude from utilization'), 

116 help_text=_('Devices of this type are excluded when calculating rack utilization.') 

117 ) 

118 is_full_depth = models.BooleanField( 

119 default=True, 

120 verbose_name=_('is full depth'), 

121 help_text=_('Device consumes both front and rear rack faces.') 

122 ) 

123 subdevice_role = models.CharField( 

124 max_length=50, 

125 choices=SubdeviceRoleChoices, 

126 blank=True, 

127 null=True, 

128 verbose_name=_('parent/child status'), 

129 help_text=_('Parent devices house child devices in device bays. Leave blank ' 

130 'if this device type is neither a parent nor a child.') 

131 ) 

132 airflow = models.CharField( 

133 verbose_name=_('airflow'), 

134 max_length=50, 

135 choices=DeviceAirflowChoices, 

136 blank=True, 

137 null=True 

138 ) 

139 cooling_method = models.CharField( 

140 verbose_name=_('cooling method'), 

141 max_length=50, 

142 choices=CoolingMethodChoices, 

143 blank=True, 

144 null=True 

145 ) 

146 end_of_life = models.DateField( 

147 verbose_name=_('end of life'), 

148 blank=True, 

149 null=True, 

150 help_text=_('The date after which this device type is no longer supported by the manufacturer') 

151 ) 

152 front_image = models.ImageField( 

153 upload_to='devicetype-images', 

154 blank=True 

155 ) 

156 rear_image = models.ImageField( 

157 upload_to='devicetype-images', 

158 blank=True 

159 ) 

160 

161 # Counter fields 

162 console_port_template_count = CounterCacheField( 

163 to_model='dcim.ConsolePortTemplate', 

164 to_field='device_type' 

165 ) 

166 console_server_port_template_count = CounterCacheField( 

167 to_model='dcim.ConsoleServerPortTemplate', 

168 to_field='device_type' 

169 ) 

170 power_port_template_count = CounterCacheField( 

171 to_model='dcim.PowerPortTemplate', 

172 to_field='device_type' 

173 ) 

174 power_outlet_template_count = CounterCacheField( 

175 to_model='dcim.PowerOutletTemplate', 

176 to_field='device_type' 

177 ) 

178 cooling_intake_template_count = CounterCacheField( 

179 to_model='dcim.CoolingIntakeTemplate', 

180 to_field='device_type' 

181 ) 

182 cooling_outflow_template_count = CounterCacheField( 

183 to_model='dcim.CoolingOutflowTemplate', 

184 to_field='device_type' 

185 ) 

186 interface_template_count = CounterCacheField( 

187 to_model='dcim.InterfaceTemplate', 

188 to_field='device_type' 

189 ) 

190 front_port_template_count = CounterCacheField( 

191 to_model='dcim.FrontPortTemplate', 

192 to_field='device_type' 

193 ) 

194 rear_port_template_count = CounterCacheField( 

195 to_model='dcim.RearPortTemplate', 

196 to_field='device_type' 

197 ) 

198 device_bay_template_count = CounterCacheField( 

199 to_model='dcim.DeviceBayTemplate', 

200 to_field='device_type' 

201 ) 

202 module_bay_template_count = CounterCacheField( 

203 to_model='dcim.ModuleBayTemplate', 

204 to_field='device_type' 

205 ) 

206 inventory_item_template_count = CounterCacheField( 

207 to_model='dcim.InventoryItemTemplate', 

208 to_field='device_type' 

209 ) 

210 device_count = CounterCacheField( 

211 to_model='dcim.Device', 

212 to_field='device_type' 

213 ) 

214 

215 clone_fields = ( 

216 'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 

217 'cooling_method', 'weight', 'weight_unit', 

218 ) 

219 prerequisite_models = ( 

220 'dcim.Manufacturer', 

221 ) 

222 

223 class Meta: 

224 ordering = ['manufacturer', 'model'] 

225 constraints = ( 

226 models.UniqueConstraint( 

227 fields=('manufacturer', 'model'), 

228 name='%(app_label)s_%(class)s_unique_manufacturer_model' 

229 ), 

230 models.UniqueConstraint( 

231 fields=('manufacturer', 'slug'), 

232 name='%(app_label)s_%(class)s_unique_manufacturer_slug' 

233 ), 

234 ) 

235 verbose_name = _('device type') 

236 verbose_name_plural = _('device types') 

237 

238 def __str__(self): 

239 return self.model 

240 

241 def __init__(self, *args, **kwargs): 

242 super().__init__(*args, **kwargs) 

243 

244 # Save a copy of u_height for validation in clean() 

245 self._original_u_height = self.__dict__.get('u_height') 

246 

247 # Save references to the original front/rear images 

248 self._original_front_image = self.__dict__.get('front_image') 

249 self._original_rear_image = self.__dict__.get('rear_image') 

250 

251 @property 

252 def full_name(self): 

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

254 

255 def get_cooling_method_color(self): 

256 return CoolingMethodChoices.colors.get(self.cooling_method) 

257 

258 def to_yaml(self): 

259 data = { 

260 'manufacturer': self.manufacturer.name, 

261 'model': self.model, 

262 'slug': self.slug, 

263 'description': self.description, 

264 'default_platform': self.default_platform.name if self.default_platform else None, 

265 'part_number': self.part_number, 

266 'u_height': float(self.u_height), 

267 'is_full_depth': self.is_full_depth, 

268 'subdevice_role': self.subdevice_role, 

269 'airflow': self.airflow, 

270 'cooling_method': self.cooling_method, 

271 'weight': float(self.weight) if self.weight is not None else None, 

272 'weight_unit': self.weight_unit, 

273 'end_of_life': self.end_of_life.isoformat() if self.end_of_life else None, 

274 'comments': self.comments, 

275 } 

276 

277 # Component templates 

278 if self.consoleporttemplates.exists(): 

279 data['console-ports'] = [ 

280 c.to_yaml() for c in self.consoleporttemplates.all() 

281 ] 

282 if self.consoleserverporttemplates.exists(): 

283 data['console-server-ports'] = [ 

284 c.to_yaml() for c in self.consoleserverporttemplates.all() 

285 ] 

286 if self.powerporttemplates.exists(): 

287 data['power-ports'] = [ 

288 c.to_yaml() for c in self.powerporttemplates.all() 

289 ] 

290 if self.poweroutlettemplates.exists(): 

291 data['power-outlets'] = [ 

292 c.to_yaml() for c in self.poweroutlettemplates.all() 

293 ] 

294 if self.coolingintaketemplates.exists(): 

295 data['cooling-intakes'] = [ 

296 c.to_yaml() for c in self.coolingintaketemplates.all() 

297 ] 

298 if self.coolingoutflowtemplates.exists(): 

299 data['cooling-outflows'] = [ 

300 c.to_yaml() for c in self.coolingoutflowtemplates.all() 

301 ] 

302 if self.interfacetemplates.exists(): 

303 data['interfaces'] = [ 

304 c.to_yaml() for c in self.interfacetemplates.all() 

305 ] 

306 if self.frontporttemplates.exists(): 

307 data['front-ports'] = [ 

308 c.to_yaml() for c in self.frontporttemplates.all() 

309 ] 

310 if self.rearporttemplates.exists(): 

311 data['rear-ports'] = [ 

312 c.to_yaml() for c in self.rearporttemplates.all() 

313 ] 

314 

315 # Port mappings 

316 port_mapping_data = [ 

317 c.to_yaml() for c in self.port_mappings.all() 

318 ] 

319 

320 if port_mapping_data: 

321 data['port-mappings'] = port_mapping_data 

322 

323 if self.modulebaytemplates.exists(): 

324 data['module-bays'] = [ 

325 c.to_yaml() for c in self.modulebaytemplates.all() 

326 ] 

327 if self.devicebaytemplates.exists(): 

328 data['device-bays'] = [ 

329 c.to_yaml() for c in self.devicebaytemplates.all() 

330 ] 

331 

332 return yaml.dump(dict(data), sort_keys=False) 

333 

334 def clean(self): 

335 super().clean() 

336 

337 # U height must be divisible by 0.5 

338 if decimal.Decimal(self.u_height) % decimal.Decimal(0.5): 338 ↛ 339line 338 didn't jump to line 339 because the condition on line 338 was never true

339 raise ValidationError({ 

340 'u_height': _("U height must be in increments of 0.5 rack units.") 

341 }) 

342 

343 # If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have 

344 # room to expand within their racks. This validation will impose a very high performance penalty when there are 

345 # many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence. 

346 if not self._state.adding and self.u_height > self._original_u_height: 346 ↛ 347line 346 didn't jump to line 347 because the condition on line 346 was never true

347 for d in Device.objects.filter(device_type=self, position__isnull=False): 

348 face_required = None if self.is_full_depth else d.face 

349 u_available = d.rack.get_available_units( 

350 u_height=self.u_height, 

351 rack_face=face_required, 

352 exclude=[d.pk] 

353 ) 

354 if d.position not in u_available: 

355 raise ValidationError({ 

356 'u_height': _( 

357 "Device {device} in rack {rack} does not have sufficient space to accommodate a " 

358 "height of {height}U" 

359 ).format(device=d, rack=d.rack, height=self.u_height) 

360 }) 

361 

362 # If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position. 

363 elif not self._state.adding and self._original_u_height > 0 and self.u_height == 0: 363 ↛ 364line 363 didn't jump to line 364 because the condition on line 363 was never true

364 racked_instance_count = Device.objects.filter( 

365 device_type=self, 

366 position__isnull=False 

367 ).count() 

368 if racked_instance_count: 

369 url = f"{reverse('dcim:device_list')}?manufactuer_id={self.manufacturer_id}&device_type_id={self.pk}" 

370 raise ValidationError({ 

371 'u_height': mark_safe(_( 

372 'Unable to set 0U height: Found <a href="{url}">{racked_instance_count} instances</a> already ' 

373 'mounted within racks.' 

374 ).format(url=url, racked_instance_count=racked_instance_count)) 

375 }) 

376 

377 if ( 377 ↛ 380line 377 didn't jump to line 380 because the condition on line 377 was never true

378 self.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT 

379 ) and self.pk and self.devicebaytemplates.count(): 

380 raise ValidationError({ 

381 'subdevice_role': _("Must delete all device bay templates associated with this device before " 

382 "declassifying it as a parent device.") 

383 }) 

384 

385 if self.u_height and self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD: 385 ↛ 386line 385 didn't jump to line 386 because the condition on line 385 was never true

386 raise ValidationError({ 

387 'u_height': _("Child device types must be 0U.") 

388 }) 

389 

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

391 ret = super().save(*args, **kwargs) 

392 

393 # Delete any previously uploaded image files that are no longer in use 

394 if self._original_front_image and self.front_image != self._original_front_image: 394 ↛ 395line 394 didn't jump to line 395 because the condition on line 394 was never true

395 default_storage.delete(self._original_front_image) 

396 if self._original_rear_image and self.rear_image != self._original_rear_image: 396 ↛ 397line 396 didn't jump to line 397 because the condition on line 396 was never true

397 default_storage.delete(self._original_rear_image) 

398 

399 return ret 

400 

401 def delete(self, *args, **kwargs): 

402 super().delete(*args, **kwargs) 

403 

404 # Delete any uploaded image files 

405 if self.front_image: 405 ↛ 406line 405 didn't jump to line 406 because the condition on line 405 was never true

406 self.front_image.delete(save=False) 

407 if self.rear_image: 407 ↛ 408line 407 didn't jump to line 408 because the condition on line 407 was never true

408 self.rear_image.delete(save=False) 

409 

410 @property 

411 def is_parent_device(self): 

412 return self.subdevice_role == SubdeviceRoleChoices.ROLE_PARENT 

413 

414 @property 

415 def is_child_device(self): 

416 return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD 

417 

418 

419# 

420# Devices 

421# 

422 

423class DeviceRole(NestedLtreeGroupModel): 

424 """ 

425 Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a 

426 color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to 

427 virtual machines as well. 

428 """ 

429 color = ColorField( 

430 verbose_name=_('color'), 

431 default=ColorChoices.COLOR_GREY 

432 ) 

433 vm_role = models.BooleanField( 

434 default=True, 

435 verbose_name=_('VM role'), 

436 help_text=_('Virtual machines may be assigned to this role') 

437 ) 

438 config_template = models.ForeignKey( 

439 to='extras.ConfigTemplate', 

440 on_delete=models.PROTECT, 

441 related_name='device_roles', 

442 blank=True, 

443 null=True 

444 ) 

445 

446 clone_fields = ('parent', 'description') 

447 

448 class Meta: 

449 ordering = ('sort_path',) 

450 indexes = ( 

451 GistIndex(fields=['path'], name='dcim_devicerole_path_gist'), 

452 models.Index(fields=['sort_path'], name='dcim_devicerole_sort_path_idx'), 

453 ) 

454 constraints = ( 

455 models.UniqueConstraint( 

456 fields=('parent', 'name'), 

457 name='%(app_label)s_%(class)s_parent_name', 

458 nulls_distinct=False, 

459 violation_error_message=_("A device role with this name already exists.") 

460 ), 

461 models.UniqueConstraint( 

462 fields=('parent', 'slug'), 

463 name='%(app_label)s_%(class)s_parent_slug', 

464 nulls_distinct=False, 

465 violation_error_message=_("A device role with this slug already exists.") 

466 ), 

467 ) 

468 verbose_name = _('device role') 

469 verbose_name_plural = _('device roles') 

470 

471 

472class Platform(NestedLtreeGroupModel): 

473 """ 

474 Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". A 

475 Platform may optionally be associated with a particular Manufacturer. 

476 """ 

477 manufacturer = models.ForeignKey( 

478 to='dcim.Manufacturer', 

479 on_delete=models.PROTECT, 

480 related_name='platforms', 

481 blank=True, 

482 null=True, 

483 help_text=_('Optionally limit this platform to devices of a certain manufacturer') 

484 ) 

485 config_template = models.ForeignKey( 

486 to='extras.ConfigTemplate', 

487 on_delete=models.PROTECT, 

488 related_name='platforms', 

489 blank=True, 

490 null=True 

491 ) 

492 

493 clone_fields = ('parent', 'description') 

494 

495 class Meta: 

496 ordering = ('sort_path',) 

497 indexes = ( 

498 GistIndex(fields=['path'], name='dcim_platform_path_gist'), 

499 models.Index(fields=['sort_path'], name='dcim_platform_sort_path_idx'), 

500 ) 

501 verbose_name = _('platform') 

502 verbose_name_plural = _('platforms') 

503 constraints = ( 

504 models.UniqueConstraint( 

505 fields=('manufacturer', 'name'), 

506 name='%(app_label)s_%(class)s_manufacturer_name', 

507 nulls_distinct=False, 

508 violation_error_message=_("Platform name must be unique.") 

509 ), 

510 models.UniqueConstraint( 

511 fields=('manufacturer', 'slug'), 

512 name='%(app_label)s_%(class)s_manufacturer_slug', 

513 nulls_distinct=False, 

514 violation_error_message=_("Platform slug must be unique.") 

515 ), 

516 ) 

517 

518 

519class Device( 

520 ContactsMixin, 

521 ImageAttachmentsMixin, 

522 RenderConfigMixin, 

523 ConfigContextModel, 

524 TrackingModelMixin, 

525 PrimaryModel 

526): 

527 """ 

528 A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, 

529 DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique. 

530 

531 Each Device must be assigned to a site, and optionally to a rack within that site. Associating a device with a 

532 particular rack face or unit is optional (for example, vertically mounted PDUs do not consume rack units). 

533 

534 When a new Device is created, console/power/interface/device bay components are created along with it as dictated 

535 by the component templates assigned to its DeviceType. Components can also be added, modified, or deleted after the 

536 creation of a Device. 

537 """ 

538 device_type = models.ForeignKey( 

539 to='dcim.DeviceType', 

540 on_delete=models.PROTECT, 

541 related_name='instances' 

542 ) 

543 role = models.ForeignKey( 

544 to='dcim.DeviceRole', 

545 on_delete=models.PROTECT, 

546 related_name='devices', 

547 help_text=_("The function this device serves") 

548 ) 

549 tenant = models.ForeignKey( 

550 to='tenancy.Tenant', 

551 on_delete=models.PROTECT, 

552 related_name='devices', 

553 blank=True, 

554 null=True 

555 ) 

556 platform = models.ForeignKey( 

557 to='dcim.Platform', 

558 on_delete=models.SET_NULL, 

559 related_name='devices', 

560 blank=True, 

561 null=True 

562 ) 

563 name = models.CharField( 

564 verbose_name=_('name'), 

565 max_length=64, 

566 blank=True, 

567 null=True, 

568 db_collation="natural_sort" 

569 ) 

570 serial = models.CharField( 

571 max_length=50, 

572 blank=True, 

573 verbose_name=_('serial number'), 

574 help_text=_("Chassis serial number, assigned by the manufacturer") 

575 ) 

576 asset_tag = models.CharField( 

577 max_length=50, 

578 blank=True, 

579 null=True, 

580 unique=True, 

581 verbose_name=_('asset tag'), 

582 help_text=_('A unique tag used to identify this device') 

583 ) 

584 site = models.ForeignKey( 

585 to='dcim.Site', 

586 on_delete=models.PROTECT, 

587 related_name='devices' 

588 ) 

589 location = models.ForeignKey( 

590 to='dcim.Location', 

591 on_delete=models.PROTECT, 

592 related_name='devices', 

593 blank=True, 

594 null=True 

595 ) 

596 rack = models.ForeignKey( 

597 to='dcim.Rack', 

598 on_delete=models.PROTECT, 

599 related_name='devices', 

600 blank=True, 

601 null=True 

602 ) 

603 position = models.DecimalField( 

604 max_digits=4, 

605 decimal_places=1, 

606 blank=True, 

607 null=True, 

608 validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX + 0.5)], 

609 verbose_name=_('position (U)'), 

610 help_text=_('The lowest-numbered unit occupied by the device') 

611 ) 

612 face = models.CharField( 

613 max_length=50, 

614 blank=True, 

615 null=True, 

616 choices=DeviceFaceChoices, 

617 verbose_name=_('rack face') 

618 ) 

619 status = models.CharField( 

620 verbose_name=_('status'), 

621 max_length=50, 

622 choices=DeviceStatusChoices, 

623 default=DeviceStatusChoices.STATUS_ACTIVE 

624 ) 

625 airflow = models.CharField( 

626 verbose_name=_('airflow'), 

627 max_length=50, 

628 choices=DeviceAirflowChoices, 

629 blank=True, 

630 null=True 

631 ) 

632 cooling_method = models.CharField( 

633 verbose_name=_('cooling method'), 

634 max_length=50, 

635 choices=CoolingMethodChoices, 

636 blank=True, 

637 null=True 

638 ) 

639 primary_ip4 = models.OneToOneField( 

640 to='ipam.IPAddress', 

641 on_delete=models.SET_NULL, 

642 related_name='+', 

643 blank=True, 

644 null=True, 

645 verbose_name=_('primary IPv4') 

646 ) 

647 primary_ip6 = models.OneToOneField( 

648 to='ipam.IPAddress', 

649 on_delete=models.SET_NULL, 

650 related_name='+', 

651 blank=True, 

652 null=True, 

653 verbose_name=_('primary IPv6') 

654 ) 

655 oob_ip = models.OneToOneField( 

656 to='ipam.IPAddress', 

657 on_delete=models.SET_NULL, 

658 related_name='+', 

659 blank=True, 

660 null=True, 

661 verbose_name=_('out-of-band IP') 

662 ) 

663 cluster = models.ForeignKey( 

664 to='virtualization.Cluster', 

665 on_delete=models.SET_NULL, 

666 related_name='devices', 

667 blank=True, 

668 null=True 

669 ) 

670 virtual_chassis = models.ForeignKey( 

671 to='VirtualChassis', 

672 on_delete=models.SET_NULL, 

673 related_name='members', 

674 blank=True, 

675 null=True 

676 ) 

677 vc_position = models.PositiveIntegerField( 

678 verbose_name=_('VC position'), 

679 blank=True, 

680 null=True, 

681 help_text=_('Virtual chassis position') 

682 ) 

683 vc_priority = models.PositiveSmallIntegerField( 

684 verbose_name=_('VC priority'), 

685 blank=True, 

686 null=True, 

687 validators=[MaxValueValidator(255)], 

688 help_text=_('Virtual chassis master election priority') 

689 ) 

690 latitude = models.DecimalField( 

691 verbose_name=_('latitude'), 

692 max_digits=8, 

693 decimal_places=6, 

694 blank=True, 

695 null=True, 

696 validators=[ 

697 MinValueValidator(decimal.Decimal('-90.0')), 

698 MaxValueValidator(decimal.Decimal('90.0')) 

699 ], 

700 help_text=_("GPS coordinate in decimal format (xx.yyyyyy)") 

701 ) 

702 longitude = models.DecimalField( 

703 verbose_name=_('longitude'), 

704 max_digits=9, 

705 decimal_places=6, 

706 blank=True, 

707 null=True, 

708 validators=[ 

709 MinValueValidator(decimal.Decimal('-180.0')), 

710 MaxValueValidator(decimal.Decimal('180.0')) 

711 ], 

712 help_text=_("GPS coordinate in decimal format (xx.yyyyyy)") 

713 ) 

714 services = GenericRelation( 

715 to='ipam.Service', 

716 content_type_field='parent_object_type', 

717 object_id_field='parent_object_id', 

718 related_query_name='device', 

719 ) 

720 

721 # Counter fields 

722 console_port_count = CounterCacheField( 

723 to_model='dcim.ConsolePort', 

724 to_field='device' 

725 ) 

726 console_server_port_count = CounterCacheField( 

727 to_model='dcim.ConsoleServerPort', 

728 to_field='device' 

729 ) 

730 power_port_count = CounterCacheField( 

731 to_model='dcim.PowerPort', 

732 to_field='device' 

733 ) 

734 power_outlet_count = CounterCacheField( 

735 to_model='dcim.PowerOutlet', 

736 to_field='device' 

737 ) 

738 cooling_intake_count = CounterCacheField( 

739 to_model='dcim.CoolingIntake', 

740 to_field='device' 

741 ) 

742 cooling_outflow_count = CounterCacheField( 

743 to_model='dcim.CoolingOutflow', 

744 to_field='device' 

745 ) 

746 interface_count = CounterCacheField( 

747 to_model='dcim.Interface', 

748 to_field='device' 

749 ) 

750 front_port_count = CounterCacheField( 

751 to_model='dcim.FrontPort', 

752 to_field='device' 

753 ) 

754 rear_port_count = CounterCacheField( 

755 to_model='dcim.RearPort', 

756 to_field='device' 

757 ) 

758 device_bay_count = CounterCacheField( 

759 to_model='dcim.DeviceBay', 

760 to_field='device' 

761 ) 

762 module_bay_count = CounterCacheField( 

763 to_model='dcim.ModuleBay', 

764 to_field='device' 

765 ) 

766 inventory_item_count = CounterCacheField( 

767 to_model='dcim.InventoryItem', 

768 to_field='device' 

769 ) 

770 

771 objects = ConfigContextModelQuerySet.as_manager() 

772 

773 clone_fields = ( 

774 'device_type', 'role', 'tenant', 'platform', 'site', 'location', 'rack', 'face', 'status', 'airflow', 

775 'cooling_method', 'cluster', 'virtual_chassis', 

776 ) 

777 prerequisite_models = ( 

778 'dcim.Site', 

779 'dcim.DeviceRole', 

780 'dcim.DeviceType', 

781 ) 

782 

783 class Meta: 

784 ordering = ('name', 'pk') # Name may be null 

785 indexes = ( 

786 models.Index(fields=('name', 'id')), # Default ordering 

787 # Partial index supporting the background renderer's scan for objects whose config 

788 # context cache has been invalidated (see extras.jobs.RenderConfigContextJob and 

789 # extras.cache). Indexing only the NULL-cache rows keeps that lookup cheap on large 

790 # tables where the steady state is for nearly every row to be populated. 

791 models.Index( 

792 fields=('id',), 

793 condition=Q(_config_context_data__isnull=True), 

794 name='dcim_device_cc_null', 

795 ), 

796 ) 

797 constraints = ( 

798 models.UniqueConstraint( 

799 Lower('name'), 'site', 'tenant', 

800 name='%(app_label)s_%(class)s_unique_name_site_tenant', 

801 condition=Q(name__isnull=False), 

802 nulls_distinct=False, 

803 violation_error_message=_("Device name must be unique per site and tenant.") 

804 ), 

805 models.UniqueConstraint( 

806 fields=('rack', 'position', 'face'), 

807 name='%(app_label)s_%(class)s_unique_rack_position_face' 

808 ), 

809 models.UniqueConstraint( 

810 fields=('virtual_chassis', 'vc_position'), 

811 name='%(app_label)s_%(class)s_unique_virtual_chassis_vc_position' 

812 ), 

813 ) 

814 verbose_name = _('device') 

815 verbose_name_plural = _('devices') 

816 permissions = [ 

817 ('render_config', 'Render configuration'), 

818 ] 

819 

820 def __str__(self): 

821 if self.label and self.asset_tag: 821 ↛ 822line 821 didn't jump to line 822 because the condition on line 821 was never true

822 return f'{self.label} ({self.asset_tag})' 

823 if self.label: 823 ↛ 825line 823 didn't jump to line 825 because the condition on line 823 was always true

824 return self.label 

825 if self.device_type and self.asset_tag: 

826 return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})' 

827 if self.device_type: 

828 return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})' 

829 return super().__str__() 

830 

831 def clean(self): 

832 super().clean() 

833 

834 # Validate site/location/rack combination 

835 if self.rack and self.site != self.rack.site: 835 ↛ 836line 835 didn't jump to line 836 because the condition on line 835 was never true

836 raise ValidationError({ 

837 'rack': _("Rack {rack} does not belong to site {site}.").format(rack=self.rack, site=self.site), 

838 }) 

839 if self.location and self.site != self.location.site: 839 ↛ 840line 839 didn't jump to line 840 because the condition on line 839 was never true

840 raise ValidationError({ 

841 'location': _( 

842 "Location {location} does not belong to site {site}." 

843 ).format(location=self.location, site=self.site) 

844 }) 

845 if self.rack and self.location and self.rack.location != self.location: 845 ↛ 846line 845 didn't jump to line 846 because the condition on line 845 was never true

846 raise ValidationError({ 

847 'rack': _( 

848 "Rack {rack} does not belong to location {location}." 

849 ).format(rack=self.rack, location=self.location) 

850 }) 

851 

852 if self.rack is None: 852 ↛ 863line 852 didn't jump to line 863 because the condition on line 852 was always true

853 if self.face: 

854 raise ValidationError({ 

855 'face': _("Cannot select a rack face without assigning a rack."), 

856 }) 

857 if self.position: 857 ↛ 858line 857 didn't jump to line 858 because the condition on line 857 was never true

858 raise ValidationError({ 

859 'position': _("Cannot select a rack position without assigning a rack."), 

860 }) 

861 

862 # Validate rack position and face 

863 if self.position and self.position % decimal.Decimal(0.5): 863 ↛ 864line 863 didn't jump to line 864 because the condition on line 863 was never true

864 raise ValidationError({ 

865 'position': _("Position must be in increments of 0.5 rack units.") 

866 }) 

867 if self.position and not self.face: 867 ↛ 868line 867 didn't jump to line 868 because the condition on line 867 was never true

868 raise ValidationError({ 

869 'face': _("Must specify rack face when defining rack position."), 

870 }) 

871 

872 # Prevent 0U devices from being assigned to a specific position 

873 if hasattr(self, 'device_type'): 873 ↛ 881line 873 didn't jump to line 881 because the condition on line 873 was always true

874 if self.position and self.device_type.u_height == 0: 874 ↛ 875line 874 didn't jump to line 875 because the condition on line 874 was never true

875 raise ValidationError({ 

876 'position': _( 

877 "A 0U device type ({device_type}) cannot be assigned to a rack position." 

878 ).format(device_type=self.device_type) 

879 }) 

880 

881 if self.rack: 881 ↛ 883line 881 didn't jump to line 883 because the condition on line 881 was never true

882 

883 try: 

884 # Child devices cannot be assigned to a rack face/unit 

885 if self.device_type.is_child_device and self.face: 

886 raise ValidationError({ 

887 'face': _( 

888 "Child device types cannot be assigned to a rack face. This is an attribute of the parent " 

889 "device." 

890 ) 

891 }) 

892 if self.device_type.is_child_device and self.position: 

893 raise ValidationError({ 

894 'position': _( 

895 "Child device types cannot be assigned to a rack position. This is an attribute of the " 

896 "parent device." 

897 ) 

898 }) 

899 

900 # Validate rack space 

901 rack_face = self.face if not self.device_type.is_full_depth else None 

902 exclude_list = [self.pk] if self.pk else [] 

903 available_units = self.rack.get_available_units( 

904 u_height=self.device_type.u_height, rack_face=rack_face, exclude=exclude_list 

905 ) 

906 if self.position and self.position not in available_units: 

907 raise ValidationError({ 

908 'position': _( 

909 "U{position} is already occupied or does not have sufficient space to accommodate this " 

910 "device type: {device_type} ({u_height}U)" 

911 ).format( 

912 position=self.position, device_type=self.device_type, u_height=self.device_type.u_height 

913 ) 

914 }) 

915 

916 except DeviceType.DoesNotExist: 

917 pass 

918 

919 # Validate primary & OOB IP addresses 

920 vc_interfaces = self.vc_interfaces(if_master=False) 

921 if self.primary_ip4: 921 ↛ 922line 921 didn't jump to line 922 because the condition on line 921 was never true

922 if self.primary_ip4.family != 4: 

923 raise ValidationError({ 

924 'primary_ip4': _("{ip} is not an IPv4 address.").format(ip=self.primary_ip4) 

925 }) 

926 if self.primary_ip4.assigned_object in vc_interfaces: 

927 pass 

928 elif ( 

929 self.primary_ip4.nat_inside is not None and 

930 self.primary_ip4.nat_inside.assigned_object in vc_interfaces 

931 ): 

932 pass 

933 else: 

934 raise ValidationError({ 

935 'primary_ip4': _( 

936 "The specified IP address ({ip}) is not assigned to this device." 

937 ).format(ip=self.primary_ip4) 

938 }) 

939 if self.primary_ip6: 939 ↛ 940line 939 didn't jump to line 940 because the condition on line 939 was never true

940 if self.primary_ip6.family != 6: 

941 raise ValidationError({ 

942 'primary_ip6': _("{ip} is not an IPv6 address.").format(ip=self.primary_ip6) 

943 }) 

944 if self.primary_ip6.assigned_object in vc_interfaces: 

945 pass 

946 elif ( 

947 self.primary_ip6.nat_inside is not None and 

948 self.primary_ip6.nat_inside.assigned_object in vc_interfaces 

949 ): 

950 pass 

951 else: 

952 raise ValidationError({ 

953 'primary_ip6': _( 

954 "The specified IP address ({ip}) is not assigned to this device." 

955 ).format(ip=self.primary_ip6) 

956 }) 

957 if self.oob_ip: 957 ↛ 958line 957 didn't jump to line 958 because the condition on line 957 was never true

958 if self.oob_ip.assigned_object in vc_interfaces: 

959 pass 

960 elif self.oob_ip.nat_inside is not None and self.oob_ip.nat_inside.assigned_object in vc_interfaces: 

961 pass 

962 else: 

963 raise ValidationError({ 

964 'oob_ip': f"The specified IP address ({self.oob_ip}) is not assigned to this device." 

965 }) 

966 

967 # Validate manufacturer/platform 

968 if hasattr(self, 'device_type') and self.platform: 968 ↛ 969line 968 didn't jump to line 969 because the condition on line 968 was never true

969 if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer: 

970 raise ValidationError({ 

971 'platform': _( 

972 "The assigned platform is limited to {platform_manufacturer} device types, but this device's " 

973 "type belongs to {devicetype_manufacturer}." 

974 ).format( 

975 platform_manufacturer=self.platform.manufacturer, 

976 devicetype_manufacturer=self.device_type.manufacturer 

977 ) 

978 }) 

979 

980 # A Device can only be assigned to a Cluster in the same Site (or no Site) 

981 if self.cluster and self.cluster._site is not None and self.cluster._site != self.site: 981 ↛ 982line 981 didn't jump to line 982 because the condition on line 981 was never true

982 raise ValidationError({ 

983 'cluster': _("The assigned cluster belongs to a different site ({site})").format( 

984 site=self.cluster._site 

985 ) 

986 }) 

987 

988 if self.cluster and self.cluster._location is not None and self.cluster._location != self.location: 988 ↛ 989line 988 didn't jump to line 989 because the condition on line 988 was never true

989 raise ValidationError({ 

990 'cluster': _("The assigned cluster belongs to a different location ({location})").format( 

991 location=self.cluster._location 

992 ) 

993 }) 

994 

995 # Validate virtual chassis assignment 

996 if self.virtual_chassis and self.vc_position is None: 996 ↛ 997line 996 didn't jump to line 997 because the condition on line 996 was never true

997 raise ValidationError({ 

998 'vc_position': _("A device assigned to a virtual chassis must have its position defined.") 

999 }) 

1000 

1001 if hasattr(self, 'vc_master_for') and self.vc_master_for and self.vc_master_for != self.virtual_chassis: 1001 ↛ 1002line 1001 didn't jump to line 1002 because the condition on line 1001 was never true

1002 raise ValidationError({ 

1003 'virtual_chassis': _( 

1004 'Device cannot be removed from virtual chassis {virtual_chassis} because it is currently ' 

1005 'designated as its master.' 

1006 ).format(virtual_chassis=self.vc_master_for) 

1007 }) 

1008 

1009 def _check_duplicate_component_names(self, components): 

1010 """ 

1011 Check for duplicate component names after resolving {vc_position} placeholders. 

1012 Raises AbortRequest if duplicates are found. 

1013 """ 

1014 names = [c.name for c in components] 

1015 duplicates = {n for n in names if names.count(n) > 1} 

1016 if duplicates: 

1017 raise AbortRequest( 

1018 _("Component name conflict after resolving {{vc_position}}: {names}").format( 

1019 names=', '.join(duplicates) 

1020 ) 

1021 ) 

1022 

1023 def _instantiate_components(self, queryset, bulk_create=True): 

1024 """ 

1025 Instantiate components for the device from the specified component templates. 

1026 

1027 Args: 

1028 bulk_create: If True, bulk_create() will be called to create all components in a single query 

1029 (default). Otherwise, save() will be called on each instance individually. 

1030 """ 

1031 model = queryset.model.component_model 

1032 using = self._state.db 

1033 

1034 if bulk_create: 

1035 components = [obj.instantiate(device=self) for obj in queryset] 

1036 if not components: 1036 ↛ 1040line 1036 didn't jump to line 1040 because the condition on line 1036 was always true

1037 return 

1038 

1039 # Check for duplicate names after resolution {vc_position} 

1040 self._check_duplicate_component_names(components) 

1041 

1042 # Set default values for any applicable custom fields 

1043 if cf_defaults := CustomField.objects.get_defaults_for_model(model): 

1044 for component in components: 

1045 component.custom_field_data = cf_defaults 

1046 # Set denormalized references 

1047 for component in components: 

1048 component._site = self.site 

1049 component._location = self.location 

1050 component._rack = self.rack 

1051 components = model.objects.using(using).bulk_create(components) 

1052 # Prefetch related objects to minimize queries needed during post_save 

1053 prefetch_fields = get_prefetchable_fields(model) 

1054 prefetch_related_objects(components, *prefetch_fields) 

1055 # Manually send the post_save signal for each of the newly created components 

1056 for component in components: 

1057 post_save.send( 

1058 sender=model, 

1059 instance=component, 

1060 created=True, 

1061 raw=False, 

1062 using=using, 

1063 update_fields=None 

1064 ) 

1065 else: 

1066 components = [obj.instantiate(device=self) for obj in queryset] 

1067 if not components: 1067 ↛ 1071line 1067 didn't jump to line 1071 because the condition on line 1067 was always true

1068 return 

1069 

1070 # Check for duplicate names after resolution {vc_position} 

1071 self._check_duplicate_component_names(components) 

1072 

1073 for component in components: 

1074 # Set default values for any applicable custom fields 

1075 if cf_defaults := CustomField.objects.get_defaults_for_model(model): 

1076 component.custom_field_data = cf_defaults 

1077 component.save(using=using) 

1078 # Copy module_bay_types from the source template (set by instantiate()). 

1079 if src := getattr(component, '_source_template', None): 

1080 if hasattr(component, 'module_bay_types'): 

1081 component.module_bay_types.set(src.module_bay_types.db_manager(using).all()) 

1082 

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

1084 is_new = not bool(self.pk) 

1085 

1086 # Inherit airflow attribute from DeviceType if not set 

1087 if is_new and not self.airflow: 

1088 self.airflow = self.device_type.airflow 

1089 

1090 # Inherit cooling_method attribute from DeviceType if not set 

1091 if is_new and not self.cooling_method: 

1092 self.cooling_method = self.device_type.cooling_method 

1093 

1094 # Inherit default_platform from DeviceType if not set 

1095 if is_new and not self.platform: 

1096 self.platform = self.device_type.default_platform 

1097 

1098 # Inherit location from Rack if not set 

1099 if self.rack and self.rack.location: 1099 ↛ 1100line 1099 didn't jump to line 1100 because the condition on line 1099 was never true

1100 self.location = self.rack.location 

1101 

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

1103 

1104 # If this is a new Device, instantiate all the related components per the DeviceType definition 

1105 if is_new: 

1106 self._instantiate_components(self.device_type.consoleporttemplates.all()) 

1107 self._instantiate_components(self.device_type.consoleserverporttemplates.all()) 

1108 self._instantiate_components(self.device_type.powerporttemplates.all()) 

1109 self._instantiate_components(self.device_type.poweroutlettemplates.all()) 

1110 self._instantiate_components(self.device_type.coolingintaketemplates.all()) 

1111 self._instantiate_components(self.device_type.coolingoutflowtemplates.all()) 

1112 self._instantiate_components(self.device_type.interfacetemplates.all()) 

1113 self._instantiate_components(self.device_type.rearporttemplates.all()) 

1114 self._instantiate_components(self.device_type.frontporttemplates.all()) 

1115 # Replicate any front/rear port mappings from the DeviceType 

1116 create_port_mappings(self, self.device_type) 

1117 # Disable bulk_create to accommodate MPTT 

1118 self._instantiate_components(self.device_type.modulebaytemplates.all(), bulk_create=False) 

1119 self._instantiate_components(self.device_type.devicebaytemplates.all()) 

1120 # Disable bulk_create to accommodate MPTT 

1121 self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False) 

1122 # Interface parents & bridges have to be set after interface instantiation. Parents are applied first so 

1123 # that channel subinterfaces validate against a populated parent. 

1124 update_interface_parents(self, self.device_type.interfacetemplates.all()) 

1125 update_interface_bridges(self, self.device_type.interfacetemplates.all()) 

1126 

1127 # Update Site and Rack assignment for any child Devices 

1128 devices = Device.objects.filter(parent_bay__device=self) 

1129 for device in devices: 1129 ↛ 1130line 1129 didn't jump to line 1130 because the loop on line 1129 never started

1130 device.site = self.site 

1131 device.rack = self.rack 

1132 device.location = self.location 

1133 device.save() 

1134 

1135 @property 

1136 def label(self): 

1137 """ 

1138 Return the device name if set; otherwise return a generated name if available. 

1139 """ 

1140 if self.name: 1140 ↛ 1142line 1140 didn't jump to line 1142 because the condition on line 1140 was always true

1141 return self.name 

1142 if self.virtual_chassis: 

1143 return f'{self.virtual_chassis.name}:{self.vc_position}' 

1144 return None 

1145 

1146 @property 

1147 def identifier(self): 

1148 """ 

1149 Return the device name if set; otherwise return the Device's primary key as {pk} 

1150 """ 

1151 return self.label or '{{{}}}'.format(self.pk) 

1152 

1153 @property 

1154 def primary_ip(self): 

1155 if ConfigItem('PREFER_IPV4')() and self.primary_ip4: 1155 ↛ 1156line 1155 didn't jump to line 1156 because the condition on line 1155 was never true

1156 return self.primary_ip4 

1157 if self.primary_ip6: 1157 ↛ 1158line 1157 didn't jump to line 1158 because the condition on line 1157 was never true

1158 return self.primary_ip6 

1159 if self.primary_ip4: 1159 ↛ 1160line 1159 didn't jump to line 1160 because the condition on line 1159 was never true

1160 return self.primary_ip4 

1161 return None 

1162 

1163 @property 

1164 def interfaces_count(self): 

1165 return self.vc_interfaces().count() 

1166 

1167 def get_vc_master(self): 

1168 """ 

1169 If this Device is a VirtualChassis member, return the VC master. Otherwise, return None. 

1170 """ 

1171 return self.virtual_chassis.master if self.virtual_chassis else None 

1172 

1173 def vc_interfaces(self, if_master=True): 

1174 """ 

1175 Return a QuerySet matching all Interfaces assigned to this Device or, if this Device is a VC master, to another 

1176 Device belonging to the same VirtualChassis. 

1177 

1178 :param if_master: If True, return VC member interfaces only if this Device is the VC master. 

1179 """ 

1180 filter = Q(device=self) if self.pk else Q() 

1181 if self.virtual_chassis and (self.virtual_chassis.master == self or not if_master): 1181 ↛ 1182line 1181 didn't jump to line 1182 because the condition on line 1181 was never true

1182 filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False) 

1183 return Interface.objects.filter(filter) 

1184 

1185 def get_cables(self, pk_list=False): 

1186 """ 

1187 Return a QuerySet or PK list matching all Cables connected to a component of this Device. 

1188 """ 

1189 from .cables import Cable 

1190 cable_pks = [] 

1191 for component_model in [ 

1192 ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, FrontPort, RearPort 

1193 ]: 

1194 cable_pks += component_model.objects.filter( 

1195 device=self, cable__isnull=False 

1196 ).values_list('cable', flat=True) 

1197 if pk_list: 

1198 return cable_pks 

1199 return Cable.objects.filter(pk__in=cable_pks) 

1200 

1201 def get_children(self): 

1202 """ 

1203 Return the set of child Devices installed in DeviceBays within this Device. 

1204 """ 

1205 return Device.objects.filter(parent_bay__device=self.pk) 

1206 

1207 def get_status_color(self): 

1208 return DeviceStatusChoices.colors.get(self.status) 

1209 

1210 def get_cooling_method_color(self): 

1211 return CoolingMethodChoices.colors.get(self.cooling_method) 

1212 

1213 @cached_property 

1214 def total_weight(self): 

1215 total_weight = sum( 

1216 module.module_type._abs_weight 

1217 for module in Module.objects.filter(device=self) 

1218 .exclude(module_type___abs_weight__isnull=True) 

1219 .prefetch_related('module_type') 

1220 ) 

1221 if self.device_type._abs_weight: 

1222 total_weight += self.device_type._abs_weight 

1223 return round(total_weight / 1000, 2) 

1224 

1225 

1226# 

1227# Virtual chassis 

1228# 

1229 

1230class VirtualChassis(PrimaryModel): 

1231 """ 

1232 A collection of Devices which operate with a shared control plane (e.g. a switch stack). 

1233 """ 

1234 master = models.OneToOneField( 

1235 to='Device', 

1236 on_delete=models.PROTECT, 

1237 related_name='vc_master_for', 

1238 blank=True, 

1239 null=True 

1240 ) 

1241 name = models.CharField( 

1242 verbose_name=_('name'), 

1243 max_length=64, 

1244 db_collation="natural_sort" 

1245 ) 

1246 domain = models.CharField( 

1247 verbose_name=_('domain'), 

1248 max_length=30, 

1249 blank=True 

1250 ) 

1251 

1252 # Counter fields 

1253 member_count = CounterCacheField( 

1254 to_model='dcim.Device', 

1255 to_field='virtual_chassis' 

1256 ) 

1257 

1258 class Meta: 

1259 ordering = ['name'] 

1260 indexes = ( 

1261 models.Index(fields=('name',)), # Default ordering 

1262 ) 

1263 verbose_name = _('virtual chassis') 

1264 verbose_name_plural = _('virtual chassis') 

1265 

1266 def __str__(self): 

1267 return self.name 

1268 

1269 def clean(self): 

1270 super().clean() 

1271 

1272 # Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new 

1273 # VirtualChassis.) 

1274 if not self._state.adding and self.master and self.master not in self.members.all(): 1274 ↛ 1275line 1274 didn't jump to line 1275 because the condition on line 1274 was never true

1275 raise ValidationError({ 

1276 'master': _("The selected master ({master}) is not assigned to this virtual chassis.").format( 

1277 master=self.master 

1278 ) 

1279 }) 

1280 

1281 def delete(self, *args, **kwargs): 

1282 # Check for LAG interfaces split across member chassis 

1283 interfaces = Interface.objects.filter( 

1284 device__in=self.members.all(), 

1285 lag__isnull=False 

1286 ).exclude( 

1287 lag__device=F('device') 

1288 ) 

1289 if interfaces: 1289 ↛ 1290line 1289 didn't jump to line 1290 because the condition on line 1289 was never true

1290 raise ProtectedError( 

1291 _( 

1292 "Unable to delete virtual chassis {virtual_chassis}. One or more member interfaces form a " 

1293 "cross-chassis LAG." 

1294 ).format(virtual_chassis=self), 

1295 set(interfaces), 

1296 ) 

1297 

1298 # Clear vc_position and vc_priority on member devices BEFORE calling super().delete() 

1299 # This must be done here because on_delete=SET_NULL executes before pre_delete signal 

1300 for device in self.members.all(): 1300 ↛ 1301line 1300 didn't jump to line 1301 because the loop on line 1300 never started

1301 device.vc_position = None 

1302 device.vc_priority = None 

1303 device.save() 

1304 

1305 return super().delete(*args, **kwargs) 

1306 

1307 

1308class VirtualDeviceContext(PrimaryModel): 

1309 device = models.ForeignKey( 

1310 to='Device', 

1311 on_delete=models.PROTECT, 

1312 related_name='vdcs', 

1313 blank=True, 

1314 null=True 

1315 ) 

1316 name = models.CharField( 

1317 verbose_name=_('name'), 

1318 max_length=64, 

1319 db_collation="natural_sort" 

1320 ) 

1321 status = models.CharField( 

1322 verbose_name=_('status'), 

1323 max_length=50, 

1324 choices=VirtualDeviceContextStatusChoices, 

1325 ) 

1326 identifier = models.PositiveSmallIntegerField( 

1327 verbose_name=_('identifier'), 

1328 help_text=_('Numeric identifier unique to the parent device'), 

1329 blank=True, 

1330 null=True, 

1331 ) 

1332 primary_ip4 = models.OneToOneField( 

1333 to='ipam.IPAddress', 

1334 on_delete=models.SET_NULL, 

1335 related_name='+', 

1336 blank=True, 

1337 null=True, 

1338 verbose_name=_('primary IPv4') 

1339 ) 

1340 primary_ip6 = models.OneToOneField( 

1341 to='ipam.IPAddress', 

1342 on_delete=models.SET_NULL, 

1343 related_name='+', 

1344 blank=True, 

1345 null=True, 

1346 verbose_name=_('primary IPv6') 

1347 ) 

1348 tenant = models.ForeignKey( 

1349 to='tenancy.Tenant', 

1350 on_delete=models.PROTECT, 

1351 related_name='vdcs', 

1352 blank=True, 

1353 null=True 

1354 ) 

1355 comments = models.TextField( 

1356 verbose_name=_('comments'), 

1357 blank=True 

1358 ) 

1359 

1360 class Meta: 

1361 ordering = ['name'] 

1362 constraints = ( 

1363 models.UniqueConstraint( 

1364 fields=('device', 'identifier',), 

1365 name='%(app_label)s_%(class)s_device_identifier' 

1366 ), 

1367 models.UniqueConstraint( 

1368 fields=('device', 'name',), 

1369 name='%(app_label)s_%(class)s_device_name' 

1370 ), 

1371 ) 

1372 indexes = ( 

1373 models.Index(fields=('name',)), # Default ordering 

1374 ) 

1375 verbose_name = _('virtual device context') 

1376 verbose_name_plural = _('virtual device contexts') 

1377 

1378 def __str__(self): 

1379 return self.name 

1380 

1381 def get_status_color(self): 

1382 return VirtualDeviceContextStatusChoices.colors.get(self.status) 

1383 

1384 @property 

1385 def primary_ip(self): 

1386 if ConfigItem('PREFER_IPV4')() and self.primary_ip4: 

1387 return self.primary_ip4 

1388 if self.primary_ip6: 

1389 return self.primary_ip6 

1390 if self.primary_ip4: 

1391 return self.primary_ip4 

1392 return None 

1393 

1394 def clean(self): 

1395 super().clean() 

1396 

1397 # Validate primary IPv4/v6 assignment 

1398 for primary_ip, family in ((self.primary_ip4, 4), (self.primary_ip6, 6)): 

1399 if not primary_ip: 

1400 continue 

1401 if primary_ip.family != family: 

1402 raise ValidationError({ 

1403 f'primary_ip{family}': _( 

1404 "{ip} is not an IPv{family} address." 

1405 ).format(family=family, ip=primary_ip) 

1406 }) 

1407 device_interfaces = self.device.vc_interfaces(if_master=False) 

1408 if primary_ip.assigned_object not in device_interfaces: 

1409 raise ValidationError({ 

1410 f'primary_ip{family}': _('Primary IP address must belong to an interface on the assigned device.') 

1411 }) 

1412 

1413 

1414# 

1415# Addressing 

1416# 

1417 

1418class MACAddress(PrimaryModel): 

1419 mac_address = MACAddressField( 

1420 verbose_name=_('MAC address') 

1421 ) 

1422 assigned_object_type = models.ForeignKey( 

1423 to='contenttypes.ContentType', 

1424 on_delete=models.PROTECT, 

1425 related_name='+', 

1426 blank=True, 

1427 null=True 

1428 ) 

1429 assigned_object_id = models.PositiveBigIntegerField( 

1430 blank=True, 

1431 null=True 

1432 ) 

1433 assigned_object = GenericForeignKey( 

1434 ct_field='assigned_object_type', 

1435 fk_field='assigned_object_id' 

1436 ) 

1437 

1438 class Meta: 

1439 ordering = ('mac_address', 'pk') 

1440 indexes = ( 

1441 models.Index(fields=('mac_address', 'id')), # Default ordering 

1442 models.Index(fields=('assigned_object_type', 'assigned_object_id')), 

1443 ) 

1444 verbose_name = _('MAC address') 

1445 verbose_name_plural = _('MAC addresses') 

1446 

1447 def __str__(self): 

1448 return str(self.mac_address) 

1449 

1450 def __init__(self, *args, **kwargs): 

1451 super().__init__(*args, **kwargs) 

1452 

1453 # Denote the original assigned object (if any) for validation in clean() 

1454 self._original_assigned_object_id = self.__dict__.get('assigned_object_id') 

1455 self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id') 

1456 

1457 @cached_property 

1458 def is_primary(self): 

1459 # Compare against primary_mac_address_id (a column already loaded on the assigned object) rather than 

1460 # dereferencing primary_mac_address, to avoid an extra query per object in list responses. 

1461 if (obj := self.assigned_object) is not None and hasattr(obj, 'primary_mac_address_id'): 

1462 return obj.primary_mac_address_id == self.pk 

1463 return False 

1464 

1465 def clean(self, *args, **kwargs): 

1466 super().clean() 

1467 if self._original_assigned_object_id and self._original_assigned_object_type_id: 

1468 assigned_object = self.assigned_object 

1469 ct = ContentType.objects.get_for_id(self._original_assigned_object_type_id) 

1470 original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id) 

1471 

1472 if ( 

1473 original_assigned_object.primary_mac_address 

1474 and original_assigned_object.primary_mac_address.pk == self.pk 

1475 ): 

1476 if not assigned_object: 

1477 raise ValidationError( 

1478 _("Cannot unassign MAC Address while it is designated as the primary MAC for an object") 

1479 ) 

1480 if original_assigned_object != assigned_object: 

1481 raise ValidationError( 

1482 _("Cannot reassign MAC Address while it is designated as the primary MAC for an object") 

1483 )