feat: add FilterForm
This commit is contained in:
parent
917e4caf9a
commit
8febef371b
|
@ -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]
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -2,14 +2,30 @@
|
||||||
|
|
||||||
{% 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 %}
|
||||||
|
<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 %}
|
||||||
|
</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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
|
@ -29,13 +45,13 @@
|
||||||
{{ 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 %}
|
||||||
|
@ -43,11 +59,15 @@
|
||||||
</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 %}
|
||||||
|
|
|
@ -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
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in New Issue