Merge pull request #1009 from ae-utbm/news-list

News list improvements
This commit is contained in:
thomas girod 2025-02-15 18:29:16 +01:00 committed by GitHub
commit 88b3f7c322
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 462 additions and 223 deletions

View File

@ -5,6 +5,8 @@ from django.http import Http404
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 @@ class CalendarController(ControllerBase):
"""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 @@ class CalendarController(ControllerBase):
@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()

View File

@ -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")

View File

@ -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;
},
}));
});

View File

@ -171,55 +171,25 @@
}
.news_event {
display: block;
padding: 0.4em;
display: flex;
flex-direction: column;
gap: .5em;
padding: 1em;
&:not(:last-child) {
border-bottom: 1px solid grey;
}
div {
margin: 0.2em;
header {
img {
height: 75px;
}
.header_content {
display: flex;
flex-direction: column;
justify-content: center;
gap: .2rem;
h4 {
margin-top: 1em;
margin-top: 0;
text-transform: uppercase;
}
.club_logo {
float: left;
min-width: 7em;
max-width: 9em;
margin: 0;
margin-right: 1em;
margin-top: 0.8em;
img {
max-height: 6em;
max-width: 8em;
display: block;
margin: 0 auto;
}
}
.news_date {
font-size: 100%;
}
.news_content {
clear: left;
.button_bar {
text-align: right;
.fb {
color: $faceblue;
}
.twitter {
color: $twitblue;
}
}
}
}
@ -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;
}

View File

@ -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
<div x-data="{state: AlertState.PENDING}">
{{ news_moderation_alert(news, user, "state") }}
</div>
```
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.
#}
<div
x-data="moderationAlert({{ news.id }})"
{% if alpineState %}
x-modelable="{{ alpineState }}"
x-model="state"
{% endif %}
>
<template x-if="state === AlertState.PENDING">
<div class="alert alert-yellow">
<div class="alert-main">
<strong>{% trans %}Waiting moderation{% endtrans %}</strong>
<p>
{% trans trimmed %}
This news isn't moderated and is visible
only by its author and the communication admins.
{% endtrans %}
</p>
<p>
{% trans trimmed %}
It will stay hidden for other users until it has been moderated.
{% endtrans %}
</p>
{% if user.has_perm("com.moderate_news") %}
{# This is an additional query for each non-moderated news,
but it will be executed only for admin users, and only one time
(if they do their job and moderated news as soon as they see them),
so it's still reasonable #}
{% set nb_event=news.dates.count() %}
{% if nb_event > 1 %}
<br>
<strong>{% trans %}Weekly event{% endtrans %}</strong>
<p>
{% trans trimmed nb=nb_event %}
This event will take place every week for {{ nb }} weeks.
If you moderate or delete this event,
it will also be moderated (or deleted) for the following weeks.
{% endtrans %}
</p>
{% endif %}
{% endif %}
</div>
{% if user.has_perm("com.moderate_news") %}
<span class="alert-aside" :aria-busy="loading">
<button class="btn btn-green" @click="moderateNews()" :disabled="loading">
<i class="fa fa-check"></i> {% trans %}Moderate{% endtrans %}
</button>
{% endif %}
{% if user.has_perm("com.delete_news") %}
<button class="btn btn-red" @click="deleteNews()" :disabled="loading">
<i class="fa fa-trash-can"></i> {% trans %}Delete{% endtrans %}
</button>
</span>
{% endif %}
</div>
</template>
<template x-if="state === AlertState.MODERATED">
<div class="alert alert-green">
{% trans %}News moderated{% endtrans %}
</div>
</template>
<template x-if="state === AlertState.DELETED">
<div class="alert alert-red">
{% trans %}News deleted{% endtrans %}
</div>
</template>
</div>
{% endmacro %}

View File

@ -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,9 +17,18 @@
<link rel="stylesheet" href="{{ static('com/css/news-detail.scss') }}">
{% endblock %}
{% block additional_js %}
<script type="module" src={{ static("bundled/com/components/moderation-alert-index.ts") }}></script>
{% endblock %}
{% block content %}
<p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p>
<section id="news_details">
<div x-data="{newsState: AlertState.PENDING}">
{% if not news.is_moderated %}
{{ news_moderation_alert(news, user, "newsState") }}
{% endif %}
<article id="news_details" x-show="newsState !== AlertState.DELETED">
<div class="club_logo">
<img src="{{ link_news_logo(news)}}" alt="{{ news.club }}" />
<a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a>
@ -48,7 +58,8 @@
{% endif %}
</div>
</div>
</section>
</article>
</div>
{% endblock %}

View File

@ -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 %}
<script type="module" src={{ static("bundled/com/components/ics-calendar-index.ts") }}></script>
<script type="module" src={{ static("bundled/com/components/moderation-alert-index.ts") }}></script>
{% endblock %}
{% block content %}
<div id="news">
<div id="left_column" class="news_column">
{% 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') %}
<h3>
{% trans %}Events today and the next few days{% endtrans %}
<a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
@ -33,51 +32,74 @@
</a>
{% endif %}
{% if user.is_com_admin %}
<a class="btn btn-blue" href="{{ url('com:news_admin_list') }}">{% trans %}Administrate news{% endtrans %}</a>
<a class="btn btn-blue" href="{{ url('com:news_admin_list') }}">
{% trans %}Administrate news{% endtrans %}
</a>
<br>
{% endif %}
{% if events_dates %}
{% for d in events_dates %}
{% for day, dates_group in news_dates %}
<div class="news_events_group">
<div class="news_events_group_date">
<div>
<div>{{ d|localtime|date('D') }}</div>
<div class="day">{{ d|localtime|date('d') }}</div>
<div>{{ d|localtime|date('b') }}</div>
<div>{{ day|date('D') }}</div>
<div class="day">{{ day|date('d') }}</div>
<div>{{ day|date('b') }}</div>
</div>
</div>
<div class="news_events_group_items">
{% 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') %}
<section class="news_event">
<div class="club_logo">
{% if news.club.logo %}
<img src="{{ news.club.logo.url }}" alt="{{ news.club }}" />
{% else %}
<img src="{{ static("com/img/news.png") }}" alt="{{ news.club }}" />
{% for date in dates_group %}
<article
class="news_event"
{%- if not date.news.is_moderated -%}
x-data="{newsState: AlertState.PENDING}"
{%- endif -%}
>
{% 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 %}
</div>
<h4> <a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a></h4>
<div><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></div>
<div
{% if not date.news.is_moderated -%}
x-show="newsState !== AlertState.DELETED"
{%- endif -%}
>
<header class="row gap">
{% if date.news.club.logo %}
<img src="{{ date.news.club.logo.url }}" alt="{{ date.news.club }}"/>
{% else %}
<img src="{{ static("com/img/news.png") }}" alt="{{ date.news.club }}"/>
{% endif %}
<div class="header_content">
<h4>
<a href="{{ url('com:news_detail', news_id=date.news_id) }}">
{{ date.news.title }}
</a>
</h4>
<a href="{{ date.news.club.get_absolute_url() }}">{{ date.news.club }}</a>
<div class="news_date">
<span>{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
<span>{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
</div>
<div class="news_content">{{ news.summary|markdown }}
<div class="button_bar">
{{ fb_quick(news) }}
{{ tweet_quick(news) }}
<time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">
{{ date.start_date|localtime|time(DATETIME_FORMAT) }}
</time> -
<time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">
{{ date.end_date|localtime|time(DATETIME_FORMAT) }}
</time>
</div>
</div>
</section>
</header>
<div class="news_content markdown">
{{ date.news.summary|markdown }}
</div>
</div>
</article>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
<div class="news_empty">
<em>{% trans %}Nothing to come...{% endtrans %}</em>
</div>
{% endif %}
{% endfor %}
<h3>
{% trans %}All coming events{% endtrans %}
@ -110,18 +132,26 @@
<ul>
<li>
<i class="fa-brands fa-discord fa-xl"></i>
<a rel="nofollow" target="#" href="https://discord.gg/QvTm3XJrHR">{% trans %}Discord AE{% endtrans %}</a>
<a rel="nofollow" target="#" href="https://discord.gg/QvTm3XJrHR">
{% trans %}Discord AE{% endtrans %}
</a>
{% if user.was_subscribed %}
- <a rel="nofollow" target="#" href="https://discord.gg/u6EuMfyGaJ">{% trans %}Dev Team{% endtrans %}</a>
- <a rel="nofollow" target="#" href="https://discord.gg/u6EuMfyGaJ">
{% trans %}Dev Team{% endtrans %}
</a>
{% endif %}
</li>
<li>
<i class="fa-brands fa-facebook fa-xl"></i>
<a rel="nofollow" target="#" href="https://www.facebook.com/@AEUTBM/">{% trans %}Facebook{% endtrans %}</a>
<a rel="nofollow" target="#" href="https://www.facebook.com/@AEUTBM/">
Facebook
</a>
</li>
<li>
<i class="fa-brands fa-square-instagram fa-xl"></i>
<a rel="nofollow" target="#" href="https://www.instagram.com/ae_utbm">{% trans %}Instagram{% endtrans %}</a>
<a rel="nofollow" target="#" href="https://www.instagram.com/ae_utbm">
Instagram
</a>
</li>
</ul>
</div>
@ -130,7 +160,7 @@
<div id="birthdays">
<h3>{% trans %}Birthdays{% endtrans %}</h3>
<div id="birthdays_content">
{%- if user.was_subscribed -%}
{%- if user.has_perm("core.view_user") -%}
<ul class="birthdays_year">
{%- for year, users in birthdays -%}
<li>
@ -143,8 +173,13 @@
</li>
{%- endfor -%}
</ul>
{%- else -%}
{%- elif not user.was_subscribed -%}
{# The user cannot view birthdays, because he never subscribed #}
<p>{% trans %}You need to subscribe to access this content{% endtrans %}</p>
{%- else -%}
{# There is another reason why user cannot view birthdays (maybe he is banned)
but we cannot know exactly what is this reason #}
<p>{% trans %}You cannot access this content{% endtrans %}</p>
{%- endif -%}
</div>
</div>

View File

@ -6,12 +6,16 @@ from unittest.mock import MagicMock, patch
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
@ -117,3 +121,66 @@ class TestInternalCalendar:
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()

View File

@ -37,9 +37,9 @@ from django.http import HttpResponseRedirect
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):

View File

@ -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;

View File

@ -39,14 +39,6 @@
<a rel="nofollow" target="#" class="share_button twitter" href="https://twitter.com/intent/tweet?text={{ news.get_full_url() }}">{% trans %}Tweet{% endtrans %}</a>
{%- endmacro %}
{% macro fb_quick(news) -%}
<a rel="nofollow" target="#" href="https://www.facebook.com/sharer/sharer.php?u={{ news.get_full_url() }}" class="fb fa-brands fa-facebook fa-2x"></a>
{%- endmacro %}
{% macro tweet_quick(news) -%}
<a rel="nofollow" target="#" href="https://twitter.com/intent/tweet?text={{ news.get_full_url() }}" class="twitter fa-brands fa-twitter-square fa-2x"></a>
{%- endmacro %}
{% macro user_mini_profile(user) %}
<div class="user_mini_profile">
<div class="user_mini_profile_infos">

View File

@ -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
@ -1263,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"
@ -1404,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
@ -1417,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"
@ -1574,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"
@ -1595,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"