Merge pull request #1027 from ae-utbm/calendar-moderation

Moderation of news through calendar and rename moderation to publish
This commit is contained in:
Bartuccio Antoine 2025-02-25 18:32:15 +01:00 committed by GitHub
commit 1f1cd2ce0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 440 additions and 169 deletions

View File

@ -2,10 +2,11 @@ from pathlib import Path
from typing import Literal from typing import Literal
from django.conf import settings from django.conf import settings
from django.http import Http404 from django.http import Http404, HttpResponse
from ninja import Query from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from com.calendar import IcsCalendar from com.calendar import IcsCalendar
@ -38,18 +39,41 @@ class CalendarController(ControllerBase):
def calendar_internal(self): def calendar_internal(self):
return send_raw_file(IcsCalendar.get_internal()) return send_raw_file(IcsCalendar.get_internal())
@route.get(
"/unpublished.ics",
permissions=[IsAuthenticated],
url_name="calendar_unpublished",
)
def calendar_unpublished(self):
return HttpResponse(
IcsCalendar.get_unpublished(self.context.request.user),
content_type="text/calendar",
)
@api_controller("/news") @api_controller("/news")
class NewsController(ControllerBase): class NewsController(ControllerBase):
@route.patch( @route.patch(
"/{int:news_id}/moderate", "/{int:news_id}/publish",
permissions=[HasPerm("com.moderate_news")], permissions=[HasPerm("com.moderate_news")],
url_name="moderate_news", url_name="moderate_news",
) )
def moderate_news(self, news_id: int): def publish_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)
if not news.is_moderated: if not news.is_published:
news.is_moderated = True news.is_published = True
news.moderator = self.context.request.user
news.save()
@route.patch(
"/{int:news_id}/unpublish",
permissions=[HasPerm("com.moderate_news")],
url_name="unpublish_news",
)
def unpublish_news(self, news_id: int):
news = self.get_object_or_exception(News, id=news_id)
if news.is_published:
news.is_published = False
news.moderator = self.context.request.user news.moderator = self.context.request.user
news.save() news.save()

View File

@ -5,6 +5,7 @@ from typing import final
import requests import requests
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.db.models import F, QuerySet
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from ical.calendar import Calendar from ical.calendar import Calendar
@ -12,6 +13,7 @@ from ical.calendar_stream import IcsCalendarStream
from ical.event import Event from ical.event import Event
from com.models import NewsDate from com.models import NewsDate
from core.models import User
@final @final
@ -55,21 +57,38 @@ class IcsCalendar:
@classmethod @classmethod
def make_internal(cls) -> Path: def make_internal(cls) -> Path:
# Updated through a post_save signal on News in com.signals # Updated through a post_save signal on News in com.signals
calendar = Calendar() # Create a file so we can offload the download to the reverse proxy if available
for news_date in NewsDate.objects.filter( cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
news__is_moderated=True, with open(cls._INTERNAL_CALENDAR, "wb") as f:
_ = f.write(
cls.ics_from_queryset(
NewsDate.objects.filter(
news__is_published=True,
end_date__gte=timezone.now() - (relativedelta(months=6)), end_date__gte=timezone.now() - (relativedelta(months=6)),
).prefetch_related("news"): )
)
)
return cls._INTERNAL_CALENDAR
@classmethod
def get_unpublished(cls, user: User) -> bytes:
return cls.ics_from_queryset(
NewsDate.objects.viewable_by(user).filter(
news__is_published=False,
end_date__gte=timezone.now() - (relativedelta(months=6)),
),
)
@classmethod
def ics_from_queryset(cls, queryset: QuerySet[NewsDate]) -> bytes:
calendar = Calendar()
for news_date in queryset.annotate(news_title=F("news__title")):
event = Event( event = Event(
summary=news_date.news.title, summary=news_date.news_title,
start=news_date.start_date, start=news_date.start_date,
end=news_date.end_date, end=news_date.end_date,
url=reverse("com:news_detail", kwargs={"news_id": news_date.news.id}), url=reverse("com:news_detail", kwargs={"news_id": news_date.news.id}),
) )
calendar.events.append(event) calendar.events.append(event)
# Create a file so we can offload the download to the reverse proxy if available return IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8")
cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
with open(cls._INTERNAL_CALENDAR, "wb") as f:
_ = f.write(IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8"))
return cls._INTERNAL_CALENDAR

View File

@ -147,8 +147,8 @@ class NewsForm(forms.ModelForm):
"content": MarkdownInput, "content": MarkdownInput,
} }
auto_moderate = forms.BooleanField( auto_publish = forms.BooleanField(
label=_("Automoderation"), label=_("Auto publication"),
widget=CheckboxInput(attrs={"class": "switch"}), widget=CheckboxInput(attrs={"class": "switch"}),
required=False, required=False,
) )
@ -182,12 +182,12 @@ class NewsForm(forms.ModelForm):
def save(self, commit: bool = True): # noqa FBT001 def save(self, commit: bool = True): # noqa FBT001
self.instance.author = self.author self.instance.author = self.author
if (self.author.is_com_admin or self.author.is_root) and ( if (self.author.is_com_admin or self.author.is_root) and (
self.cleaned_data.get("auto_moderate") is True self.cleaned_data.get("auto_publish") is True
): ):
self.instance.is_moderated = True self.instance.is_published = True
self.instance.moderator = self.author self.instance.moderator = self.author
else: else:
self.instance.is_moderated = False self.instance.is_published = False
created_news = super().save(commit=commit) created_news = super().save(commit=commit)
self.date_form.save(commit=commit, news=created_news) self.date_form.save(commit=commit, news=created_news)
return created_news return created_news

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("com", "0008_alter_news_options_alter_newsdate_options_and_more")]
operations = [
migrations.RenameField(
model_name="news", old_name="is_moderated", new_name="is_published"
),
migrations.AlterField(
model_name="news",
name="is_published",
field=models.BooleanField(default=False, verbose_name="is published"),
),
]

View File

