Compare commits

5 Commits

Author SHA1 Message Date
imperosol
a68f16ba9d add tests 2025-11-30 19:12:37 +01:00
imperosol
1a99f4096e make matmatronch form more readable 2025-11-30 19:12:37 +01:00
imperosol
559a904e0d refactor: Matmatronch 2025-11-30 19:11:51 +01:00
imperosol
fca6a58c5e feat: querystring jinja macro 2025-11-30 16:55:44 +01:00
imperosol
39c3e11d88 extract matmat forms into their own file 2025-11-29 14:48:30 +01:00
30 changed files with 212 additions and 734 deletions

View File

@@ -35,7 +35,7 @@ TODO : rewrite the pagination used in this template an Alpine one
{% csrf_token %}
{{ form }}
<p><input type="submit" value="{% trans %}Show{% endtrans %}" /></p>
<p><input type="submit" value="{% trans %}Download as csv{% endtrans %}" formaction="{{ url('club:sellings_csv', club_id=object.id) }}"/></p>
<p><input type="submit" value="{% trans %}Download as cvs{% endtrans %}" formaction="{{ url('club:sellings_csv', club_id=object.id) }}"/></p>
</form>
<p>
{% trans %}Quantity: {% endtrans %}{{ total_quantity }} {% trans %}units{% endtrans %}<br/>

View File

@@ -211,7 +211,7 @@
</li>
<li>
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
<a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
<a href="{{ url("matmat:search") }}">{% trans %}Matmatronch{% endtrans %}</a>
</li>
<li>
<i class="fa-solid fa-check-to-slot fa-xl"></i>

View File

@@ -1,3 +1,4 @@
from datetime import datetime
from pathlib import Path
from typing import Annotated, Any
@@ -8,12 +9,12 @@ from django.urls import reverse
from django.utils.text import slugify
from django.utils.translation import gettext as _
from haystack.query import SearchQuerySet
from ninja import FilterSchema, ModelSchema, Schema, UploadedFile
from pydantic import AliasChoices, Field
from ninja import FilterLookup, FilterSchema, ModelSchema, Schema, UploadedFile
from pydantic import AliasChoices, Field, field_validator
from pydantic_core.core_schema import ValidationInfo
from core.models import Group, QuickUploadImage, SithFile, User
from core.utils import is_image
from core.utils import get_last_promo, is_image
NonEmptyStr = Annotated[str, MinLen(1)]
@@ -109,7 +110,11 @@ class GroupSchema(ModelSchema):
class UserFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)]
search: Annotated[str, MinLen(1)] | None = None
role: Annotated[str, FilterLookup("role__icontains")] | None = None
department: str | None = None
promo: int | None = None
date_of_birth: datetime | None = None
exclude: list[int] | None = Field(
None, validation_alias=AliasChoices("exclude", "exclude[]")
)
@@ -138,6 +143,13 @@ class UserFilterSchema(FilterSchema):
return Q()
return ~Q(id__in=value)
@field_validator("promo", mode="after")
@classmethod
def validate_promo(cls, value: int) -> int:
if not 0 < value <= get_last_promo():
raise ValueError(f"{value} is not a valid promo")
return value
class MarkdownSchema(Schema):
text: str

View File

@@ -143,6 +143,15 @@ form {
line-height: 1;
white-space: nowrap;
.fields-centered {
padding: 10px 10px 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--nf-input-size) 10px;
justify-content: center;
}
.helptext {
margin-top: .25rem;
margin-bottom: .25rem;

View File

@@ -114,15 +114,6 @@
}
}
&-fields {
padding: 10px 10px 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--nf-input-size) 10px;
justify-content: center;
}
&-field {
display: flex;
flex-wrap: wrap;

View File

@@ -23,7 +23,7 @@
<details name="navbar" class="menu">
<summary class="head">{% trans %}Services{% endtrans %}</summary>
<ul class="content">
<li><a href="{{ url('matmat:search_clear') }}">{% trans %}Matmatronch{% endtrans %}</a></li>
<li><a href="{{ url('matmat:search') }}">{% trans %}Matmatronch{% endtrans %}</a></li>
<li><a href="{{ url('core:file_list') }}">{% trans %}Files{% endtrans %}</a></li>
<li><a href="{{ url('pedagogy:guide') }}">{% trans %}Pedagogy{% endtrans %}</a></li>
</ul>

View File

@@ -157,12 +157,12 @@
{% if current_page.has_previous() %}
<a
{% if use_htmx -%}
hx-get="?page={{ current_page.previous_page_number() }}"
hx-get="?{{ querystring(page=current_page.previous_page_number()) }}"
hx-swap="innerHTML"
hx-target="#content"
hx-push-url="true"
{%- else -%}
href="?page={{ current_page.previous_page_number() }}"
href="?{{ querystring(page=current_page.previous_page_number()) }}"
{%- endif -%}
>
<button>
@@ -180,12 +180,12 @@
{% else %}
<a
{% if use_htmx -%}
hx-get="?page={{ i }}"
hx-get="?{{ querystring(page=i) }}"
hx-swap="innerHTML"
hx-target="#content"
hx-push-url="true"
{%- else -%}
href="?page={{ i }}"
href="?{{ querystring(page=i) }}"
{%- endif -%}
>
<button>{{ i }}</button>
@@ -195,12 +195,12 @@
{% if current_page.has_next() %}
<a
{% if use_htmx -%}
hx-get="?page={{ current_page.next_page_number() }}"
hx-get="?querystring(page=current_page.next_page_number())"
hx-swap="innerHTML"
hx-target="#content"
hx-push-url="true"
{%- else -%}
href="?page={{ current_page.next_page_number() }}"
href="?querystring(page=current_page.next_page_number())"
{%- endif -%}
><button>
<i class="fa fa-caret-right"></i>
@@ -249,3 +249,17 @@
}"></div>
{% endif %}
{% endmacro %}
{% macro querystring() %}
{%- for key, values in request.GET.lists() -%}
{%- if key not in kwargs -%}
{%- for value in values -%}
{{ key }}={{ value }}&amp;
{%- endfor -%}
{%- endif -%}
{%- endfor -%}
{%- for key, value in kwargs.items() -%}
{{ key }}={{ value }}&amp;
{%- endfor -%}
{% endmacro %}

