Free and open source ticket system written in python
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #3 from aottr/feature/better-access-restriction

Better access restriction

authored by

A.Ottr and committed by
GitHub
a78c5831 04a8a781

+123 -32
+1 -1
paw/__init__.py
··· 1 1 from django import get_version 2 2 3 - VERSION = (0, 4, 0, "beta", 1) 3 + VERSION = (0, 5, 0, "beta", 0) 4 4 5 5 __version__ = get_version(VERSION)
+1
paw/static/.gitattributes
··· 1 + css/paw.css linguist-generated
+11 -9
paw/templates/ticketing/ticket_detail.html
··· 36 36 <h1 class="text-xl font-bold my-4">{% trans 'Activity' %}</h1> 37 37 <div class="mb-4"> 38 38 {% for comment in comments %} 39 - {% if not comment.is_only_for_staff or comment.is_only_for_staff and request.user.is_staff %} 39 + {% if not comment.is_only_for_staff or comment.is_only_for_staff and can_edit %} 40 40 {% if comment.user == request.user %} 41 41 <div class="chat chat-start"> 42 42 {% else %} ··· 62 62 </div> 63 63 <div class="mb-10"> 64 64 {% if ticket.status != 'closed' %} 65 - {% if request.user.is_staff %} 65 + {% if can_edit or ticket.user == request.user %} 66 + {% if can_edit %} 66 67 <form action="" method="post"> 67 68 {% csrf_token %} 68 69 <div class="flex justify-end items-center mt-4 mb-2"> ··· 88 89 </div> 89 90 <div class="grow"></div> 90 91 <button type="submit" name="submit" class="btn btn-success">{% trans 'Add Comment' %}</button> 91 - {% if request.user.is_staff %} 92 + {% if can_edit %} 92 93 <button type="submit" name="close" class="btn btn-error ml-4">{% trans 'Close Ticket' %}</button> 93 94 {% endif %} 94 95 </div> 95 96 96 97 <div class="flex justify-end"> 97 - {% if request.user.is_staff %} 98 + {% if can_edit %} 98 99 <div class="form-control mt-2"> 99 100 <label for="{{ form.hidden_from_client.id_for_label }}" class="cursor-pointer label"> 100 101 {{ form.hidden_from_client }} ··· 104 105 {% endif %} 105 106 </div> 106 107 </form> 108 + {% endif %} {% comment %} can_edit or ticket.user == request.user {% endcomment %} 107 109 {% else %} 108 110 <div class="divider">{% trans 'Ticket has been closed' %}</div> 109 - {% if request.user.is_staff and ticket.status == 'closed' %} 111 + {% if can_edit and ticket.status == 'closed' %} 110 112 <div class="flex justify-end items-center mt-4"> 111 113 <form method="post"> 112 114 {% csrf_token %} 113 115 <button class="btn btn-warning ml-2" name="reopen_ticket">{% trans 'Re-Open Ticket' %}</button> 114 116 </form> 115 117 </div> 116 - {% endif %} {% comment %} request.user.is_staff and ticket.status == 'closed' {% endcomment %} 118 + {% endif %} {% comment %} can_edit and ticket.status == 'closed' {% endcomment %} 117 119 {% endif %} 118 120 </div> 119 121 </div> ··· 162 164 <span>{% trans 'General' %}</span> 163 165 {% endif %} 164 166 </div> 165 - {% if request.user.is_staff %} 167 + {% if can_edit %} 166 168 <form action="" method="post"> 167 169 {% csrf_token %} 168 170 <h2 class="font-semibold text-xs mb-2">{% trans 'Assign to new category' %}</h2> ··· 177 179 <div class="text-base-content/85 flex items-center text-sm mb-4"> 178 180 <svg xmlns="http://www.w3.org/2000/svg" class="mr-2 w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" /></svg> 179 181 {% include 'partials/assigned_to.html' with assigned_to=ticket.assigned_to %} 180 - {% if request.user.is_staff and ticket.assigned_to != request.user %} 182 + {% if can_edit and ticket.assigned_to != request.user %} 181 183 <form method="post"> 182 184 {% csrf_token %} 183 185 <button class="btn btn-xs btn-neutral ml-2" name="assign_self">{% trans 'Assign to me' %}</button> ··· 192 194 <span class="italic">{% trans 'Unassigned' %}</span> 193 195 {% endif %} 194 196 </div> 195 - {% if request.user.is_staff %} 197 + {% if can_edit %} 196 198 <form action="" method="post"> 197 199 {% csrf_token %} 198 200 <h2 class="font-semibold text-xs mb-2">{% trans 'Assign to new team' %}</h2>
+4 -1
ticketing/admin.py
··· 15 15 16 16 admin.site.register(Comment) 17 17 admin.site.register(Template) 18 - admin.site.register(Team) 18 + 19 + @admin.register(Team) 20 + class TeamAdmin(admin.ModelAdmin): 21 + filter_horizontal = ('members',) 19 22 admin.site.register(FileAttachment)
+1 -1
ticketing/forms.py
··· 96 96 97 97 98 98 class TeamAssignmentForm(forms.Form): 99 - team_select = forms.ModelChoiceField(queryset=Team.objects.all(), empty_label=_('No Team'), required=False, widget=forms.Select( 99 + team_select = forms.ModelChoiceField(queryset=Team.objects.filter(readonly_access=False), empty_label=_('No Team'), required=False, widget=forms.Select( 100 100 attrs={'class': 'select select-bordered select-sm w-full'})) 101 101 102 102
+18
ticketing/migrations/0013_team_access_non_category_tickets.py
··· 1 + # Generated by Django 5.0.3 on 2024-03-24 14:42 2 + 3 + from django.db import migrations, models 4 + 5 + 6 + class Migration(migrations.Migration): 7 + 8 + dependencies = [ 9 + ("ticketing", "0012_seed_mailtemplates"), 10 + ] 11 + 12 + operations = [ 13 + migrations.AddField( 14 + model_name="team", 15 + name="access_non_category_tickets", 16 + field=models.BooleanField(default=False), 17 + ), 18 + ]
+18
ticketing/migrations/0014_team_readonly_access.py
··· 1 + # Generated by Django 5.0.3 on 2024-03-24 18:30 2 + 3 + from django.db import migrations, models 4 + 5 + 6 + class Migration(migrations.Migration): 7 + 8 + dependencies = [ 9 + ("ticketing", "0013_team_access_non_category_tickets"), 10 + ] 11 + 12 + operations = [ 13 + migrations.AddField( 14 + model_name="team", 15 + name="readonly_access", 16 + field=models.BooleanField(default=False), 17 + ), 18 + ]
+58 -3
ticketing/models.py
··· 18 18 name = models.CharField(max_length=200) 19 19 description = models.TextField(blank=True) 20 20 members = models.ManyToManyField(PawUser) 21 + access_non_category_tickets = models.BooleanField(default=False) 22 + readonly_access = models.BooleanField(default=False) 21 23 22 24 def __str__(self): 23 25 return self.name ··· 67 69 models.Index(fields=["priority", "title"]), 68 70 ] 69 71 72 + @classmethod 73 + def _get_tickets(cls, user) -> models.QuerySet: 74 + """ 75 + For regular users with no team: return all open tickets that are created by the user 76 + """ 77 + if user.is_superuser: 78 + return cls.objects.all() 79 + 80 + user_teams = user.team_set.all() 81 + if not user_teams: 82 + return cls.objects.filter(user=user) 83 + 84 + q = cls.objects.filter( 85 + models.Q(user=user) | # tickets created by user 86 + (models.Q(assigned_team__in=user_teams) | models.Q(assigned_to=user)) | # tickets assigned to user or user's team 87 + (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 88 + ) 89 + 90 + if not any([team.access_non_category_tickets for team in user_teams]): 91 + return q.exclude(models.Q(assigned_team=None) & models.Q(category=None) & ~models.Q(user=user)) 92 + return q 93 + 94 + @classmethod 95 + def get_open_tickets(cls, user) -> models.QuerySet: 96 + """ 97 + For regular users with no team: return all open tickets that are created by the user 98 + """ 99 + return cls._get_tickets(user).exclude(status=cls.Status.CLOSED) 100 + 101 + @classmethod 102 + def get_closed_tickets(cls, user) -> models.QuerySet: 103 + """ 104 + For regular users with no team: return all closed tickets that are created by the user 105 + """ 106 + return cls._get_tickets(user).filter(status=cls.Status.CLOSED) 107 + 108 + def can_open(self, user): 109 + if user.is_superuser: 110 + return True 111 + 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() 112 + 113 + def can_edit(self, user): 114 + if user.is_superuser: 115 + return True 116 + assigned_and_write_access = self.assigned_team in user.team_set.filter(readonly_access=False) or self.assigned_to == user 117 + unassigned_and_write_access = self.assigned_team is None and user.team_set.filter(access_non_category_tickets=True, readonly_access=False).exists() 118 + print(assigned_and_write_access, unassigned_and_write_access) 119 + return self.can_open(user) and (assigned_and_write_access or unassigned_and_write_access) 120 + 121 + 70 122 def close_ticket(self): 71 123 self.status = self.Status.CLOSED 72 124 self.save() ··· 83 135 84 136 def get_priority(self): 85 137 return self.Priority(self.priority).label 138 + 139 + def get_status(self): 140 + return self.Status(self.status).label 86 141 87 142 def __str__(self): 88 143 return self.title ··· 125 180 'ticket_description': instance.description, 'ticket_category': instance.category.name if instance.category else _('General')}) 126 181 127 182 @receiver(pre_save, sender=Ticket, dispatch_uid="mail_change_notification") 128 - def send_mail_change_notification(sender, instance, update_fields=None, **kwargs): 183 + def send_mail_change_notification(sender, instance: Ticket, update_fields=None, **kwargs): 129 184 if not instance.user.receive_email_notifications: 130 185 return None 131 186 try: ··· 138 193 if not mail_template: 139 194 return None 140 195 mail_template.send_mail(instance.user.email, { 141 - 'ticket_id': instance.id, 'ticket_creator_username': instance.user.username, 'ticket_status': instance.status, 142 - 'ticket_status_old': old_instance.status, 'ticket_title': instance.title 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 143 198 }) 144 199 145 200 class Comment(models.Model):
+11 -17
ticketing/views.py
··· 8 8 9 9 @login_required 10 10 def show_tickets(request): 11 - if request.user.is_staff: 12 - # show only tickets that are not closed and are not assigned or assigned to the current user's team 13 - tickets = Ticket.objects.filter( 14 - ~Q(status=Ticket.Status.CLOSED) & (~Q(assigned_team=None) 15 - | ~Q(assigned_team__in=request.user.team_set.all())) 16 - ).order_by("priority", "-created_at") 17 - else: 18 - tickets = Ticket.objects.filter( 19 - user=request.user).order_by("-created_at") 11 + tickets = Ticket.get_open_tickets(request.user).order_by("priority", "-updated_at") 20 12 return render(request, "ticketing/tickets.html", {"tickets": tickets}) 21 13 22 14 ··· 35 27 @login_required 36 28 def show_ticket(request, ticket_id): 37 29 ticket = get_object_or_404(Ticket, pk=ticket_id) 30 + can_edit = ticket.can_edit(request.user) 38 31 # comment_templates = Template.objects.filter(category=ticket.category) 39 32 40 - if request.user != ticket.user and not request.user.is_staff: 33 + if not ticket.can_open(request.user): 41 34 return redirect("all_tickets") 42 35 43 36 form, template_form, team_assignment_form, category_assignment_form = CommentForm( 44 37 ), TemplateForm(), TeamAssignmentForm(), CategoryAssignmentForm() 45 38 46 39 if request.method == "POST": 47 - if 'apply_template' in request.POST and request.user.is_staff: 40 + if 'apply_template' in request.POST and can_edit: 48 41 template_form = TemplateForm(request.POST) 49 42 if template_form.is_valid(): 50 43 template = template_form.cleaned_data["template_select"] 51 44 form = CommentForm(initial={"text": template.content}) 52 - elif 'assign_to_team' in request.POST and request.user.is_staff: 45 + elif 'assign_to_team' in request.POST and can_edit: 53 46 team_assignment_form = TeamAssignmentForm(request.POST) 54 47 if team_assignment_form.is_valid(): 55 48 ticket.assign_to_team( 56 49 team_assignment_form.cleaned_data["team_select"]) 57 - elif 'assign_to_category' in request.POST and request.user.is_staff: 50 + elif 'assign_to_category' in request.POST and can_edit: 58 51 category_assignment_form = CategoryAssignmentForm(request.POST) 59 52 if category_assignment_form.is_valid(): 60 53 ticket.category = category_assignment_form.cleaned_data["category_select"] 61 54 ticket.save() 62 - elif 'assign_self' in request.POST and request.user.is_staff: 55 + elif 'assign_self' in request.POST and can_edit: 63 56 ticket.assigned_to = request.user 64 57 ticket.save() 65 - elif 'reopen_ticket' in request.POST and request.user.is_staff: 58 + elif 'reopen_ticket' in request.POST and can_edit: 66 59 ticket.status = Ticket.Status.IN_PROGRESS 67 60 ticket.save() 68 61 else: ··· 77 70 for file in form.cleaned_data["attachments"]: 78 71 ticket.fileattachment_set.create(file=file) 79 72 80 - if 'close' in request.POST and request.user.is_staff: 73 + if 'close' in request.POST and can_edit: 81 74 ticket.close_ticket() 82 75 83 76 comments = ticket.comment_set.all() 84 77 context = { 85 78 "ticket": ticket, "comments": comments, "attachments": [attachment.file for attachment in ticket.fileattachment_set.all()], 86 79 "form": form, "template_form": template_form, 87 - "team_assignment_form": team_assignment_form, "category_assignment_form": category_assignment_form 80 + "team_assignment_form": team_assignment_form, "category_assignment_form": category_assignment_form, 81 + "can_edit": can_edit 88 82 } 89 83 return render(request, "ticketing/ticket_detail.html", context) 90 84