From 34e845d14da18249d08be516a68bb0a61aae5f6e Mon Sep 17 00:00:00 2001
From: Johannes Schriewer <hallo@dunkelstern.de>
Date: Mon, 6 Jan 2025 03:17:25 +0100
Subject: [PATCH] Implement fulltext search function

---
 inventory/static/inventory/css/main.css       | 22 ++++-
 inventory/static/inventory/img/search.svg     |  1 +
 inventory/templates/base.html                 | 25 +++++-
 inventory/templates/inventory/search.html     | 15 ++++
 .../templates/inventory/search_result.html    | 43 ++++++++++
 .../inventory/search_result_item.html         | 17 ++++
 inventory/urls.py                             |  4 +-
 inventory/views/__init__.py                   |  4 +-
 inventory/views/search.py                     | 84 +++++++++++++++++++
 9 files changed, 210 insertions(+), 5 deletions(-)
 create mode 100644 inventory/static/inventory/img/search.svg
 create mode 100644 inventory/templates/inventory/search.html
 create mode 100644 inventory/templates/inventory/search_result.html
 create mode 100644 inventory/templates/inventory/search_result_item.html
 create mode 100644 inventory/views/search.py

diff --git a/inventory/static/inventory/css/main.css b/inventory/static/inventory/css/main.css
index bbaf413..c8e4bb8 100644
--- a/inventory/static/inventory/css/main.css
+++ b/inventory/static/inventory/css/main.css
@@ -207,7 +207,6 @@ ul.tag-list {
     display: flex;
     flex-wrap: wrap;
     gap: 6px;
-    max-width: 500px;
     text-indent: 0;
     padding-left: 0;
 }
@@ -291,4 +290,23 @@ table.list tbody td {
 
 .right {
     text-align: right;
-}
\ No newline at end of file
+}
+
+#search-container {
+    display: none;
+    position: absolute;
+    background-color: #292981;
+    padding: 10px;
+    border: 1px solid white;
+    box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.8);
+}
+
+.search-result em {
+    color: #c00000;
+}
+
+.search-result .small {
+    font-size: 9pt;
+    font-weight: normal;
+    color: #808080;
+}
diff --git a/inventory/static/inventory/img/search.svg b/inventory/static/inventory/img/search.svg
new file mode 100644
index 0000000..97cb287
--- /dev/null
+++ b/inventory/static/inventory/img/search.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z"/></svg>
\ No newline at end of file
diff --git a/inventory/templates/base.html b/inventory/templates/base.html
index 0d507b9..2f65625 100644
--- a/inventory/templates/base.html
+++ b/inventory/templates/base.html
@@ -23,6 +23,29 @@
             {% endblock %}
         </ul>
         <ul class="icon-nav">
+            <li>
+                <script>
+                    function showSearch(e) {
+                        const searchBox = document.getElementById("search-container");
+                        if ((searchBox.style.display === '') || (searchBox.style.display === 'none')) {
+                            searchBox.style.display = 'block';
+                            searchBox.getElementsByTagName('input')[0].focus();
+                        } else {
+                            searchBox.style.display = 'none';
+                        }
+                        if (e) {
+                            e.preventDefault();
+                        }
+                    }
+                </script>
+                <a href="{% url 'search' %}" onclick="showSearch(event)"><img class="icon" title="Search" src="{% static "inventory/img/search.svg" %}"></a>
+                <div id="search-container">
+                    <form action="{% url 'search' %}" method="get">
+                        <input name="q" id="search" type="text">
+                        <button type="submit">Search</button>
+                    </form>
+                </div>
+            </li>
             <li>
                 <a href="{% url 'manufacturer-list' %}"><img class="icon" title="Manufacturers" src="{% static "inventory/img/manufacturer.svg" %}"></a>
             </li>
@@ -33,7 +56,7 @@
                 <a href="{% url 'tag-list' %}"><img class="icon" title="Tags" src="{% static "inventory/img/tags.svg" %}"></a>
             </li>
             <li>
-                <a href="{% url 'workshop-list' %}"><img class="icon" title="Workshops" src="{% static "inventory/img/workshop.svg" %}"></a>
+                <a href="{% url 'index' %}"><img class="icon" title="Workshops" src="{% static "inventory/img/workshop.svg" %}"></a>
             </li>
         </ul>
 
