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

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 _ 

5 

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 

11 

12from .device_components import CabledObjectModel, PathEndpoint 

13 

14__all__ = ( 

15 'PowerFeed', 

16 'PowerPanel', 

17) 

18 

19 

20# 

21# Power 

22# 

23 

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 ) 

43 

44 prerequisite_models = ( 

45 'dcim.Site', 

46 ) 

47 

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

58 

59 def __str__(self): 

60 return self.name 

61 

62 def clean(self): 

63 super().clean() 

64 

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 ) 

71 

72 

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 ) 

146 

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 ) 

154 

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

165 

166 def __str__(self): 

167 return self.name 

168 

169 def clean(self): 

170 super().clean() 

171 

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

182 

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

188 

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

190 

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) 

197 

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

199 

200 @property 

201 def parent_object(self): 

202 return self.power_panel 

203 

204 def get_type_color(self): 

205 return PowerFeedTypeChoices.colors.get(self.type) 

206 

207 def get_status_color(self): 

208 return PowerFeedStatusChoices.colors.get(self.status)