mirror of
https://github.com/ae-utbm/sith.git
synced 2025-12-11 07:35:59 +00:00
Compare commits
5 Commits
product-fo
...
taiste
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3edcaff14 | ||
|
|
8c127a96f7 | ||
|
|
55d6e2bbec | ||
|
|
e9fbac8264 | ||
|
|
1911f2e6dd |
@@ -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/>
|
||||
|
||||
@@ -195,18 +195,18 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.delete {
|
||||
margin-top: 10px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: orangered;
|
||||
form .link-like {
|
||||
margin-top: 10px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: orangered;
|
||||
|
||||
@media (max-width: 375px) {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
@media (max-width: 375px) {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,12 +78,6 @@
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro delete_godfather(user, profile, godfather, is_father) %}
|
||||
{% if user == profile or user.is_root or user.is_board_member %}
|
||||
<a class="delete" href="{{ url("core:user_godfathers_delete", user_id=profile.id, godfather_id=godfather.id, is_father=is_father) }}">{% trans %}Delete{% endtrans %}</a>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro paginate_alpine(page, nb_pages) %}
|
||||
{# Add pagination buttons for ajax based content with alpine
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block content %}
|
||||
|
||||
{% if target %}
|
||||
<p>{% trans user=target.get_display_name() %}Change password for {{ user }}{% endtrans %}</p>
|
||||
<p>{% trans user=form.user.get_display_name() %}Change password for {{ user }}{% endtrans %}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="">
|
||||
{% csrf_token %}
|
||||
|
||||
@@ -29,7 +29,16 @@
|
||||
<a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link">
|
||||
{{ u.get_mini_item() | safe }}
|
||||
</a>
|
||||
{{ delete_godfather(user, profile, u, True) }}
|
||||
{% if user == profile or user.is_root or user.is_board_member %}
|
||||
<form
|
||||
method="post"
|
||||
class="no-margin"
|
||||
action="{{ url("core:user_godfathers_delete", user_id=profile.id, godfather_id=u.id, is_father=True) }}"
|
||||
>
|
||||
{% csrf_token %}
|
||||
<input type="submit" class="link-like" value="{% trans %}Delete{% endtrans %}">
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -46,7 +55,16 @@
|
||||
<a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link">
|
||||
{{ u.get_mini_item()|safe }}
|
||||
</a>
|
||||
{{ delete_godfather(user, profile, u, False) }}
|
||||
{% if user == profile or user.is_root or user.is_board_member %}
|
||||
<form
|
||||
method="post"
|
||||
class="no-margin"
|
||||
action="{{ url("core:user_godfathers_delete", user_id=profile.id, godfather_id=u.id, is_father=False) }}"
|
||||
>
|
||||
{% csrf_token %}
|
||||
<input type="submit" class="link-like link-red" value="{% trans %}Delete{% endtrans %}">
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
@@ -427,6 +427,19 @@ class TestUserQuerySetViewableBy:
|
||||
assert not viewable.exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_preferences(client: Client):
|
||||
user = subscriber_user.make()
|
||||
client.force_login(user)
|
||||
url = reverse("core:user_prefs", kwargs={"user_id": user.id})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
response = client.post(url, {"notify_on_click": "true"})
|
||||
assertRedirects(response, url)
|
||||
user.preferences.refresh_from_db()
|
||||
assert user.preferences.notify_on_click is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_stats(client: Client):
|
||||
user = subscriber_user.make()
|
||||
@@ -450,3 +463,68 @@ def test_user_stats(client: Client):
|
||||
client.force_login(user)
|
||||
response = client.get(reverse("core:user_stats", kwargs={"user_id": user.id}))
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestChangeUserPassword:
|
||||
def test_as_root(self, client: Client, admin_user: User):
|
||||
client.force_login(admin_user)
|
||||
user = subscriber_user.make()
|
||||
url = reverse("core:password_root_change", kwargs={"user_id": user.id})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
response = client.post(
|
||||
url, {"new_password1": "poutou", "new_password2": "poutou"}
|
||||
)
|
||||
assertRedirects(response, reverse("core:password_change_done"))
|
||||
user.refresh_from_db()
|
||||
assert user.check_password("poutou") is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestUserGodfather:
|
||||
@pytest.mark.parametrize("godfather", [True, False])
|
||||
def test_add_family(self, client: Client, godfather):
|
||||
user = subscriber_user.make()
|
||||
other_user = subscriber_user.make()
|
||||
client.force_login(user)
|
||||
url = reverse("core:user_godfathers", kwargs={"user_id": user.id})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
response = client.post(
|
||||
url,
|
||||
{"type": "godfather" if godfather else "godchild", "user": other_user.id},
|
||||
)
|
||||
assertRedirects(response, url)
|
||||
if godfather:
|
||||
assert user.godfathers.contains(other_user)
|
||||
else:
|
||||
assert user.godchildren.contains(other_user)
|
||||
|
||||
def test_tree(self, client: Client):
|
||||
user = subscriber_user.make()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
reverse("core:user_godfathers_tree", kwargs={"user_id": user.id})
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_remove_family(self, client: Client):
|
||||
user = subscriber_user.make()
|
||||
other_user = subscriber_user.make()
|
||||
user.godfathers.add(other_user)
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
reverse(
|
||||
"core:user_godfathers_delete",
|
||||
kwargs={
|
||||
"user_id": user.id,
|
||||
"godfather_id": other_user.id,
|
||||
"is_father": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
assertRedirects(
|
||||
response, reverse("core:user_godfathers", kwargs={"user_id": user.id})
|
||||
)
|
||||
assert not user.godfathers.contains(other_user)
|
||||
|
||||
@@ -54,6 +54,7 @@ from core.views import (
|
||||
PagePropView,
|
||||
PageRevView,
|
||||
PageView,
|
||||
PasswordRootChangeView,
|
||||
SearchView,
|
||||
SithLoginView,
|
||||
SithPasswordChangeDoneView,
|
||||
@@ -80,7 +81,6 @@ from core.views import (
|
||||
delete_user_godfather,
|
||||
logout,
|
||||
notification,
|
||||
password_root_change,
|
||||
send_file,
|
||||
)
|
||||
|
||||
@@ -100,7 +100,7 @@ urlpatterns = [
|
||||
path("password_change/", SithPasswordChangeView.as_view(), name="password_change"),
|
||||
path(
|
||||
"password_change/<int:user_id>/",
|
||||
password_root_change,
|
||||
PasswordRootChangeView.as_view(),
|
||||
name="password_root_change",
|
||||
),
|
||||
path(
|
||||
|
||||
@@ -303,7 +303,6 @@ class UserGodfathersForm(forms.Form):
|
||||
)
|
||||
user = forms.ModelChoiceField(
|
||||
label=_("Select user"),
|
||||
help_text=None,
|
||||
required=True,
|
||||
widget=AutoCompleteSelectUser,
|
||||
queryset=User.objects.all(),
|
||||
@@ -315,8 +314,6 @@ class UserGodfathersForm(forms.Form):
|
||||
|
||||
def clean_user(self):
|
||||
other_user = self.cleaned_data.get("user")
|
||||
if not other_user:
|
||||
raise ValidationError(_("This user does not exist"))
|
||||
if other_user == self.target_user:
|
||||
raise ValidationError(_("You cannot be related to yourself"))
|
||||
return other_user
|
||||
|
||||
@@ -29,8 +29,9 @@ from operator import itemgetter
|
||||
from smtplib import SMTPException
|
||||
|
||||
from django.contrib.auth import login, views
|
||||
from django.contrib.auth.forms import PasswordChangeForm
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import DateField, F, QuerySet, Sum
|
||||
from django.db.models.functions import Trunc
|
||||
@@ -38,11 +39,11 @@ from django.forms.models import modelform_factory
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.loader import render_to_string
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.safestring import SafeString
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views.generic import (
|
||||
CreateView,
|
||||
DeleteView,
|
||||
@@ -98,21 +99,23 @@ def logout(request):
|
||||
return views.logout_then_login(request)
|
||||
|
||||
|
||||
def password_root_change(request, user_id):
|
||||
class PasswordRootChangeView(UserPassesTestMixin, FormView):
|
||||
"""Allows a root user to change someone's password."""
|
||||
if not request.user.is_root:
|
||||
raise PermissionDenied
|
||||
user = get_object_or_404(User, id=user_id)
|
||||
if request.method == "POST":
|
||||
form = views.SetPasswordForm(user=user, data=request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("core:password_change_done")
|
||||
else:
|
||||
form = views.SetPasswordForm(user=user)
|
||||
return TemplateResponse(
|
||||
request, "core/password_change.jinja", {"form": form, "target": user}
|
||||
)
|
||||
|
||||
template_name = "core/password_change.jinja"
|
||||
form_class = SetPasswordForm
|
||||
success_url = reverse_lazy("core:password_change_done")
|
||||
|
||||
def test_func(self):
|
||||
return self.request.user.is_root
|
||||
|
||||
def get_form_kwargs(self):
|
||||
user = get_object_or_404(User, id=self.kwargs["user_id"])
|
||||
return super().get_form_kwargs() | {"user": user}
|
||||
|
||||
def form_valid(self, form: SetPasswordForm):
|
||||
form.save()
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
@method_decorator(check_honeypot, name="post")
|
||||
@@ -287,10 +290,12 @@ class UserView(UserTabsMixin, CanViewMixin, DetailView):
|
||||
return kwargs
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def delete_user_godfather(request, user_id, godfather_id, is_father):
|
||||
user_is_admin = request.user.is_root or request.user.is_board_member
|
||||
if user_id != request.user.id and not user_is_admin:
|
||||
raise PermissionDenied()
|
||||
raise PermissionDenied
|
||||
user = get_object_or_404(User, id=user_id)
|
||||
to_remove = get_object_or_404(User, id=godfather_id)
|
||||
if is_father:
|
||||
@@ -417,7 +422,6 @@ class UserUpdateProfileView(UserTabsMixin, CanEditMixin, UpdateView):
|
||||
form_class = UserProfileForm
|
||||
current_tab = "edit"
|
||||
edit_once = ["profile_pict", "date_of_birth", "first_name", "last_name"]
|
||||
board_only = []
|
||||
|
||||
def remove_restricted_fields(self, request):
|
||||
"""Removes edit_once and board_only fields."""
|
||||
@@ -426,9 +430,6 @@ class UserUpdateProfileView(UserTabsMixin, CanEditMixin, UpdateView):
|
||||
request.user.is_board_member or request.user.is_root
|
||||
):
|
||||
self.form.fields.pop(i, None)
|
||||
for i in self.board_only:
|
||||
if not (request.user.is_board_member or request.user.is_root):
|
||||
self.form.fields.pop(i, None)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
@@ -480,10 +481,10 @@ class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, Update
|
||||
current_tab = "prefs"
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
pref = self.object.preferences
|
||||
kwargs.update({"instance": pref})
|
||||
return kwargs
|
||||
return super().get_form_kwargs() | {"instance": self.object.preferences}
|
||||
|
||||
def get_success_url(self):
|
||||
return self.request.path
|
||||
|
||||
def get_fragment_context_data(self) -> dict[str, SafeString]:
|
||||
# Avoid cyclic import error
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
6
counter/static/bundled/counter/types.d.ts
vendored
6
counter/static/bundled/counter/types.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 -%}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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-24 11:05+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"
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user