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
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import decimal
2from functools import cached_property
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 _
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
35from .device_components import *
36from .mixins import RenderConfigMixin
37from .modules import Module
39__all__ = (
40 'Device',
41 'DeviceRole',
42 'DeviceType',
43 'MACAddress',
44 'Manufacturer',
45 'Platform',
46 'VirtualChassis',
47 'VirtualDeviceContext',
48)
51#
52# Device Types
53#
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')
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).
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:
73 * 1 ConsolePortTemplate
74 * 2 PowerPortTemplates
75 * 48 InterfaceTemplates
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 )
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 )
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 )
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')
238 def __str__(self):
239 return self.model
241 def __init__(self, *args, **kwargs):
242 super().__init__(*args, **kwargs)
244 # Save a copy of u_height for validation in clean()
245 self._original_u_height = self.__dict__.get('u_height')
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')
251 @property
252 def full_name(self):
253 return f"{self.manufacturer} {self.model}"
255 def get_cooling_method_color(self):
256 return CoolingMethodChoices.colors.get(self.cooling_method)
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 }
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 ]
315 # Port mappings
316 port_mapping_data = [
317 c.to_yaml() for c in self.port_mappings.all()
318 ]
320 if port_mapping_data:
321 data['port-mappings'] = port_mapping_data
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 ]
332 return yaml.dump(dict(data), sort_keys=False)
334 def clean(self):
335 super().clean()
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 })
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 })
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 })
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 })
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 })
390 def save(self, *args, **kwargs):
391 ret = super().save(*args, **kwargs)
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)
399 return ret
401 def delete(self, *args, **kwargs):
402 super().delete(*args, **kwargs)
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)
410 @property
411 def is_parent_device(self):
412 return self.subdevice_role == SubdeviceRoleChoices.ROLE_PARENT
414 @property
415 def is_child_device(self):
416 return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
419#
420# Devices
421#
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 )
446 clone_fields = ('parent', 'description')
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')
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 )
493 clone_fields = ('parent', 'description')
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 )
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.
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).
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 )
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 )
771 objects = ConfigContextModelQuerySet.as_manager()
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 )
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 ]
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__()
831 def clean(self):
832 super().clean()
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 })
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 })
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 })
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 })
881 if self.rack: 881 ↛ 883line 881 didn't jump to line 883 because the condition on line 881 was never true
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 })
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 })
916 except DeviceType.DoesNotExist:
917 pass
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 })
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 })
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 })
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 })
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 })
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 })
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 )
1023 def _instantiate_components(self, queryset, bulk_create=True):
1024 """
1025 Instantiate components for the device from the specified component templates.
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
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
1039 # Check for duplicate names after resolution {vc_position}
1040 self._check_duplicate_component_names(components)
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
1070 # Check for duplicate names after resolution {vc_position}
1071 self._check_duplicate_component_names(components)
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())
1083 def save(self, *args, **kwargs):
1084 is_new = not bool(self.pk)
1086 # Inherit airflow attribute from DeviceType if not set
1087 if is_new and not self.airflow:
1088 self.airflow = self.device_type.airflow
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
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
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
1102 super().save(*args, **kwargs)
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())
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()
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
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)
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
1163 @property
1164 def interfaces_count(self):
1165 return self.vc_interfaces().count()
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
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.
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)
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)
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)
1207 def get_status_color(self):
1208 return DeviceStatusChoices.colors.get(self.status)
1210 def get_cooling_method_color(self):
1211 return CoolingMethodChoices.colors.get(self.cooling_method)
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)
1226#
1227# Virtual chassis
1228#
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 )
1252 # Counter fields
1253 member_count = CounterCacheField(
1254 to_model='dcim.Device',
1255 to_field='virtual_chassis'
1256 )
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')
1266 def __str__(self):
1267 return self.name
1269 def clean(self):
1270 super().clean()
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 })
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 )
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()
1305 return super().delete(*args, **kwargs)
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 )
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')
1378 def __str__(self):
1379 return self.name
1381 def get_status_color(self):
1382 return VirtualDeviceContextStatusChoices.colors.get(self.status)
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
1394 def clean(self):
1395 super().clean()
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 })
1414#
1415# Addressing
1416#
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 )
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')
1447 def __str__(self):
1448 return str(self.mac_address)
1450 def __init__(self, *args, **kwargs):
1451 super().__init__(*args, **kwargs)
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')
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
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)
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 )