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/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/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/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)); } 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/core/tests/test_user.py b/core/tests/test_user.py index 9794b9f6..c767f3f4 100644 --- a/core/tests/test_user.py +++ b/core/tests/test_user.py @@ -1,15 +1,22 @@ 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, foreign_key -from core.baker_recipes import 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 eboutic.models import Invoice, InvoiceItem class TestSearchUsers(TestCase): @@ -111,3 +118,50 @@ 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) + 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_user.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]] + + +@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/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..2b8fbfdd --- /dev/null +++ b/counter/management/commands/dump_warning_mail.py @@ -0,0 +1,91 @@ +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, QuerySet, 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): + users = list(self._get_users()) + 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 !") + + @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. + + 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..1d7dc8ed --- /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), 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 }}, 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 -%} +

+ +{% 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..49882cfe --- /dev/null +++ b/counter/tests/test_account_dump.py @@ -0,0 +1,65 @@ +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 + + +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 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/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" } } 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") ) ) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 02b4bcbe..21c0a35c 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,11 +6,11 @@ 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: 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:456 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" @@ -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" @@ -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" @@ -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 @@ -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é" @@ -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 @@ -967,15 +967,15 @@ 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" -#: 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" @@ -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" @@ -1045,20 +1045,20 @@ 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 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é" @@ -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" @@ -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" @@ -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" @@ -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" @@ -2474,13 +2474,13 @@ 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 #: 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" @@ -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,55 +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 -#, fuzzy, python-format -#| msgid "This user has already commented on this UV" +#: 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" @@ -3608,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" @@ -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" @@ -3649,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" @@ -3685,117 +3688,129 @@ 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é" -#: 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" @@ -3807,66 +3822,111 @@ 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 "" +"You received this email because your last subscription to the\n" +" Students' association ended on %(date)s." +msgstr "" +"Vous recevez ce mail car votre dernière cotisation à l'assocation des " +"étudiants de l'UTBM s'est achevée le %(date)s." + +#: counter/templates/counter/account_dump_warning_mail.jinja:11 +#, python-format +msgid "" +"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, for a total of %(amount)s €." +msgstr "" +"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, pour un total de " +"%(amount)s €. " + +#: 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." +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:25 +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:32 +msgid "Sincerely" +msgstr "Cordialement" + #: counter/templates/counter/activity.jinja:5 #: counter/templates/counter/activity.jinja:13 #, python-format @@ -3910,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é" @@ -4136,101 +4196,101 @@ 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" -#: 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)" @@ -4803,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" @@ -5008,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" @@ -5266,10 +5326,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,380 +5350,380 @@ 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" -#: 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" 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 %} + diff --git a/sith/settings.py b/sith/settings.py index cd70f49b..9b484559 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -37,9 +37,11 @@ import binascii import logging import os import sys +from datetime import timedelta 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 @@ -228,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"]}, }, } @@ -495,6 +504,11 @@ 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""" +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