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

Allow displaying more news
This commit is contained in:
thomas girod 2025-02-25 14:07:51 +01:00 committed by GitHub
commit e936f0d285
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 536 additions and 102 deletions

View File

@ -7,3 +7,17 @@ class ClubSchema(ModelSchema):
class Meta: class Meta:
model = Club model = Club
fields = ["id", "name"] fields = ["id", "name"]
class ClubProfileSchema(ModelSchema):
"""The infos needed to display a simple club profile."""
class Meta:
model = Club
fields = ["id", "name", "logo"]
url: str
@staticmethod
def resolve_url(obj: Club) -> str:
return obj.get_absolute_url()

View File

@ -1,11 +1,16 @@
from pathlib import Path from pathlib import Path
from typing import Literal
from django.conf import settings from django.conf import settings
from django.http import Http404 from django.http import Http404
from ninja_extra import ControllerBase, api_controller, route from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from com.calendar import IcsCalendar from com.calendar import IcsCalendar
from com.models import News from com.models import News, NewsDate
from com.schemas import NewsDateFilterSchema, NewsDateSchema
from core.auth.api_permissions import HasPerm from core.auth.api_permissions import HasPerm
from core.views.files import send_raw_file from core.views.files import send_raw_file
@ -37,7 +42,7 @@ class CalendarController(ControllerBase):
@api_controller("/news") @api_controller("/news")
class NewsController(ControllerBase): class NewsController(ControllerBase):
@route.patch( @route.patch(
"/{news_id}/moderate", "/{int:news_id}/moderate",
permissions=[HasPerm("com.moderate_news")], permissions=[HasPerm("com.moderate_news")],
url_name="moderate_news", url_name="moderate_news",
) )
@ -49,10 +54,27 @@ class NewsController(ControllerBase):
news.save() news.save()
@route.delete( @route.delete(
"/{news_id}", "/{int:news_id}",
permissions=[HasPerm("com.delete_news")], permissions=[HasPerm("com.delete_news")],
url_name="delete_news", url_name="delete_news",
) )
def delete_news(self, news_id: int): def delete_news(self, news_id: int):
news = self.get_object_or_exception(News, id=news_id) news = self.get_object_or_exception(News, id=news_id)
news.delete() news.delete()
@route.get(
"/date",
url_name="fetch_news_dates",
response=PaginatedResponseSchema[NewsDateSchema],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def fetch_news_dates(
self,
filters: Query[NewsDateFilterSchema],
text_format: Literal["md", "html"] = "md",
):
return filters.filter(
NewsDate.objects.viewable_by(self.context.request.user)
.order_by("start_date")
.select_related("news", "news__club")
)

58
com/schemas.py Normal file
View File

@ -0,0 +1,58 @@
from datetime import datetime
from ninja import FilterSchema, ModelSchema
from ninja_extra import service_resolver
from ninja_extra.controllers import RouteContext
from pydantic import Field
from club.schemas import ClubProfileSchema
from com.models import News, NewsDate
from core.markdown import markdown
class NewsDateFilterSchema(FilterSchema):
before: datetime | None = Field(None, q="end_date__lt")
after: datetime | None = Field(None, q="start_date__gt")
club_id: int | None = Field(None, q="news__club_id")
news_id: int | None = None
is_moderated: bool | None = Field(None, q="news__is_moderated")
title: str | None = Field(None, q="news__title__icontains")
class NewsSchema(ModelSchema):
class Meta:
model = News
fields = ["id", "title", "summary", "is_moderated"]
club: ClubProfileSchema
url: str
@staticmethod
def resolve_summary(obj: News) -> str:
# if this is returned from a route that allows the
# user to choose the text format (md or html)
# and the user chose "html", convert the markdown to html
context: RouteContext = service_resolver(RouteContext)
if context.kwargs.get("text_format", "") == "html":
return markdown(obj.summary)
return obj.summary
@staticmethod
def resolve_url(obj: News) -> str:
return obj.get_absolute_url()
class NewsDateSchema(ModelSchema):
"""Basic infos about an event occurrence.
Warning:
This uses [NewsSchema][], which itself
uses [ClubProfileSchema][club.schemas.ClubProfileSchema].
Don't forget the appropriated `select_related`.
"""
class Meta:
model = NewsDate
fields = ["id", "start_date", "end_date"]
news: NewsSchema

View File

@ -1,5 +1,5 @@
import { exportToHtml } from "#core:utils/globals"; import { exportToHtml } from "#core:utils/globals";
import { newsDeleteNews, newsModerateNews } from "#openapi"; import { newsDeleteNews, newsFetchNewsDates, newsModerateNews } from "#openapi";
// This will be used in jinja templates, // This will be used in jinja templates,
// so we cannot use real enums as those are purely an abstraction of Typescript // so we cannot use real enums as those are purely an abstraction of Typescript
@ -24,6 +24,7 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is snake case // biome-ignore lint/style/useNamingConvention: api is snake case
await newsModerateNews({ path: { news_id: this.newsId } }); await newsModerateNews({ path: { news_id: this.newsId } });
this.state = AlertState.MODERATED; this.state = AlertState.MODERATED;
this.$dispatch("news-moderated", { newsId: this.newsId, state: this.state });
this.loading = false; this.loading = false;
}, },
@ -32,7 +33,47 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is snake case // biome-ignore lint/style/useNamingConvention: api is snake case
await newsDeleteNews({ path: { news_id: this.newsId } }); await newsDeleteNews({ path: { news_id: this.newsId } });
this.state = AlertState.DELETED; this.state = AlertState.DELETED;
this.$dispatch("news-moderated", { newsId: this.newsId, state: this.state });
this.loading = false; this.loading = false;
}, },
/**
* Event receiver for when news dates are moderated.
*
* If the moderated date is linked to the same news
* as the one this moderation alert is attached to,
* then set the alert state to the same as the moderated one.
*/
dispatchModeration(event: CustomEvent) {
if (event.detail.newsId === this.newsId) {
this.state = event.detail.state;
}
},
/**
* Query the server to know the number of news dates that would be moderated
* if this one is moderated.
*/
async nbToModerate(): Promise<number> {
// What we want here is the count attribute of the response.
// We don't care about the actual results,
// so we ask for the minimum page size possible.
const response = await newsFetchNewsDates({
// biome-ignore lint/style/useNamingConvention: api is snake-case
query: { news_id: this.newsId, page: 1, page_size: 1 },
});
return response.data.count;
},
weeklyEventWarningMessage(nbEvents: number): string {
return interpolate(
gettext(
"This event will take place every week for %s weeks. " +
"If you moderate or delete this event, " +
"it will also be moderated (or deleted) for the following weeks.",
),
[nbEvents],
);
},
})); }));
}); });

