diff --git a/com/api.py b/com/api.py index e46daea9b..4a4d0e172 100644 --- a/com/api.py +++ b/com/api.py @@ -5,6 +5,8 @@ from ninja_extra import ControllerBase, api_controller, route from com.calendar import IcsCalendar +from com.models import News +from core.auth.api_permissions import HasPerm from core.views.files import send_raw_file @@ -17,7 +19,7 @@ def calendar_external(self): """Return the ICS file of the AE Google Calendar Because of Google's cors rules, we can't just do a request to google ics - from the frontend. Google is blocking CORS request in it's responses headers. + from the frontend. Google is blocking CORS request in its responses headers. The only way to do it from the frontend is to use Google Calendar API with an API key This is not especially desirable as your API key is going to be provided to the frontend. @@ -30,3 +32,27 @@ def calendar_external(self): @route.get("/internal.ics", url_name="calendar_internal") def calendar_internal(self): return send_raw_file(IcsCalendar.get_internal()) + + +@api_controller("/news") +class NewsController(ControllerBase): + @route.patch( + "/{news_id}/moderate", + permissions=[HasPerm("com.moderate_news")], + url_name="moderate_news", + ) + def moderate_news(self, news_id: int): + news = self.get_object_or_exception(News, id=news_id) + if not news.is_moderated: + news.is_moderated = True + news.moderator = self.context.request.user + news.save() + + @route.delete( + "/{news_id}", + permissions=[HasPerm("com.delete_news")], + url_name="delete_news", + ) + def delete_news(self, news_id: int): + news = self.get_object_or_exception(News, id=news_id) + news.delete() diff --git a/com/models.py b/com/models.py index 1219410aa..229d83c8f 100644 --- a/com/models.py +++ b/com/models.py @@ -172,6 +172,22 @@ def news_notification_callback(notif): notif.viewed = True +class NewsDateQuerySet(models.QuerySet): + def viewable_by(self, user: User) -> Self: + """Filter the event dates that the given user can view. + + - If the can view non moderated news, he can view all news dates + - else, he can view the dates of news that are either + authored by him or moderated. + """ + if user.has_perm("com.view_unmoderated_news"): + return self + q_filter = Q(news__is_moderated=True) + if user.is_authenticated: + q_filter |= Q(news__author_id=user.id) + return self.filter(q_filter) + + class NewsDate(models.Model): """A date associated with news. @@ -187,6 +203,8 @@ class NewsDate(models.Model): start_date = models.DateTimeField(_("start_date")) end_date = models.DateTimeField(_("end_date")) + objects = NewsDateQuerySet.as_manager() + class Meta: verbose_name = _("news date") verbose_name_plural = _("news dates") diff --git a/com/static/bundled/com/components/moderation-alert-index.ts b/com/static/bundled/com/components/moderation-alert-index.ts new file mode 100644 index 000000000..e11290ada --- /dev/null +++ b/com/static/bundled/com/components/moderation-alert-index.ts @@ -0,0 +1,38 @@ +import { exportToHtml } from "#core:utils/globals"; +import { newsDeleteNews, newsModerateNews } from "#openapi"; + +// This will be used in jinja templates, +// so we cannot use real enums as those are purely an abstraction of Typescript +const AlertState = { + // biome-ignore lint/style/useNamingConvention: this feels more like an enum + PENDING: 1, + // biome-ignore lint/style/useNamingConvention: this feels more like an enum + MODERATED: 2, + // biome-ignore lint/style/useNamingConvention: this feels more like an enum + DELETED: 3, +}; +exportToHtml("AlertState", AlertState); + +document.addEventListener("alpine:init", () => { + Alpine.data("moderationAlert", (newsId: number) => ({ + state: AlertState.PENDING, + newsId: newsId as number, + loading: false, + + async moderateNews() { + this.loading = true; + // biome-ignore lint/style/useNamingConvention: api is snake case + await newsModerateNews({ path: { news_id: this.newsId } }); + this.state = AlertState.MODERATED; + this.loading = false; + }, + + async deleteNews() { + this.loading = true; + // biome-ignore lint/style/useNamingConvention: api is snake case + await newsDeleteNews({ path: { news_id: this.newsId } }); + this.state = AlertState.DELETED; + this.loading = false; + }, + })); +}); diff --git a/com/static/com/css/news-list.scss b/com/static/com/css/news-list.scss index d073c4ac4..0df403b44 100644 --- a/com/static/com/css/news-list.scss +++ b/com/static/com/css/news-list.scss @@ -171,54 +171,24 @@ } .news_event { - display: block; - padding: 0.4em; - - &:not(:last-child) { - border-bottom: 1px solid grey; - } - - div { - margin: 0.2em; - } - - h4 { - margin-top: 1em; - text-transform: uppercase; - } - - .club_logo { - float: left; - min-width: 7em; - max-width: 9em; - margin: 0; - margin-right: 1em; - margin-top: 0.8em; + display: flex; + flex-direction: column; + gap: .5em; + padding: 1em; + header { img { - max-height: 6em; - max-width: 8em; - display: block; - margin: 0 auto; + height: 75px; } - } - - .news_date { - font-size: 100%; - } - - .news_content { - clear: left; - - .button_bar { - text-align: right; - - .fb { - color: $faceblue; - } - - .twitter { - color: $twitblue; + .header_content { + display: flex; + flex-direction: column; + justify-content: center; + gap: .2rem; + + h4 { + margin-top: 0; + text-transform: uppercase; } } } @@ -228,70 +198,6 @@ /* END EVENTS TODAY AND NEXT FEW DAYS */ - /* COMING SOON */ - .news_coming_soon { - display: list-item; - list-style-type: square; - list-style-position: inside; - margin-left: 1em; - padding-left: 0; - - a { - font-weight: bold; - text-transform: uppercase; - } - - .news_date { - font-size: 0.9em; - } - } - - /* END COMING SOON */ - - /* NOTICES */ - .news_notice { - margin: 0 0 1em 1em; - padding: 0.4em; - padding-left: 1em; - background: $secondary-neutral-light-color; - box-shadow: $shadow-color 0 0 2px; - border-radius: 18px 5px 18px 5px; - - h4 { - margin: 0; - } - - .news_content { - margin-left: 1em; - } - } - - /* END NOTICES */ - - /* CALLS */ - .news_call { - margin: 0 0 1em 1em; - padding: 0.4em; - padding-left: 1em; - background: $secondary-neutral-light-color; - border: 1px solid grey; - box-shadow: $shadow-color 1px 1px 1px; - - h4 { - margin: 0; - } - - .news_date { - font-size: 0.9em; - } - - .news_content { - margin-left: 1em; - } - } - - /* END CALLS */ - .news_empty { margin-left: 1em; } diff --git a/com/templates/com/macros.jinja b/com/templates/com/macros.jinja new file mode 100644 index 000000000..46f4b7c4c --- /dev/null +++ b/com/templates/com/macros.jinja @@ -0,0 +1,91 @@ +{% macro news_moderation_alert(news, user, alpineState = None) %} + {# An alert to display on top of non moderated news, + with actions to either moderate or delete them. + + The current state of the alert is accessible through + the given `alpineState` variable. + This state is a `AlertState`, as defined in `moderation-alert-index.ts` + + Example : + ```jinja +
+ {{ news_moderation_alert(news, user, "state") }} +
+ ``` + + Args: + news: The `News` object to which this alert is related + user: The request.user + alpineState: An alpine variable name + + Warning: + If you use this macro, you must also include `moderation-alert-index.ts` + in your template. + #} +
+ + + +
+{% endmacro %} diff --git a/com/templates/com/news_detail.jinja b/com/templates/com/news_detail.jinja index 454eb2ef2..d517ee72b 100644 --- a/com/templates/com/news_detail.jinja +++ b/com/templates/com/news_detail.jinja @@ -1,5 +1,6 @@ {% extends "core/base.jinja" %} {% from 'core/macros.jinja' import user_profile_link, facebook_share, tweet, link_news_logo, gen_news_metatags %} +{% from "com/macros.jinja" import news_moderation_alert %} {% block title %} {% trans %}News{% endtrans %} - @@ -16,39 +17,49 @@ {% endblock %} +{% block additional_js %} + +{% endblock %} + {% block content %}

{% trans %}Back to news{% endtrans %}

-
- -

{{ news.title }}

-

- {{ date.start_date|localtime|date(DATETIME_FORMAT) }} - {{ date.start_date|localtime|time(DATETIME_FORMAT) }} - - {{ date.end_date|localtime|date(DATETIME_FORMAT) }} - {{ date.end_date|localtime|time(DATETIME_FORMAT) }} -

-
-
{{ news.summary|markdown }}
-
-
{{ news.content|markdown }}
- {{ facebook_share(news) }} - {{ tweet(news) }} -
-

{% trans %}Author: {% endtrans %}{{ user_profile_link(news.author) }}

- {% if news.moderator %} -

{% trans %}Moderator: {% endtrans %}{{ user_profile_link(news.moderator) }}

- {% elif user.is_com_admin %} -

{% trans %}Moderate{% endtrans %}

- {% endif %} - {% if user.can_edit(news) %} -

{% trans %}Edit (will be moderated again){% endtrans %}

- {% endif %} +
+ + {% if not news.is_moderated %} + {{ news_moderation_alert(news, user, "newsState") }} + {% endif %} +
+ +

{{ news.title }}

+

+ {{ date.start_date|localtime|date(DATETIME_FORMAT) }} + {{ date.start_date|localtime|time(DATETIME_FORMAT) }} - + {{ date.end_date|localtime|date(DATETIME_FORMAT) }} + {{ date.end_date|localtime|time(DATETIME_FORMAT) }} +

+
+
{{ news.summary|markdown }}
+
+
{{ news.content|markdown }}
+ {{ facebook_share(news) }} + {{ tweet(news) }} +
+

{% trans %}Author: {% endtrans %}{{ user_profile_link(news.author) }}

+ {% if news.moderator %} +

{% trans %}Moderator: {% endtrans %}{{ user_profile_link(news.moderator) }}

+ {% elif user.is_com_admin %} +

{% trans %}Moderate{% endtrans %}

+ {% endif %} + {% if user.can_edit(news) %} +

{% trans %}Edit (will be moderated again){% endtrans %}

+ {% endif %} +
-
-
+ + {% endblock %} diff --git a/com/templates/com/news_list.jinja b/com/templates/com/news_list.jinja index 0f1f43016..ebb4f6e18 100644 --- a/com/templates/com/news_list.jinja +++ b/com/templates/com/news_list.jinja @@ -1,5 +1,5 @@ {% extends "core/base.jinja" %} -{% from 'core/macros.jinja' import tweet_quick, fb_quick %} +{% from "com/macros.jinja" import news_moderation_alert %} {% block title %} {% trans %}News{% endtrans %} @@ -15,13 +15,12 @@ {% block additional_js %} + {% endblock %} {% block content %} -
- {% set events_dates = NewsDate.objects.filter(end_date__gte=timezone.now(), start_date__lte=timezone.now()+timedelta(days=5), news__is_moderated=True).datetimes('start_date', 'day') %}

{% trans %}Events today and the next few days{% endtrans %} @@ -33,51 +32,74 @@ {% endif %} {% if user.is_com_admin %} - {% trans %}Administrate news{% endtrans %} + + {% trans %}Administrate news{% endtrans %} +
- {% endif %} - {% if events_dates %} - {% for d in events_dates %} -
-
-
-
{{ d|localtime|date('D') }}
-
{{ d|localtime|date('d') }}
-
{{ d|localtime|date('b') }}
-
+ {% endif %} + {% for day, dates_group in news_dates %} +
+
+
+
{{ day|date('D') }}
+
{{ day|date('d') }}
+
{{ day|date('b') }}
-
- {% for news in object_list.filter(dates__start_date__gte=d,dates__start_date__lte=d+timedelta(days=1)).exclude(dates__end_date__lt=timezone.now()).order_by('dates__start_date') %} -
- +
+ {% for date in dates_group %} +
+ {% if not date.news.is_moderated %} + {# if a non moderated news is in the object list, + the logged user is either an admin or the news author #} + {{ news_moderation_alert(date.news, user, "newsState") }} + {% endif %} +
+
+ {% if date.news.club.logo %} + {{ date.news.club }} {% else %} - {{ news.club }} + {{ date.news.club }} {% endif %} -
-

{{ news.title }}

- -
- {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }} - - {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }} -
-
{{ news.summary|markdown }} -
- {{ fb_quick(news) }} - {{ tweet_quick(news) }} +
+

+ + {{ date.news.title }} + +

+ {{ date.news.club }} +
+ - + +
+ +
+ {{ date.news.summary|markdown }}
-
- {% endfor %} -
+
+ + {% endfor %}
- {% endfor %} +
{% else %}
{% trans %}Nothing to come...{% endtrans %}
- {% endif %} + {% endfor %}

{% trans %}All coming events{% endtrans %} @@ -110,18 +132,26 @@

@@ -130,7 +160,7 @@

{% trans %}Birthdays{% endtrans %}

- {%- if user.was_subscribed -%} + {%- if user.has_perm("core.view_user") -%}
    {%- for year, users in birthdays -%}
  • @@ -143,8 +173,13 @@
  • {%- endfor -%}
- {%- else -%} + {%- elif not user.was_subscribed -%} + {# The user cannot view birthdays, because he never subscribed #}

{% trans %}You need to subscribe to access this content{% endtrans %}

+ {%- else -%} + {# There is another reason why user cannot view birthdays (maybe he is banned) + but we cannot know exactly what is this reason #} +

{% trans %}You cannot access this content{% endtrans %}

{%- endif -%}
diff --git a/com/tests/test_api.py b/com/tests/test_api.py index f131052e1..8e4b00897 100644 --- a/com/tests/test_api.py +++ b/com/tests/test_api.py @@ -6,12 +6,16 @@ import pytest from django.conf import settings +from django.contrib.auth.models import Permission from django.http import HttpResponse from django.test.client import Client from django.urls import reverse from django.utils import timezone +from model_bakery import baker from com.calendar import IcsCalendar +from com.models import News +from core.models import User @dataclass @@ -120,3 +124,66 @@ def test_fetch_success(self, client: Client): out_file = accel_redirect_to_file(response) assert out_file is not None assert out_file.exists() + + +@pytest.mark.django_db +class TestModerateNews: + @pytest.mark.parametrize("news_is_moderated", [True, False]) + def test_moderation_ok(self, client: Client, news_is_moderated: bool): # noqa FBT + user = baker.make( + User, user_permissions=[Permission.objects.get(codename="moderate_news")] + ) + # The API call should work even if the news is initially moderated. + # In the latter case, the result should be a noop, rather than an error. + news = baker.make(News, is_moderated=news_is_moderated) + initial_moderator = news.moderator + client.force_login(user) + response = client.patch( + reverse("api:moderate_news", kwargs={"news_id": news.id}) + ) + # if it wasn't moderated, it should now be moderated and the moderator should + # be the user that made the request. + # If it was already moderated, it should be a no-op, but not an error + assert response.status_code == 200 + news.refresh_from_db() + assert news.is_moderated + if not news_is_moderated: + assert news.moderator == user + else: + assert news.moderator == initial_moderator + + def test_moderation_forbidden(self, client: Client): + user = baker.make(User) + news = baker.make(News, is_moderated=False) + client.force_login(user) + response = client.patch( + reverse("api:moderate_news", kwargs={"news_id": news.id}) + ) + assert response.status_code == 403 + news.refresh_from_db() + assert not news.is_moderated + + +@pytest.mark.django_db +class TestDeleteNews: + def test_delete_news_ok(self, client: Client): + user = baker.make( + User, user_permissions=[Permission.objects.get(codename="delete_news")] + ) + news = baker.make(News) + client.force_login(user) + response = client.delete( + reverse("api:delete_news", kwargs={"news_id": news.id}) + ) + assert response.status_code == 200 + assert not News.objects.filter(id=news.id).exists() + + def test_delete_news_forbidden(self, client: Client): + user = baker.make(User) + news = baker.make(News) + client.force_login(user) + response = client.delete( + reverse("api:delete_news", kwargs={"news_id": news.id}) + ) + assert response.status_code == 403 + assert News.objects.filter(id=news.id).exists() diff --git a/com/views.py b/com/views.py index 0ab8fc1ce..3990fe9b0 100644 --- a/com/views.py +++ b/com/views.py @@ -37,9 +37,9 @@ from django.shortcuts import get_object_or_404, redirect from django.urls import reverse, reverse_lazy from django.utils import timezone -from django.utils.timezone import localdate +from django.utils.timezone import localdate, now from django.utils.translation import gettext_lazy as _ -from django.views.generic import DetailView, ListView, View +from django.views.generic import DetailView, ListView, TemplateView, View from django.views.generic.edit import CreateView, DeleteView, UpdateView from club.models import Club, Mailing @@ -236,28 +236,37 @@ class NewsAdminListView(PermissionRequiredMixin, ListView): permission_required = ["com.moderate_news", "com.delete_news"] -class NewsListView(ListView): - model = News +class NewsListView(TemplateView): template_name = "com/news_list.jinja" - queryset = News.objects.filter(is_moderated=True) - - def get_queryset(self): - return super().get_queryset().viewable_by(self.request.user) - def get_context_data(self, **kwargs): - kwargs = super().get_context_data(**kwargs) - kwargs["NewsDate"] = NewsDate - kwargs["timedelta"] = timedelta - kwargs["birthdays"] = itertools.groupby( + def get_birthdays(self): + if not self.request.user.has_perm("core.view_user"): + return [] + return itertools.groupby( User.objects.filter( date_of_birth__month=localdate().month, date_of_birth__day=localdate().day, + is_subscriber_viewable=True, ) .filter(role__in=["STUDENT", "FORMER STUDENT"]) .order_by("-date_of_birth"), key=lambda u: u.date_of_birth.year, ) - return kwargs + + def get_news_dates(self): + return itertools.groupby( + NewsDate.objects.viewable_by(self.request.user) + .filter(end_date__gt=now(), start_date__lt=now() + timedelta(days=6)) + .order_by("start_date") + .select_related("news", "news__club"), + key=lambda d: d.start_date.date(), + ) + + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | { + "news_dates": self.get_news_dates(), + "birthdays": self.get_birthdays(), + } class NewsDetailView(CanViewMixin, DetailView): diff --git a/core/static/core/style.scss b/core/static/core/style.scss index a89d33cfe..062085793 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -244,6 +244,20 @@ body { } } + &.btn-green { + $bg-color: rgba(0, 210, 83, 0.4); + background-color: $bg-color; + color: $black-color; + + &:not(:disabled):hover { + background-color: darken($bg-color, 15%); + } + + &:disabled { + background-color: lighten($bg-color, 15%); + } + } + &.btn-red { background-color: #fc8181; color: black; diff --git a/core/templates/core/macros.jinja b/core/templates/core/macros.jinja index 84c5b05a0..b43ba8445 100644 --- a/core/templates/core/macros.jinja +++ b/core/templates/core/macros.jinja @@ -39,14 +39,6 @@ {%- endmacro %} -{% macro fb_quick(news) -%} - -{%- endmacro %} - -{% macro tweet_quick(news) -%} - -{%- endmacro %} - {% macro user_mini_profile(user) %}
diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index dc6b5ee67..69fc684f3 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: 2025-01-19 18:12+0100\n" +"POT-Creation-Date: 2025-01-25 23:46+0100\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -310,7 +310,7 @@ msgstr "Compte en banque : " #: accounting/templates/accounting/club_account_details.jinja #: accounting/templates/accounting/label_list.jinja #: club/templates/club/club_sellings.jinja club/templates/club/mailing.jinja -#: com/templates/com/mailing_admin.jinja +#: com/templates/com/macros.jinja com/templates/com/mailing_admin.jinja #: com/templates/com/news_admin_list.jinja com/templates/com/poster_edit.jinja #: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja #: core/templates/core/file_detail.jinja @@ -935,10 +935,6 @@ msgstr "rôle" msgid "description" msgstr "description" -#: club/models.py -msgid "past member" -msgstr "ancien membre" - #: club/models.py msgid "Email address" msgstr "Adresse email" @@ -1267,7 +1263,7 @@ msgstr "Format : 16:9 | Résolution : 1920x1080" msgid "Start date" msgstr "Date de début" -#: com/forms.py +#: com/forms.py com/templates/com/macros.jinja msgid "Weekly event" msgstr "Événement Hebdomadaire" @@ -1408,12 +1404,35 @@ msgstr "temps d'affichage" msgid "Begin date should be before end date" msgstr "La date de début doit être avant celle de fin" -#: com/templates/com/mailing_admin.jinja com/views.py -#: core/templates/core/user_tools.jinja -msgid "Mailing lists administration" -msgstr "Administration des mailing listes" +#: com/templates/com/macros.jinja +msgid "Waiting moderation" +msgstr "En attente de modération" -#: com/templates/com/mailing_admin.jinja +#: com/templates/com/macros.jinja +msgid "" +"This news isn't moderated and is visible only by its author and the " +"communication admins." +msgstr "" +"Cette nouvelle n'est pas modérée et n'est visible que par son auteur et les " +"admins communication." + +#: com/templates/com/macros.jinja +msgid "It will stay hidden for other users until it has been moderated." +msgstr "" +"Elle sera cachée pour les autres utilisateurs tant qu'elle ne sera pas " +"modérée." + +#: com/templates/com/macros.jinja +#, python-format +msgid "" +"This event will take place every week for %(nb)s weeks. If you moderate or delete " +"this event, it will also be moderated (or deleted) for the following weeks." +msgstr "" +"Cet événement se déroulera chaque semaine pendant %(nb)s semaines. Si vous " +"modérez ou supprimez cet événement, il sera également modéré (ou supprimé) " +"pour les semaines suivantes." + +#: com/templates/com/macros.jinja com/templates/com/mailing_admin.jinja #: com/templates/com/news_admin_list.jinja com/templates/com/news_detail.jinja #: core/templates/core/file_detail.jinja #: core/templates/core/file_moderation.jinja sas/templates/sas/moderation.jinja @@ -1421,6 +1440,19 @@ msgstr "Administration des mailing listes" msgid "Moderate" msgstr "Modérer" +#: com/templates/com/macros.jinja +msgid "News moderated" +msgstr "Nouvelle modérée" + +#: com/templates/com/macros.jinja +msgid "News deleted" +msgstr "Nouvelle supprimée" + +#: com/templates/com/mailing_admin.jinja com/views.py +#: core/templates/core/user_tools.jinja +msgid "Mailing lists administration" +msgstr "Administration des mailing listes" + #: com/templates/com/mailing_admin.jinja #, python-format msgid "Moderated by %(user)s" @@ -1578,14 +1610,6 @@ msgstr "Discord AE" msgid "Dev Team" msgstr "Pôle Informatique" -#: com/templates/com/news_list.jinja -msgid "Facebook" -msgstr "Facebook" - -#: com/templates/com/news_list.jinja -msgid "Instagram" -msgstr "Instagram" - #: com/templates/com/news_list.jinja msgid "Birthdays" msgstr "Anniversaires" @@ -1599,6 +1623,10 @@ msgstr "%(age)s ans" msgid "You need to subscribe to access this content" msgstr "Vous devez cotiser pour accéder à ce contenu" +#: com/templates/com/news_list.jinja +msgid "You cannot access this content" +msgstr "Vous n'avez pas accès à ce contenu" + #: com/templates/com/poster_edit.jinja com/templates/com/poster_list.jinja msgid "Poster" msgstr "Affiche" @@ -3299,8 +3327,8 @@ msgstr "Nom d'utilisateur, email, ou numéro de compte AE" #: core/views/forms.py msgid "" -"Profile: you need to be visible on the picture, in order to be recognized " -"(e.g. by the barmen)" +"Profile: you need to be visible on the picture, in order to be recognized (e." +"g. by the barmen)" msgstr "" "Photo de profil: vous devez être visible sur la photo afin d'être reconnu " "(par exemple par les barmen)" @@ -3906,8 +3934,8 @@ msgstr "" #: counter/templates/counter/mails/account_dump.jinja msgid "If you think this was a mistake, please mail us at ae@utbm.fr." msgstr "" -"Si vous pensez qu'il s'agit d'une erreur, veuillez envoyer un mail à " -"ae@utbm.fr." +"Si vous pensez qu'il s'agit d'une erreur, veuillez envoyer un mail à ae@utbm." +"fr." #: counter/templates/counter/mails/account_dump.jinja msgid ""