Compare commits

...

3 Commits

Author SHA1 Message Date
Luca 917e4caf9a feat: implement search 2024-11-10 01:41:14 +01:00
Luca db45e163a9 chore: improve navbar style 2024-11-10 01:35:50 +01:00
Luca 41e66b0962 fix: property/value stringification 2024-11-10 01:24:51 +01:00
11 changed files with 202 additions and 10 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

@ -23,8 +23,8 @@ class Property(models.Model):
) )
def __str__(self): def __str__(self):
return ( return self.name + (
self.name + f" [{self.unit}]" if self.type == Property.Type.QUANTITY else "" f" [{self.unit}]" if self.type == Property.Type.QUANTITY else ""
) )
@ -48,13 +48,13 @@ class Value(models.Model):
def __str__(self): def __str__(self):
match self.property.type: match self.property.type:
case Property.Type.QUANTITY: case Property.Type.QUANTITY:
s = str(integer) s = str(self.integer)
scale = self.property.scale scale = self.property.scale
if scale > 0: if scale > 0:
s += "0" * scale s += "0" * scale
elif scale < 0: elif scale < 0:
s = str(integer).rjust(abs(scale) + 1, "0") s = str(self.integer).rjust(abs(scale) + 1, "0")
s = s[:scale] + "." + s[scale:] s = s[:scale] + "." + s[scale:]
return f"{s} {self.property.unit}" return f"{s} {self.property.unit}"

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

@ -1,13 +1,13 @@
{% load django_bootstrap5 %} {% load django_bootstrap5 %}
{% load i18n %} {% load i18n %}
<nav class="navbar navbar-expand-md bg-primary" role="navigation" data-bs-theme="dark"> <nav class="navbar navbar-expand-md sticky-top bg-primary mb-3" role="navigation" data-bs-theme="dark">
<div class="container-fluid"> <div class="container-fluid" data-bs-theme="light">
<a class="navbar-brand" href="{% url 'index' %}">lelcsc</a> <a class="navbar-brand" href="{% url 'index' %}">lelcsc</a>
<button class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarContent" aria-controls="navbarContent" aria-expanded="true" aria-label="{% translate "Toggle navigation" %}"> <button class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarContent" aria-controls="navbarContent" aria-expanded="true" aria-label="{% translate "Toggle navigation" %}">
<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,8 +16,11 @@
</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' %}" method="post"> <form action="{% url 'oidc_logout' %}" class="d-flex justify-content-end" method="post">
{% csrf_token %} {% csrf_token %}
{% translate "Log out" as logout_button_text %} {% translate "Log out" as logout_button_text %}
{% bootstrap_button button_type="submit" content=logout_button_text %} {% bootstrap_button button_type="submit" content=logout_button_text %}

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",
], ],
}, },
}, },