View File

@ -0,0 +1,67 @@
import { type NewsDateSchema, newsFetchNewsDates } from "#openapi";
interface ParsedNewsDateSchema extends Omit<NewsDateSchema, "start_date" | "end_date"> {
// biome-ignore lint/style/useNamingConvention: api is snake_case
start_date: Date;
// biome-ignore lint/style/useNamingConvention: api is snake_case
end_date: Date;
}
document.addEventListener("alpine:init", () => {
Alpine.data("upcomingNewsLoader", (startDate: Date) => ({
startDate: startDate,
currentPage: 1,
pageSize: 6,
hasNext: true,
loading: false,
newsDates: [] as NewsDateSchema[],
async loadMore() {
this.loading = true;
const response = await newsFetchNewsDates({
query: {
after: this.startDate.toISOString(),
// biome-ignore lint/style/useNamingConvention: api is snake_case
text_format: "html",
page: this.currentPage,
// biome-ignore lint/style/useNamingConvention: api is snake_case
page_size: this.pageSize,
},
});
if (response.response.status === 404) {
this.hasNext = false;
} else if (response.data.next === null) {
this.newsDates.push(...response.data.results);
this.hasNext = false;
} else {
this.newsDates.push(...response.data.results);
this.currentPage += 1;
}
this.loading = false;
},
groupedDates(): Record<string, NewsDateSchema[]> {
return this.newsDates
.map(
(date: NewsDateSchema): ParsedNewsDateSchema => ({
...date,
// biome-ignore lint/style/useNamingConvention: api is snake_case
start_date: new Date(date.start_date),
// biome-ignore lint/style/useNamingConvention: api is snake_case
end_date: new Date(date.end_date),
}),
)
.reduce(
(acc: Record<string, ParsedNewsDateSchema[]>, date: ParsedNewsDateSchema) => {
const key = date.start_date.toDateString();
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(date);
return acc;
},
{},
);
},
}));
});

