feat: implement search

This commit is contained in:
Luca 2024-11-10 01:41:14 +01:00
parent db45e163a9
commit 917e4caf9a
10 changed files with 195 additions and 3 deletions

View File

@ -1,5 +1,5 @@
root = true root = true
[*.html] [*.{html,js}]
indent_size = 4 indent_size = 4
indent_style = space indent_style = space

View File

@ -1,3 +1,4 @@
from .forms import SearchForm
from .signals import populate_nav from .signals import populate_nav
@ -10,3 +11,7 @@ def nav(request):
] ]
return {"nav_items": nav_items} return {"nav_items": nav_items}
def search(request):
return {"search_form": SearchForm()}

View File

@ -15,3 +15,21 @@ class AddStockForm(forms.Form):
AddStockFormSet = forms.formset_factory(AddStockForm, extra=0) AddStockFormSet = forms.formset_factory(AddStockForm, extra=0)
class SearchForm(forms.Form):
PER_PAGE_CHOICES = [
(10, "10"),
(25, "25"),
(50, "50"),
(100, "100"),
]
q = forms.CharField(label=_("Search"), required=False)
per_page = forms.TypedChoiceField(
choices=PER_PAGE_CHOICES,
coerce=int,
empty_value=25,
label=_("Results per page"),
widget=forms.Select(attrs={"form": "searchForm"}),
)

View File

@ -0,0 +1,3 @@
document.getElementById('id_per_page').addEventListener('change', event => {
event.target.form.requestSubmit();
});

View File

@ -0,0 +1,55 @@
{% extends "base.html" %}
{% load django_bootstrap5 %}
{% load i18n %}
{% load properties %}
{% load static %}
{% block page_title %}{% translate "Search results for" %} "{{ query }}"{% endblock %}
{% block content %}
<h1>{% translate "Search results for" %} "{{ query }}"</h1>
<table class="table">
<thead>
<tr>
<th>
{% translate "Part number" %}
<div class="d-sm-none fst-italic">{% translate "Total stock" %}</div>
</th>
<th class="d-none d-sm-table-cell">{% translate "Total stock" %}</th>
{% for property in properties %}
<th>{{ property.name }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for component, properties in results %}
<tr>
<td>
{{ component.part_number }}
<dl class="d-sm-none mb-0">
<dt class="visually-hidden">Total stock</dt>
<dd class="mb-0 fst-italic">{{ component.total_stock }}</dd>
</dl>
</td>
<td class="d-none d-sm-table-cell">{{ component.total_stock }}</td>
{% for _, value in properties %}
{% if value %}
<td>{{ value|format_value }}</td>
{% else %}
<td class="fst-italic">{% translate "n/a" %}</td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
<div class="d-flex flex-wrap column-gap-3 justify-content-between">
{% bootstrap_pagination page %}
{% bootstrap_field search_form.per_page show_label=False %}
</div>
{% endblock %}
{% block extra_js %}
<script src="{% static 'core/search_results.js' %}"></script>
{% endblock %}

View File

