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
« 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 _
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 *
14__all__ = (
15 'Contact',
16 'ContactAssignment',
17 'ContactGroup',
18 'ContactRole',
19)
22class ContactGroupManager(LtreeManager):
24 def annotate_contacts(self):
25 """
26 Annotate the total number of Contacts belonging to each ContactGroup.
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 )
43class ContactGroup(NestedLtreeGroupModel):
44 """
45 An arbitrary collection of Contacts.
46 """
47 objects = ContactGroupManager()
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')
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')
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 )
114 clone_fields = (
115 'groups',
116 )
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')
126 def __str__(self):
127 return self.name
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 )
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')
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}")
178 def get_absolute_url(self):
179 return reverse('tenancy:contact', args=[self.contact.pk])
181 def clean(self):
182 super().clean()
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 )
190 def to_objectchange(self, action):
191 objectchange = super().to_objectchange(action)
192 objectchange.related_object = self.object
193 return objectchange