Coverage for tenancy/models/contacts.py: 81%

71 statements  

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

1from django.contrib.contenttypes.fields import GenericForeignKey 

2from django.contrib.postgres.indexes import GistIndex 

3from django.core.exceptions import ValidationError 

4from django.db import models 

5from django.db.models.expressions import RawSQL 

6from django.urls import reverse 

7from django.utils.translation import gettext_lazy as _ 

8 

9from netbox.models import ChangeLoggedModel, NestedLtreeGroupModel, OrganizationalModel, PrimaryModel 

10from netbox.models.features import CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, has_feature 

11from netbox.models.ltree import LtreeManager 

12from tenancy.choices import * 

13 

14__all__ = ( 

15 'Contact', 

16 'ContactAssignment', 

17 'ContactGroup', 

18 'ContactRole', 

19) 

20 

21 

22class ContactGroupManager(LtreeManager): 

23 

24 def annotate_contacts(self): 

25 """ 

26 Annotate the total number of Contacts belonging to each ContactGroup. 

27 

28 Counts contacts assigned to the group itself or any descendant group (via the 

29 ltree `<@` operator on the path column). DISTINCT avoids double-counting 

30 contacts which are assigned to multiple groups in the subtree. 

31 """ 

32 return self.annotate( 

33 contact_count=RawSQL( 

34 "SELECT COUNT(DISTINCT m2m.contact_id)" 

35 " FROM tenancy_contact_groups m2m" 

36 " INNER JOIN tenancy_contactgroup cg ON m2m.contactgroup_id = cg.id" 

37 " WHERE cg.path <@ tenancy_contactgroup.path", 

38 () 

39 ) 

40 ) 

41 

42 

43class ContactGroup(NestedLtreeGroupModel): 

44 """ 

45 An arbitrary collection of Contacts. 

46 """ 

47 objects = ContactGroupManager() 

48 

49 class Meta: 

50 ordering = ('sort_path',) 

51 indexes = ( 

52 GistIndex(fields=['path'], name='tenancy_contactgroup_path_gist'), 

53 models.Index(fields=['sort_path'], name='tenancy_cg_sort_path_idx'), 

54 ) 

55 constraints = ( 

56 models.UniqueConstraint( 

57 fields=('parent', 'name'), 

58 name='%(app_label)s_%(class)s_unique_parent_name' 

59 ), 

60 ) 

61 verbose_name = _('contact group') 

62 verbose_name_plural = _('contact groups') 

63 

64 

65class ContactRole(OrganizationalModel): 

66 """ 

67 Functional role for a Contact assigned to an object. 

68 """ 

69 class Meta: 

70 ordering = ('name',) 

71 verbose_name = _('contact role') 

72 verbose_name_plural = _('contact roles') 

73 

74 

75class Contact(PrimaryModel): 

76 """ 

77 Contact information for a particular object(s) in NetBox. 

78 """ 

79 groups = models.ManyToManyField( 

80 to='tenancy.ContactGroup', 

81 related_name='contacts', 

82 related_query_name='contact', 

83 blank=True 

84 ) 

85 name = models.CharField( 

86 verbose_name=_('name'), 

87 max_length=100, 

88 db_collation="natural_sort" 

89 ) 

90 title = models.CharField( 

91 verbose_name=_('title'), 

92 max_length=100, 

93 blank=True 

94 ) 

95 phone = models.CharField( 

96 verbose_name=_('phone'), 

97 max_length=50, 

98 blank=True 

99 ) 

100 email = models.EmailField( 

101 verbose_name=_('email'), 

102 blank=True 

103 ) 

104 address = models.CharField( 

105 verbose_name=_('address'), 

106 max_length=200, 

107 blank=True 

108 ) 

109 link = models.URLField( 

110 verbose_name=_('link'), 

111 blank=True 

112 ) 

113 

114 clone_fields = ( 

115 'groups', 

116 ) 

117 

118 class Meta: 

119 ordering = ['name'] 

120 indexes = ( 

121 models.Index(fields=('name',)), # Default ordering 

122 ) 

123 verbose_name = _('contact') 

124 verbose_name_plural = _('contacts') 

125 

126 def __str__(self): 

127 return self.name 

128 

129 

130class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel): 

131 object_type = models.ForeignKey( 

132 to='contenttypes.ContentType', 

133 on_delete=models.CASCADE 

134 ) 

135 object_id = models.PositiveBigIntegerField() 

136 object = GenericForeignKey( 

137 ct_field='object_type', 

138 fk_field='object_id' 

139 ) 

140 contact = models.ForeignKey( 

141 to='tenancy.Contact', 

142 on_delete=models.PROTECT, 

143 related_name='assignments' 

144 ) 

145 role = models.ForeignKey( 

146 to='tenancy.ContactRole', 

147 on_delete=models.PROTECT, 

148 related_name='assignments' 

149 ) 

150 priority = models.CharField( 

151 verbose_name=_('priority'), 

152 max_length=50, 

153 choices=ContactPriorityChoices, 

154 blank=True, 

155 null=True 

156 ) 

157 

158 class Meta: 

159 ordering = ('contact', 'priority', 'role', 'pk') 

160 indexes = ( 

161 models.Index(fields=('contact', 'priority', 'role', 'id')), # Default ordering 

162 models.Index(fields=('object_type', 'object_id')), 

163 ) 

164 constraints = ( 

165 models.UniqueConstraint( 

166 fields=('object_type', 'object_id', 'contact', 'role'), 

167 name='%(app_label)s_%(class)s_unique_object_contact_role' 

168 ), 

169 ) 

170 verbose_name = _('contact assignment') 

171 verbose_name_plural = _('contact assignments') 

172 

173 def __str__(self): 

174 if self.priority: 

175 return f"{self.contact} ({self.get_priority_display()}) -> {self.object}" 

176 return str(f"{self.contact} -> {self.object}") 

177 

178 def get_absolute_url(self): 

179 return reverse('tenancy:contact', args=[self.contact.pk]) 

180 

181 def clean(self): 

182 super().clean() 

183 

184 # Validate the assigned object type 

185 if not has_feature(self.object_type, 'contacts'): 

186 raise ValidationError( 

187 _("Contacts cannot be assigned to this object type ({type}).").format(type=self.object_type) 

188 ) 

189 

190 def to_objectchange(self, action): 

191 objectchange = super().to_objectchange(action) 

192 objectchange.related_object = self.object 

193 return objectchange