Free and open source ticket system written in python
1from django.db import models
2from core.models import PawUser
3from django.utils import timezone
4from django.utils.translation import gettext_lazy as _
5from django.db.models.signals import post_save, pre_save
6from django.dispatch import receiver
7from uuid import uuid4
8from core.models import MailTemplate
9from .storage import SecureFileStorage
10from django.conf import settings
11
12
13def ticket_directory_path(instance, filename):
14 """ file will be uploaded to (SECURE_)MEDIA_ROOT/attachments/ticket_<id>/<filename> """
15 ext = filename.split('.')[-1]
16 return "attachments/ticket_{0}/{1}.{2}".format(instance.ticket.id, uuid4(), ext)
17
18
19class Team(models.Model):
20 name = models.CharField(max_length=200)
21 description = models.TextField(blank=True)
22 members = models.ManyToManyField(PawUser)
23 access_non_category_tickets = models.BooleanField(default=False)
24 readonly_access = models.BooleanField(default=False)
25
26 def __str__(self):
27 return self.name
28
29
30class Category(models.Model):
31 name = models.CharField(max_length=200)
32 team = models.ForeignKey(
33 Team, on_delete=models.CASCADE, null=True, blank=True, help_text=_("If a team is selected, new tickets will automatically assigned to this team."))
34
35 def __str__(self):
36 return self.name
37
38
39class Ticket(models.Model):
40
41 class Status(models.TextChoices):
42 OPEN = 'open', _('Open')
43 IN_PROGRESS = 'in_progress', _('In Progress')
44 CLOSED = 'closed', _('Closed')
45
46 class Priority(models.IntegerChoices):
47 LOW = 3, _("Low")
48 MEDIUM = 2, _("Medium")
49 HIGH = 1, _("High")
50
51 title = models.CharField(max_length=200)
52 user = models.ForeignKey(PawUser, on_delete=models.CASCADE)
53 description = models.TextField()
54 category = models.ForeignKey(
55 Category, on_delete=models.CASCADE, null=True, blank=True)
56 status = models.CharField(
57 max_length=20, choices=Status.choices, default=Status.OPEN)
58 priority = models.PositiveSmallIntegerField(
59 choices=Priority.choices, db_index=True, default=Priority.MEDIUM)
60 created_at = models.DateTimeField(auto_now_add=True)
61 updated_at = models.DateTimeField(auto_now=True)
62 assigned_to = models.ForeignKey(
63 PawUser, on_delete=models.CASCADE, related_name='assigned_to_user', null=True, blank=True)
64 assigned_team = models.ForeignKey(
65 Team, on_delete=models.CASCADE, related_name='assigned_to_team', null=True, blank=True)
66 follow_up_to = models.ForeignKey(
67 "self", on_delete=models.CASCADE, null=True, blank=True, related_name='follow_ups')
68
69 class Meta:
70 indexes = [
71 models.Index(fields=["priority", "title"]),
72 ]
73
74 @classmethod
75 def _get_tickets(cls, user) -> models.QuerySet:
76 """
77 For regular users with no team: return all open tickets that are created by the user
78 """
79 if user.is_superuser:
80 return cls.objects.all()
81
82 user_teams = user.team_set.all()
83 if not user_teams:
84 return cls.objects.filter(user=user)
85
86 q = cls.objects.filter(
87 models.Q(user=user) | # tickets created by user
88 (models.Q(assigned_team__in=user_teams) | models.Q(assigned_to=user)) | # tickets assigned to user or user's team
89 (models.Q(assigned_team=None) & models.Q(category=None)) # tickets that are not assigned and have no category (general), needs to be excluded with filter
90 )
91
92 if not any([team.access_non_category_tickets for team in user_teams]):
93 return q.exclude(models.Q(assigned_team=None) & models.Q(category=None) & ~models.Q(user=user))
94 return q
95
96 @classmethod
97 def get_open_tickets(cls, user) -> models.QuerySet:
98 """
99 For regular users with no team: return all open tickets that are created by the user
100 """
101 return cls._get_tickets(user).exclude(status=cls.Status.CLOSED)
102
103 @classmethod
104 def get_closed_tickets(cls, user) -> models.QuerySet:
105 """
106 For regular users with no team: return all closed tickets that are created by the user
107 """
108 return cls._get_tickets(user).filter(status=cls.Status.CLOSED)
109
110 def can_open(self, user):
111 if user.is_superuser:
112 return True
113 return self.user == user or self.assigned_to == user or self.assigned_team in user.team_set.all() or self.assigned_team is None and user.team_set.filter(access_non_category_tickets=True).exists()
114
115 def can_edit(self, user):
116 if user.is_superuser:
117 return True
118 assigned_and_write_access = self.assigned_team in user.team_set.filter(readonly_access=False) or self.assigned_to == user
119 unassigned_and_write_access = self.assigned_team is None and user.team_set.filter(access_non_category_tickets=True, readonly_access=False).exists()
120 return self.can_open(user) and (assigned_and_write_access or unassigned_and_write_access)
121
122
123 def close_ticket(self):
124 self.status = self.Status.CLOSED
125 self.save()
126
127 def assign_to_team(self, team):
128 if self.assigned_team != team and team is not None:
129 self.assigned_to = None
130
131 self.assigned_team = team
132 self.save()
133
134 def followed_up_by(self):
135 return Ticket.objects.filter(follow_up_to=self)
136
137 def get_priority(self):
138 return self.Priority(self.priority).label
139
140 def get_status(self):
141 return self.Status(self.status).label
142
143 def __str__(self):
144 return self.title
145
146
147@receiver(post_save, sender=Ticket, dispatch_uid="team_auto_assignment")
148def update_team_assignment(sender, instance, created, **kwargs):
149 if not created:
150 return None
151
152 if not instance.category or not instance.category.team:
153 team_addresses = list(Team.objects.filter(access_non_category_tickets=True).values_list('members__email', flat=True))
154
155 else:
156 # assign team to ticket
157 instance.assigned_team = instance.category.team
158 instance.save()
159 team_addresses = list(instance.category.team.members.values_list('email', flat=True))
160
161 mail_template = MailTemplate.get_template('ticket_assigned')
162 if not mail_template:
163 return None
164
165 mail_template.send_mail(team_addresses, {
166 'ticket_id': instance.id, 'ticket_title': instance.title, 'ticket_description': instance.description,
167 'ticket_priority': instance.get_priority(), 'ticket_category': instance.category.name if instance.category else _('General'),
168 'ticket_creator_username': instance.user.username})
169
170
171
172@receiver(post_save, sender=Ticket, dispatch_uid="mail_notification")
173def send_mail_notification(sender, instance, created, **kwargs):
174 if created and instance.user.receive_email_notifications:
175 mail_template = MailTemplate.get_template('new_ticket', instance.user.language)
176 if not mail_template:
177 return None
178 mail_template.send_mail([instance.user.email], {
179 'ticket_id': instance.id, 'ticket_creator_username': instance.user.username, 'ticket_title': instance.title,
180 'ticket_description': instance.description, 'ticket_category': instance.category.name if instance.category else _('General')})
181
182@receiver(pre_save, sender=Ticket, dispatch_uid="mail_change_notification")
183def send_mail_change_notification(sender, instance: Ticket, update_fields=None, **kwargs):
184 if not instance.user.receive_email_notifications:
185 return None
186 try:
187 old_instance = Ticket.objects.get(id=instance.id)
188 except Ticket.DoesNotExist:
189 return None
190
191 if old_instance.status != instance.status:
192 mail_template = MailTemplate.get_template('ticket_status_change', instance.user.language)
193 if not mail_template:
194 return None
195 mail_template.send_mail([instance.user.email], {
196 'ticket_id': instance.id, 'ticket_creator_username': instance.user.username, 'ticket_status': instance.get_status(),
197 'ticket_status_old': old_instance.get_status(), 'ticket_title': instance.title
198 })
199
200class Comment(models.Model):
201 ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE)
202 user = models.ForeignKey(PawUser, on_delete=models.CASCADE)
203 text = models.TextField()
204 is_only_for_staff = models.BooleanField(default=False)
205 created_at = models.DateTimeField(auto_now_add=True)
206
207 def save(self, *args, **kwargs):
208 self.ticket.status = Ticket.Status.IN_PROGRESS
209 self.ticket.updated_at = timezone.now()
210 self.ticket.save()
211
212 super().save(*args, **kwargs)
213
214 def __str__(self):
215 return f'Comment by {self.user.username} on {self.ticket.title}'
216
217@receiver(post_save, sender=Comment, dispatch_uid="mail_comment_notification")
218def send_mail_comment_notification(sender, instance, created, **kwargs):
219 if created and instance.ticket.user.receive_email_notifications and instance.user != instance.ticket.user and not instance.is_only_for_staff:
220 mail_template = MailTemplate.get_template('new_comment', instance.user.language)
221 if not mail_template:
222 return None
223 mail_template.send_mail([instance.ticket.user.email], {
224 'ticket_id': instance.ticket.id, 'ticket_title': instance.ticket.title, 'ticket_creator_username': instance.user.username,
225 'comment_text': instance.text})
226
227class FileAttachment(models.Model):
228 ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE)
229 file = models.FileField(
230 upload_to=ticket_directory_path,
231 max_length=255,
232 storage=SecureFileStorage() if settings.SECURE_MEDIA_ENABLED else None)
233 uploaded_at = models.DateTimeField(auto_now_add=True)
234
235 def __str__(self):
236 return f'{_('Attachment for')} {self.ticket.title}'
237
238 def get_attachment_url(self):
239 """
240 Returns the URL for downloading the attachment.
241 """
242 from django.urls import reverse
243 return reverse('download_attachment', args=[self.id])
244
245
246class Template(models.Model):
247 name = models.CharField(max_length=200)
248 content = models.TextField()
249 category = models.ForeignKey(
250 Category, on_delete=models.CASCADE, null=True, blank=True)
251
252 def __str__(self):
253 return self.name