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 django.conf import settings
from django.http import Http404
from django.http import Http404, HttpResponse
from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema
from com.calendar import IcsCalendar
@ -38,18 +39,41 @@ class CalendarController(ControllerBase):
def calendar_internal(self):
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")
class NewsController(ControllerBase):
@route.patch(
"/{int:news_id}/moderate",
"/{int:news_id}/publish",
permissions=[HasPerm("com.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)
if not news.is_moderated:
news.is_moderated = True
if not news.is_published:
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.save()

View File

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

View File

@ -147,8 +147,8 @@ class NewsForm(forms.ModelForm):
"content": MarkdownInput,
}
auto_moderate = forms.BooleanField(
label=_("Automoderation"),
auto_publish = forms.BooleanField(
label=_("Auto publication"),
widget=CheckboxInput(attrs={"class": "switch"}),
required=False,
)
@ -182,12 +182,12 @@ class NewsForm(forms.ModelForm):
def save(self, commit: bool = True): # noqa FBT001
self.instance.author = self.author
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
else:
self.instance.is_moderated = False
self.instance.is_published = False
created_news = super().save(commit=commit)
self.date_form.save(commit=commit, news=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):
def moderated(self) -> Self:
return self.filter(is_moderated=True)
return self.filter(is_published=True)
def viewable_by(self, user: User) -> Self:
"""Filter news that the given user can view.
@ -68,7 +68,7 @@ class NewsQuerySet(models.QuerySet):
"""
if user.has_perm("com.view_unmoderated_news"):
return self
q_filter = Q(is_moderated=True)
q_filter = Q(is_published=True)
if user.is_authenticated:
q_filter |= Q(author_id=user.id)
return self.filter(q_filter)
@ -104,7 +104,7 @@ class News(models.Model):
verbose_name=_("author"),
on_delete=models.PROTECT,
)
is_moderated = models.BooleanField(_("is moderated"), default=False)
is_published = models.BooleanField(_("is published"), default=False)
moderator = models.ForeignKey(
User,
related_name="moderated_news",
@ -127,7 +127,7 @@ class News(models.Model):
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.is_moderated:
if self.is_published:
return
for user in User.objects.filter(
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):
return (
self.is_moderated
self.is_published
or user.has_perm("com.view_unmoderated_news")
or (user.is_authenticated and self.author_id == user.id)
)
@ -162,7 +162,7 @@ class News(models.Model):
def news_notification_callback(notif):
count = News.objects.filter(
dates__start_date__gt=timezone.now(), is_moderated=False
dates__start_date__gt=timezone.now(), is_published=False
).count()
if count:
notif.viewed = False
@ -182,7 +182,7 @@ class NewsDateQuerySet(models.QuerySet):
"""
if user.has_perm("com.view_unmoderated_news"):
return self
q_filter = Q(news__is_moderated=True)
q_filter = Q(news__is_published=True)
if user.is_authenticated:
q_filter |= Q(news__author_id=user.id)
return self.filter(q_filter)
@ -337,7 +337,7 @@ class Screen(models.Model):
def active_posters(self):
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)
)

View File

@ -15,14 +15,14 @@ class NewsDateFilterSchema(FilterSchema):
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")
is_published: bool | None = Field(None, q="news__is_published")
title: str | None = Field(None, q="news__title__icontains")
class NewsSchema(ModelSchema):
class Meta:
model = News
fields = ["id", "title", "summary", "is_moderated"]
fields = ["id", "title", "summary", "is_published"]
club: ClubProfileSchema
url: str

View File

@ -7,20 +7,33 @@ import frLocale from "@fullcalendar/core/locales/fr";
import dayGridPlugin from "@fullcalendar/daygrid";
import iCalendarPlugin from "@fullcalendar/icalendar";
import listPlugin from "@fullcalendar/list";
import { calendarCalendarExternal, calendarCalendarInternal } from "#openapi";
import {
calendarCalendarExternal,
calendarCalendarInternal,
calendarCalendarUnpublished,
newsDeleteNews,
newsPublishNews,
newsUnpublishNews,
} from "#openapi";
@registerComponent("ics-calendar")
export class IcsCalendar extends inheritHtmlElement("div") {
static observedAttributes = ["locale"];
static observedAttributes = ["locale", "can_moderate", "can_delete"];
private calendar: Calendar;
private locale = "en";
private canModerate = false;
private canDelete = false;
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
if (name !== "locale") {
return;
if (name === "locale") {
this.locale = newValue;
}
if (name === "can_moderate") {
this.canModerate = newValue.toLowerCase() === "true";
}
if (name === "can_delete") {
this.canDelete = newValue.toLowerCase() === "true";
}
this.locale = newValue;
}
isMobile() {
@ -54,6 +67,104 @@ export class IcsCalendar extends inheritHtmlElement("div") {
}).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) {
// Delete previous popup
const oldPopup = document.getElementById("event-details");
@ -112,6 +223,47 @@ export class IcsCalendar extends inheritHtmlElement("div") {
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
const popup = document.createElement("div");
const popupContainer = document.createElement("div");
@ -131,6 +283,11 @@ export class IcsCalendar extends inheritHtmlElement("div") {
popupContainer.appendChild(url);
}
const tools = makePopupTools(event.event);
if (tools !== null) {
popupContainer.appendChild(tools);
}
popup.appendChild(popupContainer);
// 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() {
super.connectedCallback();
const cacheInvalidate = `?invalidate=${Date.now()}`;
this.calendar = new Calendar(this.node, {
plugins: [dayGridPlugin, iCalendarPlugin, listPlugin],
locales: [frLocale, enLocale],
@ -160,16 +316,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
locale: this.locale,
initialView: this.currentView(),
headerToolbar: this.currentToolbar(),
eventSources: [
{
url: `${await makeUrl(calendarCalendarInternal)}${cacheInvalidate}`,
format: "ics",
},
{
url: `${await makeUrl(calendarCalendarExternal)}${cacheInvalidate}`,
format: "ics",
},
],
eventSources: await this.getEventSources(),
windowResize: () => {
this.calendar.changeView(this.currentView());
this.calendar.setOption("headerToolbar", this.currentToolbar());

View File

@ -1,5 +1,5 @@
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,
// 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
PENDING: 1,
// 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
DELETED: 3,
// biome-ignore lint/style/useNamingConvention: this feels more like an enum
DISPLAYED: 4, // When published at page generation
};
exportToHtml("AlertState", AlertState);
@ -19,11 +21,11 @@ document.addEventListener("alpine:init", () => {
newsId: newsId as number,
loading: false,
async moderateNews() {
async publishNews() {
this.loading = true;
// biome-ignore lint/style/useNamingConvention: api is snake case
await newsModerateNews({ path: { news_id: this.newsId } });
this.state = AlertState.MODERATED;
await newsPublishNews({ path: { news_id: this.newsId } });
this.state = AlertState.PUBLISHED;
this.$dispatch("news-moderated", { newsId: this.newsId, state: this.state });
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
* if this one is moderated.
*/
async nbToModerate(): Promise<number> {
async nbToPublish(): 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.
@ -69,8 +71,8 @@ document.addEventListener("alpine:init", () => {
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.",
"If you publish or delete this event, " +
"it will also be published (or deleted) for the following weeks.",
),
[nbEvents],
);

View File

@ -1,6 +1,6 @@
{% 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.
{# An alert to display on top of unpublished news,
with actions to either publish or delete them.
The current state of the alert is accessible through
the given `alpineState` variable.
@ -8,7 +8,7 @@
This comes in three flavours :
- 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.
- You can also give only the news id.
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 #}
@news-moderated.window="dispatchModeration($event)"
{% if alpineState %}
x-modelable="{{ alpineState }}"
x-model="state"
x-model="{{ alpineState }}"
x-modelable="state"
{% endif %}
>
<template x-if="state === AlertState.PENDING">
<div class="alert alert-yellow">
<div class="alert-main">
<strong>{% trans %}Waiting moderation{% endtrans %}</strong>
<strong>{% trans %}Waiting publication{% endtrans %}</strong>
<p>
{% 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.
{% endtrans %}
</p>
<p>
{% 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 %}
</p>
{% if user.has_perm("com.moderate_news") %}
@ -84,7 +84,7 @@
<div
{% if news is integer or news is string %}
x-data="{ nbEvents: 0 }"
x-init="nbEvents = await nbToModerate()"
x-init="nbEvents = await nbToPublish()"
{% else %}
x-data="{ nbEvents: {{ news.dates.count() }} }"
{% endif %}
@ -101,8 +101,8 @@
</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 class="btn btn-green" @click="publishNews()" :disabled="loading">
<i class="fa fa-check"></i> {% trans %}Publish{% endtrans %}
</button>
{% endif %}
{% if user.has_perm("com.delete_news") %}
@ -113,9 +113,9 @@
{% endif %}
</div>
</template>
<template x-if="state === AlertState.MODERATED">
<template x-if="state === AlertState.PUBLISHED">
<div class="alert alert-green">
{% trans %}News moderated{% endtrans %}
{% trans %}News published{% endtrans %}
</div>
</template>
<template x-if="state === AlertState.DELETED">

View File

@ -27,7 +27,7 @@
</tr>
</thead>
<tbody>
{% for news in weeklies.filter(is_moderated=True) %}
{% for news in weeklies.filter(is_published=True) %}
<tr>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
@ -47,7 +47,7 @@
</td>
<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_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>
</td>
</tr>
@ -67,7 +67,7 @@
</tr>
</thead>
<tbody>
{% for news in weeklies.filter(is_moderated=False) %}
{% for news in weeklies.filter(is_published=False) %}
<tr>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
@ -86,7 +86,7 @@
</td>
<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_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>
</td>
</tr>
@ -111,7 +111,7 @@
</tr>
</thead>
<tbody>
{% for news in events.filter(is_moderated=True) %}
{% for news in events.filter(is_published=True) %}
<tr>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
@ -124,7 +124,7 @@
{{ 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>
<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>
</td>
</tr>
@ -145,7 +145,7 @@
</tr>
</thead>
<tbody>
{% for news in events.filter(is_moderated=False) %}
{% for news in events.filter(is_published=False) %}
<tr>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
@ -157,7 +157,7 @@
{{ 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>
<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>
</td>
</tr>

View File

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

View File

@ -80,9 +80,9 @@
</fieldset>
{% if user.is_root or user.is_com_admin %}
<fieldset>
{{ form.auto_moderate.errors }}
{{ form.auto_moderate }}
{{ form.auto_moderate.label_tag() }}
{{ form.auto_publish.errors }}
{{ form.auto_publish }}
{{ form.auto_publish.label_tag() }}
</fieldset>
{% endif %}
<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>
</div>
{% 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_date">
<div>
@ -57,19 +57,17 @@
{% for date in dates_group %}
<article
class="news_event"
{%- if not date.news.is_moderated -%}
{%- if not date.news.is_published -%}
x-data="{newsState: AlertState.PENDING}"
{% else %}
x-data="{newsState: AlertState.DISPLAYED}"
{%- 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 %}
{# if a non published news is in the object list,
the logged user is either an admin or the news author #}
{{ news_moderation_alert(date.news, user, "newsState") }}
<div
{% if not date.news.is_moderated -%}
x-show="newsState !== AlertState.DELETED"
{%- endif -%}
x-show="newsState !== AlertState.DELETED"
>
<header class="row gap">
{% if date.news.club.logo %}
@ -86,9 +84,11 @@
<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|date(DATETIME_FORMAT) }}
{{ date.start_date|localtime|time(DATETIME_FORMAT) }}
</time> -
<time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">
{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
{{ date.end_date|localtime|time(DATETIME_FORMAT) }}
</time>
</div>
@ -120,9 +120,9 @@
<template x-for="newsDate in newsList" :key="newsDate.id">
<article
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") }}
</template>
<div x-show="newsState !== AlertState.DELETED">
@ -179,7 +179,23 @@
{% trans %}All coming events{% endtrans %}
<a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
</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 id="right_column">

View File

@ -129,14 +129,14 @@ class TestInternalCalendar:
@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
@pytest.mark.parametrize("news_is_published", [True, False])
def test_moderation_ok(self, client: Client, news_is_published: 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)
news = baker.make(News, is_published=news_is_published)
initial_moderator = news.moderator
client.force_login(user)
response = client.patch(
@ -147,22 +147,22 @@ class TestModerateNews:
# 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.is_published
if not news_is_published:
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)
news = baker.make(News, is_published=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
assert not news.is_published
@pytest.mark.django_db
@ -203,7 +203,7 @@ class TestFetchNewsDates(TestCase):
value=now() + timedelta(hours=2), increment_by=timedelta(days=1)
),
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(
@ -211,7 +211,7 @@ class TestFetchNewsDates(TestCase):
NewsDate,
start_date=now() + timedelta(days=2, hours=1),
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)

View File

@ -18,7 +18,7 @@ class TestNewsViewableBy(TestCase):
cls.news = baker.make(
News,
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,
_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.anonymous)
self.new.is_moderated = True
self.new.is_published = True
self.new.save()
assert self.new.can_be_viewed_by(self.com_admin)
assert self.new.can_be_viewed_by(self.sli)
@ -258,7 +258,7 @@ class TestNewsCreation(TestCase):
created = News.objects.order_by("id").last()
assertRedirects(response, created.get_absolute_url())
assert created.title == "Test news"
assert not created.is_moderated
assert not created.is_published
dates = list(created.dates.values("start_date", "end_date"))
assert dates == [{"start_date": self.start, "end_date": self.end}]
@ -281,7 +281,7 @@ class TestNewsCreation(TestCase):
]
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(
NewsDate,
news=news,
@ -296,7 +296,7 @@ class TestNewsCreation(TestCase):
created = News.objects.order_by("id").last()
assertRedirects(response, created.get_absolute_url())
assert created.title == "Test news"
assert not created.is_moderated
assert not created.is_published
dates = list(created.dates.values("start_date", "end_date"))
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):
self.object = self.get_object()
if "remove" in request.GET:
self.object.is_moderated = False
self.object.is_published = False
else:
self.object.is_moderated = True
self.object.is_published = True
self.object.moderator = request.user
self.object.save()
if "next" in self.request.GET:
@ -253,7 +253,7 @@ class NewsListView(TemplateView):
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
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 ;
then the result is 20/03.
"""
return list(
dates = list(
NewsDate.objects.filter(end_date__gt=now())
.order_by("start_date")
.values_list("start_date__date", flat=True)
.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.
The selected events are the ones that happens between
right now and the given day (included).
"""
return itertools.groupby(
NewsDate.objects.viewable_by(self.request.user)
.filter(end_date__gt=now(), start_date__date__lte=until)
.order_by("start_date")
.select_related("news", "news__club"),
key=lambda d: d.start_date.date(),
)
return {
date: list(dates)
for date, dates in itertools.groupby(
NewsDate.objects.viewable_by(self.request.user)
.filter(end_date__gt=now(), start_date__date__lte=until)
.order_by("start_date")
.select_related("news", "news__club"),
key=lambda d: d.start_date.date(),
)
}
def get_context_data(self, **kwargs):
last_day = self.get_last_day()
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(),
"last_day": last_day,
}
@ -309,7 +315,7 @@ class NewsFeed(Feed):
def items(self):
return (
NewsDate.objects.filter(
news__is_moderated=True,
news__is_published=True,
end_date__gte=timezone.now() - (relativedelta(months=6)),
)
.select_related("news", "news__author")

View File

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

View File

@ -6,7 +6,7 @@
msgid ""
msgstr ""
"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"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@ -812,7 +812,7 @@ msgstr "Nouvelle mailing liste"
msgid "Subscribe"
msgstr "S'abonner"
#: club/forms.py com/templates/com/news_admin_list.jinja
#: club/forms.py
msgid "Remove"
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)"
#: com/forms.py
msgid "Automoderation"
msgstr "Automodération"
msgid "Auto publication"
msgstr "Publication automatique"
#: com/models.py
msgid "alert message"
@ -1344,6 +1344,10 @@ msgstr "Le club qui organise l'évènement."
msgid "author"
msgstr "auteur"
#: com/models.py
msgid "is published"
msgstr "est publié"
#: com/models.py
msgid "news"
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"
#: com/templates/com/macros.jinja
msgid "Waiting moderation"
msgstr "En attente de modération"
msgid "Waiting publication"
msgstr "En attente de publication"
#: com/templates/com/macros.jinja
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."
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."
#: 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 ""
"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/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
#: sas/templates/sas/picture.jinja
msgid "Moderate"
msgstr "Modérer"
#: com/templates/com/macros.jinja com/templates/com/news_admin_list.jinja
#: com/templates/com/news_detail.jinja
msgid "Publish"
msgstr "Publier"
#: com/templates/com/macros.jinja
msgid "News moderated"
msgstr "Nouvelle modérée"
msgid "News published"
msgstr "Nouvelle publiée"
#: com/templates/com/macros.jinja
msgid "News deleted"
@ -1447,6 +1448,12 @@ msgstr "Nouvelle supprimée"
msgid "Mailing lists administration"
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
#, python-format
msgid "Moderated by %(user)s"
@ -1514,6 +1521,10 @@ msgstr "Modérateur"
msgid "Dates"
msgstr "Dates"
#: com/templates/com/news_admin_list.jinja
msgid "Unpublish"
msgstr "Dépublier"
#: com/templates/com/news_admin_list.jinja
msgid "Weeklies to moderate"
msgstr "Nouvelles hebdomadaires à modérer"
@ -6031,13 +6042,3 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
#, python-format
msgid "Maximum characters: %(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 ""
msgstr ""
"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"
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@ -21,15 +21,25 @@ msgstr ""
msgid "More info"
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
#, 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."
"This event will take place every week for %s weeks. If you publish or delete "
"this event, it will also be published (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é) "
"publiez ou supprimez cet événement, il sera également publié (ou supprimé) "
"pour les semaines suivantes."
#: core/static/bundled/core/components/ajax-select-base.ts

View File

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