View File

@ -51,6 +51,20 @@
} }
} }
/* UPCOMING EVENTS */
#upcoming-events {
max-height: 600px;
overflow-y: scroll;
#load-more-news-button {
text-align: center;
button {
width: 150px;
}
}
}
/* LINKS/BIRTHDAYS */ /* LINKS/BIRTHDAYS */
#links, #links,
#birthdays { #birthdays {

View File

@ -6,15 +6,41 @@
the given `alpineState` variable. the given `alpineState` variable.
This state is a `AlertState`, as defined in `moderation-alert-index.ts` This state is a `AlertState`, as defined in `moderation-alert-index.ts`
Example : This comes in three flavours :
- You can pass the `News` object itself to the macro.
In this case, if `request.user` can moderate news,
it will perform an additional db query to know if it is a recurring event.
- You can also give only the news id.
In this case, a server request will be issued to know
if it is a recurring event.
- Finally, you can pass the name of an alpine variable, which value is the id.
In this case, a server request will be issued to know
if it is a recurring event.
Example with full `News` object :
```jinja ```jinja
<div x-data="{state: AlertState.PENDING}"> <div x-data="{state: AlertState.PENDING}">
{{ news_moderation_alert(news, user, "state") }} {{ news_moderation_alert(news, user, "state") }}
</div> </div>
``` ```
With an id :
```jinja
<div x-data="{state: AlertState.PENDING}">
{{ news_moderation_alert(news.id, user, "state") }}
</div>
```
An with an alpine variable
```jinja
<div x-data="{state: AlertState.PENDING, newsId: {{ news.id }}">
{{ news_moderation_alert("newsId", user, "state") }}
</div>
```
Args: Args:
news: The `News` object to which this alert is related news: (News | int | string)
Either the `News` object to which this alert is related,
or its id, or the name of an Alpine which value is its id
user: The request.user user: The request.user
alpineState: An alpine variable name alpineState: An alpine variable name
@ -23,7 +49,13 @@
in your template. in your template.
#} #}
<div <div
x-data="moderationAlert({{ news.id }})" {% if news is integer or news is string %}
x-data="moderationAlert({{ news }})"
{% else %}
x-data="moderationAlert({{ news.id }})"
{% endif %}
{# the news-moderated is received when a moderation alert is deleted or moderated #}
@news-moderated.window="dispatchModeration($event)"
{% if alpineState %} {% if alpineState %}
x-modelable="{{ alpineState }}" x-modelable="{{ alpineState }}"
x-model="state" x-model="state"
@ -49,18 +81,22 @@
but it will be executed only for admin users, and only one time 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), (if they do their job and moderated news as soon as they see them),
so it's still reasonable #} so it's still reasonable #}
{% set nb_event=news.dates.count() %} <div
{% if nb_event > 1 %} {% if news is integer or news is string %}
<br> x-data="{ nbEvents: 0 }"
<strong>{% trans %}Weekly event{% endtrans %}</strong> x-init="nbEvents = await nbToModerate()"
<p> {% else %}
{% trans trimmed nb=nb_event %} x-data="{ nbEvents: {{ news.dates.count() }} }"
This event will take place every week for {{ nb }} weeks. {% endif %}
If you moderate or delete this event, >
it will also be moderated (or deleted) for the following weeks. <template x-if="nbEvents > 1">
{% endtrans %} <div>
</p> <br>
{% endif %} <strong>{% trans %}Weekly event{% endtrans %}</strong>
<p x-text="weeklyEventWarningMessage(nbEvents)"></p>
</div>
</template>
</div>
{% endif %} {% endif %}
</div> </div>
{% if user.has_perm("com.moderate_news") %} {% if user.has_perm("com.moderate_news") %}

