Coverage for dcim/models/power.py: 66%
68 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
1from django.core.exceptions import ValidationError
2from django.core.validators import MaxValueValidator, MinValueValidator
3from django.db import models
4from django.utils.translation import gettext_lazy as _
6from dcim.choices import *
7from netbox.config import ConfigItem
8from netbox.models import PrimaryModel
9from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
10from utilities.validators import ExclusionValidator
12from .device_components import CabledObjectModel, PathEndpoint
14__all__ = (
15 'PowerFeed',
16 'PowerPanel',
17)
20#
21# Power
22#
24class PowerPanel(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
25 """
26 A distribution point for electrical power; e.g. a data center RPP.
27 """
28 site = models.ForeignKey(
29 to='Site',
30 on_delete=models.PROTECT
31 )
32 location = models.ForeignKey(
33 to='dcim.Location',
34 on_delete=models.PROTECT,
35 blank=True,
36 null=True
37 )
38 name = models.CharField(
39 verbose_name=_('name'),
40 max_length=100,
41 db_collation="natural_sort"
42 )
44 prerequisite_models = (
45 'dcim.Site',
46 )
48 class Meta:
49 ordering = ['site', 'name']
50 constraints = (
51 models.UniqueConstraint(
52 fields=('site', 'name'),
53 name='%(app_label)s_%(class)s_unique_site_name'
54 ),
55 )
56 verbose_name = _('power panel')
57 verbose_name_plural = _('power panels')
59 def __str__(self):
60 return self.name
62 def clean(self):
63 super().clean()
65 # Location must belong to assigned Site
66 if self.location and self.location.site != self.site:
67 raise ValidationError(
68 _("Location {location} ({location_site}) is in a different site than {site}").format(
69 location=self.location, location_site=self.location.site, site=self.site)
70 )
73class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
74 """
75 An electrical circuit delivered from a PowerPanel.
76 """
77 power_panel = models.ForeignKey(
78 to='PowerPanel',
79 on_delete=models.PROTECT,
80 related_name='powerfeeds'
81 )
82 rack = models.ForeignKey(
83 to='Rack',
84 on_delete=models.PROTECT,
85 related_name='powerfeeds',
86 blank=True,
87 null=True
88 )
89 name = models.CharField(
90 verbose_name=_('name'),
91 max_length=100,
92 db_collation="natural_sort"
93 )
94 status = models.CharField(
95 verbose_name=_('status'),
96 max_length=50,
97 choices=PowerFeedStatusChoices,
98 default=PowerFeedStatusChoices.STATUS_ACTIVE
99 )
100 type = models.CharField(
101 verbose_name=_('type'),
102 max_length=50,
103 choices=PowerFeedTypeChoices,
104 default=PowerFeedTypeChoices.TYPE_PRIMARY
105 )
106 supply = models.CharField(
107 verbose_name=_('supply'),
108 max_length=50,
109 choices=PowerFeedSupplyChoices,
110 default=PowerFeedSupplyChoices.SUPPLY_AC
111 )
112 phase = models.CharField(
113 verbose_name=_('phase'),
114 max_length=50,
115 choices=PowerFeedPhaseChoices,
116 default=PowerFeedPhaseChoices.PHASE_SINGLE
117 )
118 voltage = models.SmallIntegerField(
119 verbose_name=_('voltage'),
120 default=ConfigItem('POWERFEED_DEFAULT_VOLTAGE'),
121 validators=[ExclusionValidator([0])]
122 )
123 amperage = models.PositiveSmallIntegerField(
124 verbose_name=_('amperage'),
125 validators=[MinValueValidator(1)],
126 default=ConfigItem('POWERFEED_DEFAULT_AMPERAGE')
127 )
128 max_utilization = models.PositiveSmallIntegerField(
129 verbose_name=_('max utilization'),
130 validators=[MinValueValidator(1), MaxValueValidator(100)],
131 default=ConfigItem('POWERFEED_DEFAULT_MAX_UTILIZATION'),
132 help_text=_("Maximum permissible draw (percentage)")
133 )
134 available_power = models.PositiveIntegerField(
135 verbose_name=_('available power'),
136 default=0,
137 editable=False
138 )
139 tenant = models.ForeignKey(
140 to='tenancy.Tenant',
141 on_delete=models.PROTECT,
142 related_name='power_feeds',
143 blank=True,
144 null=True
145 )
147 clone_fields = (
148 'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage',
149 'max_utilization', 'tenant',
150 )
151 prerequisite_models = (
152 'dcim.PowerPanel',
153 )
155 class Meta:
156 ordering = ['power_panel', 'name']
157 constraints = (
158 models.UniqueConstraint(
159 fields=('power_panel', 'name'),
160 name='%(app_label)s_%(class)s_unique_power_panel_name'
161 ),
162 )
163 verbose_name = _('power feed')
164 verbose_name_plural = _('power feeds')
166 def __str__(self):
167 return self.name
169 def clean(self):
170 super().clean()
172 # Rack must belong to same Site as PowerPanel
173 if self.rack and self.rack.site != self.power_panel.site:
174 raise ValidationError(_(
175 "Rack {rack} ({rack_site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites."
176 ).format(
177 rack=self.rack,
178 rack_site=self.rack.site,
179 powerpanel=self.power_panel,
180 powerpanel_site=self.power_panel.site
181 ))
183 # AC voltage cannot be negative
184 if self.voltage < 0 and self.supply == PowerFeedSupplyChoices.SUPPLY_AC:
185 raise ValidationError({
186 "voltage": _("Voltage cannot be negative for AC supply")
187 })
189 def save(self, *args, **kwargs):
191 # Cache the available_power property on the instance
192 kva = abs(self.voltage) * self.amperage * (self.max_utilization / 100)
193 if self.phase == PowerFeedPhaseChoices.PHASE_3PHASE:
194 self.available_power = round(kva * 1.732)
195 else:
196 self.available_power = round(kva)
198 super().save(*args, **kwargs)
200 @property
201 def parent_object(self):
202 return self.power_panel
204 def get_type_color(self):
205 return PowerFeedTypeChoices.colors.get(self.type)
207 def get_status_color(self):
208 return PowerFeedStatusChoices.colors.get(self.status)