View File

@@ -114,7 +114,7 @@
{# All fields #}
<div class="profile-fields">
<div class="fields-centered">
{%- for field in form -%}
{%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_viewable","forum_signature"] -%}
{%- continue -%}
@@ -133,7 +133,7 @@
</div>
{# Textareas #}
<div class="profile-fields">
<div class="fields-centered">
{%- for field in [form.quote, form.forum_signature] -%}
<div class="profile-field">
{{ field.label_tag() }}

View File

@@ -5,7 +5,6 @@ from datetime import date, datetime, timezone
from dateutil.relativedelta import relativedelta
from django import forms
from django.core.validators import MaxValueValidator
from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet
from django.utils.timezone import now
@@ -35,7 +34,6 @@ from counter.models import (
Eticket,
InvoiceCall,
Product,
ProductFormula,
Refilling,
ReturnableProduct,
ScheduledProductAction,
@@ -318,6 +316,7 @@ class ProductForm(forms.ModelForm):
}
counters = forms.ModelMultipleChoiceField(
help_text=None,
label=_("Counters"),
required=False,
widget=AutoCompleteSelectMultipleCounter,
@@ -328,31 +327,10 @@ class ProductForm(forms.ModelForm):
super().__init__(*args, instance=instance, **kwargs)
if self.instance.id:
self.fields["counters"].initial = self.instance.counters.all()
if hasattr(self.instance, "formula"):
self.formula_init(self.instance.formula)
self.action_formset = ScheduledProductActionFormSet(
*args, product=self.instance, **kwargs
)
def formula_init(self, formula: ProductFormula):
"""Part of the form initialisation specific to formula products."""
self.fields["selling_price"].help_text = _(
"This product is a formula. "
"Its price cannot be greater than the price "
"of the products constituting it, which is %(price)s"
) % {"price": formula.max_selling_price}
self.fields["special_selling_price"].help_text = _(
"This product is a formula. "
"Its special price cannot be greater than the price "
"of the products constituting it, which is %(price)s"
) % {"price": formula.max_special_selling_price}
for key, price in (
("selling_price", formula.max_selling_price),
("special_selling_price", formula.max_special_selling_price),
):
self.fields[key].widget.attrs["max"] = price
self.fields[key].validators.append(MaxValueValidator(price))
def is_valid(self):
return super().is_valid() and self.action_formset.is_valid()
@@ -371,47 +349,13 @@ class ProductForm(forms.ModelForm):
return product
class ProductFormulaForm(forms.ModelForm):
class Meta:
model = ProductFormula
fields = ["products", "result"]
widgets = {
"products": AutoCompleteSelectMultipleProduct,
"result": AutoCompleteSelectProduct,
}
def clean(self):
cleaned_data = super().clean()
if cleaned_data["result"] in cleaned_data["products"]:
self.add_error(
None,
_(
"The same product cannot be at the same time "
"the result and a part of the formula."
),
)
prices = [p.selling_price for p in cleaned_data["products"]]
special_prices = [p.special_selling_price for p in cleaned_data["products"]]
selling_price = cleaned_data["result"].selling_price
special_selling_price = cleaned_data["result"].special_selling_price
if selling_price > sum(prices) or special_selling_price > sum(special_prices):
self.add_error(
"result",
_(
"The result cannot be more expensive "
"than the total of the other products."
),
)
return cleaned_data
class ReturnableProductForm(forms.ModelForm):
class Meta:
model = ReturnableProduct
fields = ["product", "returned_product", "max_return"]
widgets = {
"product": AutoCompleteSelectProduct,
"returned_product": AutoCompleteSelectProduct,
"product": AutoCompleteSelectProduct(),
"returned_product": AutoCompleteSelectProduct(),
}
def save(self, commit: bool = True) -> ReturnableProduct: # noqa FBT

View File

@@ -1,43 +0,0 @@
# Generated by Django 5.2.8 on 2025-11-26 11:34
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("counter", "0035_remove_selling_is_validated_and_more")]
operations = [
migrations.CreateModel(
name="ProductFormula",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"products",
models.ManyToManyField(
help_text="The products that constitute this formula.",
related_name="formulas",
to="counter.product",
verbose_name="products",
),
),
(
"result",
models.OneToOneField(
help_text="The formula product.",
on_delete=django.db.models.deletion.CASCADE,
to="counter.product",
verbose_name="result product",
),
),
],
),
]

View File

@@ -455,37 +455,6 @@ class Product(models.Model):
return self.selling_price - self.purchase_price
class ProductFormula(models.Model):
products = models.ManyToManyField(
Product,
related_name="formulas",
verbose_name=_("products"),
help_text=_("The products that constitute this formula."),
)
result = models.OneToOneField(
Product,
related_name="formula",
on_delete=models.CASCADE,
verbose_name=_("result product"),
help_text=_("The product got with the formula."),
)
def __str__(self):
return self.result.name
@cached_property
def max_selling_price(self) -> float:
# iterating over all products is less efficient than doing
# a simple aggregation, but this method is likely to be used in
# coordination with `max_special_selling_price`,
# and Django caches the result of the `all` queryset.
return sum(p.selling_price for p in self.products.all())
@cached_property
def max_special_selling_price(self) -> float:
return sum(p.special_selling_price for p in self.products.all())
class CounterQuerySet(models.QuerySet):
def annotate_has_barman(self, user: User) -> Self:
"""Annotate the queryset with the `user_is_barman` field.

View File

@@ -1,10 +1,6 @@
import { AlertMessage } from "#core:utils/alert-message";
import { BasketItem } from "#counter:counter/basket";
import type {
CounterConfig,
ErrorMessage,
ProductFormula,
} from "#counter:counter/types";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
import type { CounterProductSelect } from "./components/counter-product-select-index.ts";
document.addEventListener("alpine:init", () => {
@@ -51,43 +47,15 @@ document.addEventListener("alpine:init", () => {
this.basket[id] = item;
this.checkFormulas();
if (this.sumBasket() > this.customerBalance) {
item.quantity = oldQty;
if (item.quantity === 0) {
delete this.basket[id];
}
this.alertMessage.display(gettext("Not enough money"), { success: false });
return gettext("Not enough money");
}
},
checkFormulas() {
const products = new Set(
Object.keys(this.basket).map((i: string) => Number.parseInt(i)),
);
const formula: ProductFormula = config.formulas.find((f: ProductFormula) => {
return f.products.every((p: number) => products.has(p));
});
if (formula === undefined) {
return;
}
for (const product of formula.products) {
const key = product.toString();
this.basket[key].quantity -= 1;
if (this.basket[key].quantity <= 0) {
this.removeFromBasket(key);
}
}
this.alertMessage.display(
interpolate(
gettext("Formula %(formula)s applied"),
{ formula: config.products[formula.result.toString()].name },
true,
),
{ success: true },
);
this.addToBasket(formula.result.toString(), 1);
return "";
},
getBasketSize() {
@@ -102,7 +70,14 @@ document.addEventListener("alpine:init", () => {
(acc: number, cur: BasketItem) => acc + cur.sum(),
0,
) as number;
return Math.round(total * 100) / 100;
return total;
},
addToBasketWithMessage(id: string, quantity: number) {
const message = this.addToBasket(id, quantity);
if (message.length > 0) {
this.alertMessage.display(message, { success: false });
}
},
onRefillingSuccess(event: CustomEvent) {
@@ -141,7 +116,7 @@ document.addEventListener("alpine:init", () => {
this.finish();
}
} else {
this.addToBasket(code, quantity);
this.addToBasketWithMessage(code, quantity);
}
this.codeField.widget.clear();
this.codeField.widget.focus();

View File

@@ -7,16 +7,10 @@ export interface InitialFormData {
errors?: string[];
}
export interface ProductFormula {
result: number;
products: number[];
}
export interface CounterConfig {
customerBalance: number;
customerId: number;
products: Record<string, Product>;
formulas: ProductFormula[];
formInitial: InitialFormData[];
cancelUrl: string;
}

View File

@@ -10,12 +10,12 @@
float: right;
}
.basket-message-container {
.basket-error-container {
position: relative;
display: block
}
.basket-message {
.basket-error {
z-index: 10; // to get on top of tomselect
text-align: center;
position: absolute;

View File

@@ -32,11 +32,13 @@
<div id="bar-ui" x-data="counter({
customerBalance: {{ customer.amount }},
products: products,
formulas: formulas,
customerId: {{ customer.pk }},
formInitial: formInitial,
cancelUrl: '{{ cancel_url }}',
})">
<noscript>
<p class="important">Javascript is required for the counter UI.</p>
</noscript>
<div id="user_info">
<h5>{% trans %}Customer{% endtrans %}</h5>
@@ -86,12 +88,11 @@
<form x-cloak method="post" action="" x-ref="basketForm">
<div class="basket-message-container">
<div class="basket-error-container">
<div
x-cloak
class="alert basket-message"
:class="alertMessage.success ? 'alert-green' : 'alert-red'"
x-show="alertMessage.open"
class="alert alert-red basket-error"
x-show="alertMessage.show"
x-transition.duration.500ms
x-text="alertMessage.content"
></div>
@@ -110,9 +111,9 @@
</div>
</template>
<button @click.prevent="addToBasket(item.product.id, -1)">-</button>
<button @click.prevent="addToBasketWithMessage(item.product.id, -1)">-</button>
<span class="quantity" x-text="item.quantity"></span>
<button @click.prevent="addToBasket(item.product.id, 1)">+</button>
<button @click.prevent="addToBasketWithMessage(item.product.id, 1)">+</button>
<span x-text="item.product.name"></span> :
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span>
@@ -212,7 +213,7 @@
<h5 class="margin-bottom">{{ category }}</h5>
<div class="row gap-2x">
{% for product in categories[category] -%}
<button class="card shadow" @click="addToBasket('{{ product.id }}', 1)">
<button class="card shadow" @click="addToBasketWithMessage('{{ product.id }}', 1)">
<img
class="card-image"
alt="image de {{ product.name }}"
@@ -251,18 +252,6 @@
},
{%- endfor -%}
};
const formulas = [
{%- for formula in formulas -%}
{
result: {{ formula.result_id }},
products: [
{%- for product in formula.products.all() -%}
{{ product.id }},
{%- endfor -%}
]
},
{%- endfor -%}
];
const formInitial = [
{%- for f in form -%}
{%- if f.cleaned_data -%}

View File

@@ -1,35 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Product formulas{% endtrans %}
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
<link rel="stylesheet" href="{{ static("counter/css/admin.scss") }}">
{% endblock %}
{% block content %}
<main>
<h3 class="margin-bottom">{% trans %}Product formulas{% endtrans %}</h3>
<p>
<a href="{{ url('counter:product_formula_create') }}" class="btn btn-blue">
{% trans %}New formula{% endtrans %}
<i class="fa fa-plus"></i>
</a>
</p>
<ul class="product-group">
{%- for formula in object_list -%}
<li>
<a href="{{ url('counter:product_formula_edit', formula_id=formula.id) }}">
{{ formula.result.name }}
</a>
<a href="{{ url('counter:product_formula_delete', formula_id=formula.id) }}">
<i class="fa fa-trash delete-action"></i>
</a>
</li>
{%- endfor -%}
</ul>
</div>
</main>
{% endblock %}

View File

@@ -89,7 +89,7 @@
:disabled="csvLoading"
:aria-busy="csvLoading"
>
{% trans %}Download as csv{% endtrans %} <i class="fa fa-file-arrow-down"></i>
{% trans %}Download as cvs{% endtrans %} <i class="fa fa-file-arrow-down"></i>
</button>
</div>

View File

@@ -1,59 +0,0 @@
from django.test import TestCase
from counter.baker_recipes import product_recipe
from counter.forms import ProductFormulaForm
class TestFormulaForm(TestCase):
@classmethod
def setUpTestData(cls):
cls.products = product_recipe.make(
selling_price=iter([1.5, 1, 1]),
special_selling_price=iter([1.4, 0.9, 0.9]),
_quantity=3,
_bulk_create=True,
)
def test_ok(self):
form = ProductFormulaForm(
data={
"result": self.products[0].id,
"products": [self.products[1].id, self.products[2].id],
}
)
assert form.is_valid()
formula = form.save()
assert formula.result == self.products[0]
assert set(formula.products.all()) == set(self.products[1:])
def test_price_invalid(self):
self.products[0].selling_price = 2.1
self.products[0].save()
form = ProductFormulaForm(
data={
"result": self.products[0].id,
"products": [self.products[1].id, self.products[2].id],
}
)
assert not form.is_valid()
assert form.errors == {
"result": [
"Le résultat ne peut pas être plus cher "
"que le total des autres produits."
]
}
def test_product_both_in_result_and_products(self):
form = ProductFormulaForm(
data={
"result": self.products[0].id,
"products": [self.products[0].id, self.products[1].id],
}
)
assert not form.is_valid()
assert form.errors == {
"__all__": [
"Un même produit ne peut pas être à la fois "
"le résultat et un élément de la formule."
]
}

View File

@@ -15,9 +15,8 @@ from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club
from core.baker_recipes import board_user, subscriber_user
from core.models import Group, User
from counter.baker_recipes import product_recipe
from counter.forms import ProductForm
from counter.models import Product, ProductFormula, ProductType
from counter.models import Product, ProductType
@pytest.mark.django_db
@@ -94,9 +93,6 @@ class TestCreateProduct(TestCase):
def setUpTestData(cls):
cls.product_type = baker.make(ProductType)
cls.club = baker.make(Club)
cls.counter_admin = baker.make(
User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)]
)
cls.data = {
"name": "foo",
"description": "bar",
@@ -120,36 +116,13 @@ class TestCreateProduct(TestCase):
assert instance.name == "foo"
assert instance.selling_price == 1.0
def test_form_with_product_from_formula(self):
"""Test when the edited product is a result of a formula."""
self.client.force_login(self.counter_admin)
products = product_recipe.make(
selling_price=iter([1.5, 1, 1]),
special_selling_price=iter([1.4, 0.9, 0.9]),
_quantity=3,
_bulk_create=True,
)
baker.make(ProductFormula, result=products[0], products=products[1:])
data = self.data | {"selling_price": 1.7, "special_selling_price": 1.5}
form = ProductForm(data=data, instance=products[0])
assert form.is_valid()
# it shouldn't be possible to give a price higher than the formula's products
data = self.data | {"selling_price": 2.1, "special_selling_price": 1.9}
form = ProductForm(data=data, instance=products[0])
assert not form.is_valid()
assert form.errors == {
"selling_price": [
"Assurez-vous que cette valeur est inférieure ou égale à 2.00."
],
"special_selling_price": [
"Assurez-vous que cette valeur est inférieure ou égale à 1.80."
],
}
def test_view(self):
self.client.force_login(self.counter_admin)
self.client.force_login(
baker.make(
User,
groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)],
)
)
url = reverse("counter:new_product")
response = self.client.get(url)
assert response.status_code == 200

View File

@@ -25,10 +25,6 @@ from counter.views.admin import (
CounterStatView,
ProductCreateView,
ProductEditView,
ProductFormulaCreateView,
ProductFormulaDeleteView,
ProductFormulaEditView,
ProductFormulaListView,
ProductListView,
ProductTypeCreateView,
ProductTypeEditView,
@@ -120,24 +116,6 @@ urlpatterns = [
ProductEditView.as_view(),
name="product_edit",
),
path(
"admin/formula/", ProductFormulaListView.as_view(), name="product_formula_list"
),
path(
"admin/formula/new/",
ProductFormulaCreateView.as_view(),
name="product_formula_create",
),
path(
"admin/formula/<int:formula_id>/edit",
ProductFormulaEditView.as_view(),
name="product_formula_edit",
),
path(
"admin/formula/<int:formula_id>/delete",
ProductFormulaDeleteView.as_view(),
name="product_formula_delete",
),
path(
"admin/product-type/list/",
ProductTypeListView.as_view(),

View File

@@ -34,13 +34,11 @@ from counter.forms import (
CloseCustomerAccountForm,
CounterEditForm,
ProductForm,
ProductFormulaForm,
ReturnableProductForm,
)
from counter.models import (
Counter,
Product,
ProductFormula,
ProductType,
Refilling,
ReturnableProduct,
@@ -164,49 +162,6 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
current_tab = "products"
class ProductFormulaListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
model = ProductFormula
queryset = ProductFormula.objects.select_related("result")
template_name = "counter/formula_list.jinja"
current_tab = "formulas"
permission_required = "counter.view_productformula"
class ProductFormulaCreateView(
CounterAdminTabsMixin, PermissionRequiredMixin, CreateView
):
model = ProductFormula
form_class = ProductFormulaForm
pk_url_kwarg = "formula_id"
template_name = "core/create.jinja"
current_tab = "formulas"
success_url = reverse_lazy("counter:product_formula_list")
permission_required = "counter.add_productformula"
class ProductFormulaEditView(
CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView
):
model = ProductFormula
form_class = ProductFormulaForm
pk_url_kwarg = "formula_id"
template_name = "core/edit.jinja"
current_tab = "formulas"
success_url = reverse_lazy("counter:product_formula_list")
permission_required = "counter.change_productformula"
class ProductFormulaDeleteView(
CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView
):
model = ProductFormula
pk_url_kwarg = "formula_id"
template_name = "core/delete_confirm.jinja"
current_tab = "formulas"
success_url = reverse_lazy("counter:product_formula_list")
permission_required = "counter.delete_productformula"
class ReturnableProductListView(
CounterAdminTabsMixin, PermissionRequiredMixin, ListView
):

View File

@@ -12,7 +12,6 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from collections import defaultdict
from django.core.exceptions import PermissionDenied
from django.db import transaction
@@ -32,7 +31,6 @@ from counter.forms import BasketForm, RefillForm
from counter.models import (
Counter,
Customer,
ProductFormula,
ReturnableProduct,
Selling,
)
@@ -208,13 +206,12 @@ class CounterClick(
"""Add customer to the context."""
kwargs = super().get_context_data(**kwargs)
kwargs["products"] = self.products
kwargs["formulas"] = ProductFormula.objects.filter(
result__in=self.products
).prefetch_related("products")
kwargs["categories"] = defaultdict(list)
kwargs["categories"] = {}
for product in kwargs["products"]:
if product.product_type:
kwargs["categories"][product.product_type].append(product)
kwargs["categories"].setdefault(product.product_type, []).append(
product
)
kwargs["customer"] = self.customer
kwargs["cancel_url"] = self.get_success_url()

View File

@@ -100,11 +100,6 @@ class CounterAdminTabsMixin(TabedViewMixin):
"slug": "products",
"name": _("Products"),
},
{
"url": reverse_lazy("counter:product_formula_list"),
"slug": "formulas",
"name": _("Formulas"),
},
{
"url": reverse_lazy("counter:product_type_list"),
"slug": "product_types",

View File

@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-27 14:22+0100\n"
"POT-Creation-Date: 2025-11-30 18:23+0100\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -388,7 +388,7 @@ msgstr "Montrer"
#: club/templates/club/club_sellings.jinja
#: counter/templates/counter/product_list.jinja
msgid "Download as csv"
msgid "Download as cvs"
msgstr "Télécharger en CSV"
#: club/templates/club/club_sellings.jinja
@@ -2960,38 +2960,6 @@ msgstr ""
"Décrivez le produit. Si c'est un click pour un évènement, donnez quelques "
"détails dessus, comme la date (en incluant l'année)."
#: counter/forms.py
#, python-format
msgid ""
"This product is a formula. Its price cannot be greater than the price of the "
"products constituting it, which is %(price)s"
msgstr ""
"Ce produit est une formule. Son prix ne peut pas être supérieur au prix des "
"produits qui la constituent, soit %(price)s €."
#: counter/forms.py
#, python-format
msgid ""
"This product is a formula. Its special price cannot be greater than the "
"price of the products constituting it, which is %(price)s"
msgstr ""
"Ce produit est une formule. Son prix spécial ne peut pas être supérieur au "
"prix des produits qui la constituent, soit %(price)s €."
#: counter/forms.py
msgid ""
"The same product cannot be at the same time the result and a part of the "
"formula."
msgstr ""
"Un même produit ne peut pas être à la fois le résultat et un élément de la "
"formule."
#: counter/forms.py
msgid ""
"The result cannot be more expensive than the total of the other products."
msgstr ""
"Le résultat ne peut pas être plus cher que le total des autres produits."
#: counter/forms.py
msgid "Refound this account"
msgstr "Rembourser ce compte"
@@ -3152,18 +3120,6 @@ msgstr "produit"
msgid "products"
msgstr "produits"
#: counter/models.py
msgid "The products that constitute this formula."
msgstr "Les produits qui constituent cette formule."
#: counter/models.py
msgid "result product"
msgstr "produit résultat"
#: counter/models.py
msgid "The product got with the formula."
msgstr "Le produit obtenu par la formule."
#: counter/models.py
msgid "counter type"
msgstr "type de comptoir"
@@ -3584,14 +3540,6 @@ msgstr "Nouveau eticket"
msgid "There is no eticket in this website."
msgstr "Il n'y a pas de eticket sur ce site web."
#: counter/templates/counter/formula_list.jinja
msgid "Product formulas"
msgstr "Formules de produits"
#: counter/templates/counter/formula_list.jinja
msgid "New formula"
msgstr "Nouvelle formule"
#: counter/templates/counter/fragments/create_student_card.jinja
msgid "No student card registered."
msgstr "Aucune carte étudiante enregistrée."
@@ -3931,10 +3879,6 @@ msgstr "Dernières opérations"
msgid "Counter administration"
msgstr "Administration des comptoirs"
#: counter/views/mixins.py
msgid "Formulas"
msgstr "Formules"
#: counter/views/mixins.py
msgid "Product types"
msgstr "Types de produit"
@@ -4440,6 +4384,10 @@ msgstr "Galaxie de %(user_name)s"
msgid "This citizen has not yet joined the galaxy"
msgstr "Ce citoyen n'a pas encore rejoint la galaxie"
#: matmat/forms.py
msgid "Last/First name or nickname"
msgstr "Nom de famille, prénom ou surnom"
#: matmat/templates/matmat/search_form.jinja
msgid "Search user"
msgstr "Rechercher un utilisateur"
@@ -4448,22 +4396,6 @@ msgstr "Rechercher un utilisateur"
msgid "Results"
msgstr "Résultats"
#: matmat/templates/matmat/search_form.jinja
msgid "Search by profile"
msgstr "Recherche par profil"
#: matmat/templates/matmat/search_form.jinja
msgid "Inverted search"
msgstr "Recherche inversée"
#: matmat/templates/matmat/search_form.jinja
msgid "Quick search"
msgstr "Recherche rapide"
#: matmat/views.py
msgid "Last/First name or nickname"
msgstr "Nom de famille, prénom ou surnom"
#: pedagogy/forms.py
msgid "Do not vote"
msgstr "Ne pas voter"

View File

@@ -7,7 +7,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-26 15:45+0100\n"
"POT-Creation-Date: 2025-08-23 15:30+0200\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -206,10 +206,6 @@ msgstr "capture.%s"
msgid "Not enough money"
msgstr "Pas assez d'argent"
#: counter/static/bundled/counter/counter-click-index.ts
msgid "Formula %(formula)s applied"
msgstr "Formule %(formula)s appliquée"
#: counter/static/bundled/counter/counter-click-index.ts
msgid "You can't send an empty basket."
msgstr "Vous ne pouvez pas envoyer un panier vide."
@@ -266,9 +262,3 @@ msgstr "Il n'a pas été possible de modérer l'image"
#: sas/static/bundled/sas/viewer-index.ts
msgid "Couldn't delete picture"
msgstr "Il n'a pas été possible de supprimer l'image"
#: timetable/static/bundled/timetable/generator-index.ts
msgid ""
"Wrong timetable format. Make sure you copied if from your student folder."
msgstr ""
"Mauvais format d'emploi du temps. Assurez-vous que vous l'avez copié depuis votre dossier étudiants."

48
matmat/forms.py Normal file
View File

@@ -0,0 +1,48 @@
#
# Copyright 2025
# - Maréchal <thomas.girod@utbm.fr>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from typing import Any
from django import forms
from django.utils.translation import gettext_lazy as _
from core.models import User
from core.views.forms import SelectDate
class SearchForm(forms.ModelForm):
class Meta:
model = User
fields = ["promo", "role", "department", "semester", "date_of_birth"]
widgets = {"date_of_birth": SelectDate}
name = forms.CharField(
label=_("Last/First name or nickname"), min_length=1, max_length=255
)
field_order = ["name", "promo", "role", "department", "semester", "date_of_birth"]
def __init__(self, *args, initial: dict[str, Any], **kwargs):
super().__init__(*args, initial=initial, **kwargs)
for key in self.fields:
self.fields[key].required = False
if key not in initial:
self.fields[key].initial = None

View File

@@ -1,12 +1,13 @@
{% extends "core/base.jinja" %}
{% from "core/macros.jinja" import user_mini_profile, paginate_jinja %}
{% from "core/macros.jinja" import user_mini_profile, paginate_jinja with context %}
{% block title %}
{% trans %}Search user{% endtrans %}
{% endblock %}
{% block content %}
{% if result_exists %}
{% if paginator.count > 0 %}
<h2>{% trans %}Results{% endtrans %}</h2>
<div class="matmat_results">
{% for user in object_list %}
@@ -24,42 +25,12 @@
<hr>
{% endif %}
<h2>{% trans %}Search user{% endtrans %}</h2>
<h3>{% trans %}Search by profile{% endtrans %}</h3>
<form action="{{ url('matmat:search') }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
{% for field in form %}
{% if field.name not in ('phone', 'quick') %}
<p>
{{ field.errors }}
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
</p>
{% endif %}
{% endfor %}
<p><input type="submit" value="{% trans %}Search{% endtrans %}" /></p>
</form>
<h3>{% trans %}Inverted search{% endtrans %}</h3>
<form action="{{ url('matmat:search_reverse') }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<p>
{{ form.phone.errors }}
<label for="{{ form.phone.id_for_label }}">{{ form.phone.label }}</label>
{{ form.phone }}
<p><input type="submit" value="{% trans %}Search{% endtrans %}" /></p>
</p>
</form>
<h3>{% trans %}Quick search{% endtrans %}</h3>
<form action="{{ url('matmat:search_quick') }}" method="post">
{% csrf_token %}
<p>
{{ form.quick.errors }}
<label for="{{ form.quick.id_for_label }}">{{ form.quick.label }}</label>
{{ form.quick }}
<p><input type="submit" value="{% trans %}Search{% endtrans %}" /></p>
</p>
<form action="{{ url('matmat:search') }}" method="get">
<fieldset class="fields-centered">
{{ form }}
</fieldset>
<div class="fields-centered">
<input class="btn btn-blue" type="submit" value="{% trans %}Search{% endtrans %}" />
</div>
</form>
{% endblock %}
{% block script %}
{{ super() }}
{% endblock %}

View File

@@ -1 +1,35 @@
# Create your tests here.
from django.core.management import call_command
from django.test import TestCase
from django.urls import reverse
from model_bakery import baker
from com.models import News
from core.baker_recipes import subscriber_user
from core.models import User
class TestMatmatronch(TestCase):
@classmethod
def setUpTestData(cls):
News.objects.all().delete()
User.objects.all().delete()
users = [
baker.prepare(User, promo=17),
baker.prepare(User, promo=17),
baker.prepare(User, promo=17, department="INFO"),
baker.prepare(User, promo=18, department="INFO"),
]
cls.users = User.objects.bulk_create(users)
call_command("update_index", "core", "--remove")
def test_search(self):
self.client.force_login(subscriber_user.make())
response = self.client.get(reverse("matmat:search"))
assert response.status_code == 200
response = self.client.get(
reverse("matmat:search", query={"promo": 17, "department": "INFO"})
)
assert response.status_code == 200
assert list(response.context_data["object_list"]) == [self.users[2]]

View File

@@ -23,16 +23,8 @@
from django.urls import path
from matmat.views import (
SearchClearFormView,
SearchNormalFormView,
SearchQuickFormView,
SearchReverseFormView,
)
from matmat.views import MatmatronchView
urlpatterns = [
path("", SearchNormalFormView.as_view(), name="search"),
path("reverse/", SearchReverseFormView.as_view(), name="search_reverse"),
path("quick/", SearchQuickFormView.as_view(), name="search_quick"),
path("clear/", SearchClearFormView.as_view(), name="search_clear"),
path("", MatmatronchView.as_view(), name="search"),
]

View File

@@ -20,191 +20,44 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from ast import literal_eval
from enum import Enum
from django import forms
from django.db.models import F
from django.http.response import HttpResponseRedirect
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import ListView, View
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import FormView
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from django.views.generic import ListView
from django.views.generic.edit import FormMixin
from core.auth.mixins import FormerSubscriberMixin
from core.models import User
from core.models import User, UserQuerySet
from core.schemas import UserFilterSchema
from core.views.forms import SelectDate
# Enum to select search type
from matmat.forms import SearchForm
class SearchType(Enum):
NORMAL = 1
REVERSE = 2
QUICK = 3
# Custom form
class SearchForm(forms.ModelForm):
class Meta:
model = User
fields = [
"first_name",
"last_name",
"nick_name",
"role",
"department",
"semester",
"promo",
"date_of_birth",
"phone",
]
widgets = {
"date_of_birth": SelectDate,
"phone": RegionalPhoneNumberWidget,
}
quick = forms.CharField(label=_("Last/First name or nickname"), max_length=255)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for key in self.fields:
self.fields[key].required = False
@property
def cleaned_data_json(self):
data = self.cleaned_data
for key, val in data.items():
if key in ("date_of_birth", "phone") and val is not None:
data[key] = str(val)
return data
# Views
class SearchFormListView(FormerSubscriberMixin, SingleObjectMixin, ListView):
class MatmatronchView(FormerSubscriberMixin, FormMixin, ListView):
model = User
ordering = ["-id"]
paginate_by = 12
paginate_by = 20
template_name = "matmat/search_form.jinja"
def dispatch(self, request, *args, **kwargs):
self.form_class = kwargs["form"]
self.search_type = kwargs["search_type"]
self.session = request.session
self.last_search = self.session.get("matmat_search_result", str([]))
self.last_search = literal_eval(self.last_search)
self.valid_form = kwargs.get("valid_form")
self.init_query = self.model.objects
self.can_see_hidden = True
if not (request.user.is_board_member or request.user.is_root):
self.can_see_hidden = False
self.init_query = self.init_query.filter(is_viewable=True)
return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
return self.get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
self.object = None
kwargs = super().get_context_data(**kwargs)
kwargs["form"] = self.form_class
kwargs["result_exists"] = self.result_exists
return kwargs
def get_queryset(self):
q = self.init_query
if self.valid_form is not None:
if self.search_type == SearchType.REVERSE:
q = q.filter(phone=self.valid_form["phone"]).all()
elif self.search_type == SearchType.QUICK:
if self.valid_form["quick"].strip():
q = list(
UserFilterSchema(search=self.valid_form["quick"])
.filter(User.objects.viewable_by(self.request.user))
.order_by(F("last_login").desc(nulls_last=True))
)
else:
q = []
else:
search_dict = {}
for key, value in self.valid_form.items():
if key not in ("phone", "quick") and not (
value == "" or value is None
):
search_dict[key + "__icontains"] = value
q = q.filter(**search_dict).all()
else:
q = q.filter(pk__in=self.last_search).all()
if isinstance(q, list):
self.result_exists = len(q) > 0
else:
self.result_exists = q.exists()
self.last_search = []
for user in q:
self.last_search.append(user.id)
self.session["matmat_search_result"] = str(self.last_search)
return q
class SearchFormView(FormerSubscriberMixin, FormView):
"""Allows users to search inside the user list."""
form_class = SearchForm
def dispatch(self, request, *args, **kwargs):
self.session = request.session
self.init_query = User.objects
kwargs["form"] = self.get_form()
kwargs["search_type"] = self.search_type
return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
view = SearchFormListView.as_view()
return view(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
form = self.get_form()
view = SearchFormListView.as_view()
if form.is_valid():
kwargs["valid_form"] = form.clean()
request.session["matmat_search_form"] = form.cleaned_data_json
return view(request, *args, **kwargs)
self.form = self.get_form()
return super().get(request, *args, **kwargs)
def get_initial(self):
init = self.session.get("matmat_search_form", {})
if not init:
init["department"] = ""
return init
return self.request.GET
def get_form_kwargs(self):
res = super().get_form_kwargs()
if self.request.GET:
res["data"] = self.request.GET
return res
class SearchNormalFormView(SearchFormView):
search_type = SearchType.NORMAL
def get_queryset(self) -> UserQuerySet:
if not self.form.is_valid():
return User.objects.none()
data = self.form.cleaned_data
data["search"] = data.get("name")
filters = UserFilterSchema(**{key: val for key, val in data.items() if val})
qs = User.objects.viewable_by(self.request.user).select_related("profile_pict")
return filters.filter(qs).order_by(F("last_login").desc(nulls_last=True))
class SearchReverseFormView(SearchFormView):
search_type = SearchType.REVERSE
class SearchQuickFormView(SearchFormView):
search_type = SearchType.QUICK
class SearchClearFormView(FormerSubscriberMixin, View):
"""Clear SearchFormView and redirect to it."""
def dispatch(self, request, *args, **kwargs):
super().dispatch(request, *args, **kwargs)
if "matmat_search_form" in request.session:
request.session.pop("matmat_search_form")
if "matmat_search_result" in request.session:
request.session.pop("matmat_search_result")
return HttpResponseRedirect(reverse("matmat:search"))
def get_context_data(self, **kwargs):
return super().get_context_data(form=self.form, **kwargs)