1from django.core.exceptions import ObjectDoesNotExist, ValidationError
2from django.core.validators import MaxValueValidator, MinValueValidator
3from django.db import models
4
5from dcim.choices import *
6from dcim.constants import *
7from extras.utils import extras_features
8from netbox.models import ChangeLoggedModel
9from utilities.fields import ColorField, NaturalOrderingField
10from utilities.querysets import RestrictedQuerySet
11from utilities.ordering import naturalize_interface
12from .device_components import (
13    ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort,
14)
15
16
17__all__ = (
18    'ConsolePortTemplate',
19    'ConsoleServerPortTemplate',
20    'DeviceBayTemplate',
21    'FrontPortTemplate',
22    'InterfaceTemplate',
23    'PowerOutletTemplate',
24    'PowerPortTemplate',
25    'RearPortTemplate',
26)
27
28
29class ComponentTemplateModel(ChangeLoggedModel):
30    device_type = models.ForeignKey(
31        to='dcim.DeviceType',
32        on_delete=models.CASCADE,
33        related_name='%(class)ss'
34    )
35    name = models.CharField(
36        max_length=64
37    )
38    _name = NaturalOrderingField(
39        target_field='name',
40        max_length=100,
41        blank=True
42    )
43    label = models.CharField(
44        max_length=64,
45        blank=True,
46        help_text="Physical label"
47    )
48    description = models.CharField(
49        max_length=200,
50        blank=True
51    )
52
53    objects = RestrictedQuerySet.as_manager()
54
55    class Meta:
56        abstract = True
57
58    def __str__(self):
59        if self.label:
60            return f"{self.name} ({self.label})"
61        return self.name
62
63    def instantiate(self, device):
64        """
65        Instantiate a new component on the specified Device.
66        """
67        raise NotImplementedError()
68
69    def to_objectchange(self, action):
70        # Annotate the parent DeviceType
71        try:
72            device_type = self.device_type
73        except ObjectDoesNotExist:
74            # The parent DeviceType has already been deleted
75            device_type = None
76        return super().to_objectchange(action, related_object=device_type)
77
78
79@extras_features('webhooks')
80class ConsolePortTemplate(ComponentTemplateModel):
81    """
82    A template for a ConsolePort to be created for a new Device.
83    """
84    type = models.CharField(
85        max_length=50,
86        choices=ConsolePortTypeChoices,
87        blank=True
88    )
89
90    class Meta:
91        ordering = ('device_type', '_name')
92        unique_together = ('device_type', 'name')
93
94    def instantiate(self, device):
95        return ConsolePort(
96            device=device,
97            name=self.name,
98            label=self.label,
99            type=self.type
100        )
101
102
103@extras_features('webhooks')
104class ConsoleServerPortTemplate(ComponentTemplateModel):
105    """
106    A template for a ConsoleServerPort to be created for a new Device.
107    """
108    type = models.CharField(
109        max_length=50,
110        choices=ConsolePortTypeChoices,
111        blank=True
112    )
113
114    class Meta:
115        ordering = ('device_type', '_name')
116        unique_together = ('device_type', 'name')
117
118    def instantiate(self, device):
119        return ConsoleServerPort(
120            device=device,
121            name=self.name,
122            label=self.label,
123            type=self.type
124        )
125
126
127@extras_features('webhooks')
128class PowerPortTemplate(ComponentTemplateModel):
129    """
130    A template for a PowerPort to be created for a new Device.
131    """
132    type = models.CharField(
133        max_length=50,
134        choices=PowerPortTypeChoices,
135        blank=True
136    )
137    maximum_draw = models.PositiveSmallIntegerField(
138        blank=True,
139        null=True,
140        validators=[MinValueValidator(1)],
141        help_text="Maximum power draw (watts)"
142    )
143    allocated_draw = models.PositiveSmallIntegerField(
144        blank=True,
145        null=True,
146        validators=[MinValueValidator(1)],
147        help_text="Allocated power draw (watts)"
148    )
149
150    class Meta:
151        ordering = ('device_type', '_name')
152        unique_together = ('device_type', 'name')
153
154    def instantiate(self, device):
155        return PowerPort(
156            device=device,
157            name=self.name,
158            label=self.label,
159            type=self.type,
160            maximum_draw=self.maximum_draw,
161            allocated_draw=self.allocated_draw
162        )
163
164    def clean(self):
165        super().clean()
166
167        if self.maximum_draw is not None and self.allocated_draw is not None:
168            if self.allocated_draw > self.maximum_draw:
169                raise ValidationError({
170                    'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
171                })
172
173
174@extras_features('webhooks')
175class PowerOutletTemplate(ComponentTemplateModel):
176    """
177    A template for a PowerOutlet to be created for a new Device.
178    """
179    type = models.CharField(
180        max_length=50,
181        choices=PowerOutletTypeChoices,
182        blank=True
183    )
184    power_port = models.ForeignKey(
185        to='dcim.PowerPortTemplate',
186        on_delete=models.SET_NULL,
187        blank=True,
188        null=True,
189        related_name='poweroutlet_templates'
190    )
191    feed_leg = models.CharField(
192        max_length=50,
193        choices=PowerOutletFeedLegChoices,
194        blank=True,
195        help_text="Phase (for three-phase feeds)"
196    )
197
198    class Meta:
199        ordering = ('device_type', '_name')
200        unique_together = ('device_type', 'name')
201
202    def clean(self):
203        super().clean()
204
205        # Validate power port assignment
206        if self.power_port and self.power_port.device_type != self.device_type:
207            raise ValidationError(
208                "Parent power port ({}) must belong to the same device type".format(self.power_port)
209            )
210
211    def instantiate(self, device):
212        if self.power_port:
213            power_port = PowerPort.objects.get(device=device, name=self.power_port.name)
214        else:
215            power_port = None
216        return PowerOutlet(
217            device=device,
218            name=self.name,
219            label=self.label,
220            type=self.type,
221            power_port=power_port,
222            feed_leg=self.feed_leg
223        )
224
225
226@extras_features('webhooks')
227class InterfaceTemplate(ComponentTemplateModel):
228    """
229    A template for a physical data interface on a new Device.
230    """
231    # Override ComponentTemplateModel._name to specify naturalize_interface function
232    _name = NaturalOrderingField(
233        target_field='name',
234        naturalize_function=naturalize_interface,
235        max_length=100,
236        blank=True
237    )
238    type = models.CharField(
239        max_length=50,
240        choices=InterfaceTypeChoices
241    )
242    mgmt_only = models.BooleanField(
243        default=False,
244        verbose_name='Management only'
245    )
246
247    class Meta:
248        ordering = ('device_type', '_name')
249        unique_together = ('device_type', 'name')
250
251    def instantiate(self, device):
252        return Interface(
253            device=device,
254            name=self.name,
255            label=self.label,
256            type=self.type,
257            mgmt_only=self.mgmt_only
258        )
259
260
261@extras_features('webhooks')
262class FrontPortTemplate(ComponentTemplateModel):
263    """
264    Template for a pass-through port on the front of a new Device.
265    """
266    type = models.CharField(
267        max_length=50,
268        choices=PortTypeChoices
269    )
270    color = ColorField(
271        blank=True
272    )
273    rear_port = models.ForeignKey(
274        to='dcim.RearPortTemplate',
275        on_delete=models.CASCADE,
276        related_name='frontport_templates'
277    )
278    rear_port_position = models.PositiveSmallIntegerField(
279        default=1,
280        validators=[
281            MinValueValidator(REARPORT_POSITIONS_MIN),
282            MaxValueValidator(REARPORT_POSITIONS_MAX)
283        ]
284    )
285
286    class Meta:
287        ordering = ('device_type', '_name')
288        unique_together = (
289            ('device_type', 'name'),
290            ('rear_port', 'rear_port_position'),
291        )
292
293    def clean(self):
294        super().clean()
295
296        try:
297
298            # Validate rear port assignment
299            if self.rear_port.device_type != self.device_type:
300                raise ValidationError(
301                    "Rear port ({}) must belong to the same device type".format(self.rear_port)
302                )
303
304            # Validate rear port position assignment
305            if self.rear_port_position > self.rear_port.positions:
306                raise ValidationError(
307                    "Invalid rear port position ({}); rear port {} has only {} positions".format(
308                        self.rear_port_position, self.rear_port.name, self.rear_port.positions
309                    )
310                )
311
312        except RearPortTemplate.DoesNotExist:
313            pass
314
315    def instantiate(self, device):
316        if self.rear_port:
317            rear_port = RearPort.objects.get(device=device, name=self.rear_port.name)
318        else:
319            rear_port = None
320        return FrontPort(
321            device=device,
322            name=self.name,
323            label=self.label,
324            type=self.type,
325            color=self.color,
326            rear_port=rear_port,
327            rear_port_position=self.rear_port_position
328        )
329
330
331@extras_features('webhooks')
332class RearPortTemplate(ComponentTemplateModel):
333    """
334    Template for a pass-through port on the rear of a new Device.
335    """
336    type = models.CharField(
337        max_length=50,
338        choices=PortTypeChoices
339    )
340    color = ColorField(
341        blank=True
342    )
343    positions = models.PositiveSmallIntegerField(
344        default=1,
345        validators=[
346            MinValueValidator(REARPORT_POSITIONS_MIN),
347            MaxValueValidator(REARPORT_POSITIONS_MAX)
348        ]
349    )
350
351    class Meta:
352        ordering = ('device_type', '_name')
353        unique_together = ('device_type', 'name')
354
355    def instantiate(self, device):
356        return RearPort(
357            device=device,
358            name=self.name,
359            label=self.label,
360            type=self.type,
361            color=self.color,
362            positions=self.positions
363        )
364
365
366@extras_features('webhooks')
367class DeviceBayTemplate(ComponentTemplateModel):
368    """
369    A template for a DeviceBay to be created for a new parent Device.
370    """
371    class Meta:
372        ordering = ('device_type', '_name')
373        unique_together = ('device_type', 'name')
374
375    def instantiate(self, device):
376        return DeviceBay(
377            device=device,
378            name=self.name,
379            label=self.label
380        )
381
382    def clean(self):
383        if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT:
384            raise ValidationError(
385                f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays."
386            )
387