diff --git a/inventory/templates/inventory/search.html b/inventory/templates/inventory/search.html
new file mode 100644
index 0000000..53a0af3
--- /dev/null
+++ b/inventory/templates/inventory/search.html
@@ -0,0 +1,15 @@
+{% extends "base.html" %}
+{% load static %}
+
+{% block title %}Search{% endblock %}
+
+{% block header_bar %}
+    Search
+{% endblock %}
+
+{% block content %}
+    <form method="get" action="{% url 'search' %}">
+        <input type="text" id="search" name="q">
+        <button type="submit">Search</button>
+    </form>
+{% endblock %}
\ No newline at end of file
diff --git a/inventory/templates/inventory/search_result.html b/inventory/templates/inventory/search_result.html
new file mode 100644
index 0000000..ee74ecd
--- /dev/null
+++ b/inventory/templates/inventory/search_result.html
@@ -0,0 +1,43 @@
+{% extends "base.html" %}
+{% load static %}
+{% load hilight %}
+
+{% block title %}Search{% endblock %}
+
+{% block header_bar %}
+    Search
+{% endblock %}
+
+{% block content %}
+<form method="get" action="{% url 'search' %}">
+    <input type="text" id="search" name="q" value="{{ q }}">
+    <button type="submit">Search</button>
+</form>
+
+    <h2>Search result for '{{ q }}'</h2>
+
+    {% for result in results %}
+        <div class="search-result">
+            <h3><a href="{{ result.url }}">{{ result.title | hilight:tokens }}</a></h3>
+            {{ result.text | safe }}
+            <hr>
+        </div>
+    {% empty %}
+        <p>Noting found</p>
+    {% endfor %}
+
+    {% if pages > 1 %}
+    <div class="pagination">
+        {% if page > 1 %}
+            <a href="{% url 'search' %}?q={{ q }}&page=1"><img src="{% static 'inventory/img/first.svg' %}" class="icon" title="First page"></a>
+            <a href="{% url 'search' %}?q={{ q }}&page={{ page | add:'-1'}}"><img src="{% static 'inventory/img/previous.svg' %}" class="icon" title="Previous page"></a>
+        {% endif %}
+        {{ page }}/{{ pages }}
+        {% if page < pages %}
+            <a href="{% url 'search' %}?q={{ q }}&page={{ page | add:'1'}}"><img src="{% static 'inventory/img/next.svg' %}" class="icon" title="Next page"></a>
+            <a href="{% url 'search' %}?q={{ q }}&page={{ pages }}"><img src="{% static 'inventory/img/last.svg' %}" class="icon" title="Last page"></a>
+        {% endif %}
+    </div>
+    {% endif %}
+    
+{% endblock %}
\ No newline at end of file
diff --git a/inventory/templates/inventory/search_result_item.html b/inventory/templates/inventory/search_result_item.html
new file mode 100644
index 0000000..ea8c9e3
--- /dev/null
+++ b/inventory/templates/inventory/search_result_item.html
@@ -0,0 +1,17 @@
+{% load static %}
+{% load hilight %}
+<p>
+{% if item.documentation.all %}
+    <a class="datasheet" href="{{ item.documentation.all.0.file.url }}"><img class="icon" src="{% static "inventory/img/datasheet.svg" %}"></a>
+{% endif %}
+{{ item | hilight:tokens }}
+<span class="small">{{item.form_factor.name}}</span>
+</p>
+<p>Contained in: <a href="{{ item.container_url }}?hilight={{ item.id }}">{{ item.container.display_name }}</a></p>
+<ul class="tag-list">
+    {% for tag in item.all_tags %}
+        <li><a href="{% url 'tag-detail' tag.id %}" title="{{ tag.name }}">{{ tag.name }}</a></li>
+    {% empty %}
+        No tags
+    {% endfor %}
+</ul>
diff --git a/inventory/urls.py b/inventory/urls.py
index 6576519..fcc3f68 100644
--- a/inventory/urls.py
+++ b/inventory/urls.py
@@ -33,7 +33,8 @@ from .views import (
     DistributorView,
     ManufacturerView,
     IndexView,
-    TagView
+    TagView,
+    SearchView
 )
 
 urlpatterns = [
@@ -52,5 +53,6 @@ urlpatterns = [
     path('distributor/<int:pk>', DistributorView.as_view(), name='distributor-detail'),
     path('tags', TagListView.as_view(), name='tag-list'),
     path('tag/<int:pk>', TagView.as_view(), name='tag-detail'),
+    path('search', SearchView.as_view(), name='search'),
     path('', IndexView.as_view(), name='index')
 ]
diff --git a/inventory/views/__init__.py b/inventory/views/__init__.py
index 36ff231..a5ea7b5 100644
--- a/inventory/views/__init__.py
+++ b/inventory/views/__init__.py
@@ -6,6 +6,7 @@ from .manufacturer import ManufacturerView, ManufacturerListView
 from .workshop import WorkshopView, WorkshopListView
 from .index import IndexView
 from .tag import TagListView, TagView
+from .search import SearchView
 
 __all__ = [
     AreaView, AreaListView,
@@ -15,5 +16,6 @@ __all__ = [
     ManufacturerView, ManufacturerListView,
     WorkshopView, WorkshopListView,
     IndexView,
-    TagView, TagListView
+    TagView, TagListView,
+    SearchView
 ]
\ No newline at end of file
diff --git a/inventory/views/search.py b/inventory/views/search.py
new file mode 100644
index 0000000..cc60b3e
--- /dev/null
+++ b/inventory/views/search.py
@@ -0,0 +1,84 @@
+from django.contrib.auth.decorators import login_required
+from django.utils.decorators import method_decorator
+from django.views.generic import View
+from django.shortcuts import render
+from django.urls import reverse
+from django.template.loader import get_template
+from django.db.models import Q
+from django.core.paginator import Paginator
+from django.conf import settings
+
+from re import finditer
+
+from inventory.models import Item
+
+@method_decorator(login_required, name='dispatch')
+class SearchView(View):
+    
+    def get(self, request):
+        page = int(request.GET.get('page', "1"))
+        query = request.GET.get('q', None)
+        if not query:
+            return render(request, "inventory/search.html")
+                
+        results: list[dict[str, str]] = []
+
+        tokens = query.split(" ")
+        tokens = map(str.lower, map(str.strip, tokens))
+        
+        q: Q = None
+        item_template = get_template("inventory/search_result_item.html")
+        t: list[str] = []
+        for token in tokens:
+            combiner = 'or'
+            if token.startswith("+"):
+                token = token[1:]
+                combiner = 'and'
+            elif token.startswith("-"):
+                token = token[1:]
+                combiner = 'and not'
+
+            t.append(token)
+
+            q1 = Q(name__icontains=f'{token}')
+            q2 = Q(description__icontains=f'{token}')
+            q3 = Q(tags__name__icontains=f'{token}')
+            q4 = Q(tags__description__icontains=f'{token}')
+            q5 = Q(form_factor__tags__name__icontains=f'{token}')
+            q6 = Q(form_factor__tags__description__icontains=f'{token}')
+
+            qx = q1 | q2 | q3 | q4 | q5 | q6
+
+            if q == None:
+                q = qx
+            elif combiner == 'or':
+                q = q | qx
+            elif combiner == 'and':
+                q = q & qx
+            elif combiner == 'and not':
+                q = q & ~qx
+        items = Item.objects.filter(q).select_related("container", "form_factor").prefetch_related("tags", "documentation").distinct()
+
+        # FIXME: Rank search results
+
+        paginator = Paginator(items, getattr(settings, "PAGE_SIZE", 10))
+
+        for item in paginator.get_page(page):
+            text = item_template.render({
+                "item": item,
+                "tokens": t
+            })
+            result = {
+                "title": item.name,
+                "text": text,
+                "url": reverse('item-detail', kwargs={"pk": item.pk})
+            }
+            results.append(result)
+
+        return render(request, "inventory/search_result.html", {
+            "q": query,
+            "results": results,
+            "tokens": t,
+            "page": page,
+            "pages": paginator.num_pages
+        })
\ No newline at end of file