@ -7,7 +7,7 @@
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="navbar-collapse collapse show" id="navbarContent"> <div class="navbar-collapse collapse show" id="navbarContent">
<ul class="navbar-nav me-auto"> <ul class="navbar-nav">
{% for item in nav_items %} {% for item in nav_items %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link{% if item.is_active %} active{% endif %}" href="{{ item.link }}"{% if item.is_active %} aria-current="page"{% endif %}> <a class="nav-link{% if item.is_active %} active{% endif %}" href="{{ item.link }}"{% if item.is_active %} aria-current="page"{% endif %}>
@ -16,6 +16,9 @@
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<form action="{% url 'search' %}" class="d-flex mx-auto" id="searchForm" method="get" role="search">
{% bootstrap_field search_form.q show_label="skip" wrapper_class="mb-1 mb-md-0 w-100" %}
</form>
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<form action="{% url 'oidc_logout' %}" class="d-flex justify-content-end" method="post"> <form action="{% url 'oidc_logout' %}" class="d-flex justify-content-end" method="post">
{% csrf_token %} {% csrf_token %}

View File

@ -0,0 +1,49 @@
from django import template
from ..models import Property, Value
SI_PREFIXES = {
-12: "p",
-9: "n",
-6: "µ",
-3: "m",
3: "k",
6: "M",
9: "G",
}
register = template.Library()
@register.filter
def format_value(value):
if not isinstance(value, Value):
return value
property = value.property
match property.type:
case Property.Type.QUANTITY:
s = str(value.integer)
scale = property.scale
if scale > 0:
s += "0" * scale
elif scale < 0:
s = str(value.integer).rjust(abs(scale) + 1, "0")
s = s[:scale] + "." + s[scale:]
s = s.rstrip("0").removesuffix(".")
leading_zeroes = len(s.removeprefix("0.")) - len(
s.removeprefix("0.").lstrip("0")
)
if prefix := SI_PREFIXES.get(-leading_zeroes // 3 * 3, ""):
s = s.removeprefix("0.").lstrip("0")
s = (s[: -leading_zeroes % 3] or "0") + "." + s[-leading_zeroes % 3 :]
trailing_zeroes = len(s) - len(s.rstrip("0"))
if prefix := SI_PREFIXES.get(trailing_zeroes // 3 * 3, ""):
s = s.rstrip("0") + "0" * (trailing_zeroes % 3)
return f"{s} {prefix}{value.property.unit}"
case Property.Type.TEXT:
return value.text

View File

@ -6,5 +6,6 @@ urlpatterns = [
path("", views.index, name="index"), path("", views.index, name="index"),
path("add-stock", views.add_stock, name="add_stock"), path("add-stock", views.add_stock, name="add_stock"),
path("unauthorized", views.unauthorized, name="unauthorized"), path("unauthorized", views.unauthorized, name="unauthorized"),
path("search", views.search, name="search"),
path("admin/login/", views.admin_login), path("admin/login/", views.admin_login),
] ]

View File

@ -1,13 +1,15 @@
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db import transaction from django.db import transaction
from django.db.models import Q, Sum
from django.forms.models import model_to_dict from django.forms.models import model_to_dict
from django.http import QueryDict from django.http import QueryDict
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.translation import ngettext from django.utils.translation import ngettext
from .forms import AddStockFormSet from .forms import AddStockFormSet, SearchForm
from .models import Component, Property, Stock, User, Value from .models import Component, Property, Stock, User, Value
@ -115,6 +117,61 @@ def add_stock(request):
) )
@login_required
def search(request):
data = request.GET.copy()
data.setdefault("per_page", 25)
form = SearchForm(data)
if not form.is_valid() or form.cleaned_data["q"] == "":
return redirect("index")
query = form.cleaned_data["q"]
results = (
Component.objects.prefetch_related("properties")
.annotate(total_stock=Sum("stock__quantity", distinct=True))
.filter(
Q(part_number__icontains=query)
| Q(value__text__icontains=query, value__property__searchable=True)
)
.order_by("pk")
)
paginator = Paginator(results, form.cleaned_data["per_page"])
page = paginator.get_page(data.get("page"))
properties = set()
values = {}
for component in page:
properties |= set(component.properties.all())
values[component.part_number] = {
value.property.name: value for value in component.value_set.all()
}
properties = list(properties)
return render(
request,
"core/search_results.html",
{
"page": page,
"properties": properties,
"query": query,
"results": [
(
component,
[
(property, values[component.part_number].get(property.name))
for property in properties
],
)
for component in page
],
"search_form": SearchForm(initial=data),
},
)
def admin_login(request): def admin_login(request):
if request.user.is_authenticated: if request.user.is_authenticated:
return redirect("unauthorized") return redirect("unauthorized")

View File

@ -75,6 +75,7 @@ TEMPLATES = [
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"lelcsc.core.context_processors.nav", "lelcsc.core.context_processors.nav",
"lelcsc.core.context_processors.search",
], ],
}, },
}, },