Coverage for circuits/models/virtual_circuits.py: 63%

79 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1from functools import cached_property 

2 

3from django.contrib.contenttypes.fields import GenericRelation 

4from django.core.exceptions import ValidationError 

5from django.db import models 

6from django.urls import reverse 

7from django.utils.translation import gettext_lazy as _ 

8 

9from circuits.choices import * 

10from netbox.models import ChangeLoggedModel, PrimaryModel 

11from netbox.models.features import ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin 

12 

13from .base import BaseCircuitType 

14 

15__all__ = ( 

16 'VirtualCircuit', 

17 'VirtualCircuitTermination', 

18 'VirtualCircuitType', 

19) 

20 

21 

22class VirtualCircuitType(BaseCircuitType): 

23 """ 

24 Like physical circuits, virtual circuits can be organized by their functional role. For example, a user might wish 

25 to categorize virtual circuits by their technological nature or by product name. 

26 """ 

27 class Meta: 

28 ordering = ('name',) 

29 verbose_name = _('virtual circuit type') 

30 verbose_name_plural = _('virtual circuit types') 

31 

32 

33class VirtualCircuit(ContactsMixin, PrimaryModel): 

34 """ 

35 A virtual connection between two or more endpoints, delivered across one or more physical circuits. 

36 """ 

37 cid = models.CharField( 

38 max_length=100, 

39 verbose_name=_('circuit ID'), 

40 help_text=_('Unique circuit ID') 

41 ) 

42 provider_network = models.ForeignKey( 

43 to='circuits.ProviderNetwork', 

44 on_delete=models.PROTECT, 

45 related_name='virtual_circuits' 

46 ) 

47 provider_account = models.ForeignKey( 

48 to='circuits.ProviderAccount', 

49 on_delete=models.PROTECT, 

50 related_name='virtual_circuits', 

51 blank=True, 

52 null=True 

53 ) 

54 type = models.ForeignKey( 

55 to='circuits.VirtualCircuitType', 

56 on_delete=models.PROTECT, 

57 related_name='virtual_circuits' 

58 ) 

59 status = models.CharField( 

60 verbose_name=_('status'), 

61 max_length=50, 

62 choices=CircuitStatusChoices, 

63 default=CircuitStatusChoices.STATUS_ACTIVE 

64 ) 

65 tenant = models.ForeignKey( 

66 to='tenancy.Tenant', 

67 on_delete=models.PROTECT, 

68 related_name='virtual_circuits', 

69 blank=True, 

70 null=True 

71 ) 

72 

73 group_assignments = GenericRelation( 

74 to='circuits.CircuitGroupAssignment', 

75 content_type_field='member_type', 

76 object_id_field='member_id', 

77 related_query_name='virtual_circuit' 

78 ) 

79 

80 clone_fields = ( 

81 'provider_network', 'provider_account', 'status', 'tenant', 'description', 

82 ) 

83 prerequisite_models = ( 

84 'circuits.ProviderNetwork', 

85 'circuits.VirtualCircuitType', 

86 ) 

87 

88 class Meta: 

89 ordering = ['provider_network', 'provider_account', 'cid'] 

90 constraints = ( 

91 models.UniqueConstraint( 

92 fields=('provider_network', 'cid'), 

93 name='%(app_label)s_%(class)s_unique_provider_network_cid' 

94 ), 

95 models.UniqueConstraint( 

96 fields=('provider_account', 'cid'), 

97 name='%(app_label)s_%(class)s_unique_provideraccount_cid' 

98 ), 

99 ) 

100 indexes = ( 

101 models.Index(fields=('provider_network', 'provider_account', 'cid')), # Default ordering 

102 ) 

103 verbose_name = _('virtual circuit') 

104 verbose_name_plural = _('virtual circuits') 

105 

106 def __str__(self): 

107 return self.cid 

108 

109 def get_status_color(self): 

110 return CircuitStatusChoices.colors.get(self.status) 

111 

112 def clean(self): 

113 super().clean() 

114 

115 if self.provider_account and self.provider_network.provider != self.provider_account.provider: 

116 raise ValidationError({ 

117 'provider_account': "The assigned account must belong to the provider of the assigned network." 

118 }) 

119 

120 @property 

121 def provider(self): 

122 return self.provider_network.provider 

123 

124 

125class VirtualCircuitTermination( 

126 CustomFieldsMixin, 

127 CustomLinksMixin, 

128 ExportTemplatesMixin, 

129 TagsMixin, 

130 ChangeLoggedModel 

131): 

132 virtual_circuit = models.ForeignKey( 

133 to='circuits.VirtualCircuit', 

134 on_delete=models.CASCADE, 

135 related_name='terminations' 

136 ) 

137 role = models.CharField( 

138 verbose_name=_('role'), 

139 max_length=50, 

140 choices=VirtualCircuitTerminationRoleChoices, 

141 default=VirtualCircuitTerminationRoleChoices.ROLE_PEER 

142 ) 

143 interface = models.OneToOneField( 

144 to='dcim.Interface', 

145 on_delete=models.CASCADE, 

146 related_name='virtual_circuit_termination' 

147 ) 

148 description = models.CharField( 

149 verbose_name=_('description'), 

150 max_length=200, 

151 blank=True 

152 ) 

153 

154 class Meta: 

155 ordering = ['virtual_circuit', 'role', 'pk'] 

156 indexes = ( 

157 models.Index(fields=('virtual_circuit', 'role', 'id')), # Default ordering 

158 ) 

159 verbose_name = _('virtual circuit termination') 

160 verbose_name_plural = _('virtual circuit terminations') 

161 

162 def __str__(self): 

163 return f'{self.virtual_circuit}: {self.get_role_display()} termination' 

164 

165 def get_absolute_url(self): 

166 return reverse('circuits:virtualcircuittermination', args=[self.pk]) 

167 

168 def get_role_color(self): 

169 return VirtualCircuitTerminationRoleChoices.colors.get(self.role) 

170 

171 def to_objectchange(self, action): 

172 objectchange = super().to_objectchange(action) 

173 objectchange.related_object = self.virtual_circuit 

174 return objectchange 

175 

176 @property 

177 def parent_object(self): 

178 return self.virtual_circuit 

179 

180 @cached_property 

181 def peer_terminations(self): 

182 if self.role == VirtualCircuitTerminationRoleChoices.ROLE_PEER: 

183 return self.virtual_circuit.terminations.exclude(pk=self.pk).filter( 

184 role=VirtualCircuitTerminationRoleChoices.ROLE_PEER 

185 ) 

186 if self.role == VirtualCircuitTerminationRoleChoices.ROLE_HUB: 

187 return self.virtual_circuit.terminations.filter( 

188 role=VirtualCircuitTerminationRoleChoices.ROLE_SPOKE 

189 ) 

190 if self.role == VirtualCircuitTerminationRoleChoices.ROLE_SPOKE: 

191 return self.virtual_circuit.terminations.filter( 

192 role=VirtualCircuitTerminationRoleChoices.ROLE_HUB 

193 ) 

194 # Fallback for unexpected roles 

195 return self.virtual_circuit.terminations.none() 

196 

197 def clean(self): 

198 super().clean() 

199 

200 if self.interface and not self.interface.is_virtual: 

201 raise ValidationError("Virtual circuits may be terminated only to virtual interfaces.")