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.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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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.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,
|
||||
|
|
Loading…
Reference in New Issue