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.utils.translation import gettext_lazy as _
from .models import Property
class AddStockForm(forms.Form):
part_number = forms.CharField(label=_("Component"))
@ -33,3 +37,31 @@ class SearchForm(forms.Form):
label=_("Results per page"),
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.utils.text import slugify
from django.utils.translation import gettext_lazy as _
@ -22,6 +23,20 @@ class Property(models.Model):
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):
return self.name + (
f" [{self.unit}]" if self.type == Property.Type.QUANTITY else ""
@ -40,6 +55,16 @@ class Value(models.Model):
class Meta:
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)
component = models.ForeignKey(Component, on_delete=models.CASCADE)
text = models.TextField()
@ -54,9 +79,27 @@ class Value(models.Model):
if scale > 0:
s += "0" * scale
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.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:
return self.text

View File

@ -1,3 +1,15 @@
document.getElementById('id_per_page').addEventListener('change', event => {
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 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>
{% if results %}
<div class="mb-1 overflow-x-scroll">
<div class="d-flex gap-3 mb-2" data-filter-form="">
{% for field in filter_form %}
{% bootstrap_field field wrapper_class="col-6 col-sm-4 col-md-3 col-lg-2" %}
{% empty %}
<h4 class="mb-0">No filters available</h4>
{% endfor %}
</tr>
</thead>
<tbody>
</div>
</div>
<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 %}
<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>
<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|default:0 }}</dd>
</dl>
</td>
<td class="d-none d-sm-table-cell">{{ component.total_stock|default:0 }}</td>
{% for _, value in properties %}
{% if value %}
<td>{{ value|format_value }}</td>
<td>{{ value }}</td>
{% else %}
<td class="fst-italic">{% translate "n/a" %}</td>
<td class="fst-italic">{% translate "n/a" %}</td>
{% endif %}
{% endfor %}
</tr>
</tr>
{% endfor %}
</tbody>
</table>
</tbody>
</table>
</div>
<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>
{% else %}
<h3>{% translate "No results" %}</h3>
{% endif %}
{% endblock %}
{% 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.utils.translation import ngettext
from .forms import AddStockFormSet, SearchForm
from .forms import AddStockFormSet, FilterForm, SearchForm
from .models import Component, Property, Stock, User, Value
@ -100,11 +100,11 @@ def add_stock(request):
request,
messages.SUCCESS,
ngettext(
formset.total_form_count,
formset.total_form_count(),
"Added %(count)d stock entry",
"Added %(count)d stock entries",
)
% {"count": formset.total_form_count},
% {"count": formset.total_form_count()},
)
return render(
@ -128,7 +128,7 @@ def search(request):
query = form.cleaned_data["q"]
results = (
Component.objects.prefetch_related("properties")
Component.objects.prefetch_related("properties", "value_set")
.annotate(total_stock=Sum("stock__quantity", distinct=True))
.filter(
Q(part_number__icontains=query)
@ -137,6 +137,20 @@ def search(request):
.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"])
page = paginator.get_page(data.get("page"))
@ -154,6 +168,13 @@ def search(request):
request,
"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,
"properties": properties,
"query": query,