feat: implement search
This commit is contained in:
parent
db45e163a9
commit
917e4caf9a
|
@ -1,5 +1,5 @@
|
||||||
root = true
|
root = true
|
||||||
|
|
||||||
[*.html]
|
[*.{html,js}]
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
indent_style = space
|
indent_style = space
|
||||||
|
|
|
@ -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()}
|
||||||
|
|
|
@ -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"}),
|
||||||
|
)
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
document.getElementById('id_per_page').addEventListener('change', event => {
|
||||||
|
event.target.form.requestSubmit();
|
||||||
|
});
|
|
@ -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 %}
|
|
@ -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 %}
|
||||||
|
|
|
@ -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
|
|
@ -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),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in New Issue