Free and open source ticket system written in python
at main 253 lines 10 kB view raw
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