Coverage for wireless/models.py: 80%

70 statements  

« 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 _ 

5 

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 

11 

12from .choices import * 

13from .constants import * 

14 

15__all__ = ( 

16 'WirelessLAN', 

17 'WirelessLANGroup', 

18 'WirelessLink', 

19) 

20 

21 

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 ) 

45 

46 class Meta: 

47 abstract = True 

48 

49 

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

66 

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

75 

76 

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 ) 

112 

113 clone_fields = ('ssid', 'group', 'scope', 'tenant', 'description') 

114 

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

123 

124 def __str__(self): 

125 return self.ssid 

126 

127 def get_status_color(self): 

128 return WirelessLANStatusChoices.colors.get(self.status) 

129 

130 

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 ) 

165 

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 ) 

182 

183 clone_fields = ('ssid', 'status') 

184 

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

195 

196 def __str__(self): 

197 return self.ssid or f'#{self.pk}' 

198 

199 def get_status_color(self): 

200 return LinkStatusChoices.colors.get(self.status) 

201 

202 def clean(self): 

203 super().clean() 

204 

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

218 

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 

223 

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