From 93895fc247a329ae51ed82c5c0303210cb0c524e Mon Sep 17 00:00:00 2001 From: George Katsikas <giorgakis.katsikas@gmail.com> Date: Tue, 11 Jul 2023 15:33:29 +0200 Subject: [PATCH] add search/filter form to tickes queue list view --- scipost_django/helpdesk/forms.py | 111 ++++++++++ .../templates/helpdesk/queue_detail.html | 198 ++++++++++++------ scipost_django/helpdesk/urls.py | 5 + scipost_django/helpdesk/views.py | 30 ++- 4 files changed, 275 insertions(+), 69 deletions(-) diff --git a/scipost_django/helpdesk/forms.py b/scipost_django/helpdesk/forms.py index 57f548d83..eae797776 100644 --- a/scipost_django/helpdesk/forms.py +++ b/scipost_django/helpdesk/forms.py @@ -4,8 +4,14 @@ __license__ = "AGPL v3" from django import forms from django.contrib.auth.models import User +from django.shortcuts import get_object_or_404 from .models import Queue, Ticket, Followup +from .constants import TICKET_PRIORITIES, TICKET_STATUSES +from crispy_forms.helper import FormHelper, Layout +from crispy_bootstrap5.bootstrap5 import FloatingField, Field +from crispy_forms.layout import Div +from django.db.models import Q class QueueForm(forms.ModelForm): @@ -87,3 +93,108 @@ class FollowupForm(forms.ModelForm): self.fields["by"].widget = forms.HiddenInput() self.fields["timestamp"].widget = forms.HiddenInput() self.fields["action"].widget = forms.HiddenInput() + + +class TicketSearchForm(forms.Form): + title = forms.CharField(max_length=64, required=False) + description = forms.CharField(max_length=512, required=False) + priority = forms.MultipleChoiceField(choices=TICKET_PRIORITIES, required=False) + status = forms.MultipleChoiceField(choices=TICKET_STATUSES, required=False) + + orderby = forms.ChoiceField( + label="Order by", + choices=( + ("defined_on", "Opened date"), + ("followups__latest__timestamp", "Latest activity"), + ("status", "Status"), + ("priority", "Priority"), + ), + required=False, + ) + ordering = forms.ChoiceField( + label="Ordering", + choices=( + # FIXME: Emperically, the ordering appers to be reversed for dates? + ("-", "Descending"), + ("+", "Ascending"), + ), + required=False, + ) + + def __init__(self, *args, **kwargs): + if queue_slug := kwargs.pop("queue_slug", None): + self.queue = get_object_or_404(Queue, slug=queue_slug) + self.tickets = Ticket.objects.filter(queue=self.queue) + else: + self.tickets = Ticket.objects.all() + super().__init__(*args, **kwargs) + + self.helper = FormHelper() + self.helper.layout = Layout( + Div( + Div( + Div( + Div(Field("priority", size=4), css_class="col-12"), + Div(FloatingField("title"), css_class="col-12"), + css_class="row mb-0", + ), + css_class="col-6", + ), + Div(Field("status", size=8), css_class="col-6"), + Div(FloatingField("description"), css_class="col-12"), + css_class="row mb-0", + ), + Div( + Div(Field("ordering"), css_class="col-6"), + Div(Field("orderby"), css_class="col-6"), + css_class="row mb-0", + ), + ) + + def search_results(self): + tickets = self.tickets + + if title := self.cleaned_data.get("title"): + tickets = tickets.filter(title__icontains=title) + if description := self.cleaned_data.get("description"): + tickets = tickets.filter(description__icontains=description) + + def is_in_or_null(queryset, key, value, implicit_all=True): + """ + Filter a queryset by a list of values. If the list contains a 0, then + also include objects where the key is null. If the list is empty, then + include all objects if implicit_all is True. + """ + value = self.cleaned_data.get(value) + has_unassigned = "0" in value + is_unassigned = Q(**{key + "__isnull": True}) + is_in_values = Q(**{key + "__in": list(filter(lambda x: x != 0, value))}) + + if has_unassigned: + return queryset.filter(is_unassigned | is_in_values) + elif implicit_all and not value: + return queryset + else: + return queryset.filter(is_in_values) + + tickets = is_in_or_null(tickets, "priority", "priority") + tickets = is_in_or_null(tickets, "status", "status") + + # Ordering of streams + # Only order if both fields are set + if (orderby_value := self.cleaned_data.get("orderby")) and ( + ordering_value := self.cleaned_data.get("ordering") + ): + # Remove the + from the ordering value, causes a Django error + ordering_value = ordering_value.replace("+", "") + + # Ordering string is built by the ordering (+/-), and the field name + # from the orderby field split by "," and joined together + tickets = tickets.order_by( + *[ + ordering_value + order_part + for order_part in orderby_value.split(",") + ] + ) + + return tickets diff --git a/scipost_django/helpdesk/templates/helpdesk/queue_detail.html b/scipost_django/helpdesk/templates/helpdesk/queue_detail.html index 3b29875dd..00e508282 100644 --- a/scipost_django/helpdesk/templates/helpdesk/queue_detail.html +++ b/scipost_django/helpdesk/templates/helpdesk/queue_detail.html @@ -1,96 +1,160 @@ {% extends 'helpdesk/base.html' %} - {% load bootstrap %} {% load guardian_tags %} {% load automarkup %} +{% load crispy_forms_tags %} {% block breadcrumb_items %} - {{ block.super }} - <span class="breadcrumb-item">Queue: {{ queue.name }}</span> + {{ block.super }} <span class="breadcrumb-item">Queue: {{ queue.name }}</span> {% endblock %} -{% block pagetitle %}: Queue details{% endblock pagetitle %} +{% block pagetitle %} + : Queue details +{% endblock pagetitle %} {% get_obj_perms request.user for queue as "user_perms" %} {% block content %} - <div class="row"> <div class="col-12"> - <h3 class="highlight">Queue: {{ queue.name }}</h3> {% if queue.parent_queue %} - <p>Parent: <a href="{% url 'helpdesk:queue_detail' slug=queue.parent_queue.slug %}">{{ queue.parent_queue }}</a></p> + <p> + Parent: <a href="{% url 'helpdesk:queue_detail' slug=queue.parent_queue.slug %}">{{ queue.parent_queue }}</a> + </p> {% endif %} + {% if queue.sub_queues.all|length > 0 %} - <p>Sub-queues: {% for sub in queue.sub_queues.all %}<a href="{% url 'helpdesk:queue_detail' slug=sub.slug %}">{{ sub }}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</p> - {% endif %} + <p> + Sub-queues: - {% if perms.helpdesk.add_queue or request.user in queue.managing_group.users.all %} - <div class="container border border-danger p-2"> - <h4>Admin actions:</h4> - - {% if perms.helpdesk.delete_queue or "delete_queue" in user_perms %} - <a class="btn btn-sm btn-danger" role="button" href="{% url 'helpdesk:queue_delete' slug=queue.slug %}">{% include 'bi/trash-fill.html' %} Delete this Queue</a> - {% endif %} - {% if perms.helpdesk.update_queue or "update_queue" in user_perms %} - <a class="btn btn-sm btn-warning" role="button" href="{% url 'helpdesk:queue_update' slug=queue.slug %}">{% include 'bi/pencil-square.html' %} Update this Queue</a> - {% endif %} - {% if perms.helpdesk.add_queue or request.user in queue.managing_group.users.all %} - <a class="btn btn-sm btn-primary" href="{% url 'helpdesk:queue_create' parent_slug=queue.slug %}">{% include 'bi/plus-square-fill.html' %} Add a sub-Queue to this Queue</a> - {% endif %} - - <hr/> - <div class="card"> - <div class="card-header"> - Permissions on this Queue instance - <button class="btn btn-link small" data-bs-toggle="collapse" data-bs-target="#permissionsCard"> - View/manage</button> - </div> - <div class="card-body collapse" id="permissionsCard"> - <strong>Note: Permissions are handled at the group level. To change managing and/or response groups, click on the <span class="bg-warning p-1">Update</span> button above.</strong> - <br/><br/> - <h4>Managing group:</h4> - {% get_obj_perms queue.managing_group for queue as "group_perms" %} - <ul> - <li>{{ queue.managing_group }}: {{ group_perms }}</li> - </ul> - - <h4>Response groups:</h4> - <ul> - {% for group in queue.response_groups.all %} - {% get_obj_perms group for queue as "group_perms" %} - <li>{{ group.name }}: {{ group_perms }}</li> - {% empty %} - <li>No group has permissions on this Queue</li> - {% endfor %} - </ul> - - <p>Users with permissions:</p> - <ul> - {% for u in users_with_perms %} - {% get_obj_perms u for queue as "u_perms" %} - <li>{{ u.first_name }} {{ u.last_name }}: {{ u_perms }}</li> - {% empty %} - <li>No user has permissions on this Queue</li> - {% endfor %} - </ul> - </div> - </div> - - </div> + {% for sub in queue.sub_queues.all %} + <a href="{% url 'helpdesk:queue_detail' slug=sub.slug %}">{{ sub }}</a> + {% if not forloop.last %},{% endif %} + {% endfor %} + + </p> {% endif %} - <h3 class="highlight">Description</h3> - {% automarkup queue.description %} - <h3 class="highlight">Tickets in this Queue</h3> - {% include 'helpdesk/tickets_table.html' with tickets=queue.tickets.all %} + {% if perms.helpdesk.add_queue or request.user in queue.managing_group.users.all %} + + <div class="container border border-danger p-2"> + <h4>Admin actions:</h4> + + {% if perms.helpdesk.delete_queue or "delete_queue" in user_perms %} + <a class="btn btn-sm btn-danger" + role="button" + href="{% url 'helpdesk:queue_delete' slug=queue.slug %}"> + + {% include 'bi/trash-fill.html' %} + + Delete this Queue</a> + {% endif %} + + {% if perms.helpdesk.update_queue or "update_queue" in user_perms %} + <a class="btn btn-sm btn-warning" + role="button" + href="{% url 'helpdesk:queue_update' slug=queue.slug %}"> + + {% include 'bi/pencil-square.html' %} + + Update this Queue</a> + {% endif %} + + {% if perms.helpdesk.add_queue or request.user in queue.managing_group.users.all %} + <a class="btn btn-sm btn-primary" + href="{% url 'helpdesk:queue_create' parent_slug=queue.slug %}"> + + {% include 'bi/plus-square-fill.html' %} + + Add a sub-Queue to this Queue</a> + {% endif %} + + <hr /> + + <div class="card"> + <div class="card-header"> + Permissions on this Queue instance + <button class="btn btn-link small" + data-bs-toggle="collapse" + data-bs-target="#permissionsCard">View/manage</button> + </div> + + <div class="card-body collapse" id="permissionsCard"> + <strong>Note: Permissions are handled at the group level. To change managing and/or response groups, click on the <span class="bg-warning p-1">Update</span> button above.</strong> + <br /> + <br /> + + <h4>Managing group:</h4> + {% get_obj_perms queue.managing_group for queue as "group_perms" %} + + <ul> + <li>{{ queue.managing_group }}: {{ group_perms }}</li> + </ul> + + <h4>Response groups:</h4> + <ul> + + {% for group in queue.response_groups.all %} + {% get_obj_perms group for queue as "group_perms" %} + <li>{{ group.name }}: {{ group_perms }}</li> + {% empty %} + <li>No group has permissions on this Queue</li> + {% endfor %} + + + </ul> + <p>Users with permissions:</p> + <ul> + + {% for u in users_with_perms %} + {% get_obj_perms u for queue as "u_perms" %} + <li>{{ u.first_name }} {{ u.last_name }}: {{ u_perms }}</li> + {% empty %} + <li>No user has permissions on this Queue</li> + {% endfor %} + + </ul> + </div> + </div> + + </div> + {% endif %} + + <h3 class="highlight">Description</h3> + {% automarkup queue.description %} + + <div class="highlight d-flex justify-content-between align-items-center px-3"> + <div class="fs-6">Tickets in this Queue</div> + <div id="indicator-search-tickets" class="htmx-indicator"> + <button class="btn btn-warning text-white d-none d-md-block me-2" + type="button" + disabled> + <strong>Loading...</strong> + <div class="spinner-grow spinner-grow-sm ms-2" + role="status" + aria-hidden="true"></div> + </button> + </div> + </div> + + <div id="ticket-search-form-container"> + <form hx-post="{% url 'helpdesk:_hx_ticket_table' slug=queue.slug %}" + hx-trigger="load, keyup delay:500ms, change delay:500ms, click from:#refresh-button" + hx-sync="#search-tickets-form:replace" + hx-target="#search-tickets-results" + hx-indicator="#indicator-search-tickets"> + <div id="search-tickets-form">{% crispy search_tickets_form %}</div> + </form> + </div> + + <div id="search-tickets-results" class="mt-2"></div> + </div> </div> - {% endblock content %} diff --git a/scipost_django/helpdesk/urls.py b/scipost_django/helpdesk/urls.py index ed5b48c6f..df3ad968b 100644 --- a/scipost_django/helpdesk/urls.py +++ b/scipost_django/helpdesk/urls.py @@ -27,6 +27,11 @@ urlpatterns = [ name="queue_delete", ), path("queue/<slug:slug>/", views.QueueDetailView.as_view(), name="queue_detail"), + path( + "queue/<slug:slug>/_hx_ticket_table", + views._hx_ticket_table, + name="_hx_ticket_table", + ), path( "ticket/add/<int:concerning_type_id>/<int:concerning_object_id>/", views.TicketCreateView.as_view(), diff --git a/scipost_django/helpdesk/views.py b/scipost_django/helpdesk/views.py index 96c3ec5d8..a6f976be7 100644 --- a/scipost_django/helpdesk/views.py +++ b/scipost_django/helpdesk/views.py @@ -6,7 +6,7 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType -from django.shortcuts import get_object_or_404, redirect +from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse_lazy from django.utils import timezone from django.views.generic.detail import DetailView @@ -39,7 +39,13 @@ from .constants import ( TICKET_FOLLOWUP_ACTION_MARK_CLOSED, ) from .models import Queue, Ticket, Followup -from .forms import QueueForm, TicketForm, TicketAssignForm, FollowupForm +from .forms import ( + QueueForm, + TicketForm, + TicketAssignForm, + FollowupForm, + TicketSearchForm, +) from mails.utils import DirectMailUtil @@ -163,6 +169,11 @@ class QueueDetailView(PermissionRequiredMixin, DetailView): def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) context["users_with_perms"] = get_users_with_perms(self.object) + + if queue := context.get("queue"): + search_tickets_form = TicketSearchForm(None, queue_slug=queue.slug) + context["search_tickets_form"] = search_tickets_form + return context @@ -370,3 +381,18 @@ class TicketMarkClosed(TicketFollowupView): ticket.save() self.object = form.save() return redirect(self.get_success_url()) + + +def _hx_ticket_table(request, slug): + form = TicketSearchForm(request.POST or None, queue_slug=slug) + + if form.is_valid(): + tickets = form.search_results() + else: + tickets = form.tickets + + return render( + request, + "helpdesk/tickets_table.html", + {"tickets": tickets}, + ) -- GitLab