@ -56,7 +56,7 @@ class Sith(models.Model):
class NewsQuerySet(models.QuerySet): class NewsQuerySet(models.QuerySet):
def moderated(self) -> Self: def moderated(self) -> Self:
return self.filter(is_moderated=True) return self.filter(is_published=True)
def viewable_by(self, user: User) -> Self: def viewable_by(self, user: User) -> Self:
"""Filter news that the given user can view. """Filter news that the given user can view.
@ -68,7 +68,7 @@ class NewsQuerySet(models.QuerySet):
""" """
if user.has_perm("com.view_unmoderated_news"): if user.has_perm("com.view_unmoderated_news"):
return self return self
q_filter = Q(is_moderated=True) q_filter = Q(is_published=True)
if user.is_authenticated: if user.is_authenticated:
q_filter |= Q(author_id=user.id) q_filter |= Q(author_id=user.id)
return self.filter(q_filter) return self.filter(q_filter)
@ -104,7 +104,7 @@ class News(models.Model):
verbose_name=_("author"), verbose_name=_("author"),
on_delete=models.PROTECT, on_delete=models.PROTECT,
) )
is_moderated = models.BooleanField(_("is moderated"), default=False) is_published = models.BooleanField(_("is published"), default=False)
moderator = models.ForeignKey( moderator = models.ForeignKey(
User, User,
related_name="moderated_news", related_name="moderated_news",
@ -127,7 +127,7 @@ class News(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
super().save(*args, **kwargs) super().save(*args, **kwargs)
if self.is_moderated: if self.is_published:
return return
for user in User.objects.filter( for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID] groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
@ -154,7 +154,7 @@ class News(models.Model):
def can_be_viewed_by(self, user: User): def can_be_viewed_by(self, user: User):
return ( return (
self.is_moderated self.is_published
or user.has_perm("com.view_unmoderated_news") or user.has_perm("com.view_unmoderated_news")
or (user.is_authenticated and self.author_id == user.id) or (user.is_authenticated and self.author_id == user.id)
) )
@ -162,7 +162,7 @@ class News(models.Model):
def news_notification_callback(notif): def news_notification_callback(notif):
count = News.objects.filter( count = News.objects.filter(
dates__start_date__gt=timezone.now(), is_moderated=False dates__start_date__gt=timezone.now(), is_published=False
).count() ).count()
if count: if count:
notif.viewed = False notif.viewed = False
@ -182,7 +182,7 @@ class NewsDateQuerySet(models.QuerySet):
""" """
if user.has_perm("com.view_unmoderated_news"): if user.has_perm("com.view_unmoderated_news"):
return self return self
q_filter = Q(news__is_moderated=True) q_filter = Q(news__is_published=True)
if user.is_authenticated: if user.is_authenticated:
q_filter |= Q(news__author_id=user.id) q_filter |= Q(news__author_id=user.id)
return self.filter(q_filter) return self.filter(q_filter)
@ -337,7 +337,7 @@ class Screen(models.Model):
def active_posters(self): def active_posters(self):
now = timezone.now() now = timezone.now()
return self.posters.filter(is_moderated=True, date_begin__lte=now).filter( return self.posters.filter(d=True, date_begin__lte=now).filter(
Q(date_end__isnull=True) | Q(date_end__gte=now) Q(date_end__isnull=True) | Q(date_end__gte=now)
) )

View File

@ -15,14 +15,14 @@ class NewsDateFilterSchema(FilterSchema):
after: datetime | None = Field(None, q="start_date__gt") after: datetime | None = Field(None, q="start_date__gt")
club_id: int | None = Field(None, q="news__club_id") club_id: int | None = Field(None, q="news__club_id")
news_id: int | None = None news_id: int | None = None
is_moderated: bool | None = Field(None, q="news__is_moderated") is_published: bool | None = Field(None, q="news__is_published")
title: str | None = Field(None, q="news__title__icontains") title: str | None = Field(None, q="news__title__icontains")
class NewsSchema(ModelSchema): class NewsSchema(ModelSchema):
class Meta: class Meta:
model = News model = News
fields = ["id", "title", "summary", "is_moderated"] fields = ["id", "title", "summary", "is_published"]
club: ClubProfileSchema club: ClubProfileSchema
url: str url: str

View File

@ -7,21 +7,34 @@ import frLocale from "@fullcalendar/core/locales/fr";
import dayGridPlugin from "@fullcalendar/daygrid"; import dayGridPlugin from "@fullcalendar/daygrid";
import iCalendarPlugin from "@fullcalendar/icalendar"; import iCalendarPlugin from "@fullcalendar/icalendar";
import listPlugin from "@fullcalendar/list"; import listPlugin from "@fullcalendar/list";
import { calendarCalendarExternal, calendarCalendarInternal } from "#openapi"; import {
calendarCalendarExternal,
calendarCalendarInternal,
calendarCalendarUnpublished,
newsDeleteNews,
newsPublishNews,
newsUnpublishNews,
} from "#openapi";
@registerComponent("ics-calendar") @registerComponent("ics-calendar")
export class IcsCalendar extends inheritHtmlElement("div") { export class IcsCalendar extends inheritHtmlElement("div") {
static observedAttributes = ["locale"]; static observedAttributes = ["locale", "can_moderate", "can_delete"];
private calendar: Calendar; private calendar: Calendar;
private locale = "en"; private locale = "en";
private canModerate = false;
private canDelete = false;
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) { attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
if (name !== "locale") { if (name === "locale") {
return;
}
this.locale = newValue; this.locale = newValue;
} }
if (name === "can_moderate") {
this.canModerate = newValue.toLowerCase() === "true";
}
if (name === "can_delete") {
this.canDelete = newValue.toLowerCase() === "true";
}
}
isMobile() { isMobile() {
return window.innerWidth < 765; return window.innerWidth < 765;
@ -54,6 +67,104 @@ export class IcsCalendar extends inheritHtmlElement("div") {
}).format(date); }).format(date);
} }
getNewsId(event: EventImpl) {
return Number.parseInt(
event.url
.toString()
.split("/")
.filter((s) => s) // Remove blank characters
.pop(),
);
}
async refreshEvents() {
this.click(); // Remove focus from popup
// We can't just refresh events because some ics files are in
// local browser cache (especially internal.ics)
// To invalidate the cache, we need to remove the source and add it again
this.calendar.removeAllEventSources();
for (const source of await this.getEventSources()) {
this.calendar.addEventSource(source);
}
this.calendar.refetchEvents();
}
async publishNews(id: number) {
await newsPublishNews({
path: {
// biome-ignore lint/style/useNamingConvention: python API
news_id: id,
},
});
this.dispatchEvent(
new CustomEvent("calendar-publish", {
bubbles: true,
detail: {
id: id,
},
}),
);
await this.refreshEvents();
}
async unpublishNews(id: number) {
await newsUnpublishNews({
path: {
// biome-ignore lint/style/useNamingConvention: python API
news_id: id,
},
});
this.dispatchEvent(
new CustomEvent("calendar-unpublish", {
bubbles: true,
detail: {
id: id,
},
}),
);
await this.refreshEvents();
}
async deleteNews(id: number) {
await newsDeleteNews({
path: {
// biome-ignore lint/style/useNamingConvention: python API
news_id: id,
},
});
this.dispatchEvent(
new CustomEvent("calendar-delete", {
bubbles: true,
detail: {
id: id,
},
}),
);
await this.refreshEvents();
}
async getEventSources() {
const cacheInvalidate = `?invalidate=${Date.now()}`;
return [
{
url: `${await makeUrl(calendarCalendarInternal)}${cacheInvalidate}`,
format: "ics",
className: "internal",
},
{
url: `${await makeUrl(calendarCalendarExternal)}${cacheInvalidate}`,
format: "ics",
className: "external",
},
{
url: `${await makeUrl(calendarCalendarUnpublished)}${cacheInvalidate}`,
format: "ics",
color: "red",
className: "unpublished",
},
];
}
createEventDetailPopup(event: EventClickArg) { createEventDetailPopup(event: EventClickArg) {
// Delete previous popup // Delete previous popup
const oldPopup = document.getElementById("event-details"); const oldPopup = document.getElementById("event-details");
@ -112,6 +223,47 @@ export class IcsCalendar extends inheritHtmlElement("div") {
return makePopupInfo(url, "fa-solid fa-link"); return makePopupInfo(url, "fa-solid fa-link");
}; };
const makePopupTools = (event: EventImpl) => {
if (event.source.internalEventSource.ui.classNames.includes("external")) {
return null;
}
if (!(this.canDelete || this.canModerate)) {
return null;
}
const newsId = this.getNewsId(event);
const div = document.createElement("div");
if (this.canModerate) {
if (event.source.internalEventSource.ui.classNames.includes("unpublished")) {
const button = document.createElement("button");
button.innerHTML = `<i class="fa fa-check"></i>${gettext("Publish")}`;
button.setAttribute("class", "btn btn-green");
button.onclick = () => {
this.publishNews(newsId);
};
div.appendChild(button);
} else {
const button = document.createElement("button");
button.innerHTML = `<i class="fa fa-times"></i>${gettext("Unpublish")}`;
button.setAttribute("class", "btn btn-orange");
button.onclick = () => {
this.unpublishNews(newsId);
};
div.appendChild(button);
}
}
if (this.canDelete) {
const button = document.createElement("button");
button.innerHTML = `<i class="fa fa-trash-can"></i>${gettext("Delete")}`;
button.setAttribute("class", "btn btn-red");
button.onclick = () => {
this.deleteNews(newsId);
};
div.appendChild(button);
}
return makePopupInfo(div, "fa-solid fa-toolbox");
};
// Create new popup // Create new popup
const popup = document.createElement("div"); const popup = document.createElement("div");
const popupContainer = document.createElement("div"); const popupContainer = document.createElement("div");
@ -131,6 +283,11 @@ export class IcsCalendar extends inheritHtmlElement("div") {
popupContainer.appendChild(url); popupContainer.appendChild(url);
} }
const tools = makePopupTools(event.event);
if (tools !== null) {
popupContainer.appendChild(tools);
}
popup.appendChild(popupContainer); popup.appendChild(popupContainer);
// We can't just add the element relative to the one we want to appear under // We can't just add the element relative to the one we want to appear under
@ -152,7 +309,6 @@ export class IcsCalendar extends inheritHtmlElement("div") {
async connectedCallback() { async connectedCallback() {
super.connectedCallback(); super.connectedCallback();
const cacheInvalidate = `?invalidate=${Date.now()}`;
this.calendar = new Calendar(this.node, { this.calendar = new Calendar(this.node, {
plugins: [dayGridPlugin, iCalendarPlugin, listPlugin], plugins: [dayGridPlugin, iCalendarPlugin, listPlugin],
locales: [frLocale, enLocale], locales: [frLocale, enLocale],
@ -160,16 +316,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
locale: this.locale, locale: this.locale,
initialView: this.currentView(), initialView: this.currentView(),
headerToolbar: this.currentToolbar(), headerToolbar: this.currentToolbar(),
eventSources: [ eventSources: await this.getEventSources(),
{
url: `${await makeUrl(calendarCalendarInternal)}${cacheInvalidate}`,
format: "ics",
},
{
url: `${await makeUrl(calendarCalendarExternal)}${cacheInvalidate}`,
format: "ics",
},
],
windowResize: () => { windowResize: () => {
this.calendar.changeView(this.currentView()); this.calendar.changeView(this.currentView());
this.calendar.setOption("headerToolbar", this.currentToolbar()); this.calendar.setOption("headerToolbar", this.currentToolbar());

View File

@ -1,5 +1,5 @@
import { exportToHtml } from "#core:utils/globals"; import { exportToHtml } from "#core:utils/globals";
import { newsDeleteNews, newsFetchNewsDates, newsModerateNews } from "#openapi"; import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } 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
@ -7,9 +7,11 @@ const AlertState = {
// biome-ignore lint/style/useNamingConvention: this feels more like an enum // biome-ignore lint/style/useNamingConvention: this feels more like an enum
PENDING: 1, PENDING: 1,
// biome-ignore lint/style/useNamingConvention: this feels more like an enum // biome-ignore lint/style/useNamingConvention: this feels more like an enum
MODERATED: 2, PUBLISHED: 2,
// biome-ignore lint/style/useNamingConvention: this feels more like an enum // biome-ignore lint/style/useNamingConvention: this feels more like an enum
DELETED: 3, DELETED: 3,
// biome-ignore lint/style/useNamingConvention: this feels more like an enum
DISPLAYED: 4, // When published at page generation
}; };
exportToHtml("AlertState", AlertState); exportToHtml("AlertState", AlertState);
@ -19,11 +21,11 @@ document.addEventListener("alpine:init", () => {
newsId: newsId as number, newsId: newsId as number,
loading: false, loading: false,
async moderateNews() { async publishNews() {
this.loading = true; this.loading = true;
// 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 newsPublishNews({ path: { news_id: this.newsId } });
this.state = AlertState.MODERATED; this.state = AlertState.PUBLISHED;
this.$dispatch("news-moderated", { newsId: this.newsId, state: this.state }); this.$dispatch("news-moderated", { newsId: this.newsId, state: this.state });
this.loading = false; this.loading = false;
}, },
@ -54,7 +56,7 @@ document.addEventListener("alpine:init", () => {
* Query the server to know the number of news dates that would be moderated * Query the server to know the number of news dates that would be moderated
* if this one is moderated. * if this one is moderated.
*/ */
async nbToModerate(): Promise<number> { async nbToPublish(): Promise<number> {
// What we want here is the count attribute of the response. // What we want here is the count attribute of the response.
// We don't care about the actual results, // We don't care about the actual results,
// so we ask for the minimum page size possible. // so we ask for the minimum page size possible.
@ -69,8 +71,8 @@ document.addEventListener("alpine:init", () => {
return interpolate( return interpolate(
gettext( gettext(
"This event will take place every week for %s weeks. " + "This event will take place every week for %s weeks. " +
"If you moderate or delete this event, " + "If you publish or delete this event, " +
"it will also be moderated (or deleted) for the following weeks.", "it will also be published (or deleted) for the following weeks.",
), ),
[nbEvents], [nbEvents],
); );

View File

@ -1,6 +1,6 @@
{% macro news_moderation_alert(news, user, alpineState = None) %} {% macro news_moderation_alert(news, user, alpineState = None) %}
{# An alert to display on top of non moderated news, {# An alert to display on top of unpublished news,
with actions to either moderate or delete them. with actions to either publish or delete them.
The current state of the alert is accessible through The current state of the alert is accessible through
the given `alpineState` variable. the given `alpineState` variable.
@ -8,7 +8,7 @@
This comes in three flavours : This comes in three flavours :
- You can pass the `News` object itself to the macro. - You can pass the `News` object itself to the macro.
In this case, if `request.user` can moderate news, In this case, if `request.user` can publish news,
it will perform an additional db query to know if it is a recurring event. it will perform an additional db query to know if it is a recurring event.
- You can also give only the news id. - You can also give only the news id.
In this case, a server request will be issued to know In this case, a server request will be issued to know
@ -57,23 +57,23 @@
{# the news-moderated is received when a moderation alert is deleted or moderated #} {# the news-moderated is received when a moderation alert is deleted or moderated #}
@news-moderated.window="dispatchModeration($event)" @news-moderated.window="dispatchModeration($event)"
{% if alpineState %} {% if alpineState %}
x-modelable="{{ alpineState }}" x-model="{{ alpineState }}"
x-model="state" x-modelable="state"
{% endif %} {% endif %}
> >
<template x-if="state === AlertState.PENDING"> <template x-if="state === AlertState.PENDING">
<div class="alert alert-yellow"> <div class="alert alert-yellow">
<div class="alert-main"> <div class="alert-main">
<strong>{% trans %}Waiting moderation{% endtrans %}</strong> <strong>{% trans %}Waiting publication{% endtrans %}</strong>
<p> <p>
{% trans trimmed %} {% trans trimmed %}
This news isn't moderated and is visible This news isn't published and is visible
only by its author and the communication admins. only by its author and the communication admins.
{% endtrans %} {% endtrans %}
</p> </p>
<p> <p>
{% trans trimmed %} {% trans trimmed %}
It will stay hidden for other users until it has been moderated. It will stay hidden for other users until it has been published.
{% endtrans %} {% endtrans %}
</p> </p>
{% if user.has_perm("com.moderate_news") %} {% if user.has_perm("com.moderate_news") %}
@ -84,7 +84,7 @@
<div <div
{% if news is integer or news is string %} {% if news is integer or news is string %}
x-data="{ nbEvents: 0 }" x-data="{ nbEvents: 0 }"
x-init="nbEvents = await nbToModerate()" x-init="nbEvents = await nbToPublish()"
{% else %} {% else %}
x-data="{ nbEvents: {{ news.dates.count() }} }" x-data="{ nbEvents: {{ news.dates.count() }} }"
{% endif %} {% endif %}
@ -101,8 +101,8 @@
</div> </div>
{% if user.has_perm("com.moderate_news") %} {% if user.has_perm("com.moderate_news") %}
<span class="alert-aside" :aria-busy="loading"> <span class="alert-aside" :aria-busy="loading">
<button class="btn btn-green" @click="moderateNews()" :disabled="loading"> <button class="btn btn-green" @click="publishNews()" :disabled="loading">
<i class="fa fa-check"></i> {% trans %}Moderate{% endtrans %} <i class="fa fa-check"></i> {% trans %}Publish{% endtrans %}
</button> </button>
{% endif %} {% endif %}
{% if user.has_perm("com.delete_news") %} {% if user.has_perm("com.delete_news") %}
@ -113,9 +113,9 @@
{% endif %} {% endif %}
</div> </div>
</template> </template>
<template x-if="state === AlertState.MODERATED"> <template x-if="state === AlertState.PUBLISHED">
<div class="alert alert-green"> <div class="alert alert-green">
{% trans %}News moderated{% endtrans %} {% trans %}News published{% endtrans %}
</div> </div>
</template> </template>
<template x-if="state === AlertState.DELETED"> <template x-if="state === AlertState.DELETED">

View File

@ -27,7 +27,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for news in weeklies.filter(is_moderated=True) %} {% for news in weeklies.filter(is_published=True) %}
<tr> <tr>
<td>{{ news.title }}</td> <td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td> <td>{{ news.summary|markdown }}</td>
@ -47,7 +47,7 @@
</td> </td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a> <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a> <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a> <a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Unpublish{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a> <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td> </td>
</tr> </tr>
@ -67,7 +67,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for news in weeklies.filter(is_moderated=False) %} {% for news in weeklies.filter(is_published=False) %}
<tr> <tr>
<td>{{ news.title }}</td> <td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td> <td>{{ news.summary|markdown }}</td>
@ -86,7 +86,7 @@
</td> </td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a> <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a> <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a> <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Publish{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a> <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td> </td>
</tr> </tr>
@ -111,7 +111,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for news in events.filter(is_moderated=True) %} {% for news in events.filter(is_published=True) %}
<tr> <tr>
<td>{{ news.title }}</td> <td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td> <td>{{ news.summary|markdown }}</td>
@ -124,7 +124,7 @@
{{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }}</td> {{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a> <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a> <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a> <a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Unpublish{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a> <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td> </td>
</tr> </tr>
@ -145,7 +145,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for news in events.filter(is_moderated=False) %} {% for news in events.filter(is_published=False) %}
<tr> <tr>
<td>{{ news.title }}</td> <td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td> <td>{{ news.summary|markdown }}</td>
@ -157,7 +157,7 @@
{{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }}</td> {{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a> <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a> <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a> <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Publish{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a> <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td> </td>
</tr> </tr>

View File

@ -25,7 +25,7 @@
<p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p> <p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p>
<div x-data="{newsState: AlertState.PENDING}"> <div x-data="{newsState: AlertState.PENDING}">
{% if not news.is_moderated %} {% if not news.is_published %}
{{ news_moderation_alert(news, user, "newsState") }} {{ news_moderation_alert(news, user, "newsState") }}
{% endif %} {% endif %}
<article id="news_details" x-show="newsState !== AlertState.DELETED"> <article id="news_details" x-show="newsState !== AlertState.DELETED">
@ -35,10 +35,10 @@
</div> </div>
<h4>{{ news.title }}</h4> <h4>{{ news.title }}</h4>
<p class="date"> <p class="date">
<span>{{ date.start_date|localtime|date(DATETIME_FORMAT) }} <time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
{{ date.start_date|localtime|time(DATETIME_FORMAT) }}</span> - {{ date.start_date|localtime|time(DATETIME_FORMAT) }}</time> -
<span>{{ date.end_date|localtime|date(DATETIME_FORMAT) }} <time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
{{ date.end_date|localtime|time(DATETIME_FORMAT) }}</span> {{ date.end_date|localtime|time(DATETIME_FORMAT) }}</time>
</p> </p>
<div class="news_content"> <div class="news_content">
<div><em>{{ news.summary|markdown }}</em></div> <div><em>{{ news.summary|markdown }}</em></div>
@ -51,7 +51,7 @@
{% if news.moderator %} {% if news.moderator %}
<p>{% trans %}Moderator: {% endtrans %}{{ user_profile_link(news.moderator) }}</p> <p>{% trans %}Moderator: {% endtrans %}{{ user_profile_link(news.moderator) }}</p>
{% elif user.is_com_admin %} {% elif user.is_com_admin %}
<p> <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a></p> <p> <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Publish{% endtrans %}</a></p>
{% endif %} {% endif %}
{% if user.can_edit(news) %} {% if user.can_edit(news) %}
<p> <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit (will be moderated again){% endtrans %}</a></p> <p> <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit (will be moderated again){% endtrans %}</a></p>

View File

@ -80,9 +80,9 @@
</fieldset> </fieldset>
{% if user.is_root or user.is_com_admin %} {% if user.is_root or user.is_com_admin %}
<fieldset> <fieldset>
{{ form.auto_moderate.errors }} {{ form.auto_publish.errors }}
{{ form.auto_moderate }} {{ form.auto_publish }}
{{ form.auto_moderate.label_tag() }} {{ form.auto_publish.label_tag() }}
</fieldset> </fieldset>
{% endif %} {% endif %}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" class="btn btn-blue"/></p> <p><input type="submit" value="{% trans %}Save{% endtrans %}" class="btn btn-blue"/></p>

View File

@ -44,7 +44,7 @@
<em>{% trans %}Nothing to come...{% endtrans %}</em> <em>{% trans %}Nothing to come...{% endtrans %}</em>
</div> </div>
{% else %} {% else %}
{% for day, dates_group in news_dates %} {% for day, dates_group in news_dates.items() %}
<div class="news_events_group"> <div class="news_events_group">
<div class="news_events_group_date"> <div class="news_events_group_date">
<div> <div>
@ -57,19 +57,17 @@
{% for date in dates_group %} {% for date in dates_group %}
<article <article
class="news_event" class="news_event"
{%- if not date.news.is_moderated -%} {%- if not date.news.is_published -%}
x-data="{newsState: AlertState.PENDING}" x-data="{newsState: AlertState.PENDING}"
{% else %}
x-data="{newsState: AlertState.DISPLAYED}"
{%- endif -%} {%- endif -%}
> >
{% if not date.news.is_moderated %} {# if a non published news is in the object list,
{# if a non moderated news is in the object list,
the logged user is either an admin or the news author #} the logged user is either an admin or the news author #}
{{ news_moderation_alert(date.news, user, "newsState") }} {{ news_moderation_alert(date.news, user, "newsState") }}
{% endif %}
<div <div
{% if not date.news.is_moderated -%}
x-show="newsState !== AlertState.DELETED" x-show="newsState !== AlertState.DELETED"
{%- endif -%}
> >
<header class="row gap"> <header class="row gap">
{% if date.news.club.logo %} {% if date.news.club.logo %}
@ -86,9 +84,11 @@
<a href="{{ date.news.club.get_absolute_url() }}">{{ date.news.club }}</a> <a href="{{ date.news.club.get_absolute_url() }}">{{ date.news.club }}</a>
<div class="news_date"> <div class="news_date">
<time datetime="{{ date.start_date.isoformat(timespec="seconds") }}"> <time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">
{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
{{ date.start_date|localtime|time(DATETIME_FORMAT) }} {{ date.start_date|localtime|time(DATETIME_FORMAT) }}
</time> - </time> -
<time datetime="{{ date.end_date.isoformat(timespec="seconds") }}"> <time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">
{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
{{ date.end_date|localtime|time(DATETIME_FORMAT) }} {{ date.end_date|localtime|time(DATETIME_FORMAT) }}
</time> </time>
</div> </div>
@ -120,9 +120,9 @@
<template x-for="newsDate in newsList" :key="newsDate.id"> <template x-for="newsDate in newsList" :key="newsDate.id">
<article <article
class="news_event" class="news_event"
x-data="{ newsState: newsDate.news.is_moderated ? AlertState.MODERATED : AlertState.PENDING }" x-data="{ newsState: newsDate.news.is_published ? AlertState.PUBLISHED : AlertState.PENDING }"
> >
<template x-if="!newsDate.news.is_moderated"> <template x-if="!newsDate.news.is_published">
{{ news_moderation_alert("newsDate.news.id", user, "newsState") }} {{ news_moderation_alert("newsDate.news.id", user, "newsState") }}
</template> </template>
<div x-show="newsState !== AlertState.DELETED"> <div x-show="newsState !== AlertState.DELETED">
@ -179,7 +179,23 @@
{% trans %}All coming events{% endtrans %} {% trans %}All coming events{% endtrans %}
<a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a> <a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
</h3> </h3>
<ics-calendar locale="{{ get_language() }}"></ics-calendar> <ics-calendar
x-data
x-ref="calendar"
@news-moderated.window="
if ($event.target !== $refs.calendar){
// Avoid triggering a refresh with a dispatch
// from the calendar itself
$refs.calendar.refreshEvents($event);
}
"
@calendar-delete="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.DELETED})"
@calendar-unpublish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PENDING})"
@calendar-publish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PUBLISHED})"
locale="{{ get_language() }}"
can_moderate="{{ user.has_perm("com.moderate_news") }}"
can_delete="{{ user.has_perm("com.delete_news") }}"
></ics-calendar>
</div> </div>
<div id="right_column"> <div id="right_column">

View File

@ -129,14 +129,14 @@ class TestInternalCalendar:
@pytest.mark.django_db @pytest.mark.django_db
class TestModerateNews: class TestModerateNews:
@pytest.mark.parametrize("news_is_moderated", [True, False]) @pytest.mark.parametrize("news_is_published", [True, False])
def test_moderation_ok(self, client: Client, news_is_moderated: bool): # noqa FBT def test_moderation_ok(self, client: Client, news_is_published: bool): # noqa FBT
user = baker.make( user = baker.make(
User, user_permissions=[Permission.objects.get(codename="moderate_news")] User, user_permissions=[Permission.objects.get(codename="moderate_news")]
) )
# The API call should work even if the news is initially moderated. # 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. # In the latter case, the result should be a noop, rather than an error.
news = baker.make(News, is_moderated=news_is_moderated) news = baker.make(News, is_published=news_is_published)
initial_moderator = news.moderator initial_moderator = news.moderator
client.force_login(user) client.force_login(user)
response = client.patch( response = client.patch(
@ -147,22 +147,22 @@ class TestModerateNews:
# If it was already moderated, it should be a no-op, but not an error # If it was already moderated, it should be a no-op, but not an error
assert response.status_code == 200 assert response.status_code == 200
news.refresh_from_db() news.refresh_from_db()
assert news.is_moderated assert news.is_published
if not news_is_moderated: if not news_is_published:
assert news.moderator == user assert news.moderator == user
else: else:
assert news.moderator == initial_moderator assert news.moderator == initial_moderator
def test_moderation_forbidden(self, client: Client): def test_moderation_forbidden(self, client: Client):
user = baker.make(User) user = baker.make(User)
news = baker.make(News, is_moderated=False) news = baker.make(News, is_published=False)
client.force_login(user) client.force_login(user)
response = client.patch( response = client.patch(
reverse("api:moderate_news", kwargs={"news_id": news.id}) reverse("api:moderate_news", kwargs={"news_id": news.id})
) )
assert response.status_code == 403 assert response.status_code == 403
news.refresh_from_db() news.refresh_from_db()
assert not news.is_moderated assert not news.is_published
@pytest.mark.django_db @pytest.mark.django_db
@ -203,7 +203,7 @@ class TestFetchNewsDates(TestCase):
value=now() + timedelta(hours=2), increment_by=timedelta(days=1) value=now() + timedelta(hours=2), increment_by=timedelta(days=1)
), ),
news=iter( news=iter(
baker.make(News, is_moderated=True, _quantity=5, _bulk_create=True) baker.make(News, is_published=True, _quantity=5, _bulk_create=True)
), ),
) )
cls.dates.append( cls.dates.append(
@ -211,7 +211,7 @@ class TestFetchNewsDates(TestCase):
NewsDate, NewsDate,
start_date=now() + timedelta(days=2, hours=1), start_date=now() + timedelta(days=2, hours=1),
end_date=now() + timedelta(days=2, hours=5), end_date=now() + timedelta(days=2, hours=5),
news=baker.make(News, is_moderated=True), news=baker.make(News, is_published=True),
) )
) )
cls.dates.sort(key=lambda d: d.start_date) cls.dates.sort(key=lambda d: d.start_date)

View File

@ -18,7 +18,7 @@ class TestNewsViewableBy(TestCase):
cls.news = baker.make( cls.news = baker.make(
News, News,
author=itertools.cycle(cls.users), author=itertools.cycle(cls.users),
is_moderated=iter([True, True, True, False, False, False]), is_published=iter([True, True, True, False, False, False]),
_quantity=6, _quantity=6,
_bulk_create=True, _bulk_create=True,
) )

View File

@ -168,7 +168,7 @@ class TestNews(TestCase):
assert not self.new.can_be_viewed_by(self.sli) assert not self.new.can_be_viewed_by(self.sli)
assert not self.new.can_be_viewed_by(self.anonymous) assert not self.new.can_be_viewed_by(self.anonymous)
self.new.is_moderated = True self.new.is_published = True
self.new.save() self.new.save()
assert self.new.can_be_viewed_by(self.com_admin) assert self.new.can_be_viewed_by(self.com_admin)
assert self.new.can_be_viewed_by(self.sli) assert self.new.can_be_viewed_by(self.sli)
@ -258,7 +258,7 @@ class TestNewsCreation(TestCase):
created = News.objects.order_by("id").last() created = News.objects.order_by("id").last()
assertRedirects(response, created.get_absolute_url()) assertRedirects(response, created.get_absolute_url())
assert created.title == "Test news" assert created.title == "Test news"
assert not created.is_moderated assert not created.is_published
dates = list(created.dates.values("start_date", "end_date")) dates = list(created.dates.values("start_date", "end_date"))
assert dates == [{"start_date": self.start, "end_date": self.end}] assert dates == [{"start_date": self.start, "end_date": self.end}]
@ -281,7 +281,7 @@ class TestNewsCreation(TestCase):
] ]
def test_edit_news(self): def test_edit_news(self):
news = baker.make(News, author=self.user, is_moderated=True) news = baker.make(News, author=self.user, is_published=True)
baker.make( baker.make(
NewsDate, NewsDate,
news=news, news=news,
@ -296,7 +296,7 @@ class TestNewsCreation(TestCase):
created = News.objects.order_by("id").last() created = News.objects.order_by("id").last()
assertRedirects(response, created.get_absolute_url()) assertRedirects(response, created.get_absolute_url())
assert created.title == "Test news" assert created.title == "Test news"
assert not created.is_moderated assert not created.is_published
dates = list(created.dates.values("start_date", "end_date")) dates = list(created.dates.values("start_date", "end_date"))
assert dates == [{"start_date": self.start, "end_date": self.end}] assert dates == [{"start_date": self.start, "end_date": self.end}]

View File

@ -217,9 +217,9 @@ class NewsModerateView(PermissionRequiredMixin, DetailView):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
if "remove" in request.GET: if "remove" in request.GET:
self.object.is_moderated = False self.object.is_published = False
else: else:
self.object.is_moderated = True self.object.is_published = True
self.object.moderator = request.user self.object.moderator = request.user
self.object.save() self.object.save()
if "next" in self.request.GET: if "next" in self.request.GET:
@ -253,7 +253,7 @@ class NewsListView(TemplateView):
key=lambda u: u.date_of_birth.year, key=lambda u: u.date_of_birth.year,
) )
def get_last_day(self) -> date: def get_last_day(self) -> date | None:
"""Get the last day when news will be displayed """Get the last day when news will be displayed
The returned day is the third one where something happen. The returned day is the third one where something happen.
@ -261,31 +261,37 @@ class NewsListView(TemplateView):
D on 20/03, E on 21/03 and F on 22/03 ; D on 20/03, E on 21/03 and F on 22/03 ;
then the result is 20/03. then the result is 20/03.
""" """
return list( dates = list(
NewsDate.objects.filter(end_date__gt=now()) NewsDate.objects.filter(end_date__gt=now())
.order_by("start_date") .order_by("start_date")
.values_list("start_date__date", flat=True) .values_list("start_date__date", flat=True)
.distinct()[:4] .distinct()[:4]
)[-1] )
return dates[-1] if len(dates) > 0 else None
def get_news_dates(self, until: date): def get_news_dates(self, until: date) -> dict[date, list[date]]:
"""Return the event dates to display. """Return the event dates to display.
The selected events are the ones that happens between The selected events are the ones that happens between
right now and the given day (included). right now and the given day (included).
""" """
return itertools.groupby( return {
date: list(dates)
for date, dates in itertools.groupby(
NewsDate.objects.viewable_by(self.request.user) NewsDate.objects.viewable_by(self.request.user)
.filter(end_date__gt=now(), start_date__date__lte=until) .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() last_day = self.get_last_day()
return super().get_context_data(**kwargs) | { return super().get_context_data(**kwargs) | {
"news_dates": self.get_news_dates(until=last_day), "news_dates": self.get_news_dates(until=last_day)
if last_day is not None
else {},
"birthdays": self.get_birthdays(), "birthdays": self.get_birthdays(),
"last_day": last_day, "last_day": last_day,
} }
@ -309,7 +315,7 @@ class NewsFeed(Feed):
def items(self): def items(self):
return ( return (
NewsDate.objects.filter( NewsDate.objects.filter(
news__is_moderated=True, news__is_published=True,
end_date__gte=timezone.now() - (relativedelta(months=6)), end_date__gte=timezone.now() - (relativedelta(months=6)),
) )
.select_related("news", "news__author") .select_related("news", "news__author")

View File

@ -690,7 +690,7 @@ Welcome to the wiki page!
content="Glou glou glou glou glou glou glou", content="Glou glou glou glou glou glou glou",
club=bar_club, club=bar_club,
author=subscriber, author=subscriber,
is_moderated=True, is_published=True,
moderator=skia, moderator=skia,
) )
news_dates.append( news_dates.append(
@ -704,12 +704,11 @@ Welcome to the wiki page!
title="Repas barman", title="Repas barman",
summary="Enjoy la fin du semestre!", summary="Enjoy la fin du semestre!",
content=( content=(
"Viens donc t'enjailler avec les autres barmans aux " "Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/"
"frais du BdF! \\o/"
), ),
club=bar_club, club=bar_club,
author=subscriber, author=subscriber,
is_moderated=True, is_published=True,
moderator=skia, moderator=skia,
) )
news_dates.append( news_dates.append(
@ -725,7 +724,7 @@ Welcome to the wiki page!
content="Fô viendre mangey d'la bonne fondue!", content="Fô viendre mangey d'la bonne fondue!",
club=bar_club, club=bar_club,
author=subscriber, author=subscriber,
is_moderated=True, is_published=True,
moderator=skia, moderator=skia,
) )
news_dates.append( news_dates.append(
@ -741,7 +740,7 @@ Welcome to the wiki page!
content="Viens faire la fête avec tout plein de gens!", content="Viens faire la fête avec tout plein de gens!",
club=bar_club, club=bar_club,
author=subscriber, author=subscriber,
is_moderated=True, is_published=True,
moderator=skia, moderator=skia,
) )
news_dates.append( news_dates.append(
@ -759,7 +758,7 @@ Welcome to the wiki page!
"t'amuser le Vendredi soir!", "t'amuser le Vendredi soir!",
club=troll, club=troll,
author=subscriber, author=subscriber,
is_moderated=True, is_published=True,
moderator=skia, moderator=skia,
) )
news_dates.extend( news_dates.extend(

View File

@ -0,0 +1,16 @@
# Generated by Django 4.2.17 on 2025-02-25 14:45
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0043_bangroup_alter_group_description_alter_user_groups_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="userban",
options={"verbose_name": "user ban", "verbose_name_plural": "user bans"},
),
]

View File

@ -272,6 +272,20 @@ body {
} }
} }
&.btn-orange {
background-color: #fcbf81;
color: black;
&:not(:disabled):hover {
background-color: darken(#fcbf81, 15%);
}
&:disabled {
background-color: lighten(#fcbf81, 15%);
color: grey;
}
}
&:not(.btn-no-text) { &:not(.btn-no-text) {
i { i {
margin-right: 4px; margin-right: 4px;

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-25 11:04+0100\n" "POT-Creation-Date: 2025-02-25 16:38+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"
@ -812,7 +812,7 @@ msgstr "Nouvelle mailing liste"
msgid "Subscribe" msgid "Subscribe"
msgstr "S'abonner" msgstr "S'abonner"
#: club/forms.py com/templates/com/news_admin_list.jinja #: club/forms.py
msgid "Remove" msgid "Remove"
msgstr "Retirer" msgstr "Retirer"
@ -1296,8 +1296,8 @@ msgstr ""
"Combien de fois l'événement doit-il se répéter (en incluant la première fois)" "Combien de fois l'événement doit-il se répéter (en incluant la première fois)"
#: com/forms.py #: com/forms.py
msgid "Automoderation" msgid "Auto publication"
msgstr "Automodération" msgstr "Publication automatique"
#: com/models.py #: com/models.py
msgid "alert message" msgid "alert message"
@ -1344,6 +1344,10 @@ msgstr "Le club qui organise l'évènement."
msgid "author" msgid "author"
msgstr "auteur" msgstr "auteur"
#: com/models.py
msgid "is published"
msgstr "est publié"
#: com/models.py #: com/models.py
msgid "news" msgid "news"
msgstr "nouvelle" msgstr "nouvelle"
@ -1409,34 +1413,31 @@ msgid "Begin date should be before end date"
msgstr "La date de début doit être avant celle de fin" msgstr "La date de début doit être avant celle de fin"
#: com/templates/com/macros.jinja #: com/templates/com/macros.jinja
msgid "Waiting moderation" msgid "Waiting publication"
msgstr "En attente de modération" msgstr "En attente de publication"
#: com/templates/com/macros.jinja #: com/templates/com/macros.jinja
msgid "" msgid ""
"This news isn't moderated and is visible only by its author and the " "This news isn't published and is visible only by its author and the "
"communication admins." "communication admins."
msgstr "" msgstr ""
"Cette nouvelle n'est pas modérée et n'est visible que par son auteur et les " "Cette nouvelle n'est pas publiée et n'est visible que par son auteur et les "
"admins communication." "admins communication."
#: com/templates/com/macros.jinja #: com/templates/com/macros.jinja
msgid "It will stay hidden for other users until it has been moderated." msgid "It will stay hidden for other users until it has been published."
msgstr "" 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." "publiée."
#: com/templates/com/macros.jinja com/templates/com/mailing_admin.jinja #: com/templates/com/macros.jinja com/templates/com/news_admin_list.jinja
#: com/templates/com/news_admin_list.jinja com/templates/com/news_detail.jinja #: com/templates/com/news_detail.jinja
#: core/templates/core/file_detail.jinja msgid "Publish"
#: core/templates/core/file_moderation.jinja sas/templates/sas/moderation.jinja msgstr "Publier"
#: sas/templates/sas/picture.jinja
msgid "Moderate"
msgstr "Modérer"
#: com/templates/com/macros.jinja #: com/templates/com/macros.jinja
msgid "News moderated" msgid "News published"
msgstr "Nouvelle modérée" msgstr "Nouvelle publiée"
#: com/templates/com/macros.jinja #: com/templates/com/macros.jinja
msgid "News deleted" msgid "News deleted"
@ -1447,6 +1448,12 @@ msgstr "Nouvelle supprimée"
msgid "Mailing lists administration" msgid "Mailing lists administration"
msgstr "Administration des mailing listes" msgstr "Administration des mailing listes"
#: com/templates/com/mailing_admin.jinja core/templates/core/file_detail.jinja
#: core/templates/core/file_moderation.jinja sas/templates/sas/moderation.jinja
#: sas/templates/sas/picture.jinja
msgid "Moderate"
msgstr "Modérer"
#: com/templates/com/mailing_admin.jinja #: com/templates/com/mailing_admin.jinja
#, python-format #, python-format
msgid "Moderated by %(user)s" msgid "Moderated by %(user)s"
@ -1514,6 +1521,10 @@ msgstr "Modérateur"
msgid "Dates" msgid "Dates"
msgstr "Dates" msgstr "Dates"
#: com/templates/com/news_admin_list.jinja
msgid "Unpublish"
msgstr "Dépublier"
#: com/templates/com/news_admin_list.jinja #: com/templates/com/news_admin_list.jinja
msgid "Weeklies to moderate" msgid "Weeklies to moderate"
msgstr "Nouvelles hebdomadaires à modérer" msgstr "Nouvelles hebdomadaires à modérer"
@ -6031,13 +6042,3 @@ 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-02-25 11:05+0100\n" "POT-Creation-Date: 2025-02-25 16:10+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,15 +21,25 @@ msgstr ""
msgid "More info" msgid "More info"
msgstr "Plus d'informations" msgstr "Plus d'informations"
#: com/static/bundled/com/components/ics-calendar-index.ts
msgid "Publish"
msgstr "Publier"
#: com/static/bundled/com/components/ics-calendar-index.ts
msgid "Unpublish"
msgstr "Dépublier"
#: com/static/bundled/com/components/ics-calendar-index.ts
msgid "Delete"
msgstr "Supprimer"
#: com/static/bundled/com/components/moderation-alert-index.ts #: com/static/bundled/com/components/moderation-alert-index.ts
#, javascript-format
msgid "" msgid ""
"This event will take place every week for %s weeks. If you moderate or " "This event will take place every week for %s weeks. If you publish or delete "
"delete this event, it will also be moderated (or deleted) for the following " "this event, it will also be published (or deleted) for the following weeks."
"weeks."
msgstr "" msgstr ""
"Cet événement se déroulera chaque semaine pendant %s semaines. Si vous " "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é) " "publiez ou supprimez cet événement, il sera également publié (ou supprimé) "
"pour les semaines suivantes." "pour les semaines suivantes."
#: core/static/bundled/core/components/ajax-select-base.ts #: core/static/bundled/core/components/ajax-select-base.ts

View File

@ -12,6 +12,7 @@
"esModuleInterop": true, "esModuleInterop": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"types": ["jquery", "alpinejs"], "types": ["jquery", "alpinejs"],
"lib": ["es7"],
"paths": { "paths": {
"#openapi": ["./staticfiles/generated/openapi/index.ts"], "#openapi": ["./staticfiles/generated/openapi/index.ts"],
"#core:*": ["./core/static/bundled/*"], "#core:*": ["./core/static/bundled/*"],