From 6a64e05247318f9aaa4ad57a046df689aca6a94a Mon Sep 17 00:00:00 2001
From: imperosol
Date: Sun, 6 Oct 2024 13:22:09 +0200
Subject: [PATCH 1/8] select inactive users
---
core/migrations/0039_alter_user_managers.py | 15 +++
core/models.py | 105 ++++++++++++--------
core/tests/test_user.py | 41 +++++++-
counter/views.py | 5 +-
sith/settings.py | 4 +
5 files changed, 124 insertions(+), 46 deletions(-)
create mode 100644 core/migrations/0039_alter_user_managers.py
diff --git a/core/migrations/0039_alter_user_managers.py b/core/migrations/0039_alter_user_managers.py
new file mode 100644
index 00000000..3073bbe6
--- /dev/null
+++ b/core/migrations/0039_alter_user_managers.py
@@ -0,0 +1,15 @@
+# Generated by Django 4.2.16 on 2024-10-06 14:52
+
+from django.db import migrations
+
+import core.models
+
+
+class Migration(migrations.Migration):
+ dependencies = [("core", "0038_alter_preferences_receive_weekmail")]
+
+ operations = [
+ migrations.AlterModelManagers(
+ name="user", managers=[("objects", core.models.CustomUserManager())]
+ )
+ ]
diff --git a/core/models.py b/core/models.py
index 646cbca8..36d0902d 100644
--- a/core/models.py
+++ b/core/models.py
@@ -27,15 +27,12 @@ import importlib
import logging
import os
import unicodedata
-from datetime import date, timedelta
+from datetime import timedelta
from pathlib import Path
-from typing import TYPE_CHECKING, Optional
+from typing import TYPE_CHECKING, Any, Optional, Self
from django.conf import settings
-from django.contrib.auth.models import (
- AbstractBaseUser,
- UserManager,
-)
+from django.contrib.auth.models import AbstractBaseUser, UserManager
from django.contrib.auth.models import (
AnonymousUser as AuthAnonymousUser,
)
@@ -51,15 +48,18 @@ from django.core.cache import cache
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.mail import send_mail
from django.db import models, transaction
+from django.db.models import Exists, OuterRef, Q
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.html import escape
+from django.utils.timezone import localdate, now
from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
-from pydantic.v1 import NonNegativeInt
if TYPE_CHECKING:
+ from pydantic import NonNegativeInt
+
from club.models import Club
@@ -91,15 +91,15 @@ class Group(AuthGroup):
class Meta:
ordering = ["name"]
- def get_absolute_url(self):
+ def get_absolute_url(self) -> str:
return reverse("core:group_list")
- def save(self, *args, **kwargs):
+ def save(self, *args, **kwargs) -> None:
super().save(*args, **kwargs)
cache.set(f"sith_group_{self.id}", self)
cache.set(f"sith_group_{self.name.replace(' ', '_')}", self)
- def delete(self, *args, **kwargs):
+ def delete(self, *args, **kwargs) -> None:
super().delete(*args, **kwargs)
cache.delete(f"sith_group_{self.id}")
cache.delete(f"sith_group_{self.name.replace(' ', '_')}")
@@ -164,9 +164,9 @@ class RealGroup(Group):
proxy = True
-def validate_promo(value):
+def validate_promo(value: int) -> None:
start_year = settings.SITH_SCHOOL_START_YEAR
- delta = (date.today() + timedelta(days=180)).year - start_year
+ delta = (localdate() + timedelta(days=180)).year - start_year
if value < 0 or delta < value:
raise ValidationError(
_("%(value)s is not a valid promo (between 0 and %(end)s)"),
@@ -174,7 +174,7 @@ def validate_promo(value):
)
-def get_group(*, pk: int = None, name: str = None) -> Optional[Group]:
+def get_group(*, pk: int = None, name: str = None) -> Group | None:
"""Search for a group by its primary key or its name.
Either one of the two must be set.
@@ -216,6 +216,31 @@ def get_group(*, pk: int = None, name: str = None) -> Optional[Group]:
return group
+class UserQuerySet(models.QuerySet):
+ def filter_inactive(self) -> Self:
+ from counter.models import Refilling, Selling
+ from subscription.models import Subscription
+
+ threshold = now() - settings.SITH_ACCOUNT_INACTIVITY_DELTA
+ subscriptions = Subscription.objects.filter(
+ member_id=OuterRef("pk"), subscription_end__gt=localdate(threshold)
+ )
+ refills = Refilling.objects.filter(
+ customer__user_id=OuterRef("pk"), date__gt=threshold
+ )
+ purchases = Selling.objects.filter(
+ customer__user_id=OuterRef("pk"), date__gt=threshold
+ )
+ return self.exclude(
+ Q(Exists(subscriptions)) | Q(Exists(refills)) | Q(Exists(purchases))
+ )
+
+
+class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
+ # see https://docs.djangoproject.com/fr/stable/topics/migrations/#model-managers
+ pass
+
+
class User(AbstractBaseUser):
"""Defines the base user class, useable in every app.
@@ -373,36 +398,41 @@ class User(AbstractBaseUser):
)
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
- objects = UserManager()
+ objects = CustomUserManager()
USERNAME_FIELD = "username"
- def promo_has_logo(self):
- return Path(
- settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
- ).exists()
-
- def has_module_perms(self, package_name):
- return self.is_active
-
- def has_perm(self, perm, obj=None):
- return self.is_active and self.is_superuser
-
- def get_absolute_url(self):
- return reverse("core:user_profile", kwargs={"user_id": self.pk})
-
def __str__(self):
return self.get_display_name()
- def to_dict(self):
- return self.__dict__
+ def save(self, *args, **kwargs):
+ with transaction.atomic():
+ if self.id:
+ old = User.objects.filter(id=self.id).first()
+ if old and old.username != self.username:
+ self._change_username(self.username)
+ super().save(*args, **kwargs)
+
+ def get_absolute_url(self) -> str:
+ return reverse("core:user_profile", kwargs={"user_id": self.pk})
+
+ def promo_has_logo(self) -> bool:
+ return Path(
+ settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
+ ).exists()
+
+ def has_module_perms(self, package_name: str) -> bool:
+ return self.is_active
+
+ def has_perm(self, perm: str, obj: Any = None) -> bool:
+ return self.is_active and self.is_superuser
@cached_property
- def was_subscribed(self):
+ def was_subscribed(self) -> bool:
return self.subscriptions.exists()
@cached_property
- def is_subscribed(self):
+ def is_subscribed(self) -> bool:
s = self.subscriptions.filter(
subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now()
)
@@ -542,17 +572,6 @@ class User(AbstractBaseUser):
)
return age
- def save(self, *args, **kwargs):
- create = False
- with transaction.atomic():
- if self.id:
- old = User.objects.filter(id=self.id).first()
- if old and old.username != self.username:
- self._change_username(self.username)
- else:
- create = True
- super().save(*args, **kwargs)
-
def make_home(self):
if self.home is None:
home_root = SithFile.objects.filter(parent=None, name="users").first()
diff --git a/core/tests/test_user.py b/core/tests/test_user.py
index 9794b9f6..5454a302 100644
--- a/core/tests/test_user.py
+++ b/core/tests/test_user.py
@@ -1,15 +1,18 @@
from datetime import timedelta
import pytest
+from django.conf import settings
from django.core.management import call_command
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker, seq
-from model_bakery.recipe import Recipe
+from model_bakery.recipe import Recipe, related
-from core.baker_recipes import subscriber_user
+from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User
+from counter.models import Counter, Refilling, Selling
+from subscription.models import Subscription
class TestSearchUsers(TestCase):
@@ -111,3 +114,37 @@ def test_user_account_not_found(client: Client):
)
)
assert res.status_code == 404
+
+
+class TestFilterInactive(TestCase):
+ @classmethod
+ def setUpTestData(cls):
+ time_active = now() - settings.SITH_ACCOUNT_INACTIVITY_DELTA + timedelta(days=1)
+ time_inactive = time_active - timedelta(days=3)
+ very_old_subscriber = old_subscriber_user.extend(
+ subscriptions=related(Recipe(Subscription, subscription_end=time_inactive))
+ )
+ counter, seller = baker.make(Counter), baker.make(User)
+ sale_recipe = Recipe(
+ Selling,
+ counter=counter,
+ club=counter.club,
+ seller=seller,
+ is_validated=True,
+ )
+
+ cls.users = [
+ baker.make(User),
+ subscriber_user.make(),
+ old_subscriber_user.make(),
+ *very_old_subscriber.make(_quantity=3),
+ ]
+ sale_recipe.make(customer=cls.users[3].customer, date=time_active)
+ baker.make(
+ Refilling, customer=cls.users[4].customer, date=time_active, counter=counter
+ )
+ sale_recipe.make(customer=cls.users[5].customer, date=time_inactive)
+
+ def test_filter_inactive(self):
+ res = User.objects.filter(id__in=[u.id for u in self.users]).filter_inactive()
+ assert list(res) == [self.users[0], self.users[5]]
diff --git a/counter/views.py b/counter/views.py
index 50bf02b0..e5a64bf1 100644
--- a/counter/views.py
+++ b/counter/views.py
@@ -16,6 +16,7 @@ import re
from datetime import datetime, timedelta
from datetime import timezone as tz
from http import HTTPStatus
+from typing import TYPE_CHECKING
from urllib.parse import parse_qs
from django import forms
@@ -49,7 +50,6 @@ from django.views.generic.edit import (
)
from accounting.models import CurrencyField
-from core.models import User
from core.utils import get_semester_code, get_start_of_semester
from core.views import CanEditMixin, CanViewMixin, TabedViewMixin
from core.views.forms import LoginForm
@@ -78,6 +78,9 @@ from counter.models import (
)
from counter.utils import is_logged_in_counter
+if TYPE_CHECKING:
+ from core.models import User
+
class CounterAdminMixin(View):
"""Protect counter admin section."""
diff --git a/sith/settings.py b/sith/settings.py
index cd70f49b..a4eca6b3 100644
--- a/sith/settings.py
+++ b/sith/settings.py
@@ -40,6 +40,7 @@ import sys
from pathlib import Path
import sentry_sdk
+from dateutil.relativedelta import relativedelta
from django.utils.translation import gettext_lazy as _
from sentry_sdk.integrations.django import DjangoIntegration
@@ -495,6 +496,9 @@ SITH_ECOCUP_LIMIT = 3
# Defines pagination for cash summary
SITH_COUNTER_CASH_SUMMARY_LENGTH = 50
+SITH_ACCOUNT_INACTIVITY_DELTA = relativedelta(years=2)
+"""Time before which a user account is considered inactive"""
+
# Defines which product type is the refilling type, and thus increases the account amount
SITH_COUNTER_PRODUCTTYPE_REFILLING = 3
From 5a8052ae476cd011bb0a5c4782e91ccad28a7890 Mon Sep 17 00:00:00 2001
From: imperosol
Date: Sun, 6 Oct 2024 22:24:20 +0200
Subject: [PATCH 2/8] send mail to inactive users
---
counter/admin.py | 12 +
counter/management/__init__.py | 0
counter/management/commands/__init__.py | 0
.../management/commands/dump_warning_mail.py | 88 ++++
...untdump_accountdump_unique_ongoing_dump.py | 64 +++
counter/models.py | 47 +-
.../counter/account_dump_warning_mail.jinja | 43 ++
counter/tests/test_account_dump.py | 7 +
locale/fr/LC_MESSAGES/django.po | 475 ++++++++++--------
sith/settings.py | 12 +-
10 files changed, 541 insertions(+), 207 deletions(-)
create mode 100644 counter/management/__init__.py
create mode 100644 counter/management/commands/__init__.py
create mode 100644 counter/management/commands/dump_warning_mail.py
create mode 100644 counter/migrations/0024_accountdump_accountdump_unique_ongoing_dump.py
create mode 100644 counter/templates/counter/account_dump_warning_mail.jinja
create mode 100644 counter/tests/test_account_dump.py
diff --git a/counter/admin.py b/counter/admin.py
index 966f5b28..42943338 100644
--- a/counter/admin.py
+++ b/counter/admin.py
@@ -49,6 +49,18 @@ class BillingInfoAdmin(admin.ModelAdmin):
autocomplete_fields = ("customer",)
+@admin.register(AccountDump)
+class AccountDumpAdmin(admin.ModelAdmin):
+ list_display = (
+ "customer",
+ "warning_mail_sent_at",
+ "warning_mail_error",
+ "dump_operation",
+ )
+ autocomplete_fields = ("customer",)
+ list_filter = ("warning_mail_error",)
+
+
@admin.register(Counter)
class CounterAdmin(admin.ModelAdmin):
list_display = ("name", "club", "type")
diff --git a/counter/management/__init__.py b/counter/management/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/counter/management/commands/__init__.py b/counter/management/commands/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/counter/management/commands/dump_warning_mail.py b/counter/management/commands/dump_warning_mail.py
new file mode 100644
index 00000000..0a1d7f78
--- /dev/null
+++ b/counter/management/commands/dump_warning_mail.py
@@ -0,0 +1,88 @@
+import logging
+from smtplib import SMTPException
+
+from django.conf import settings
+from django.core.mail import send_mail
+from django.core.management.base import BaseCommand
+from django.db.models import Exists, OuterRef, Subquery
+from django.template.loader import render_to_string
+from django.utils.timezone import localdate, now
+from django.utils.translation import gettext as _
+
+from core.models import User
+from counter.models import AccountDump
+from subscription.models import Subscription
+
+
+class Command(BaseCommand):
+ """Send mail to inactive users, warning them that their account is about to be dumped.
+
+ This command should be automated with a cron task.
+ """
+
+ def __init__(self, *args, **kwargs):
+ self.logger = logging.getLogger("account_dump_mail")
+ self.logger.setLevel(logging.INFO)
+ super().__init__(*args, **kwargs)
+
+ def handle(self, *args, **options):
+ ongoing_dump_operation = AccountDump.objects.ongoing().filter(
+ customer__user=OuterRef("pk")
+ )
+ users = list(
+ User.objects
+ .filter_inactive()
+ .filter(customer__amount__gt=0)
+ .exclude(Exists(ongoing_dump_operation))
+ .annotate(
+ last_subscription_date=Subquery(
+ Subscription.objects.filter(member=OuterRef("pk"))
+ .order_by("-subscription_end")
+ .values("subscription_end")[:1]
+ ),
+ )
+ .select_related("customer")
+ )
+ self.stdout.write(f"{len(users)} users will be warned of their account dump")
+ dumps = []
+ for user in users:
+ is_success = self._send_mail(user)
+ dumps.append(
+ AccountDump(
+ customer_id=user.id,
+ warning_mail_sent_at=now(),
+ warning_mail_error=not is_success,
+ )
+ )
+ AccountDump.objects.bulk_create(dumps)
+ self.stdout.write("Finished !")
+
+ def _send_mail(self, user: User) -> bool:
+ """Send the warning email to the given user.
+
+ Returns:
+ True if the mail was successfully sent, else False
+ """
+ message = render_to_string(
+ "counter/account_dump_warning_mail.jinja",
+ {
+ "balance": user.customer.amount,
+ "last_subscription_date": user.last_subscription_date,
+ "dump_date": localdate() + settings.SITH_ACCOUNT_DUMP_DELTA,
+ },
+ )
+ try:
+ # sending mails one by one is long and ineffective,
+ # but it makes easier to know which emails failed (and how).
+ # Also, there won't be that much mails sent (except on the first run)
+ send_mail(
+ _("Clearing of your AE account"),
+ message,
+ settings.DEFAULT_FROM_EMAIL,
+ [user.email],
+ )
+ self.logger.info(f"Mail successfully sent to {user.email}")
+ return True
+ except SMTPException as e:
+ self.logger.error(f"failed mail to {user.email} :\n{e}")
+ return False
diff --git a/counter/migrations/0024_accountdump_accountdump_unique_ongoing_dump.py b/counter/migrations/0024_accountdump_accountdump_unique_ongoing_dump.py
new file mode 100644
index 00000000..e5b478a7
--- /dev/null
+++ b/counter/migrations/0024_accountdump_accountdump_unique_ongoing_dump.py
@@ -0,0 +1,64 @@
+# Generated by Django 4.2.16 on 2024-10-06 14:33
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [("counter", "0023_billinginfo_phone_number")]
+
+ operations = [
+ migrations.CreateModel(
+ name="AccountDump",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "warning_mail_sent_at",
+ models.DateTimeField(
+ help_text="When the mail warning that the account was about to be dumped was sent."
+ ),
+ ),
+ (
+ "warning_mail_error",
+ models.BooleanField(
+ default=False,
+ help_text="Set this to True if the warning mail received an error",
+ ),
+ ),
+ (
+ "customer",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="dumps",
+ to="counter.customer",
+ ),
+ ),
+ (
+ "dump_operation",
+ models.OneToOneField(
+ blank=True,
+ help_text="The operation that emptied the account.",
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="counter.selling",
+ ),
+ ),
+ ],
+ ),
+ migrations.AddConstraint(
+ model_name="accountdump",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("dump_operation", None)),
+ fields=("customer",),
+ name="unique_ongoing_dump",
+ ),
+ ),
+ ]
diff --git a/counter/models.py b/counter/models.py
index 2e58760a..e6d5b061 100644
--- a/counter/models.py
+++ b/counter/models.py
@@ -26,7 +26,7 @@ from dict2xml import dict2xml
from django.conf import settings
from django.core.validators import MinLengthValidator
from django.db import models
-from django.db.models import Exists, F, OuterRef, QuerySet, Sum, Value
+from django.db.models import Exists, F, OuterRef, Q, QuerySet, Sum, Value
from django.db.models.functions import Concat, Length
from django.forms import ValidationError
from django.urls import reverse
@@ -211,6 +211,51 @@ class BillingInfo(models.Model):
return '' + xml
+class AccountDumpQuerySet(models.QuerySet):
+ def ongoing(self) -> Self:
+ """Filter dump operations that are not completed yet."""
+ return self.filter(dump_operation=None)
+
+
+class AccountDump(models.Model):
+ """The process of dumping an account."""
+
+ customer = models.ForeignKey(
+ Customer, related_name="dumps", on_delete=models.CASCADE
+ )
+ warning_mail_sent_at = models.DateTimeField(
+ help_text=_(
+ "When the mail warning that the account was about to be dumped was sent."
+ )
+ )
+ warning_mail_error = models.BooleanField(
+ default=False,
+ help_text=_("Set this to True if the warning mail received an error"),
+ )
+ dump_operation = models.OneToOneField(
+ "Selling",
+ null=True,
+ blank=True,
+ on_delete=models.CASCADE,
+ help_text=_("The operation that emptied the account."),
+ )
+
+ objects = AccountDumpQuerySet.as_manager()
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(
+ fields=["customer"],
+ condition=Q(dump_operation=None),
+ name="unique_ongoing_dump",
+ ),
+ ]
+
+ def __str__(self):
+ status = "ongoing" if self.dump_operation is None else "finished"
+ return f"{self.customer} - {status}"
+
+
class ProductType(models.Model):
"""A product type.
diff --git a/counter/templates/counter/account_dump_warning_mail.jinja b/counter/templates/counter/account_dump_warning_mail.jinja
new file mode 100644
index 00000000..faf52431
--- /dev/null
+++ b/counter/templates/counter/account_dump_warning_mail.jinja
@@ -0,0 +1,43 @@
+
+ Bonjour,
+
+
+
+ {% trans date=last_subscription_date|date(DATETIME_FORMAT) %}
+ You received this email because your last subscription to the
+ Students' association ended on {{ date }}.
+ {% endtrans %}
+
+
+
+ {% trans date=dump_date|date(DATETIME_FORMAT) %}
+ In accordance with the Internal Regulations, the balance of any
+ inactive AE account for more than 2 years automatically goes back
+ to the AE.
+ The money present on your account will therefore be recovered in full
+ on {{ date }}.
+ {% endtrans %}
+
+
+
+ {% trans %}However, if your subscription is renewed by this date,
+ your right to keep the money in your AE account will be renewed.{% endtrans %}
+
+
+{% if balance >= 10 %}
+
+ {% trans %}You can also request a refund by sending an email to
+ ae@utbm.fr
+ before the aforementioned date.{% endtrans %}
+
+{% endif %}
+
+
+ {% trans %}Sincerely{% endtrans %},
+
+
+
+ L'association des étudiants de l'UTBM
+ 6, Boulevard Anatole France
+ 90000 Belfort
+
diff --git a/counter/tests/test_account_dump.py b/counter/tests/test_account_dump.py
new file mode 100644
index 00000000..79228c26
--- /dev/null
+++ b/counter/tests/test_account_dump.py
@@ -0,0 +1,7 @@
+import pytest
+
+
+@pytest.mark.django_db
+def test_account_dump():
+ # TODO write the fucking test
+ pass
diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po
index 02b4bcbe..5df95185 100644
--- a/locale/fr/LC_MESSAGES/django.po
+++ b/locale/fr/LC_MESSAGES/django.po
@@ -8,9 +8,9 @@ msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-10-10 19:37+0200\n"
"PO-Revision-Date: 2016-07-18\n"
-"Last-Translator: Skia \n"
+"Last-Translator: Maréchal \n"
-"Language: \n"
+"Language: Français\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
@@ -18,8 +18,8 @@ msgstr ""
#: accounting/models.py:62 accounting/models.py:103 accounting/models.py:136
#: accounting/models.py:203 club/models.py:55 com/models.py:274
-#: com/models.py:293 counter/models.py:220 counter/models.py:253
-#: counter/models.py:411 forum/models.py:59 launderette/models.py:29
+#: com/models.py:293 counter/models.py:265 counter/models.py:298
+#: counter/models.py:433 forum/models.py:59 launderette/models.py:29
#: launderette/models.py:84 launderette/models.py:122
msgid "name"
msgstr "nom"
@@ -40,7 +40,7 @@ msgstr "code postal"
msgid "country"
msgstr "pays"
-#: accounting/models.py:67 core/models.py:365
+#: accounting/models.py:67 core/models.py:390
msgid "phone"
msgstr "téléphone"
@@ -218,7 +218,7 @@ msgstr "Compte"
msgid "Company"
msgstr "Entreprise"
-#: accounting/models.py:324 core/models.py:312 sith/settings.py:411
+#: accounting/models.py:324 core/models.py:337 sith/settings.py:420
msgid "Other"
msgstr "Autre"
@@ -264,7 +264,7 @@ msgstr ""
"Vous devez fournir soit un type comptable simplifié ou un type comptable "
"standard"
-#: accounting/models.py:442 counter/models.py:263 pedagogy/models.py:41
+#: accounting/models.py:442 counter/models.py:308 pedagogy/models.py:41
msgid "code"
msgstr "code"
@@ -517,7 +517,7 @@ msgid "Effective amount"
msgstr "Montant effectif"
#: accounting/templates/accounting/club_account_details.jinja:36
-#: sith/settings.py:457
+#: sith/settings.py:466
msgid "Closed"
msgstr "Fermé"
@@ -967,7 +967,7 @@ msgstr "Date de fin"
#: club/forms.py:160 club/templates/club/club_sellings.jinja:49
#: core/templates/core/user_account_detail.jinja:17
#: core/templates/core/user_account_detail.jinja:56
-#: counter/templates/counter/cash_summary_list.jinja:33 counter/views.py:138
+#: counter/templates/counter/cash_summary_list.jinja:33 counter/views.py:141
msgid "Counter"
msgstr "Comptoir"
@@ -1029,11 +1029,11 @@ msgstr "actif"
msgid "short description"
msgstr "description courte"
-#: club/models.py:81 core/models.py:367
+#: club/models.py:81 core/models.py:392
msgid "address"
msgstr "Adresse"
-#: club/models.py:98 core/models.py:278
+#: club/models.py:98 core/models.py:303
msgid "home"
msgstr "home"
@@ -1052,13 +1052,13 @@ msgstr "Un club avec ce nom UNIX existe déjà."
msgid "user"
msgstr "nom d'utilisateur"
-#: club/models.py:354 core/models.py:331 election/models.py:178
+#: club/models.py:354 core/models.py:356 election/models.py:178
#: election/models.py:212 trombi/models.py:211
msgid "role"
msgstr "rôle"
-#: club/models.py:359 core/models.py:89 counter/models.py:221
-#: counter/models.py:254 election/models.py:13 election/models.py:115
+#: club/models.py:359 core/models.py:89 counter/models.py:266
+#: counter/models.py:299 election/models.py:13 election/models.py:115
#: election/models.py:188 forum/models.py:60 forum/models.py:244
msgid "description"
msgstr "description"
@@ -1072,7 +1072,7 @@ msgid "Enter a valid address. Only the root of the address is needed."
msgstr ""
"Entrez une adresse valide. Seule la racine de l'adresse est nécessaire."
-#: club/models.py:429 com/models.py:82 com/models.py:309 core/models.py:941
+#: club/models.py:429 com/models.py:82 com/models.py:309 core/models.py:960
msgid "is moderated"
msgstr "est modéré"
@@ -1442,7 +1442,7 @@ msgstr "résumé"
msgid "content"
msgstr "contenu"
-#: com/models.py:71 core/models.py:1503 launderette/models.py:92
+#: com/models.py:71 core/models.py:1522 launderette/models.py:92
#: launderette/models.py:130 launderette/models.py:181
msgid "type"
msgstr "type"
@@ -1492,7 +1492,7 @@ msgstr "weekmail"
msgid "rank"
msgstr "rang"
-#: com/models.py:295 core/models.py:906 core/models.py:956
+#: com/models.py:295 core/models.py:925 core/models.py:975
msgid "file"
msgstr "fichier"
@@ -1988,17 +1988,17 @@ msgstr "Si un groupe est un meta-groupe ou pas"
msgid "%(value)s is not a valid promo (between 0 and %(end)s)"
msgstr "%(value)s n'est pas une promo valide (doit être entre 0 et %(end)s)"
-#: core/models.py:231
+#: core/models.py:256
msgid "username"
msgstr "nom d'utilisateur"
-#: core/models.py:235
+#: core/models.py:260
msgid "Required. 254 characters or fewer. Letters, digits and ./+/-/_ only."
msgstr ""
"Requis. Pas plus de 254 caractères. Uniquement des lettres, numéros, et ./"
"+/-/_"
-#: core/models.py:241
+#: core/models.py:266
msgid ""
"Enter a valid username. This value may contain only letters, numbers and ./"
"+/-/_ characters."
@@ -2006,43 +2006,43 @@ msgstr ""
"Entrez un nom d'utilisateur correct. Uniquement des lettres, numéros, et ./"
"+/-/_"
-#: core/models.py:247
+#: core/models.py:272
msgid "A user with that username already exists."
msgstr "Un utilisateur de ce nom existe déjà"
-#: core/models.py:249
+#: core/models.py:274
msgid "first name"
msgstr "Prénom"
-#: core/models.py:250
+#: core/models.py:275
msgid "last name"
msgstr "Nom"
-#: core/models.py:251
+#: core/models.py:276
msgid "email address"
msgstr "adresse email"
-#: core/models.py:252
+#: core/models.py:277
msgid "date of birth"
msgstr "date de naissance"
-#: core/models.py:253
+#: core/models.py:278
msgid "nick name"
msgstr "surnom"
-#: core/models.py:255
+#: core/models.py:280
msgid "staff status"
msgstr "status \"staff\""
-#: core/models.py:257
+#: core/models.py:282
msgid "Designates whether the user can log into this admin site."
msgstr "Est-ce que l'utilisateur peut se logger à la partie admin du site."
-#: core/models.py:260
+#: core/models.py:285
msgid "active"
msgstr "actif"
-#: core/models.py:263
+#: core/models.py:288
msgid ""
"Designates whether this user should be treated as active. Unselect this "
"instead of deleting accounts."
@@ -2050,163 +2050,163 @@ msgstr ""
"Est-ce que l'utilisateur doit être traité comme actif. Désélectionnez au "
"lieu de supprimer les comptes."
-#: core/models.py:267
+#: core/models.py:292
msgid "date joined"
msgstr "date d'inscription"
-#: core/models.py:268
+#: core/models.py:293
msgid "last update"
msgstr "dernière mise à jour"
-#: core/models.py:270
+#: core/models.py:295
msgid "superuser"
msgstr "super-utilisateur"
-#: core/models.py:272
+#: core/models.py:297
msgid "Designates whether this user is a superuser. "
msgstr "Est-ce que l'utilisateur est super-utilisateur."
-#: core/models.py:286
+#: core/models.py:311
msgid "profile"
msgstr "profil"
-#: core/models.py:294
+#: core/models.py:319
msgid "avatar"
msgstr "avatar"
-#: core/models.py:302
+#: core/models.py:327
msgid "scrub"
msgstr "blouse"
-#: core/models.py:308
+#: core/models.py:333
msgid "sex"
msgstr "Genre"
-#: core/models.py:312
+#: core/models.py:337
msgid "Man"
msgstr "Homme"
-#: core/models.py:312
+#: core/models.py:337
msgid "Woman"
msgstr "Femme"
-#: core/models.py:314
+#: core/models.py:339
msgid "pronouns"
msgstr "pronoms"
-#: core/models.py:316
+#: core/models.py:341
msgid "tshirt size"
msgstr "taille de t-shirt"
-#: core/models.py:319
+#: core/models.py:344
msgid "-"
msgstr "-"
-#: core/models.py:320
+#: core/models.py:345
msgid "XS"
msgstr "XS"
-#: core/models.py:321
+#: core/models.py:346
msgid "S"
msgstr "S"
-#: core/models.py:322
+#: core/models.py:347
msgid "M"
msgstr "M"
-#: core/models.py:323
+#: core/models.py:348
msgid "L"
msgstr "L"
-#: core/models.py:324
+#: core/models.py:349
msgid "XL"
msgstr "XL"
-#: core/models.py:325
+#: core/models.py:350
msgid "XXL"
msgstr "XXL"
-#: core/models.py:326
+#: core/models.py:351
msgid "XXXL"
msgstr "XXXL"
-#: core/models.py:334
+#: core/models.py:359
msgid "Student"
msgstr "Étudiant"
-#: core/models.py:335
+#: core/models.py:360
msgid "Administrative agent"
msgstr "Personnel administratif"
-#: core/models.py:336
+#: core/models.py:361
msgid "Teacher"
msgstr "Enseignant"
-#: core/models.py:337
+#: core/models.py:362
msgid "Agent"
msgstr "Personnel"
-#: core/models.py:338
+#: core/models.py:363
msgid "Doctor"
msgstr "Doctorant"
-#: core/models.py:339
+#: core/models.py:364
msgid "Former student"
msgstr "Ancien étudiant"
-#: core/models.py:340
+#: core/models.py:365
msgid "Service"
msgstr "Service"
-#: core/models.py:346
+#: core/models.py:371
msgid "department"
msgstr "département"
-#: core/models.py:353
+#: core/models.py:378
msgid "dpt option"
msgstr "Filière"
-#: core/models.py:355 pedagogy/models.py:69 pedagogy/models.py:293
+#: core/models.py:380 pedagogy/models.py:69 pedagogy/models.py:293
msgid "semester"
msgstr "semestre"
-#: core/models.py:356
+#: core/models.py:381
msgid "quote"
msgstr "citation"
-#: core/models.py:357
+#: core/models.py:382
msgid "school"
msgstr "école"
-#: core/models.py:359
+#: core/models.py:384
msgid "promo"
msgstr "promo"
-#: core/models.py:362
+#: core/models.py:387
msgid "forum signature"
msgstr "signature du forum"
-#: core/models.py:364
+#: core/models.py:389
msgid "second email address"
msgstr "adresse email secondaire"
-#: core/models.py:366
+#: core/models.py:391
msgid "parent phone"
msgstr "téléphone des parents"
-#: core/models.py:369
+#: core/models.py:394
msgid "parent address"
msgstr "adresse des parents"
-#: core/models.py:372
+#: core/models.py:397
msgid "is subscriber viewable"
msgstr "profil visible par les cotisants"
-#: core/models.py:572
+#: core/models.py:591
msgid "A user with that username already exists"
msgstr "Un utilisateur de ce nom d'utilisateur existe déjà"
-#: core/models.py:737 core/templates/core/macros.jinja:75
+#: core/models.py:756 core/templates/core/macros.jinja:75
#: core/templates/core/macros.jinja:77 core/templates/core/macros.jinja:78
#: core/templates/core/user_detail.jinja:100
#: core/templates/core/user_detail.jinja:101
@@ -2226,101 +2226,101 @@ msgstr "Un utilisateur de ce nom d'utilisateur existe déjà"
msgid "Profile"
msgstr "Profil"
-#: core/models.py:856
+#: core/models.py:875
msgid "Visitor"
msgstr "Visiteur"
-#: core/models.py:863
+#: core/models.py:882
msgid "receive the Weekmail"
msgstr "recevoir le Weekmail"
-#: core/models.py:864
+#: core/models.py:883
msgid "show your stats to others"
msgstr "montrez vos statistiques aux autres"
-#: core/models.py:866
+#: core/models.py:885
msgid "get a notification for every click"
msgstr "avoir une notification pour chaque click"
-#: core/models.py:869
+#: core/models.py:888
msgid "get a notification for every refilling"
msgstr "avoir une notification pour chaque rechargement"
-#: core/models.py:895 sas/views.py:309
+#: core/models.py:914 sas/views.py:309
msgid "file name"
msgstr "nom du fichier"
-#: core/models.py:899 core/models.py:1252
+#: core/models.py:918 core/models.py:1271
msgid "parent"
msgstr "parent"
-#: core/models.py:913
+#: core/models.py:932
msgid "compressed file"
msgstr "version allégée"
-#: core/models.py:920
+#: core/models.py:939
msgid "thumbnail"
msgstr "miniature"
-#: core/models.py:928 core/models.py:945
+#: core/models.py:947 core/models.py:964
msgid "owner"
msgstr "propriétaire"
-#: core/models.py:932 core/models.py:1269 core/views/files.py:223
+#: core/models.py:951 core/models.py:1288 core/views/files.py:223
msgid "edit group"
msgstr "groupe d'édition"
-#: core/models.py:935 core/models.py:1272 core/views/files.py:226
+#: core/models.py:954 core/models.py:1291 core/views/files.py:226
msgid "view group"
msgstr "groupe de vue"
-#: core/models.py:937
+#: core/models.py:956
msgid "is folder"
msgstr "est un dossier"
-#: core/models.py:938
+#: core/models.py:957
msgid "mime type"
msgstr "type mime"
-#: core/models.py:939
+#: core/models.py:958
msgid "size"
msgstr "taille"
-#: core/models.py:950
+#: core/models.py:969
msgid "asked for removal"
msgstr "retrait demandé"
-#: core/models.py:952
+#: core/models.py:971
msgid "is in the SAS"
msgstr "est dans le SAS"
-#: core/models.py:1021
+#: core/models.py:1040
msgid "Character '/' not authorized in name"
msgstr "Le caractère '/' n'est pas autorisé dans les noms de fichier"
-#: core/models.py:1023 core/models.py:1027
+#: core/models.py:1042 core/models.py:1046
msgid "Loop in folder tree"
msgstr "Boucle dans l'arborescence des dossiers"
-#: core/models.py:1030
+#: core/models.py:1049
msgid "You can not make a file be a children of a non folder file"
msgstr ""
"Vous ne pouvez pas mettre un fichier enfant de quelque chose qui n'est pas "
"un dossier"
-#: core/models.py:1041
+#: core/models.py:1060
msgid "Duplicate file"
msgstr "Un fichier de ce nom existe déjà"
-#: core/models.py:1058
+#: core/models.py:1077
msgid "You must provide a file"
msgstr "Vous devez fournir un fichier"
-#: core/models.py:1235
+#: core/models.py:1254
msgid "page unix name"
msgstr "nom unix de la page"
-#: core/models.py:1241
+#: core/models.py:1260
msgid ""
"Enter a valid page name. This value may contain only unaccented letters, "
"numbers and ./+/-/_ characters."
@@ -2328,55 +2328,55 @@ msgstr ""
"Entrez un nom de page correct. Uniquement des lettres non accentuées, "
"numéros, et ./+/-/_"
-#: core/models.py:1259
+#: core/models.py:1278
msgid "page name"
msgstr "nom de la page"
-#: core/models.py:1264
+#: core/models.py:1283
msgid "owner group"
msgstr "groupe propriétaire"
-#: core/models.py:1277
+#: core/models.py:1296
msgid "lock user"
msgstr "utilisateur bloquant"
-#: core/models.py:1284
+#: core/models.py:1303
msgid "lock_timeout"
msgstr "décompte du déblocage"
-#: core/models.py:1334
+#: core/models.py:1353
msgid "Duplicate page"
msgstr "Une page de ce nom existe déjà"
-#: core/models.py:1337
+#: core/models.py:1356
msgid "Loop in page tree"
msgstr "Boucle dans l'arborescence des pages"
-#: core/models.py:1457
+#: core/models.py:1476
msgid "revision"
msgstr "révision"
-#: core/models.py:1458
+#: core/models.py:1477
msgid "page title"
msgstr "titre de la page"
-#: core/models.py:1459
+#: core/models.py:1478
msgid "page content"
msgstr "contenu de la page"
-#: core/models.py:1500
+#: core/models.py:1519
msgid "url"
msgstr "url"
-#: core/models.py:1501
+#: core/models.py:1520
msgid "param"
msgstr "param"
-#: core/models.py:1506
+#: core/models.py:1525
msgid "viewed"
msgstr "vue"
-#: core/models.py:1564
+#: core/models.py:1583
msgid "operation type"
msgstr "type d'opération"
@@ -2480,7 +2480,7 @@ msgstr "Photos"
#: eboutic/templates/eboutic/eboutic_main.jinja:22
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:16
#: eboutic/templates/eboutic/eboutic_payment_result.jinja:4
-#: sith/settings.py:410 sith/settings.py:418
+#: sith/settings.py:419 sith/settings.py:427
msgid "Eboutic"
msgstr "Eboutic"
@@ -3567,8 +3567,7 @@ msgid "%s is already your godfather"
msgstr "%s est déjà votre parrain/marraine"
#: core/views/forms.py:347
-#, fuzzy, python-format
-#| msgid "This user has already commented on this UV"
+#, python-format
msgid "%s is already your godchild"
msgstr "%s est déjà votre fillot/fillote"
@@ -3629,6 +3628,10 @@ msgstr "Produit parent"
msgid "Buying groups"
msgstr "Groupes d'achat"
+#: counter/management/commands/dump_warning_mail.py:82
+msgid "Clearing of your AE account"
+msgstr "Vidange de votre compte AE"
+
#: counter/migrations/0013_customer_recorded_products.py:25
msgid "Ecocup regularization"
msgstr "Régularization des ecocups"
@@ -3685,43 +3688,55 @@ msgstr "Pays"
msgid "Phone number"
msgstr "Numéro de téléphone"
-#: counter/models.py:232 counter/models.py:258
+#: counter/models.py:228
+msgid "When the mail warning that the account was about to be dumped was sent."
+msgstr "Quand le mail d'avertissement de la vidange du compte a été envoyé."
+
+#: counter/models.py:233
+msgid "Set this to True if the warning mail received an error"
+msgstr "Mettre à True si le mail a reçu une erreur"
+
+#: counter/models.py:240
+msgid "The operation that emptied the account."
+msgstr "L'opération qui a vidé le compte."
+
+#: counter/models.py:277 counter/models.py:303
msgid "product type"
msgstr "type du produit"
-#: counter/models.py:264
+#: counter/models.py:309
msgid "purchase price"
msgstr "prix d'achat"
-#: counter/models.py:265
+#: counter/models.py:310
msgid "selling price"
msgstr "prix de vente"
-#: counter/models.py:266
+#: counter/models.py:311
msgid "special selling price"
msgstr "prix de vente spécial"
-#: counter/models.py:273
+#: counter/models.py:318
msgid "icon"
msgstr "icône"
-#: counter/models.py:278
+#: counter/models.py:323
msgid "limit age"
msgstr "âge limite"
-#: counter/models.py:279
+#: counter/models.py:324
msgid "tray price"
msgstr "prix plateau"
-#: counter/models.py:283
+#: counter/models.py:328
msgid "parent product"
msgstr "produit parent"
-#: counter/models.py:289
+#: counter/models.py:334
msgid "buying groups"
msgstr "groupe d'achat"
-#: counter/models.py:291 election/models.py:50
+#: counter/models.py:336 election/models.py:50
msgid "archived"
msgstr "archivé"
@@ -3867,6 +3882,58 @@ msgstr "uid"
msgid "student cards"
msgstr "cartes étudiante"
+#: counter/templates/counter/account_dump_warning_mail.jinja:6
+#, python-format
+msgid ""
+"\n"
+" You received this email because your last subscription to the\n"
+" Students' association ended on %(date)s.\n"
+" "
+msgstr ""
+"\n"
+"Vous recevez ce mail car votre dernière cotisation à l'assocation des "
+"étudiants de l'UTBM s'est achevée le %(date)s.\n"
+" "
+
+#: counter/templates/counter/account_dump_warning_mail.jinja:13
+#, python-format
+msgid ""
+"\n"
+" In accordance with the Internal Regulations, the balance of any\n"
+" inactive AE account for more than 2 years automatically goes back\n"
+" to the AE.\n"
+" The money present on your account will therefore be recovered in full\n"
+" on %(date)s.\n"
+" "
+msgstr ""
+"\n"
+"Conformément au Règlement intérieur, le solde de tout compte AE inactif "
+"depuis plus de 2 ans revient de droit à l'AE. L'argent présent sur votre "
+"compte sera donc récupéré en totalité le %(date)s.\n"
+" "
+
+#: counter/templates/counter/account_dump_warning_mail.jinja:23
+msgid ""
+"However, if your subscription is renewed by this date,\n"
+" your right to keep the money in your AE account will be renewed."
+msgstr ""
+"Cependant, si votre cotisation est renouvelée d'ici cette date, votre droit "
+"à conserver l'argent de votre compte AE sera renouvelé."
+
+#: counter/templates/counter/account_dump_warning_mail.jinja:29
+msgid ""
+"You can also request a refund by sending an email to\n"
+" ae@utbm.fr\n"
+" before the aforementioned date."
+msgstr ""
+"Vous pouvez également effectuer une demande de remboursement par mail à "
+"l'adresse ae@utbm.fr avant la date "
+"susmentionnée."
+
+#: counter/templates/counter/account_dump_warning_mail.jinja:36
+msgid "Sincerely"
+msgstr "Cordialement"
+
#: counter/templates/counter/activity.jinja:5
#: counter/templates/counter/activity.jinja:13
#, python-format
@@ -4136,23 +4203,23 @@ msgstr "Temps"
msgid "Top 100 barman %(counter_name)s (all semesters)"
msgstr "Top 100 barman %(counter_name)s (tous les semestres)"
-#: counter/views.py:148
+#: counter/views.py:151
msgid "Cash summary"
msgstr "Relevé de caisse"
-#: counter/views.py:157
+#: counter/views.py:160
msgid "Last operations"
msgstr "Dernières opérations"
-#: counter/views.py:204
+#: counter/views.py:207
msgid "Bad credentials"
msgstr "Mauvais identifiants"
-#: counter/views.py:206
+#: counter/views.py:209
msgid "User is not barman"
msgstr "L'utilisateur n'est pas barman."
-#: counter/views.py:211
+#: counter/views.py:214
msgid "Bad location, someone is already logged in somewhere else"
msgstr "Mauvais comptoir, quelqu'un est déjà connecté ailleurs"
@@ -5266,10 +5333,8 @@ msgid "Ask for removal"
msgstr "Demander le retrait"
#: sas/templates/sas/picture.jinja:118 sas/templates/sas/picture.jinja:129
-#, fuzzy
-#| msgid "Previous"
msgid "Previous picture"
-msgstr "Précédent"
+msgstr "Image précédente"
#: sas/templates/sas/picture.jinja:137
msgid "People"
@@ -5292,311 +5357,311 @@ msgstr "Erreur de création de l'album %(album)s : %(msg)s"
msgid "Add user"
msgstr "Ajouter une personne"
-#: sith/settings.py:246 sith/settings.py:465
+#: sith/settings.py:255 sith/settings.py:474
msgid "English"
msgstr "Anglais"
-#: sith/settings.py:246 sith/settings.py:464
+#: sith/settings.py:255 sith/settings.py:473
msgid "French"
msgstr "Français"
-#: sith/settings.py:384
+#: sith/settings.py:393
msgid "TC"
msgstr "TC"
-#: sith/settings.py:385
+#: sith/settings.py:394
msgid "IMSI"
msgstr "IMSI"
-#: sith/settings.py:386
+#: sith/settings.py:395
msgid "IMAP"
msgstr "IMAP"
-#: sith/settings.py:387
+#: sith/settings.py:396
msgid "INFO"
msgstr "INFO"
-#: sith/settings.py:388
+#: sith/settings.py:397
msgid "GI"
msgstr "GI"
-#: sith/settings.py:389 sith/settings.py:475
+#: sith/settings.py:398 sith/settings.py:484
msgid "E"
msgstr "E"
-#: sith/settings.py:390
+#: sith/settings.py:399
msgid "EE"
msgstr "EE"
-#: sith/settings.py:391
+#: sith/settings.py:400
msgid "GESC"
msgstr "GESC"
-#: sith/settings.py:392
+#: sith/settings.py:401
msgid "GMC"
msgstr "GMC"
-#: sith/settings.py:393
+#: sith/settings.py:402
msgid "MC"
msgstr "MC"
-#: sith/settings.py:394
+#: sith/settings.py:403
msgid "EDIM"
msgstr "EDIM"
-#: sith/settings.py:395
+#: sith/settings.py:404
msgid "Humanities"
msgstr "Humanités"
-#: sith/settings.py:396
+#: sith/settings.py:405
msgid "N/A"
msgstr "N/A"
-#: sith/settings.py:400 sith/settings.py:407 sith/settings.py:426
+#: sith/settings.py:409 sith/settings.py:416 sith/settings.py:435
msgid "Check"
msgstr "Chèque"
-#: sith/settings.py:401 sith/settings.py:409 sith/settings.py:427
+#: sith/settings.py:410 sith/settings.py:418 sith/settings.py:436
msgid "Cash"
msgstr "Espèces"
-#: sith/settings.py:402
+#: sith/settings.py:411
msgid "Transfert"
msgstr "Virement"
-#: sith/settings.py:415
+#: sith/settings.py:424
msgid "Belfort"
msgstr "Belfort"
-#: sith/settings.py:416
+#: sith/settings.py:425
msgid "Sevenans"
msgstr "Sevenans"
-#: sith/settings.py:417
+#: sith/settings.py:426
msgid "Montbéliard"
msgstr "Montbéliard"
-#: sith/settings.py:445
+#: sith/settings.py:454
msgid "Free"
msgstr "Libre"
-#: sith/settings.py:446
+#: sith/settings.py:455
msgid "CS"
msgstr "CS"
-#: sith/settings.py:447
+#: sith/settings.py:456
msgid "TM"
msgstr "TM"
-#: sith/settings.py:448
+#: sith/settings.py:457
msgid "OM"
msgstr "OM"
-#: sith/settings.py:449
+#: sith/settings.py:458
msgid "QC"
msgstr "QC"
-#: sith/settings.py:450
+#: sith/settings.py:459
msgid "EC"
msgstr "EC"
-#: sith/settings.py:451
+#: sith/settings.py:460
msgid "RN"
msgstr "RN"
-#: sith/settings.py:452
+#: sith/settings.py:461
msgid "ST"
msgstr "ST"
-#: sith/settings.py:453
+#: sith/settings.py:462
msgid "EXT"
msgstr "EXT"
-#: sith/settings.py:458
+#: sith/settings.py:467
msgid "Autumn"
msgstr "Automne"
-#: sith/settings.py:459
+#: sith/settings.py:468
msgid "Spring"
msgstr "Printemps"
-#: sith/settings.py:460
+#: sith/settings.py:469
msgid "Autumn and spring"
msgstr "Automne et printemps"
-#: sith/settings.py:466
+#: sith/settings.py:475
msgid "German"
msgstr "Allemand"
-#: sith/settings.py:467
+#: sith/settings.py:476
msgid "Spanish"
msgstr "Espagnol"
-#: sith/settings.py:471
+#: sith/settings.py:480
msgid "A"
msgstr "A"
-#: sith/settings.py:472
+#: sith/settings.py:481
msgid "B"
msgstr "B"
-#: sith/settings.py:473
+#: sith/settings.py:482
msgid "C"
msgstr "C"
-#: sith/settings.py:474
+#: sith/settings.py:483
msgid "D"
msgstr "D"
-#: sith/settings.py:476
+#: sith/settings.py:485
msgid "FX"
msgstr "FX"
-#: sith/settings.py:477
+#: sith/settings.py:486
msgid "F"
msgstr "F"
-#: sith/settings.py:478
+#: sith/settings.py:487
msgid "Abs"
msgstr "Abs"
-#: sith/settings.py:482
+#: sith/settings.py:491
msgid "Selling deletion"
msgstr "Suppression de vente"
-#: sith/settings.py:483
+#: sith/settings.py:492
msgid "Refilling deletion"
msgstr "Suppression de rechargement"
-#: sith/settings.py:520
+#: sith/settings.py:534
msgid "One semester"
msgstr "Un semestre, 20 €"
-#: sith/settings.py:521
+#: sith/settings.py:535
msgid "Two semesters"
msgstr "Deux semestres, 35 €"
-#: sith/settings.py:523
+#: sith/settings.py:537
msgid "Common core cursus"
msgstr "Cursus tronc commun, 60 €"
-#: sith/settings.py:527
+#: sith/settings.py:541
msgid "Branch cursus"
msgstr "Cursus branche, 60 €"
-#: sith/settings.py:528
+#: sith/settings.py:542
msgid "Alternating cursus"
msgstr "Cursus alternant, 30 €"
-#: sith/settings.py:529
+#: sith/settings.py:543
msgid "Honorary member"
msgstr "Membre honoraire, 0 €"
-#: sith/settings.py:530
+#: sith/settings.py:544
msgid "Assidu member"
msgstr "Membre d'Assidu, 0 €"
-#: sith/settings.py:531
+#: sith/settings.py:545
msgid "Amicale/DOCEO member"
msgstr "Membre de l'Amicale/DOCEO, 0 €"
-#: sith/settings.py:532
+#: sith/settings.py:546
msgid "UT network member"
msgstr "Cotisant du réseau UT, 0 €"
-#: sith/settings.py:533
+#: sith/settings.py:547
msgid "CROUS member"
msgstr "Membres du CROUS, 0 €"
-#: sith/settings.py:534
+#: sith/settings.py:548
msgid "Sbarro/ESTA member"
msgstr "Membre de Sbarro ou de l'ESTA, 20 €"
-#: sith/settings.py:536
+#: sith/settings.py:550
msgid "One semester Welcome Week"
msgstr "Un semestre Welcome Week"
-#: sith/settings.py:540
+#: sith/settings.py:554
msgid "One month for free"
msgstr "Un mois gratuit"
-#: sith/settings.py:541
+#: sith/settings.py:555
msgid "Two months for free"
msgstr "Deux mois gratuits"
-#: sith/settings.py:542
+#: sith/settings.py:556
msgid "Eurok's volunteer"
msgstr "Bénévole Eurockéennes"
-#: sith/settings.py:544
+#: sith/settings.py:558
msgid "Six weeks for free"
msgstr "6 semaines gratuites"
-#: sith/settings.py:548
+#: sith/settings.py:562
msgid "One day"
msgstr "Un jour"
-#: sith/settings.py:549
+#: sith/settings.py:563
msgid "GA staff member"
msgstr "Membre staff GA (2 semaines), 1 €"
-#: sith/settings.py:552
+#: sith/settings.py:566
msgid "One semester (-20%)"
msgstr "Un semestre (-20%), 12 €"
-#: sith/settings.py:557
+#: sith/settings.py:571
msgid "Two semesters (-20%)"
msgstr "Deux semestres (-20%), 22 €"
-#: sith/settings.py:562
+#: sith/settings.py:576
msgid "Common core cursus (-20%)"
msgstr "Cursus tronc commun (-20%), 36 €"
-#: sith/settings.py:567
+#: sith/settings.py:581
msgid "Branch cursus (-20%)"
msgstr "Cursus branche (-20%), 36 €"
-#: sith/settings.py:572
+#: sith/settings.py:586
msgid "Alternating cursus (-20%)"
msgstr "Cursus alternant (-20%), 24 €"
-#: sith/settings.py:578
+#: sith/settings.py:592
msgid "One year for free(CA offer)"
msgstr "Une année offerte (Offre CA)"
-#: sith/settings.py:598
+#: sith/settings.py:612
msgid "President"
msgstr "Président⸱e"
-#: sith/settings.py:599
+#: sith/settings.py:613
msgid "Vice-President"
msgstr "Vice-Président⸱e"
-#: sith/settings.py:600
+#: sith/settings.py:614
msgid "Treasurer"
msgstr "Trésorier⸱e"
-#: sith/settings.py:601
+#: sith/settings.py:615
msgid "Communication supervisor"
msgstr "Responsable communication"
-#: sith/settings.py:602
+#: sith/settings.py:616
msgid "Secretary"
msgstr "Secrétaire"
-#: sith/settings.py:603
+#: sith/settings.py:617
msgid "IT supervisor"
msgstr "Responsable info"
-#: sith/settings.py:604
+#: sith/settings.py:618
msgid "Board member"
msgstr "Membre du bureau"
-#: sith/settings.py:605
+#: sith/settings.py:619
msgid "Active member"
msgstr "Membre actif⸱ve"
-#: sith/settings.py:606
+#: sith/settings.py:620
msgid "Curious"
msgstr "Curieux⸱euse"
diff --git a/sith/settings.py b/sith/settings.py
index a4eca6b3..9b484559 100644
--- a/sith/settings.py
+++ b/sith/settings.py
@@ -37,6 +37,7 @@ import binascii
import logging
import os
import sys
+from datetime import timedelta
from pathlib import Path
import sentry_sdk
@@ -229,13 +230,20 @@ LOGGING = {
"class": "logging.StreamHandler",
"formatter": "simple",
},
+ "dump_mail_file": {
+ "level": "DEBUG",
+ "class": "logging.FileHandler",
+ "filename": "account_dump_mail.log",
+ "formatter": "simple",
+ },
},
"loggers": {
"main": {
"handlers": ["log_to_stdout"],
"level": "INFO",
"propagate": True,
- }
+ },
+ "account_dump_mail": {"handlers": ["dump_mail_file", "log_to_stdout"]},
},
}
@@ -498,6 +506,8 @@ SITH_COUNTER_CASH_SUMMARY_LENGTH = 50
SITH_ACCOUNT_INACTIVITY_DELTA = relativedelta(years=2)
"""Time before which a user account is considered inactive"""
+SITH_ACCOUNT_DUMP_DELTA = timedelta(days=30)
+"""timedelta between the warning mail and the actual account dump"""
# Defines which product type is the refilling type, and thus increases the account amount
SITH_COUNTER_PRODUCTTYPE_REFILLING = 3
From 465e0f31d9d8d2ede3be755fbef772a6e7ad9248 Mon Sep 17 00:00:00 2001
From: imperosol
Date: Wed, 9 Oct 2024 13:12:09 +0200
Subject: [PATCH 3/8] write command test
---
core/baker_recipes.py | 22 ++++--
core/tests/test_user.py | 14 ++--
.../management/commands/dump_warning_mail.py | 39 ++++++-----
counter/tests/test_account_dump.py | 68 +++++++++++++++++--
4 files changed, 108 insertions(+), 35 deletions(-)
diff --git a/core/baker_recipes.py b/core/baker_recipes.py
index 7e49e09b..0abd83e0 100644
--- a/core/baker_recipes.py
+++ b/core/baker_recipes.py
@@ -1,7 +1,8 @@
from datetime import timedelta
+from dateutil.relativedelta import relativedelta
from django.conf import settings
-from django.utils.timezone import now
+from django.utils.timezone import localdate, now
from model_bakery import seq
from model_bakery.recipe import Recipe, related
@@ -11,13 +12,13 @@ from subscription.models import Subscription
active_subscription = Recipe(
Subscription,
- subscription_start=now() - timedelta(days=30),
- subscription_end=now() + timedelta(days=30),
+ subscription_start=localdate() - timedelta(days=30),
+ subscription_end=localdate() + timedelta(days=30),
)
ended_subscription = Recipe(
Subscription,
- subscription_start=now() - timedelta(days=60),
- subscription_end=now() - timedelta(days=30),
+ subscription_start=localdate() - timedelta(days=60),
+ subscription_end=localdate() - timedelta(days=30),
)
subscriber_user = Recipe(
@@ -36,6 +37,17 @@ old_subscriber_user = Recipe(
)
"""A user with an ended subscription."""
+__inactivity = localdate() - settings.SITH_ACCOUNT_INACTIVITY_DELTA
+very_old_subscriber_user = old_subscriber_user.extend(
+ subscriptions=related(
+ ended_subscription.extend(
+ subscription_start=__inactivity - relativedelta(months=6, days=1),
+ subscription_end=__inactivity - relativedelta(days=1),
+ )
+ )
+)
+"""A user which subscription ended enough time ago to be considered as inactive."""
+
ae_board_membership = Recipe(
Membership,
start_date=now() - timedelta(days=30),
diff --git a/core/tests/test_user.py b/core/tests/test_user.py
index 5454a302..25125d5c 100644
--- a/core/tests/test_user.py
+++ b/core/tests/test_user.py
@@ -7,12 +7,15 @@ from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker, seq
-from model_bakery.recipe import Recipe, related
+from model_bakery.recipe import Recipe
-from core.baker_recipes import old_subscriber_user, subscriber_user
+from core.baker_recipes import (
+ old_subscriber_user,
+ subscriber_user,
+ very_old_subscriber_user,
+)
from core.models import User
from counter.models import Counter, Refilling, Selling
-from subscription.models import Subscription
class TestSearchUsers(TestCase):
@@ -121,9 +124,6 @@ class TestFilterInactive(TestCase):
def setUpTestData(cls):
time_active = now() - settings.SITH_ACCOUNT_INACTIVITY_DELTA + timedelta(days=1)
time_inactive = time_active - timedelta(days=3)
- very_old_subscriber = old_subscriber_user.extend(
- subscriptions=related(Recipe(Subscription, subscription_end=time_inactive))
- )
counter, seller = baker.make(Counter), baker.make(User)
sale_recipe = Recipe(
Selling,
@@ -137,7 +137,7 @@ class TestFilterInactive(TestCase):
baker.make(User),
subscriber_user.make(),
old_subscriber_user.make(),
- *very_old_subscriber.make(_quantity=3),
+ *very_old_subscriber_user.make(_quantity=3),
]
sale_recipe.make(customer=cls.users[3].customer, date=time_active)
baker.make(
diff --git a/counter/management/commands/dump_warning_mail.py b/counter/management/commands/dump_warning_mail.py
index 0a1d7f78..2b8fbfdd 100644
--- a/counter/management/commands/dump_warning_mail.py
+++ b/counter/management/commands/dump_warning_mail.py
@@ -4,7 +4,7 @@ from smtplib import SMTPException
from django.conf import settings
from django.core.mail import send_mail
from django.core.management.base import BaseCommand
-from django.db.models import Exists, OuterRef, Subquery
+from django.db.models import Exists, OuterRef, QuerySet, Subquery
from django.template.loader import render_to_string
from django.utils.timezone import localdate, now
from django.utils.translation import gettext as _
@@ -26,23 +26,7 @@ class Command(BaseCommand):
super().__init__(*args, **kwargs)
def handle(self, *args, **options):
- ongoing_dump_operation = AccountDump.objects.ongoing().filter(
- customer__user=OuterRef("pk")
- )
- users = list(
- User.objects
- .filter_inactive()
- .filter(customer__amount__gt=0)
- .exclude(Exists(ongoing_dump_operation))
- .annotate(
- last_subscription_date=Subquery(
- Subscription.objects.filter(member=OuterRef("pk"))
- .order_by("-subscription_end")
- .values("subscription_end")[:1]
- ),
- )
- .select_related("customer")
- )
+ users = list(self._get_users())
self.stdout.write(f"{len(users)} users will be warned of their account dump")
dumps = []
for user in users:
@@ -57,6 +41,25 @@ class Command(BaseCommand):
AccountDump.objects.bulk_create(dumps)
self.stdout.write("Finished !")
+ @staticmethod
+ def _get_users() -> QuerySet[User]:
+ ongoing_dump_operation = AccountDump.objects.ongoing().filter(
+ customer__user=OuterRef("pk")
+ )
+ return (
+ User.objects.filter_inactive()
+ .filter(customer__amount__gt=0)
+ .exclude(Exists(ongoing_dump_operation))
+ .annotate(
+ last_subscription_date=Subquery(
+ Subscription.objects.filter(member=OuterRef("pk"))
+ .order_by("-subscription_end")
+ .values("subscription_end")[:1]
+ ),
+ )
+ .select_related("customer")
+ )
+
def _send_mail(self, user: User) -> bool:
"""Send the warning email to the given user.
diff --git a/counter/tests/test_account_dump.py b/counter/tests/test_account_dump.py
index 79228c26..49882cfe 100644
--- a/counter/tests/test_account_dump.py
+++ b/counter/tests/test_account_dump.py
@@ -1,7 +1,65 @@
-import pytest
+from datetime import timedelta
+
+from django.conf import settings
+from django.core import mail
+from django.core.management import call_command
+from django.test import TestCase
+from django.utils.timezone import now
+from model_bakery import baker
+from model_bakery.recipe import Recipe
+
+from core.baker_recipes import subscriber_user, very_old_subscriber_user
+from counter.management.commands.dump_warning_mail import Command
+from counter.models import AccountDump, Customer, Refilling
-@pytest.mark.django_db
-def test_account_dump():
- # TODO write the fucking test
- pass
+class TestAccountDumpWarningMailCommand(TestCase):
+ @classmethod
+ def setUpTestData(cls):
+ # delete existing customers to avoid side effect
+ Customer.objects.all().delete()
+ refill_recipe = Recipe(Refilling, amount=10)
+ cls.notified_users = very_old_subscriber_user.make(_quantity=3)
+ inactive_date = (
+ now() - settings.SITH_ACCOUNT_INACTIVITY_DELTA - timedelta(days=1)
+ )
+ refill_recipe.make(
+ customer=(u.customer for u in cls.notified_users),
+ date=inactive_date,
+ _quantity=len(cls.notified_users),
+ )
+ cls.not_notified_users = [
+ subscriber_user.make(),
+ very_old_subscriber_user.make(), # inactive, but account already empty
+ very_old_subscriber_user.make(), # inactive, but with a recent transaction
+ very_old_subscriber_user.make(), # inactive, but already warned
+ ]
+ refill_recipe.make(
+ customer=cls.not_notified_users[2].customer, date=now() - timedelta(days=1)
+ )
+ refill_recipe.make(
+ customer=cls.not_notified_users[3].customer, date=inactive_date
+ )
+ baker.make(
+ AccountDump,
+ customer=cls.not_notified_users[3].customer,
+ dump_operation=None,
+ )
+
+ def test_user_selection(self):
+ """Test that the user to warn are well selected."""
+ users = list(Command._get_users())
+ assert len(users) == 3
+ assert set(users) == set(self.notified_users)
+
+ def test_command(self):
+ """The actual command test."""
+ call_command("dump_warning_mail")
+ # 1 already existing + 3 new account dump objects
+ assert AccountDump.objects.count() == 4
+ sent_mails = list(mail.outbox)
+ assert len(sent_mails) == 3
+ target_emails = {u.email for u in self.notified_users}
+ for sent in sent_mails:
+ assert len(sent.to) == 1
+ assert sent.to[0] in target_emails
From 29b32f6cbfd44feebd5ab7dfb4e7de42e65e6f14 Mon Sep 17 00:00:00 2001
From: imperosol
Date: Thu, 10 Oct 2024 22:03:46 +0200
Subject: [PATCH 4/8] Tell the customer balance in the warning mail
---
.../counter/account_dump_warning_mail.jinja | 18 +-
locale/fr/LC_MESSAGES/django.po | 291 +++++++++---------
2 files changed, 151 insertions(+), 158 deletions(-)
diff --git a/counter/templates/counter/account_dump_warning_mail.jinja b/counter/templates/counter/account_dump_warning_mail.jinja
index faf52431..1d7dc8ed 100644
--- a/counter/templates/counter/account_dump_warning_mail.jinja
+++ b/counter/templates/counter/account_dump_warning_mail.jinja
@@ -3,32 +3,32 @@
- {% trans date=last_subscription_date|date(DATETIME_FORMAT) %}
+ {%- trans date=last_subscription_date|date(DATETIME_FORMAT) -%}
You received this email because your last subscription to the
Students' association ended on {{ date }}.
- {% endtrans %}
+ {%- endtrans -%}
- {% trans date=dump_date|date(DATETIME_FORMAT) %}
+ {%- trans date=dump_date|date(DATETIME_FORMAT), amount=balance -%}
In accordance with the Internal Regulations, the balance of any
inactive AE account for more than 2 years automatically goes back
to the AE.
The money present on your account will therefore be recovered in full
- on {{ date }}.
- {% endtrans %}
+ on {{ date }}, for a total of {{ amount }} €.
+ {%- endtrans -%}
- {% trans %}However, if your subscription is renewed by this date,
- your right to keep the money in your AE account will be renewed.{% endtrans %}
+ {%- trans -%}However, if your subscription is renewed by this date,
+ your right to keep the money in your AE account will be renewed.{%- endtrans -%}
{% if balance >= 10 %}
- {% trans %}You can also request a refund by sending an email to
+ {%- trans -%}You can also request a refund by sending an email to
ae@utbm.fr
- before the aforementioned date.{% endtrans %}
+ before the aforementioned date.{%- endtrans -%}
{% endif %}
diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po
index 5df95185..21c0a35c 100644
--- a/locale/fr/LC_MESSAGES/django.po
+++ b/locale/fr/LC_MESSAGES/django.po
@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2024-10-10 19:37+0200\n"
+"POT-Creation-Date: 2024-10-11 09:58+0200\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal \n"
@@ -19,7 +19,7 @@ msgstr ""
#: accounting/models.py:62 accounting/models.py:103 accounting/models.py:136
#: accounting/models.py:203 club/models.py:55 com/models.py:274
#: com/models.py:293 counter/models.py:265 counter/models.py:298
-#: counter/models.py:433 forum/models.py:59 launderette/models.py:29
+#: counter/models.py:456 forum/models.py:59 launderette/models.py:29
#: launderette/models.py:84 launderette/models.py:122
msgid "name"
msgstr "nom"
@@ -65,8 +65,8 @@ msgid "account number"
msgstr "numéro de compte"
#: accounting/models.py:109 accounting/models.py:140 club/models.py:345
-#: com/models.py:74 com/models.py:259 com/models.py:299 counter/models.py:276
-#: counter/models.py:413 trombi/models.py:210
+#: com/models.py:74 com/models.py:259 com/models.py:299 counter/models.py:321
+#: counter/models.py:458 trombi/models.py:210
msgid "club"
msgstr "club"
@@ -87,12 +87,12 @@ msgstr "Compte club"
msgid "%(club_account)s on %(bank_account)s"
msgstr "%(club_account)s sur %(bank_account)s"
-#: accounting/models.py:201 club/models.py:351 counter/models.py:899
+#: accounting/models.py:201 club/models.py:351 counter/models.py:944
#: election/models.py:16 launderette/models.py:179
msgid "start date"
msgstr "date de début"
-#: accounting/models.py:202 club/models.py:352 counter/models.py:900
+#: accounting/models.py:202 club/models.py:352 counter/models.py:945
#: election/models.py:17
msgid "end date"
msgstr "date de fin"
@@ -106,7 +106,7 @@ msgid "club account"
msgstr "compte club"
#: accounting/models.py:212 accounting/models.py:272 counter/models.py:57
-#: counter/models.py:609
+#: counter/models.py:654
msgid "amount"
msgstr "montant"
@@ -126,20 +126,20 @@ msgstr "numéro"
msgid "journal"
msgstr "classeur"
-#: accounting/models.py:273 core/models.py:940 core/models.py:1460
-#: core/models.py:1505 core/models.py:1534 core/models.py:1558
-#: counter/models.py:619 counter/models.py:723 counter/models.py:935
+#: accounting/models.py:273 core/models.py:959 core/models.py:1479
+#: core/models.py:1524 core/models.py:1553 core/models.py:1577
+#: counter/models.py:664 counter/models.py:768 counter/models.py:980
#: eboutic/models.py:57 eboutic/models.py:189 forum/models.py:311
#: forum/models.py:412
msgid "date"
msgstr "date"
-#: accounting/models.py:274 counter/models.py:222 counter/models.py:936
+#: accounting/models.py:274 counter/models.py:267 counter/models.py:981
#: pedagogy/models.py:207
msgid "comment"
msgstr "commentaire"
-#: accounting/models.py:276 counter/models.py:621 counter/models.py:725
+#: accounting/models.py:276 counter/models.py:666 counter/models.py:770
#: subscription/models.py:56
msgid "payment method"
msgstr "méthode de paiement"
@@ -165,8 +165,8 @@ msgid "accounting type"
msgstr "type comptable"
#: accounting/models.py:311 accounting/models.py:450 accounting/models.py:483
-#: accounting/models.py:515 core/models.py:1533 core/models.py:1559
-#: counter/models.py:689
+#: accounting/models.py:515 core/models.py:1552 core/models.py:1578
+#: counter/models.py:734
msgid "label"
msgstr "étiquette"
@@ -375,8 +375,8 @@ msgstr "Compte en banque : "
#: election/templates/election/election_detail.jinja:187
#: forum/templates/forum/macros.jinja:21
#: launderette/templates/launderette/launderette_admin.jinja:16
-#: launderette/views.py:217 pedagogy/templates/pedagogy/guide.jinja:95
-#: pedagogy/templates/pedagogy/guide.jinja:110
+#: launderette/views.py:217 pedagogy/templates/pedagogy/guide.jinja:99
+#: pedagogy/templates/pedagogy/guide.jinja:114
#: pedagogy/templates/pedagogy/uv_detail.jinja:189
#: sas/templates/sas/album.jinja:32 sas/templates/sas/moderation.jinja:18
#: sas/templates/sas/picture.jinja:50 trombi/templates/trombi/detail.jinja:35
@@ -427,8 +427,8 @@ msgstr "Nouveau compte club"
#: election/templates/election/election_detail.jinja:184
#: forum/templates/forum/macros.jinja:20 forum/templates/forum/macros.jinja:62
#: launderette/templates/launderette/launderette_list.jinja:16
-#: pedagogy/templates/pedagogy/guide.jinja:94
-#: pedagogy/templates/pedagogy/guide.jinja:109
+#: pedagogy/templates/pedagogy/guide.jinja:98
+#: pedagogy/templates/pedagogy/guide.jinja:113
#: pedagogy/templates/pedagogy/uv_detail.jinja:188
#: sas/templates/sas/album.jinja:31 trombi/templates/trombi/detail.jinja:9
#: trombi/templates/trombi/edit_profile.jinja:34
@@ -650,7 +650,7 @@ msgid "Done"
msgstr "Effectuées"
#: accounting/templates/accounting/journal_details.jinja:41
-#: counter/templates/counter/cash_summary_list.jinja:37 counter/views.py:941
+#: counter/templates/counter/cash_summary_list.jinja:37 counter/views.py:944
#: pedagogy/templates/pedagogy/moderation.jinja:13
#: pedagogy/templates/pedagogy/uv_detail.jinja:142
#: trombi/templates/trombi/comment.jinja:4
@@ -971,11 +971,11 @@ msgstr "Date de fin"
msgid "Counter"
msgstr "Comptoir"
-#: club/forms.py:167 counter/views.py:685
+#: club/forms.py:167 counter/views.py:688
msgid "Products"
msgstr "Produits"
-#: club/forms.py:172 counter/views.py:690
+#: club/forms.py:172 counter/views.py:693
msgid "Archived products"
msgstr "Produits archivés"
@@ -1045,7 +1045,7 @@ msgstr "Vous ne pouvez pas faire de boucles dans les clubs"
msgid "A club with that unix_name already exists"
msgstr "Un club avec ce nom UNIX existe déjà."
-#: club/models.py:337 counter/models.py:890 counter/models.py:926
+#: club/models.py:337 counter/models.py:935 counter/models.py:971
#: eboutic/models.py:53 eboutic/models.py:185 election/models.py:183
#: launderette/models.py:136 launderette/models.py:198 sas/models.py:274
#: trombi/models.py:206
@@ -1150,7 +1150,7 @@ msgid "There are no members in this club."
msgstr "Il n'y a pas de membres dans ce club."
#: club/templates/club/club_members.jinja:80
-#: core/templates/core/file_detail.jinja:19 core/views/forms.py:314
+#: core/templates/core/file_detail.jinja:19 core/views/forms.py:312
#: launderette/views.py:217 trombi/templates/trombi/detail.jinja:19
msgid "Add"
msgstr "Ajouter"
@@ -1589,7 +1589,7 @@ msgstr "Type"
#: com/templates/com/weekmail.jinja:19 com/templates/com/weekmail.jinja:48
#: forum/templates/forum/forum.jinja:32 forum/templates/forum/forum.jinja:51
#: forum/templates/forum/main.jinja:34 forum/views.py:246
-#: pedagogy/templates/pedagogy/guide.jinja:88
+#: pedagogy/templates/pedagogy/guide.jinja:92
msgid "Title"
msgstr "Titre"
@@ -2474,7 +2474,7 @@ msgstr "Forum"
msgid "Gallery"
msgstr "Photos"
-#: core/templates/core/base.jinja:230 counter/models.py:421
+#: core/templates/core/base.jinja:230 counter/models.py:466
#: counter/templates/counter/counter_list.jinja:11
#: eboutic/templates/eboutic/eboutic_main.jinja:4
#: eboutic/templates/eboutic/eboutic_main.jinja:22
@@ -3039,7 +3039,7 @@ msgid "Eboutic invoices"
msgstr "Facture eboutic"
#: core/templates/core/user_account.jinja:54
-#: core/templates/core/user_tools.jinja:58 counter/views.py:710
+#: core/templates/core/user_tools.jinja:58 counter/views.py:713
msgid "Etickets"
msgstr "Etickets"
@@ -3375,7 +3375,7 @@ msgid "Subscription stats"
msgstr "Statistiques de cotisation"
#: core/templates/core/user_tools.jinja:48 counter/forms.py:164
-#: counter/views.py:680
+#: counter/views.py:683
msgid "Counters"
msgstr "Comptoirs"
@@ -3392,12 +3392,12 @@ msgid "Product types management"
msgstr "Gestion des types de produit"
#: core/templates/core/user_tools.jinja:56
-#: counter/templates/counter/cash_summary_list.jinja:23 counter/views.py:700
+#: counter/templates/counter/cash_summary_list.jinja:23 counter/views.py:703
msgid "Cash register summaries"
msgstr "Relevés de caisse"
#: core/templates/core/user_tools.jinja:57
-#: counter/templates/counter/invoices_call.jinja:4 counter/views.py:705
+#: counter/templates/counter/invoices_call.jinja:4 counter/views.py:708
msgid "Invoices call"
msgstr "Appels à facture"
@@ -3449,12 +3449,12 @@ msgid "Moderate pictures"
msgstr "Modérer les photos"
#: core/templates/core/user_tools.jinja:165
-#: pedagogy/templates/pedagogy/guide.jinja:21
+#: pedagogy/templates/pedagogy/guide.jinja:25
msgid "Create UV"
msgstr "Créer UV"
#: core/templates/core/user_tools.jinja:166
-#: pedagogy/templates/pedagogy/guide.jinja:24
+#: pedagogy/templates/pedagogy/guide.jinja:28
#: trombi/templates/trombi/detail.jinja:10
msgid "Moderate comments"
msgstr "Modérer les commentaires"
@@ -3495,7 +3495,7 @@ msgstr "Ajouter un nouveau dossier"
msgid "Error creating folder %(folder_name)s: %(msg)s"
msgstr "Erreur de création du dossier %(folder_name)s : %(msg)s"
-#: core/views/files.py:153 core/views/forms.py:279 core/views/forms.py:286
+#: core/views/files.py:153 core/views/forms.py:277 core/views/forms.py:284
#: sas/views.py:81
#, python-format
msgid "Error uploading file %(file_name)s: %(msg)s"
@@ -3505,23 +3505,23 @@ msgstr "Erreur d'envoi du fichier %(file_name)s : %(msg)s"
msgid "Apply rights recursively"
msgstr "Appliquer les droits récursivement"
-#: core/views/forms.py:88
+#: core/views/forms.py:86
msgid "Unsupported NFC card"
msgstr "Carte NFC non supportée"
-#: core/views/forms.py:102 core/views/forms.py:110
+#: core/views/forms.py:100 core/views/forms.py:108
msgid "Choose file"
msgstr "Choisir un fichier"
-#: core/views/forms.py:126 core/views/forms.py:134
+#: core/views/forms.py:124 core/views/forms.py:132
msgid "Choose user"
msgstr "Choisir un utilisateur"
-#: core/views/forms.py:166
+#: core/views/forms.py:164
msgid "Username, email, or account number"
msgstr "Nom d'utilisateur, email, ou numéro de compte AE"
-#: core/views/forms.py:229
+#: core/views/forms.py:227
msgid ""
"Profile: you need to be visible on the picture, in order to be recognized (e."
"g. by the barmen)"
@@ -3529,54 +3529,54 @@ msgstr ""
"Photo de profil: vous devez être visible sur la photo afin d'être reconnu "
"(par exemple par les barmen)"
-#: core/views/forms.py:234
+#: core/views/forms.py:232
msgid "Avatar: used on the forum"
msgstr "Avatar : utilisé sur le forum"
-#: core/views/forms.py:238
+#: core/views/forms.py:236
msgid "Scrub: let other know how your scrub looks like!"
msgstr "Blouse : montrez aux autres à quoi ressemble votre blouse !"
-#: core/views/forms.py:290
+#: core/views/forms.py:288
msgid "Bad image format, only jpeg, png, webp and gif are accepted"
msgstr "Mauvais format d'image, seuls les jpeg, png, webp et gif sont acceptés"
-#: core/views/forms.py:311
+#: core/views/forms.py:309
msgid "Godfather / Godmother"
msgstr "Parrain / Marraine"
-#: core/views/forms.py:312
+#: core/views/forms.py:310
msgid "Godchild"
msgstr "Fillot / Fillote"
-#: core/views/forms.py:317 counter/forms.py:72 trombi/views.py:149
+#: core/views/forms.py:315 counter/forms.py:72 trombi/views.py:149
msgid "Select user"
msgstr "Choisir un utilisateur"
-#: core/views/forms.py:327
+#: core/views/forms.py:325
msgid "This user does not exist"
msgstr "Cet utilisateur n'existe pas"
-#: core/views/forms.py:329
+#: core/views/forms.py:327
msgid "You cannot be related to yourself"
msgstr "Vous ne pouvez pas être relié à vous-même"
-#: core/views/forms.py:341
+#: core/views/forms.py:339
#, python-format
msgid "%s is already your godfather"
msgstr "%s est déjà votre parrain/marraine"
-#: core/views/forms.py:347
+#: core/views/forms.py:345
#, python-format
msgid "%s is already your godchild"
msgstr "%s est déjà votre fillot/fillote"
-#: core/views/forms.py:361 core/views/forms.py:379 election/models.py:22
+#: core/views/forms.py:359 core/views/forms.py:377 election/models.py:22
#: election/views.py:147
msgid "edit groups"
msgstr "groupe d'édition"
-#: core/views/forms.py:364 core/views/forms.py:382 election/models.py:29
+#: core/views/forms.py:362 core/views/forms.py:380 election/models.py:29
#: election/views.py:150
msgid "view groups"
msgstr "groupe de vue"
@@ -3607,8 +3607,8 @@ msgstr "Photos"
msgid "Galaxy"
msgstr "Galaxie"
-#: counter/apps.py:30 counter/models.py:437 counter/models.py:896
-#: counter/models.py:932 launderette/models.py:32
+#: counter/apps.py:30 counter/models.py:482 counter/models.py:941
+#: counter/models.py:977 launderette/models.py:32
msgid "counter"
msgstr "comptoir"
@@ -3652,7 +3652,7 @@ msgstr "client"
msgid "customers"
msgstr "clients"
-#: counter/models.py:74 counter/views.py:262
+#: counter/models.py:74 counter/views.py:265
msgid "Not enough money"
msgstr "Solde insuffisant"
@@ -3740,77 +3740,77 @@ msgstr "groupe d'achat"
msgid "archived"
msgstr "archivé"
-#: counter/models.py:294 counter/models.py:1032
+#: counter/models.py:339 counter/models.py:1077
msgid "product"
msgstr "produit"
-#: counter/models.py:416
+#: counter/models.py:461
msgid "products"
msgstr "produits"
-#: counter/models.py:419
+#: counter/models.py:464
msgid "counter type"
msgstr "type de comptoir"
-#: counter/models.py:421
+#: counter/models.py:466
msgid "Bar"
msgstr "Bar"
-#: counter/models.py:421
+#: counter/models.py:466
msgid "Office"
msgstr "Bureau"
-#: counter/models.py:424
+#: counter/models.py:469
msgid "sellers"
msgstr "vendeurs"
-#: counter/models.py:432 launderette/models.py:192
+#: counter/models.py:477 launderette/models.py:192
msgid "token"
msgstr "jeton"
-#: counter/models.py:627
+#: counter/models.py:672
msgid "bank"
msgstr "banque"
-#: counter/models.py:629 counter/models.py:730
+#: counter/models.py:674 counter/models.py:775
msgid "is validated"
msgstr "est validé"
-#: counter/models.py:634
+#: counter/models.py:679
msgid "refilling"
msgstr "rechargement"
-#: counter/models.py:707 eboutic/models.py:245
+#: counter/models.py:752 eboutic/models.py:245
msgid "unit price"
msgstr "prix unitaire"
-#: counter/models.py:708 counter/models.py:1012 eboutic/models.py:246
+#: counter/models.py:753 counter/models.py:1057 eboutic/models.py:246
msgid "quantity"
msgstr "quantité"
-#: counter/models.py:727
+#: counter/models.py:772
msgid "Sith account"
msgstr "Compte utilisateur"
-#: counter/models.py:727 sith/settings.py:403 sith/settings.py:408
-#: sith/settings.py:428
+#: counter/models.py:772 sith/settings.py:412 sith/settings.py:417
+#: sith/settings.py:437
msgid "Credit card"
msgstr "Carte bancaire"
-#: counter/models.py:735
+#: counter/models.py:780
msgid "selling"
msgstr "vente"
-#: counter/models.py:839
+#: counter/models.py:884
msgid "Unknown event"
msgstr "Événement inconnu"
-#: counter/models.py:840
+#: counter/models.py:885
#, python-format
msgid "Eticket bought for the event %(event)s"
msgstr "Eticket acheté pour l'événement %(event)s"
-#: counter/models.py:842 counter/models.py:865
+#: counter/models.py:887 counter/models.py:910
#, python-format
msgid ""
"You bought an eticket for the event %(event)s.\n"
@@ -3822,97 +3822,90 @@ msgstr ""
"Vous pouvez également retrouver tous vos e-tickets sur votre page de compte "
"%(url)s."
-#: counter/models.py:901
+#: counter/models.py:946
msgid "last activity date"
msgstr "dernière activité"
-#: counter/models.py:904
+#: counter/models.py:949
msgid "permanency"
msgstr "permanence"
-#: counter/models.py:937
+#: counter/models.py:982
msgid "emptied"
msgstr "coffre vidée"
-#: counter/models.py:940
+#: counter/models.py:985
msgid "cash register summary"
msgstr "relevé de caisse"
-#: counter/models.py:1008
+#: counter/models.py:1053
msgid "cash summary"
msgstr "relevé"
-#: counter/models.py:1011
+#: counter/models.py:1056
msgid "value"
msgstr "valeur"
-#: counter/models.py:1014
+#: counter/models.py:1059
msgid "check"
msgstr "chèque"
-#: counter/models.py:1016
+#: counter/models.py:1061
msgid "True if this is a bank check, else False"
msgstr "Vrai si c'est un chèque, sinon Faux."
-#: counter/models.py:1020
+#: counter/models.py:1065
msgid "cash register summary item"
msgstr "élément de relevé de caisse"
-#: counter/models.py:1036
+#: counter/models.py:1081
msgid "banner"
msgstr "bannière"
-#: counter/models.py:1038
+#: counter/models.py:1083
msgid "event date"
msgstr "date de l'événement"
-#: counter/models.py:1040
+#: counter/models.py:1085
msgid "event title"
msgstr "titre de l'événement"
-#: counter/models.py:1042
+#: counter/models.py:1087
msgid "secret"
msgstr "secret"
-#: counter/models.py:1081
+#: counter/models.py:1126
msgid "uid"
msgstr "uid"
-#: counter/models.py:1086
+#: counter/models.py:1131
msgid "student cards"
msgstr "cartes étudiante"
#: counter/templates/counter/account_dump_warning_mail.jinja:6
#, python-format
msgid ""
-"\n"
-" You received this email because your last subscription to the\n"
-" Students' association ended on %(date)s.\n"
-" "
+"You received this email because your last subscription to the\n"
+" Students' association ended on %(date)s."
msgstr ""
-"\n"
"Vous recevez ce mail car votre dernière cotisation à l'assocation des "
-"étudiants de l'UTBM s'est achevée le %(date)s.\n"
-" "
+"étudiants de l'UTBM s'est achevée le %(date)s."
-#: counter/templates/counter/account_dump_warning_mail.jinja:13
+#: counter/templates/counter/account_dump_warning_mail.jinja:11
#, python-format
msgid ""
-"\n"
-" In accordance with the Internal Regulations, the balance of any\n"
+"In accordance with the Internal Regulations, the balance of any\n"
" inactive AE account for more than 2 years automatically goes back\n"
" to the AE.\n"
" The money present on your account will therefore be recovered in full\n"
-" on %(date)s.\n"
-" "
+" on %(date)s, for a total of %(amount)s €."
msgstr ""
-"\n"
"Conformément au Règlement intérieur, le solde de tout compte AE inactif "
"depuis plus de 2 ans revient de droit à l'AE. L'argent présent sur votre "
-"compte sera donc récupéré en totalité le %(date)s.\n"
-" "
+"compte sera donc récupéré en totalité le %(date)s, pour un total de "
+"%(amount)s €. "
-#: counter/templates/counter/account_dump_warning_mail.jinja:23
+#: counter/templates/counter/account_dump_warning_mail.jinja:19
msgid ""
"However, if your subscription is renewed by this date,\n"
" your right to keep the money in your AE account will be renewed."
@@ -3920,7 +3913,7 @@ msgstr ""
"Cependant, si votre cotisation est renouvelée d'ici cette date, votre droit "
"à conserver l'argent de votre compte AE sera renouvelé."
-#: counter/templates/counter/account_dump_warning_mail.jinja:29
+#: counter/templates/counter/account_dump_warning_mail.jinja:25
msgid ""
"You can also request a refund by sending an email to\n"
" ae@utbm.fr\n"
@@ -3930,7 +3923,7 @@ msgstr ""
"l'adresse ae@utbm.fr avant la date "
"susmentionnée."
-#: counter/templates/counter/account_dump_warning_mail.jinja:36
+#: counter/templates/counter/account_dump_warning_mail.jinja:32
msgid "Sincerely"
msgstr "Cordialement"
@@ -3977,7 +3970,7 @@ msgstr "Liste des relevés de caisse"
msgid "Theoric sums"
msgstr "Sommes théoriques"
-#: counter/templates/counter/cash_summary_list.jinja:36 counter/views.py:942
+#: counter/templates/counter/cash_summary_list.jinja:36 counter/views.py:945
msgid "Emptied"
msgstr "Coffre vidé"
@@ -4223,81 +4216,81 @@ msgstr "L'utilisateur n'est pas barman."
msgid "Bad location, someone is already logged in somewhere else"
msgstr "Mauvais comptoir, quelqu'un est déjà connecté ailleurs"
-#: counter/views.py:253
+#: counter/views.py:256
msgid "Too young for that product"
msgstr "Trop jeune pour ce produit"
-#: counter/views.py:256
+#: counter/views.py:259
msgid "Not allowed for that product"
msgstr "Non autorisé pour ce produit"
-#: counter/views.py:259
+#: counter/views.py:262
msgid "No date of birth provided"
msgstr "Pas de date de naissance renseignée"
-#: counter/views.py:548
+#: counter/views.py:551
msgid "You have not enough money to buy all the basket"
msgstr "Vous n'avez pas assez d'argent pour acheter le panier"
-#: counter/views.py:675
+#: counter/views.py:678
msgid "Counter administration"
msgstr "Administration des comptoirs"
-#: counter/views.py:695
+#: counter/views.py:698
msgid "Product types"
msgstr "Types de produit"
-#: counter/views.py:899
+#: counter/views.py:902
msgid "10 cents"
msgstr "10 centimes"
-#: counter/views.py:900
+#: counter/views.py:903
msgid "20 cents"
msgstr "20 centimes"
-#: counter/views.py:901
+#: counter/views.py:904
msgid "50 cents"
msgstr "50 centimes"
-#: counter/views.py:902
+#: counter/views.py:905
msgid "1 euro"
msgstr "1 €"
-#: counter/views.py:903
+#: counter/views.py:906
msgid "2 euros"
msgstr "2 €"
-#: counter/views.py:904
+#: counter/views.py:907
msgid "5 euros"
msgstr "5 €"
-#: counter/views.py:905
+#: counter/views.py:908
msgid "10 euros"
msgstr "10 €"
-#: counter/views.py:906
+#: counter/views.py:909
msgid "20 euros"
msgstr "20 €"
-#: counter/views.py:907
+#: counter/views.py:910
msgid "50 euros"
msgstr "50 €"
-#: counter/views.py:909
+#: counter/views.py:912
msgid "100 euros"
msgstr "100 €"
-#: counter/views.py:912 counter/views.py:918 counter/views.py:924
-#: counter/views.py:930 counter/views.py:936
+#: counter/views.py:915 counter/views.py:921 counter/views.py:927
+#: counter/views.py:933 counter/views.py:939
msgid "Check amount"
msgstr "Montant du chèque"
-#: counter/views.py:915 counter/views.py:921 counter/views.py:927
-#: counter/views.py:933 counter/views.py:939
+#: counter/views.py:918 counter/views.py:924 counter/views.py:930
+#: counter/views.py:936 counter/views.py:942
msgid "Check quantity"
msgstr "Nombre de chèque"
-#: counter/views.py:1459
+#: counter/views.py:1462
msgid "people(s)"
msgstr "personne(s)"
@@ -4870,12 +4863,12 @@ msgid "Washing and drying"
msgstr "Lavage et séchage"
#: launderette/templates/launderette/launderette_book.jinja:27
-#: sith/settings.py:639
+#: sith/settings.py:653
msgid "Washing"
msgstr "Lavage"
#: launderette/templates/launderette/launderette_book.jinja:31
-#: sith/settings.py:639
+#: sith/settings.py:653
msgid "Drying"
msgstr "Séchage"
@@ -5075,26 +5068,26 @@ msgstr "raison"
msgid "UV Guide"
msgstr "Guide des UVs"
-#: pedagogy/templates/pedagogy/guide.jinja:55
+#: pedagogy/templates/pedagogy/guide.jinja:59
#, python-format
msgid "%(display_name)s"
msgstr "%(display_name)s"
-#: pedagogy/templates/pedagogy/guide.jinja:69
+#: pedagogy/templates/pedagogy/guide.jinja:73
#, python-format
msgid "%(credit_type)s"
msgstr "%(credit_type)s"
-#: pedagogy/templates/pedagogy/guide.jinja:87
+#: pedagogy/templates/pedagogy/guide.jinja:91
#: pedagogy/templates/pedagogy/moderation.jinja:12
msgid "UV"
msgstr "UE"
-#: pedagogy/templates/pedagogy/guide.jinja:89
+#: pedagogy/templates/pedagogy/guide.jinja:93
msgid "Department"
msgstr "Département"
-#: pedagogy/templates/pedagogy/guide.jinja:90
+#: pedagogy/templates/pedagogy/guide.jinja:94
msgid "Credit type"
msgstr "Type de crédit"
@@ -5665,72 +5658,72 @@ msgstr "Membre actif⸱ve"
msgid "Curious"
msgstr "Curieux⸱euse"
-#: sith/settings.py:643
+#: sith/settings.py:657
msgid "A new poster needs to be moderated"
msgstr "Une nouvelle affiche a besoin d'être modérée"
-#: sith/settings.py:644
+#: sith/settings.py:658
msgid "A new mailing list needs to be moderated"
msgstr "Une nouvelle mailing list a besoin d'être modérée"
-#: sith/settings.py:647
+#: sith/settings.py:661
msgid "A new pedagogy comment has been signaled for moderation"
msgstr ""
"Un nouveau commentaire de la pédagogie a été signalé pour la modération"
-#: sith/settings.py:649
+#: sith/settings.py:663
#, python-format
msgid "There are %s fresh news to be moderated"
msgstr "Il y a %s nouvelles toutes fraîches à modérer"
-#: sith/settings.py:650
+#: sith/settings.py:664
msgid "New files to be moderated"
msgstr "Nouveaux fichiers à modérer"
-#: sith/settings.py:651
+#: sith/settings.py:665
#, python-format
msgid "There are %s pictures to be moderated in the SAS"
msgstr "Il y a %s photos à modérer dans le SAS"
-#: sith/settings.py:652
+#: sith/settings.py:666
msgid "You've been identified on some pictures"
msgstr "Vous avez été identifié sur des photos"
-#: sith/settings.py:653
+#: sith/settings.py:667
#, python-format
msgid "You just refilled of %s €"
msgstr "Vous avez rechargé votre compte de %s€"
-#: sith/settings.py:654
+#: sith/settings.py:668
#, python-format
msgid "You just bought %s"
msgstr "Vous avez acheté %s"
-#: sith/settings.py:655
+#: sith/settings.py:669
msgid "You have a notification"
msgstr "Vous avez une notification"
-#: sith/settings.py:667
+#: sith/settings.py:681
msgid "Success!"
msgstr "Succès !"
-#: sith/settings.py:668
+#: sith/settings.py:682
msgid "Fail!"
msgstr "Échec !"
-#: sith/settings.py:669
+#: sith/settings.py:683
msgid "You successfully posted an article in the Weekmail"
msgstr "Article posté avec succès dans le Weekmail"
-#: sith/settings.py:670
+#: sith/settings.py:684
msgid "You successfully edited an article in the Weekmail"
msgstr "Article édité avec succès dans le Weekmail"
-#: sith/settings.py:671
+#: sith/settings.py:685
msgid "You successfully sent the Weekmail"
msgstr "Weekmail envoyé avec succès"
-#: sith/settings.py:679
+#: sith/settings.py:693
msgid "AE tee-shirt"
msgstr "Tee-shirt AE"
From 7312580a8d081378d7b966c0715e4da52243f0ee Mon Sep 17 00:00:00 2001
From: imperosol
Date: Sat, 12 Oct 2024 14:58:23 +0200
Subject: [PATCH 5/8] fix: InvoiceQuerySet.annotate_total()
---
core/tests/test_user.py | 19 ++++++++++++++++++-
eboutic/models.py | 7 ++++++-
2 files changed, 24 insertions(+), 2 deletions(-)
diff --git a/core/tests/test_user.py b/core/tests/test_user.py
index 25125d5c..c767f3f4 100644
--- a/core/tests/test_user.py
+++ b/core/tests/test_user.py
@@ -7,7 +7,7 @@ from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker, seq
-from model_bakery.recipe import Recipe
+from model_bakery.recipe import Recipe, foreign_key
from core.baker_recipes import (
old_subscriber_user,
@@ -16,6 +16,7 @@ from core.baker_recipes import (
)
from core.models import User
from counter.models import Counter, Refilling, Selling
+from eboutic.models import Invoice, InvoiceItem
class TestSearchUsers(TestCase):
@@ -148,3 +149,19 @@ class TestFilterInactive(TestCase):
def test_filter_inactive(self):
res = User.objects.filter(id__in=[u.id for u in self.users]).filter_inactive()
assert list(res) == [self.users[0], self.users[5]]
+
+
+@pytest.mark.django_db
+def test_user_invoice_with_multiple_items():
+ """Test that annotate_total() works when invoices contain multiple items."""
+ user: User = subscriber_user.make()
+ item_recipe = Recipe(InvoiceItem, invoice=foreign_key(Recipe(Invoice, user=user)))
+ item_recipe.make(_quantity=3, quantity=1, product_unit_price=5)
+ item_recipe.make(_quantity=1, quantity=1, product_unit_price=5)
+ res = list(
+ Invoice.objects.filter(user=user)
+ .annotate_total()
+ .order_by("-total")
+ .values_list("total", flat=True)
+ )
+ assert res == [15, 5]
diff --git a/eboutic/models.py b/eboutic/models.py
index 0b8c30e1..468562ad 100644
--- a/eboutic/models.py
+++ b/eboutic/models.py
@@ -167,10 +167,15 @@ class InvoiceQueryset(models.QuerySet):
The total amount is the sum of (product_unit_price * quantity)
for all items related to the invoice.
"""
+ # aggregates within subqueries require a little bit of black magic,
+ # but hopefully, django gives a comprehensive documentation for that :
+ # https://docs.djangoproject.com/en/stable/ref/models/expressions/#using-aggregates-within-a-subquery-expression
return self.annotate(
total=Subquery(
InvoiceItem.objects.filter(invoice_id=OuterRef("pk"))
- .annotate(total=Sum(F("product_unit_price") * F("quantity")))
+ .annotate(item_amount=F("product_unit_price") * F("quantity"))
+ .values("item_amount")
+ .annotate(total=Sum("item_amount"))
.values("total")
)
)
From a1bae7ced3bcc8cafaf1dfad23cb04a3d626f6a7 Mon Sep 17 00:00:00 2001
From: NaNoMelo
Date: Sat, 12 Oct 2024 18:48:06 +0200
Subject: [PATCH 6/8] fix empty options in paginated with typescript
---
core/static/webpack/utils/api.ts | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/core/static/webpack/utils/api.ts b/core/static/webpack/utils/api.ts
index a2c872c7..72df568b 100644
--- a/core/static/webpack/utils/api.ts
+++ b/core/static/webpack/utils/api.ts
@@ -27,10 +27,12 @@ export const paginated = async (
options?: PaginatedRequest,
) => {
const maxPerPage = 199;
- options.query.page_size = maxPerPage;
- options.query.page = 1;
+ const queryParams = options ?? {};
+ queryParams.query = queryParams.query ?? {};
+ queryParams.query.page_size = maxPerPage;
+ queryParams.query.page = 1;
- const firstPage = (await endpoint(options)).data;
+ const firstPage = (await endpoint(queryParams)).data;
const results = firstPage.results;
const nbElements = firstPage.count;
@@ -39,7 +41,7 @@ export const paginated = async (
if (nbPages > 1) {
const promises: Promise[] = [];
for (let i = 2; i <= nbPages; i++) {
- const nextPage = structuredClone(options);
+ const nextPage = structuredClone(queryParams);
nextPage.query.page = i;
promises.push(endpoint(nextPage).then((res) => res.data.results));
}
From f07a855e7ef7605d6f31e469d87d8eb08861ede6 Mon Sep 17 00:00:00 2001
From: Sli
Date: Fri, 11 Oct 2024 14:31:32 +0200
Subject: [PATCH 7/8] Remove history management from script.js and migrate sas
albums to webpack
---
core/static/core/js/script.js | 49 ---------------
.../static/webpack/user/family-graph-index.js | 9 +--
core/static/webpack/utils/history.ts | 40 +++++++++++++
.../static/webpack/pedagogy/guide-index.js | 18 +++---
sas/static/webpack/sas/album-index.js | 59 +++++++++++++++++++
sas/templates/sas/album.jinja | 54 ++++-------------
6 files changed, 123 insertions(+), 106 deletions(-)
create mode 100644 core/static/webpack/utils/history.ts
create mode 100644 sas/static/webpack/sas/album-index.js
diff --git a/core/static/core/js/script.js b/core/static/core/js/script.js
index adb15b06..71748ffb 100644
--- a/core/static/core/js/script.js
+++ b/core/static/core/js/script.js
@@ -74,52 +74,3 @@ function displayNotif() {
function getCSRFToken() {
return $("[name=csrfmiddlewaretoken]").val();
}
-
-// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
-const initialUrlParams = new URLSearchParams(window.location.search);
-
-/**
- * @readonly
- * @enum {number}
- */
-const History = {
- // biome-ignore lint/style/useNamingConvention: this feels more like an enum
- NONE: 0,
- // biome-ignore lint/style/useNamingConvention: this feels more like an enum
- PUSH: 1,
- // biome-ignore lint/style/useNamingConvention: this feels more like an enum
- REPLACE: 2,
-};
-
-/**
- * @param {string} key
- * @param {string | string[] | null} value
- * @param {History} action
- * @param {URL | null} url
- */
-// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
-function updateQueryString(key, value, action = History.REPLACE, url = null) {
- let ret = url;
- if (!ret) {
- ret = new URL(window.location.href);
- }
- if (value === undefined || value === null || value === "") {
- // If the value is null, undefined or empty => delete it
- ret.searchParams.delete(key);
- } else if (Array.isArray(value)) {
- ret.searchParams.delete(key);
- for (const v of value) {
- ret.searchParams.append(key, v);
- }
- } else {
- ret.searchParams.set(key, value);
- }
-
- if (action === History.PUSH) {
- window.history.pushState(null, "", ret.toString());
- } else if (action === History.REPLACE) {
- window.history.replaceState(null, "", ret.toString());
- }
-
- return ret;
-}
diff --git a/core/static/webpack/user/family-graph-index.js b/core/static/webpack/user/family-graph-index.js
index c6eb7278..706697b1 100644
--- a/core/static/webpack/user/family-graph-index.js
+++ b/core/static/webpack/user/family-graph-index.js
@@ -1,3 +1,4 @@
+import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import cytoscape from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu";
import klay from "cytoscape-klay";
@@ -184,7 +185,6 @@ window.loadFamilyGraph = (config) => {
const defaultDepth = 2;
function getInitialDepth(prop) {
- // biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
const value = Number.parseInt(initialUrlParams.get(prop));
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
return defaultDepth;
@@ -196,7 +196,6 @@ window.loadFamilyGraph = (config) => {
loading: false,
godfathersDepth: getInitialDepth("godfathersDepth"),
godchildrenDepth: getInitialDepth("godchildrenDepth"),
- // biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
graph: undefined,
graphData: {},
@@ -210,14 +209,12 @@ window.loadFamilyGraph = (config) => {
if (value < config.depthMin || value > config.depthMax) {
return;
}
- // biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
- updateQueryString(param, value, History.REPLACE);
+ updateQueryString(param, value, History.Replace);
await delayedFetch();
});
}
this.$watch("reverse", async (value) => {
- // biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
- updateQueryString("reverse", value, History.REPLACE);
+ updateQueryString("reverse", value, History.Replace);
await this.reverseGraph();
});
this.$watch("graphData", async () => {
diff --git a/core/static/webpack/utils/history.ts b/core/static/webpack/utils/history.ts
new file mode 100644
index 00000000..690b2b88
--- /dev/null
+++ b/core/static/webpack/utils/history.ts
@@ -0,0 +1,40 @@
+export enum History {
+ None = 0,
+ Push = 1,
+ Replace = 2,
+}
+
+export const initialUrlParams = new URLSearchParams(window.location.search);
+export const getCurrentUrlParams = () => {
+ return new URLSearchParams(window.location.search);
+};
+
+export function updateQueryString(
+ key: string,
+ value?: string | string[],
+ action?: History,
+ url?: string,
+) {
+ const historyAction = action ?? History.Replace;
+ const ret = new URL(url ?? window.location.href);
+
+ if (value === undefined || value === null || value === "") {
+ // If the value is null, undefined or empty => delete it
+ ret.searchParams.delete(key);
+ } else if (Array.isArray(value)) {
+ ret.searchParams.delete(key);
+ for (const v of value) {
+ ret.searchParams.append(key, v);
+ }
+ } else {
+ ret.searchParams.set(key, value);
+ }
+
+ if (historyAction === History.Push) {
+ window.history.pushState(null, "", ret.toString());
+ } else if (historyAction === History.Replace) {
+ window.history.replaceState(null, "", ret.toString());
+ }
+
+ return ret;
+}
diff --git a/pedagogy/static/webpack/pedagogy/guide-index.js b/pedagogy/static/webpack/pedagogy/guide-index.js
index 6740b935..31c04ede 100644
--- a/pedagogy/static/webpack/pedagogy/guide-index.js
+++ b/pedagogy/static/webpack/pedagogy/guide-index.js
@@ -1,3 +1,4 @@
+import { History, getCurrentUrlParams, updateQueryString } from "#core:utils/history";
import { uvFetchUvList } from "#openapi";
const pageDefault = 1;
@@ -22,13 +23,13 @@ document.addEventListener("alpine:init", () => {
semester: [],
// biome-ignore lint/style/useNamingConvention: api is in snake_case
to_change: [],
- pushstate: History.PUSH,
+ pushstate: History.Push,
update: undefined,
initializeArgs() {
- const url = new URLSearchParams(window.location.search);
- this.pushstate = History.REPLACE;
+ const url = getCurrentUrlParams();
+ this.pushstate = History.Replace;
this.page = Number.parseInt(url.get("page")) || pageDefault;
this.page_size = Number.parseInt(url.get("page_size")) || pageSizeDefault;
@@ -47,17 +48,14 @@ document.addEventListener("alpine:init", () => {
this.update = Alpine.debounce(async () => {
/* Create the whole url before changing everything all at once */
const first = this.to_change.shift();
- // biome-ignore lint/correctness/noUndeclaredVariables: defined in script.js
- let url = updateQueryString(first.param, first.value, History.NONE);
+ let url = updateQueryString(first.param, first.value, History.None);
for (const value of this.to_change) {
- // biome-ignore lint/correctness/noUndeclaredVariables: defined in script.js
- url = updateQueryString(value.param, value.value, History.NONE, url);
+ url = updateQueryString(value.param, value.value, History.None, url);
}
- // biome-ignore lint/correctness/noUndeclaredVariables: defined in script.js
updateQueryString(first.param, first.value, this.pushstate, url);
await this.fetchData(); /* reload data on form change */
this.to_change = [];
- this.pushstate = History.PUSH;
+ this.pushstate = History.Push;
}, 50);
const searchParams = ["search", "department", "credit_type", "semester"];
@@ -65,7 +63,7 @@ document.addEventListener("alpine:init", () => {
for (const param of searchParams) {
this.$watch(param, () => {
- if (this.pushstate !== History.PUSH) {
+ if (this.pushstate !== History.Push) {
/* This means that we are doing a mass param edit */
return;
}
diff --git a/sas/static/webpack/sas/album-index.js b/sas/static/webpack/sas/album-index.js
new file mode 100644
index 00000000..f09fa6b2
--- /dev/null
+++ b/sas/static/webpack/sas/album-index.js
@@ -0,0 +1,59 @@
+import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
+import { picturesFetchPictures } from "#openapi";
+
+/**
+ * @typedef AlbumConfig
+ * @property {number} albumId id of the album to visualize
+ * @property {number} maxPageSize maximum number of elements to show on a page
+ **/
+
+/**
+ * Create a family graph of an user
+ * @param {AlbumConfig} config
+ **/
+window.loadAlbum = (config) => {
+ document.addEventListener("alpine:init", () => {
+ Alpine.data("pictures", () => ({
+ pictures: {},
+ page: Number.parseInt(initialUrlParams.get("page")) || 1,
+ pushstate: History.Push /* Used to avoid pushing a state on a back action */,
+ loading: false,
+
+ async init() {
+ await this.fetchPictures();
+ this.$watch("page", () => {
+ updateQueryString("page", this.page === 1 ? null : this.page, this.pushstate);
+ this.pushstate = History.Push;
+ this.fetchPictures();
+ });
+
+ window.addEventListener("popstate", () => {
+ this.pushstate = History.Replace;
+ this.page =
+ Number.parseInt(new URLSearchParams(window.location.search).get("page")) ||
+ 1;
+ });
+ },
+
+ async fetchPictures() {
+ this.loading = true;
+ this.pictures = (
+ await picturesFetchPictures({
+ query: {
+ // biome-ignore lint/style/useNamingConvention: API is in snake_case
+ album_id: config.albumId,
+ page: this.page,
+ // biome-ignore lint/style/useNamingConvention: API is in snake_case
+ page_size: config.maxPageSize,
+ },
+ })
+ ).data;
+ this.loading = false;
+ },
+
+ nbPages() {
+ return Math.ceil(this.pictures.count / config.maxPageSize);
+ },
+ }));
+ });
+};
diff --git a/sas/templates/sas/album.jinja b/sas/templates/sas/album.jinja
index 1958531a..15cd10f5 100644
--- a/sas/templates/sas/album.jinja
+++ b/sas/templates/sas/album.jinja
@@ -5,6 +5,10 @@
{%- endblock -%}
+{%- block additional_js -%}
+
+{%- endblock -%}
+
{% block title %}
{% trans %}SAS{% endtrans %}
{% endblock %}
@@ -108,48 +112,14 @@
{% block script %}
{{ super() }}
+
{% endblock %}
+
From 768e2867b5078a67dfb65873e15b8e268f8287d5 Mon Sep 17 00:00:00 2001
From: Sli
Date: Sat, 12 Oct 2024 19:38:56 +0200
Subject: [PATCH 8/8] Fix wrong formatter doc on vscode
---
docs/tutorial/devtools.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/tutorial/devtools.md b/docs/tutorial/devtools.md
index 083c05fa..b5f98098 100644
--- a/docs/tutorial/devtools.md
+++ b/docs/tutorial/devtools.md
@@ -187,8 +187,8 @@ que sont VsCode et Sublime Text.
```json
{
- "editor.defaultFormatter": "",
"[javascript]": {
+ "editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome"
}
}