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' %}&nbsp;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' %}&nbsp;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' %}&nbsp;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' %}
+
+            &nbsp;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' %}
+
+            &nbsp;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' %}
+
+            &nbsp;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