feat: add FilterForm

This commit is contained in:
Luca 2024-11-16 20:47:32 +01:00
parent 917e4caf9a
commit 8febef371b
6 changed files with 162 additions and 83 deletions

View File

@ -1,6 +1,10 @@
from operator import itemgetter
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import Property
class AddStockForm(forms.Form): class AddStockForm(forms.Form):
part_number = forms.CharField(label=_("Component")) part_number = forms.CharField(label=_("Component"))
@ -33,3 +37,31 @@ class SearchForm(forms.Form):
label=_("Results per page"), label=_("Results per page"),
widget=forms.Select(attrs={"form": "searchForm"}), widget=forms.Select(attrs={"form": "searchForm"}),
) )
class FilterForm(forms.Form):
def __init__(self, properties, components, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
self.properties = properties
for property in self.properties:
choices = set()
for value in property.value_set.filter(component__in=components):
choices.add((getattr(value, property.value_field), str(value)))
# FIXME: If two or more properties share the same slug, the last one will replace all the others
self.fields[property.key] = forms.MultipleChoiceField(
choices=list(sorted(choices, key=itemgetter(0))),
label=property.name,
required=False,
widget=forms.SelectMultiple(attrs={"form": "searchForm"}),
)
def values(self):
for property in self.properties:
key = property.key
if key not in self.cleaned_data or not self.cleaned_data[key]:
continue
yield property, self.cleaned_data[key]

View File

@ -1,4 +1,5 @@
from django.db import models from django.db import models
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -22,6 +23,20 @@ class Property(models.Model):
help_text=_("Exponent of the smallest possible value") help_text=_("Exponent of the smallest possible value")
) )
@property
def key(self):
return slugify(self.name).replace("-", "_")
@property
def value_field(self):
match self.type:
case Property.Type.QUANTITY:
return "integer"
case Property.Type.TEXT:
return "text"
case _:
return None
def __str__(self): def __str__(self):
return self.name + ( return self.name + (
f" [{self.unit}]" if self.type == Property.Type.QUANTITY else "" f" [{self.unit}]" if self.type == Property.Type.QUANTITY else ""
@ -40,6 +55,16 @@ class Value(models.Model):
class Meta: class Meta:
unique_together = ("component", "property") unique_together = ("component", "property")
SI_PREFIXES = {
-12: "p",
-9: "n",
-6: "µ",
-3: "m",
3: "k",
6: "M",
9: "G",
}
property = models.ForeignKey(Property, on_delete=models.CASCADE) property = models.ForeignKey(Property, on_delete=models.CASCADE)
component = models.ForeignKey(Component, on_delete=models.CASCADE) component = models.ForeignKey(Component, on_delete=models.CASCADE)
text = models.TextField() text = models.TextField()
@ -54,9 +79,27 @@ class Value(models.Model):
if scale > 0: if scale > 0:
s += "0" * scale s += "0" * scale
elif scale < 0: elif scale < 0:
s = str(self.integer).rjust(abs(scale) + 1, "0") s = s.rjust(abs(scale) + 1, "0")
s = s[:scale] + "." + s[scale:] s = s[:scale] + "." + s[scale:]
s = s.rstrip("0").removesuffix(".")
return f"{s} {self.property.unit}" leading_zeroes = len(s.removeprefix("0.")) - len(
s.removeprefix("0.").lstrip("0")
)
if prefix := Value.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 not prefix and (
prefix := Value.SI_PREFIXES.get(trailing_zeroes // 3 * 3, "")
):
s = s.rstrip("0") + "0" * (trailing_zeroes % 3)
return f"{s} {prefix}{self.property.unit}"
case Property.Type.TEXT: case Property.Type.TEXT:
return self.text return self.text

View File

@ -1,3 +1,15 @@
document.getElementById('id_per_page').addEventListener('change', event => { document.getElementById('id_per_page').addEventListener('change', event => {
event.target.form.requestSubmit(); event.target.form.requestSubmit();
}); });
const resetButton = document.getElementById('resetButton');
if (resetButton !== null) {
resetButton.addEventListener('click', event => {
document.querySelectorAll('[data-filter-form] select[multiple]').forEach(filter => {
filter.value = '';
});
document.getElementById('searchForm').requestSubmit();
});
}

View File

@ -2,52 +2,72 @@
{% load django_bootstrap5 %} {% load django_bootstrap5 %}
{% load i18n %} {% load i18n %}
{% load properties %}
{% load static %} {% load static %}
{% block page_title %}{% translate "Search results for" %} "{{ query }}"{% endblock %} {% block page_title %}{% translate "Search results for" %} "{{ query }}"{% endblock %}
{% block content %} {% block content %}
<h1>{% translate "Search results for" %} "{{ query }}"</h1> <h1>{% translate "Search results for" %} "{{ query }}"</h1>
<table class="table"> {% if results %}
<thead> <div class="mb-1 overflow-x-scroll">
<tr> <div class="d-flex gap-3 mb-2" data-filter-form="">
<th> {% for field in filter_form %}
{% translate "Part number" %} {% bootstrap_field field wrapper_class="col-6 col-sm-4 col-md-3 col-lg-2" %}
<div class="d-sm-none fst-italic">{% translate "Total stock" %}</div> {% empty %}
</th> <h4 class="mb-0">No filters available</h4>
<th class="d-none d-sm-table-cell">{% translate "Total stock" %}</th>
{% for property in properties %}
<th>{{ property.name }}</th>
{% endfor %} {% endfor %}
</tr> </div>
</thead> </div>
<tbody> <div class="d-flex gap-1 justify-content-end mb-3">
{% translate "Reset filters" as reset_button_text %}
{% bootstrap_button reset_button_text button_class="btn-outline-primary" button_type="button" id="resetButton" %}
{% translate "Filter results" as filter_button_text %}
{% bootstrap_button filter_button_text button_type="submit" form="searchForm" %}
</div>
<div class="table-responsive mb-3">
<table class="table mb-0">
<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 %} {% for component, properties in results %}
<tr> <tr>
<td> <td>
{{ component.part_number }} {{ component.part_number }}
<dl class="d-sm-none mb-0"> <dl class="d-sm-none mb-0">
<dt class="visually-hidden">Total stock</dt> <dt class="visually-hidden">Total stock</dt>
<dd class="mb-0 fst-italic">{{ component.total_stock }}</dd> <dd class="mb-0 fst-italic">{{ component.total_stock|default:0 }}</dd>
</dl> </dl>
</td> </td>
<td class="d-none d-sm-table-cell">{{ component.total_stock }}</td> <td class="d-none d-sm-table-cell">{{ component.total_stock|default:0 }}</td>
{% for _, value in properties %} {% for _, value in properties %}
{% if value %} {% if value %}
<td>{{ value|format_value }}</td> <td>{{ value }}</td>
{% else %} {% else %}
<td class="fst-italic">{% translate "n/a" %}</td> <td class="fst-italic">{% translate "n/a" %}</td>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
<div class="d-flex flex-wrap column-gap-3 justify-content-between"> <div class="d-flex flex-wrap column-gap-3 justify-content-between">
{% bootstrap_pagination page %} {% bootstrap_pagination page %}
{% bootstrap_field search_form.per_page show_label=False %} {% bootstrap_field search_form.per_page show_label=False %}
</div> </div>
{% else %}
<h3>{% translate "No results" %}</h3>
{% endif %}
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}

View File

@ -1,49 +0,0 @@
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

@ -9,7 +9,7 @@ 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, SearchForm from .forms import AddStockFormSet, FilterForm, SearchForm
from .models import Component, Property, Stock, User, Value from .models import Component, Property, Stock, User, Value
@ -100,11 +100,11 @@ def add_stock(request):
request, request,
messages.SUCCESS, messages.SUCCESS,
ngettext( ngettext(
formset.total_form_count, formset.total_form_count(),
"Added %(count)d stock entry", "Added %(count)d stock entry",
"Added %(count)d stock entries", "Added %(count)d stock entries",
) )
% {"count": formset.total_form_count}, % {"count": formset.total_form_count()},
) )
return render( return render(
@ -128,7 +128,7 @@ def search(request):
query = form.cleaned_data["q"] query = form.cleaned_data["q"]
results = ( results = (
Component.objects.prefetch_related("properties") Component.objects.prefetch_related("properties", "value_set")
.annotate(total_stock=Sum("stock__quantity", distinct=True)) .annotate(total_stock=Sum("stock__quantity", distinct=True))
.filter( .filter(
Q(part_number__icontains=query) Q(part_number__icontains=query)
@ -137,6 +137,20 @@ def search(request):
.order_by("pk") .order_by("pk")
) )
filter_form = FilterForm(
Property.objects.prefetch_related("value_set")
.filter(filterable=True, component__in=results)
.distinct(),
results,
data,
)
if filter_form.is_valid():
for property, selected_values in filter_form.values():
results = results.filter(
value__property=property,
**{f"value__{property.value_field}__in": selected_values},
)
paginator = Paginator(results, form.cleaned_data["per_page"]) paginator = Paginator(results, form.cleaned_data["per_page"])
page = paginator.get_page(data.get("page")) page = paginator.get_page(data.get("page"))
@ -154,6 +168,13 @@ def search(request):
request, request,
"core/search_results.html", "core/search_results.html",
{ {
"filter_form": FilterForm(
Property.objects.prefetch_related("value_set")
.filter(filterable=True, component__in=results)
.distinct(),
results,
initial=data,
),
"page": page, "page": page,
"properties": properties, "properties": properties,
"query": query, "query": query,