Coverage for wireless/models.py: 80%
70 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.contrib.postgres.indexes import GistIndex
2from django.core.exceptions import ValidationError
3from django.db import models
4from django.utils.translation import gettext_lazy as _
6from dcim.choices import LinkStatusChoices
7from dcim.constants import WIRELESS_IFACE_TYPES
8from dcim.models.mixins import CachedScopeMixin
9from netbox.models import NestedLtreeGroupModel, PrimaryModel
10from netbox.models.mixins import DistanceMixin
12from .choices import *
13from .constants import *
15__all__ = (
16 'WirelessLAN',
17 'WirelessLANGroup',
18 'WirelessLink',
19)
22class WirelessAuthenticationBase(models.Model):
23 """
24 Abstract model for attaching attributes related to wireless authentication.
25 """
26 auth_type = models.CharField(
27 max_length=50,
28 choices=WirelessAuthTypeChoices,
29 blank=True,
30 null=True,
31 verbose_name=_("authentication type"),
32 )
33 auth_cipher = models.CharField(
34 verbose_name=_('authentication cipher'),
35 max_length=50,
36 choices=WirelessAuthCipherChoices,
37 blank=True,
38 null=True
39 )
40 auth_psk = models.CharField(
41 max_length=PSK_MAX_LENGTH,
42 blank=True,
43 verbose_name=_('pre-shared key')
44 )
46 class Meta:
47 abstract = True
50class WirelessLANGroup(NestedLtreeGroupModel):
51 """
52 A nested grouping of WirelessLANs
53 """
54 name = models.CharField(
55 verbose_name=_('name'),
56 max_length=100,
57 unique=True,
58 db_collation="natural_sort"
59 )
60 slug = models.SlugField(
61 verbose_name=_('slug'),
62 max_length=100,
63 unique=True
64 )
65 # sort_path inherits natural_sort collation from `name` automatically (LtreeModelBase).
67 class Meta:
68 ordering = ('sort_path',)
69 indexes = (
70 GistIndex(fields=['path'], name='wireless_lan_grp_path_gist'),
71 models.Index(fields=['sort_path'], name='wireless_lan_grp_sort_idx'),
72 )
73 verbose_name = _('wireless LAN group')
74 verbose_name_plural = _('wireless LAN groups')
77class WirelessLAN(WirelessAuthenticationBase, CachedScopeMixin, PrimaryModel):
78 """
79 A wireless network formed among an arbitrary number of access point and clients.
80 """
81 ssid = models.CharField(
82 max_length=SSID_MAX_LENGTH,
83 verbose_name=_('SSID')
84 )
85 group = models.ForeignKey(
86 to='wireless.WirelessLANGroup',
87 on_delete=models.SET_NULL,
88 related_name='wireless_lans',
89 blank=True,
90 null=True
91 )
92 status = models.CharField(
93 max_length=50,
94 choices=WirelessLANStatusChoices,
95 default=WirelessLANStatusChoices.STATUS_ACTIVE,
96 verbose_name=_('status')
97 )
98 vlan = models.ForeignKey(
99 to='ipam.VLAN',
100 on_delete=models.PROTECT,
101 blank=True,
102 null=True,
103 verbose_name=_('VLAN')
104 )
105 tenant = models.ForeignKey(
106 to='tenancy.Tenant',
107 on_delete=models.PROTECT,
108 related_name='wireless_lans',
109 blank=True,
110 null=True
111 )
113 clone_fields = ('ssid', 'group', 'scope', 'tenant', 'description')
115 class Meta:
116 ordering = ('ssid', 'pk')
117 indexes = (
118 models.Index(fields=('ssid', 'id')), # Default ordering
119 models.Index(fields=('scope_type', 'scope_id')),
120 )
121 verbose_name = _('wireless LAN')
122 verbose_name_plural = _('wireless LANs')
124 def __str__(self):
125 return self.ssid
127 def get_status_color(self):
128 return WirelessLANStatusChoices.colors.get(self.status)
131class WirelessLink(WirelessAuthenticationBase, DistanceMixin, PrimaryModel):
132 """
133 A point-to-point connection between two wireless Interfaces.
134 """
135 interface_a = models.ForeignKey(
136 to='dcim.Interface',
137 on_delete=models.PROTECT,
138 related_name='+',
139 verbose_name=_('interface A'),
140 )
141 interface_b = models.ForeignKey(
142 to='dcim.Interface',
143 on_delete=models.PROTECT,
144 related_name='+',
145 verbose_name=_('interface B'),
146 )
147 ssid = models.CharField(
148 max_length=SSID_MAX_LENGTH,
149 blank=True,
150 verbose_name=_('SSID')
151 )
152 status = models.CharField(
153 verbose_name=_('status'),
154 max_length=50,
155 choices=LinkStatusChoices,
156 default=LinkStatusChoices.STATUS_CONNECTED
157 )
158 tenant = models.ForeignKey(
159 to='tenancy.Tenant',
160 on_delete=models.PROTECT,
161 related_name='wireless_links',
162 blank=True,
163 null=True
164 )
166 # Cache the associated device for the A and B interfaces. This enables filtering of WirelessLinks by their
167 # associated Devices.
168 _interface_a_device = models.ForeignKey(
169 to='dcim.Device',
170 on_delete=models.CASCADE,
171 related_name='+',
172 blank=True,
173 null=True
174 )
175 _interface_b_device = models.ForeignKey(
176 to='dcim.Device',
177 on_delete=models.CASCADE,
178 related_name='+',
179 blank=True,
180 null=True
181 )
183 clone_fields = ('ssid', 'status')
185 class Meta:
186 ordering = ['pk']
187 constraints = (
188 models.UniqueConstraint(
189 fields=('interface_a', 'interface_b'),
190 name='%(app_label)s_%(class)s_unique_interfaces'
191 ),
192 )
193 verbose_name = _('wireless link')
194 verbose_name_plural = _('wireless links')
196 def __str__(self):
197 return self.ssid or f'#{self.pk}'
199 def get_status_color(self):
200 return LinkStatusChoices.colors.get(self.status)
202 def clean(self):
203 super().clean()
205 # Validate interface types
206 if hasattr(self, "interface_a") and self.interface_a.type not in WIRELESS_IFACE_TYPES:
207 raise ValidationError({
208 'interface_a': _(
209 "{type} is not a wireless interface."
210 ).format(type=self.interface_a.get_type_display())
211 })
212 if hasattr(self, "interface_b") and self.interface_b.type not in WIRELESS_IFACE_TYPES:
213 raise ValidationError({
214 'interface_b': _(
215 "{type} is not a wireless interface."
216 ).format(type=self.interface_b.get_type_display())
217 })
219 def save(self, *args, **kwargs):
220 # Store the parent Device for the A and B interfaces
221 self._interface_a_device = self.interface_a.device
222 self._interface_b_device = self.interface_b.device
224 super().save(*args, **kwargs)