View File

@ -16,6 +16,7 @@
{% block additional_js %} {% block additional_js %}
<script type="module" src={{ static("bundled/com/components/ics-calendar-index.ts") }}></script> <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> <script type="module" src={{ static("bundled/com/components/moderation-alert-index.ts") }}></script>
<script type="module" src={{ static("bundled/com/components/upcoming-news-loader-index.ts") }}></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@ -37,69 +38,142 @@
</a> </a>
<br> <br>
{% endif %} {% endif %}
{% for day, dates_group in news_dates %} <section id="upcoming-events">
<div class="news_events_group"> {% if not news_dates %}
<div class="news_events_group_date"> <div class="news_empty">
<div> <em>{% trans %}Nothing to come...{% endtrans %}</em>
<div>{{ day|date('D') }}</div>
<div class="day">{{ day|date('d') }}</div>
<div>{{ day|date('b') }}</div>
</div>
</div> </div>
<div class="news_events_group_items"> {% else %}
{% for date in dates_group %} {% for day, dates_group in news_dates %}
<article <div class="news_events_group">
class="news_event" <div class="news_events_group_date">
{%- if not date.news.is_moderated -%} <div>
x-data="{newsState: AlertState.PENDING}" <div>{{ day|date('D') }}</div>
{%- endif -%} <div class="day">{{ day|date('d') }}</div>
> <div>{{ day|date('b') }}</div>
{% if not date.news.is_moderated %} </div>
{# if a non moderated news is in the object list, </div>
the logged user is either an admin or the news author #} <div class="news_events_group_items">
{{ news_moderation_alert(date.news, user, "newsState") }} {% for date in dates_group %}
{% endif %} <article
<div class="news_event"
{% if not date.news.is_moderated -%} {%- if not date.news.is_moderated -%}
x-show="newsState !== AlertState.DELETED" x-data="{newsState: AlertState.PENDING}"
{%- endif -%} {%- endif -%}
> >
<header class="row gap"> {% if not date.news.is_moderated %}
{% if date.news.club.logo %} {# if a non moderated news is in the object list,
<img src="{{ date.news.club.logo.url }}" alt="{{ date.news.club }}"/> the logged user is either an admin or the news author #}
{% else %} {{ news_moderation_alert(date.news, user, "newsState") }}
<img src="{{ static("com/img/news.png") }}" alt="{{ date.news.club }}"/>
{% endif %} {% endif %}
<div class="header_content"> <div
<h4> {% if not date.news.is_moderated -%}
<a href="{{ url('com:news_detail', news_id=date.news_id) }}"> x-show="newsState !== AlertState.DELETED"
{{ date.news.title }} {%- endif -%}
</a> >
</h4> <header class="row gap">
<a href="{{ date.news.club.get_absolute_url() }}">{{ date.news.club }}</a> {% if date.news.club.logo %}
<div class="news_date"> <img src="{{ date.news.club.logo.url }}" alt="{{ date.news.club }}"/>
<time datetime="{{ date.start_date.isoformat(timespec="seconds") }}"> {% else %}
{{ date.start_date|localtime|time(DATETIME_FORMAT) }} <img src="{{ static("com/img/news.png") }}" alt="{{ date.news.club }}"/>
</time> - {% endif %}
<time datetime="{{ date.end_date.isoformat(timespec="seconds") }}"> <div class="header_content">
{{ date.end_date|localtime|time(DATETIME_FORMAT) }} <h4>
</time> <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">
<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>
</header>
<div class="news_content markdown">
{{ date.news.summary|markdown }}
</div> </div>
</div> </div>
</header> </article>
<div class="news_content markdown"> {% endfor %}
{{ date.news.summary|markdown }} </div>
</div>
{% endfor %}
<div x-data="upcomingNewsLoader(new Date('{{ last_day + timedelta(days=1) }}'))">
<template x-for="newsList in Object.values(groupedDates())">
<div class="news_events_group">
<div class="news_events_group_date">
<div x-data="{day: newsList[0].start_date}">
<div x-text="day.toLocaleString('{{ get_language() }}', { weekday: 'short' }).substring(0, 3)"></div>
<div
class="day"
x-text="day.toLocaleString('{{ get_language() }}', { day: 'numeric' })"
></div>
<div x-text="day.toLocaleString('{{ get_language() }}', { month: 'short' }).substring(0, 3)"></div>
</div> </div>
</div> </div>
</article> <div class="news_events_group_items">
{% endfor %} <template x-for="newsDate in newsList" :key="newsDate.id">
<article
class="news_event"
x-data="{ newsState: newsDate.news.is_moderated ? AlertState.MODERATED : AlertState.PENDING }"
>
<template x-if="!newsDate.news.is_moderated">
{{ news_moderation_alert("newsDate.news.id", user, "newsState") }}
</template>
<div x-show="newsState !== AlertState.DELETED">
<header class="row gap">
<img
:src="newsDate.news.club.logo || '{{ static("com/img/news.png") }}'"
:alt="newsDate.news.club.name"
/>
<div class="header_content">
<h4>
<a :href="newsDate.news.url" x-text="newsDate.news.title"></a>
</h4>
<a :href="newsDate.news.club.url" x-text="newsDate.news.club.name"></a>
<div class="news_date">
<time
:datetime="newsDate.start_date.toISOString()"
x-text="`${newsDate.start_date.getHours()}:${newsDate.start_date.getMinutes()}`"
></time> -
<time
:datetime="newsDate.end_date.toISOString()"
x-text="`${newsDate.end_date.getHours()}:${newsDate.end_date.getMinutes()}`"
></time>
</div>
</div>
</header>
{# The API returns a summary in html.
It's generated from our markdown subset, so it should be safe #}
<div class="news_content markdown" x-html="newsDate.news.summary"></div>
</div>
</article>
</template>
</div>
</div>
</template>
<div id="load-more-news-button" :aria-busy="loading">
<button class="btn btn-grey" x-show="!loading && hasNext" @click="loadMore()">
{% trans %}See more{% endtrans %} &nbsp;<i class="fa fa-arrow-down"></i>
</button>
<p x-show="!loading && !hasNext">
<em>
{% trans trimmed %}
It was too short.
You already reached the end of the upcoming events list.
{% endtrans %}
</em>
</p>
</div>
</div> </div>
</div> {% endif %}
{% else %} </section>
<div class="news_empty">
<em>{% trans %}Nothing to come...{% endtrans %}</em>
</div>
{% endfor %}
<h3> <h3>
{% trans %}All coming events{% endtrans %} {% trans %}All coming events{% endtrans %}

View File

@ -3,18 +3,22 @@ from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from urllib.parse import quote
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.http import HttpResponse from django.http import HttpResponse
from django.test.client import Client from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from model_bakery import baker from django.utils.timezone import now
from model_bakery import baker, seq
from pytest_django.asserts import assertNumQueries
from com.calendar import IcsCalendar from com.calendar import IcsCalendar
from com.models import News from com.models import News, NewsDate
from core.markdown import markdown
from core.models import User from core.models import User
@ -184,3 +188,63 @@ class TestDeleteNews:
) )
assert response.status_code == 403 assert response.status_code == 403
assert News.objects.filter(id=news.id).exists() assert News.objects.filter(id=news.id).exists()
class TestFetchNewsDates(TestCase):
@classmethod
def setUpTestData(cls):
News.objects.all().delete()
cls.dates = baker.make(
NewsDate,
_quantity=5,
_bulk_create=True,
start_date=seq(value=now(), increment_by=timedelta(days=1)),
end_date=seq(
value=now() + timedelta(hours=2), increment_by=timedelta(days=1)
),
news=iter(
baker.make(News, is_moderated=True, _quantity=5, _bulk_create=True)
),
)
cls.dates.append(
baker.make(
NewsDate,
start_date=now() + timedelta(days=2, hours=1),
end_date=now() + timedelta(days=2, hours=5),
news=baker.make(News, is_moderated=True),
)
)
cls.dates.sort(key=lambda d: d.start_date)
def test_num_queries(self):
with assertNumQueries(2):
self.client.get(reverse("api:fetch_news_dates"))
def test_html_format(self):
"""Test that when the summary is asked in html, the summary is in html."""
summary_1 = "# First event\nThere is something happening.\n"
self.dates[0].news.summary = summary_1
self.dates[0].news.save()
summary_2 = (
"# Second event\n"
"There is something happening **for real**.\n"
"Everything is [here](https://youtu.be/dQw4w9WgXcQ)\n"
)
self.dates[1].news.summary = summary_2
self.dates[1].news.save()
response = self.client.get(
reverse("api:fetch_news_dates") + "?page_size=2&text_format=html"
)
assert response.status_code == 200
dates = response.json()["results"]
assert dates[0]["news"]["summary"] == markdown(summary_1)
assert dates[1]["news"]["summary"] == markdown(summary_2)
def test_fetch(self):
after = quote((now() + timedelta(days=1)).isoformat())
response = self.client.get(
reverse("api:fetch_news_dates") + f"?page_size=3&after={after}"
)
assert response.status_code == 200
dates = response.json()["results"]
assert [d["id"] for d in dates] == [d.id for d in self.dates[1:4]]

View File

@ -22,7 +22,7 @@
# #
# #
import itertools import itertools
from datetime import timedelta from datetime import date, timedelta
from smtplib import SMTPRecipientsRefused from smtplib import SMTPRecipientsRefused
from typing import Any from typing import Any
@ -253,19 +253,41 @@ class NewsListView(TemplateView):
key=lambda u: u.date_of_birth.year, key=lambda u: u.date_of_birth.year,
) )
def get_news_dates(self): def get_last_day(self) -> date:
"""Get the last day when news will be displayed
The returned day is the third one where something happen.
For example, if there are 6 events : A on 15/03, B and C on 17/03,
D on 20/03, E on 21/03 and F on 22/03 ;
then the result is 20/03.
"""
return list(
NewsDate.objects.filter(end_date__gt=now())
.order_by("start_date")
.values_list("start_date__date", flat=True)
.distinct()[:4]
)[-1]
def get_news_dates(self, until: date):
"""Return the event dates to display.
The selected events are the ones that happens between
right now and the given day (included).
"""
return itertools.groupby( return itertools.groupby(
NewsDate.objects.viewable_by(self.request.user) NewsDate.objects.viewable_by(self.request.user)
.filter(end_date__gt=now(), start_date__lt=now() + timedelta(days=6)) .filter(end_date__gt=now(), start_date__date__lte=until)
.order_by("start_date") .order_by("start_date")
.select_related("news", "news__club"), .select_related("news", "news__club"),
key=lambda d: d.start_date.date(), key=lambda d: d.start_date.date(),
) )
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
last_day = self.get_last_day()
return super().get_context_data(**kwargs) | { return super().get_context_data(**kwargs) | {
"news_dates": self.get_news_dates(), "news_dates": self.get_news_dates(until=last_day),
"birthdays": self.get_birthdays(), "birthdays": self.get_birthdays(),
"last_day": last_day,
} }

