From 8febef371ba8fe9e63194f963e9940a3420e21bd Mon Sep 17 00:00:00 2001 From: Luca Date: Sat, 16 Nov 2024 20:47:32 +0100 Subject: [PATCH] feat: add FilterForm --- lelcsc/core/forms.py | 32 ++++++++ lelcsc/core/models/component.py | 47 +++++++++++- lelcsc/core/static/core/search_results.js | 12 +++ .../core/templates/core/search_results.html | 76 ++++++++++++------- lelcsc/core/templatetags/properties.py | 49 ------------ lelcsc/core/views.py | 29 ++++++- 6 files changed, 162 insertions(+), 83 deletions(-) delete mode 100644 lelcsc/core/templatetags/properties.py diff --git a/lelcsc/core/forms.py b/lelcsc/core/forms.py index 9036a55..d7a349e 100644 --- a/lelcsc/core/forms.py +++ b/lelcsc/core/forms.py @@ -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] diff --git a/lelcsc/core/models/component.py b/lelcsc/core/models/component.py index e910096..3052e7f 100644 --- a/lelcsc/core/models/component.py +++ b/lelcsc/core/models/component.py @@ -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 diff --git a/lelcsc/core/static/core/search_results.js b/lelcsc/core/static/core/search_results.js index 3c17c93..0219fc1 100644 --- a/lelcsc/core/static/core/search_results.js +++ b/lelcsc/core/static/core/search_results.js @@ -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(); + }); +} diff --git a/lelcsc/core/templates/core/search_results.html b/lelcsc/core/templates/core/search_results.html index 92daaa9..11f5bd8 100644 --- a/lelcsc/core/templates/core/search_results.html +++ b/lelcsc/core/templates/core/search_results.html @@ -2,52 +2,72 @@ {% load django_bootstrap5 %} {% load i18n %} -{% load properties %} {% load static %} {% block page_title %}{% translate "Search results for" %} "{{ query }}"{% endblock %} {% block content %}

{% translate "Search results for" %} "{{ query }}"

- - - - - -{% for property in properties %} - +{% if results %} +
+
+{% for field in filter_form %} + {% bootstrap_field field wrapper_class="col-6 col-sm-4 col-md-3 col-lg-2" %} +{% empty %} +

No filters available

{% endfor %} -
- - + + +
+ {% 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" %} +
+
+
- {% translate "Part number" %} -
{% translate "Total stock" %}
-
{% translate "Total stock" %}{{ property.name }}
+ + + + +{% for property in properties %} + +{% endfor %} + + + {% for component, properties in results %} - - - + + + {% for _, value in properties %} {% if value %} - + {% else %} - + {% endif %} {% endfor %} - + {% endfor %} - -
+ {% translate "Part number" %} +
{% translate "Total stock" %}
+
{% translate "Total stock" %}{{ property.name }}
- {{ component.part_number }} -
-
Total stock
-
{{ component.total_stock }}
-
-
{{ component.total_stock }}
+ {{ component.part_number }} +
+
Total stock
+
{{ component.total_stock|default:0 }}
+
+
{{ component.total_stock|default:0 }}{{ value|format_value }}{{ value }}{% translate "n/a" %}{% translate "n/a" %}
+ + +
{% bootstrap_pagination page %} {% bootstrap_field search_form.per_page show_label=False %}
+{% else %} +

{% translate "No results" %}

+{% endif %} {% endblock %} {% block extra_js %} diff --git a/lelcsc/core/templatetags/properties.py b/lelcsc/core/templatetags/properties.py deleted file mode 100644 index 33ba63d..0000000 --- a/lelcsc/core/templatetags/properties.py +++ /dev/null @@ -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 diff --git a/lelcsc/core/views.py b/lelcsc/core/views.py index 1c8411d..6b674e9 100644 --- a/lelcsc/core/views.py +++ b/lelcsc/core/views.py @@ -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,