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
[*.html]
[*.{html,js}]
indent_size = 4
indent_style = space

View File

@ -1,3 +1,4 @@
from .forms import SearchForm
from .signals import populate_nav
@ -10,3 +11,7 @@ def nav(request):
]
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)
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):
return (
self.name + f" [{self.unit}]" if self.type == Property.Type.QUANTITY else ""
return self.name + (
f" [{self.unit}]" if self.type == Property.Type.QUANTITY else ""
)
@ -48,13 +48,13 @@ class Value(models.Model):
def __str__(self):
match self.property.type:
case Property.Type.QUANTITY:
s = str(integer)
s = str(self.integer)
scale = self.property.scale
if scale > 0:
s += "0" * scale
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:]
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 i18n %}
<nav class="navbar navbar-expand-md bg-primary" role="navigation" data-bs-theme="dark">
<div class="container-fluid">
<nav class="navbar navbar-expand-md sticky-top bg-primary mb-3" role="navigation" data-bs-theme="dark">
<div class="container-fluid" data-bs-theme="light">
<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" %}">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse show" id="navbarContent">
<ul class="navbar-nav me-auto">
<ul class="navbar-nav">
{% for item in nav_items %}
<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 %}>
@ -16,8 +16,11 @@
</li>
{% endfor %}
</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 %}
<form action="{% url 'oidc_logout' %}" method="post">
<form action="{% url 'oidc_logout' %}" class="d-flex justify-content-end" method="post">
{% csrf_token %}
{% translate "Log out" as 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("add-stock", views.add_stock, name="add_stock"),
path("unauthorized", views.unauthorized, name="unauthorized"),
path("search", views.search, name="search"),
path("admin/login/", views.admin_login),
]

View File

@ -1,13 +1,15 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db import transaction
from django.db.models import Q, Sum
from django.forms.models import model_to_dict
from django.http import QueryDict
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.translation import ngettext
from .forms import AddStockFormSet
from .forms import AddStockFormSet, SearchForm
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):
if request.user.is_authenticated:
return redirect("unauthorized")

View File

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