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