View File

@ -169,7 +169,7 @@ class Command(BaseCommand):
Weekmail().save() Weekmail().save()
# Here we add a lot of test datas, that are not necessary for the Sith, but that provide a basic development environment # Here we add a lot of test datas, that are not necessary for the Sith, but that provide a basic development environment
self.now = timezone.now().replace(hour=12) self.now = timezone.now().replace(hour=12, second=0)
skia = User.objects.create_user( skia = User.objects.create_user(
username="skia", username="skia",
@ -681,7 +681,7 @@ Welcome to the wiki page!
friday = self.now friday = self.now
while friday.weekday() != 4: while friday.weekday() != 4:
friday += timedelta(hours=6) friday += timedelta(hours=6)
friday.replace(hour=20, minute=0, second=0) friday.replace(hour=20, minute=0)
# Event # Event
news_dates = [] news_dates = []
n = News.objects.create( n = News.objects.create(

View File

@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-18 15:03+0100\n" "POT-Creation-Date: 2025-02-25 11:04+0100\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -1426,17 +1426,6 @@ msgstr ""
"Elle sera cachée pour les autres utilisateurs tant qu'elle ne sera pas " "Elle sera cachée pour les autres utilisateurs tant qu'elle ne sera pas "
"modérée." "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/macros.jinja com/templates/com/mailing_admin.jinja
#: com/templates/com/news_admin_list.jinja com/templates/com/news_detail.jinja #: com/templates/com/news_admin_list.jinja com/templates/com/news_detail.jinja
#: core/templates/core/file_detail.jinja #: core/templates/core/file_detail.jinja
@ -1578,6 +1567,17 @@ msgstr "Administrer les news"
msgid "Nothing to come..." msgid "Nothing to come..."
msgstr "Rien à venir..." msgstr "Rien à venir..."
#: com/templates/com/news_list.jinja
msgid "See more"
msgstr "Voir plus"
#: com/templates/com/news_list.jinja
msgid ""
"It was too short. You already reached the end of the upcoming events list."
msgstr ""
"C'était trop court. Vous êtes déjà arrivés à la fin de la liste des "
"événements à venir."
#: com/templates/com/news_list.jinja #: com/templates/com/news_list.jinja
msgid "All coming events" msgid "All coming events"
msgstr "Tous les événements à venir" msgstr "Tous les événements à venir"
@ -6031,3 +6031,13 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
#, python-format #, python-format
msgid "Maximum characters: %(max_length)s" msgid "Maximum characters: %(max_length)s"
msgstr "Nombre de caractères max: %(max_length)s" msgstr "Nombre de caractères max: %(max_length)s"
#, python-format
#~ msgid ""
#~ "This event will take place every week for %%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 %%s semaines. Si vous "
#~ "modérez ou supprimez cet événement, il sera également modéré (ou "
#~ "supprimé) pour les semaines suivantes."

View File

@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-08 12:23+0100\n" "POT-Creation-Date: 2025-02-25 11:05+0100\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -21,6 +21,17 @@ msgstr ""
msgid "More info" msgid "More info"
msgstr "Plus d'informations" msgstr "Plus d'informations"
#: com/static/bundled/com/components/moderation-alert-index.ts
#, javascript-format
msgid ""
"This event will take place every week for %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 %s semaines. Si vous "
"modérez ou supprimez cet événement, il sera également modéré (ou supprimé) "
"pour les semaines suivantes."
#: core/static/bundled/core/components/ajax-select-base.ts #: core/static/bundled/core/components/ajax-select-base.ts
msgid "Remove" msgid "Remove"
msgstr "Retirer" msgstr "Retirer"
@ -125,10 +136,6 @@ msgstr "Montrer plus"
msgid "family_tree.%(extension)s" msgid "family_tree.%(extension)s"
msgstr "arbre_genealogique.%(extension)s" msgstr "arbre_genealogique.%(extension)s"
#: core/static/bundled/user/pictures-index.js
msgid "pictures.%(extension)s"
msgstr "photos.%(extension)s"
#: core/static/user/js/user_edit.js #: core/static/user/js/user_edit.js
#, javascript-format #, javascript-format
msgid "captured.%s" msgid "captured.%s"
@ -187,6 +194,10 @@ msgstr "La réorganisation des types de produit a échoué avec le code : %d"
msgid "Incorrect value" msgid "Incorrect value"
msgstr "Valeur incorrecte" msgstr "Valeur incorrecte"
#: sas/static/bundled/sas/pictures-download-index.ts
msgid "pictures.%(extension)s"
msgstr "photos.%(extension)s"
#: sas/static/bundled/sas/viewer-index.ts #: sas/static/bundled/sas/viewer-index.ts
msgid "Couldn't moderate picture" msgid "Couldn't moderate picture"
msgstr "Il n'a pas été possible de modérer l'image" msgstr "Il n'a pas été possible de modérer l'image"

View File

@ -171,6 +171,7 @@ TEMPLATES = [
"timezone": "django.utils.timezone", "timezone": "django.utils.timezone",
"get_sith": "com.views.sith", "get_sith": "com.views.sith",
"get_language": "django.utils.translation.get_language", "get_language": "django.utils.translation.get_language",
"timedelta": "datetime.timedelta",
}, },
"bytecode_cache": { "bytecode_cache": {
"name": "default", "name": "default",