Add moderation through calendar widget

This commit is contained in:
Antoine Bartuccio 2025-02-20 17:47:51 +01:00
parent e936f0d285
commit a1bf86dabf
4 changed files with 166 additions and 28 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,6 +39,17 @@ 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(
"/unmoderated.ics",
permissions=[IsAuthenticated],
url_name="calendar_unmoderated",
)
def calendar_unmoderated(self):
return HttpResponse(
IcsCalendar.get_unmoderated(self.context.request.user),
content_type="text/calendar",
)
@api_controller("/news") @api_controller("/news")
class NewsController(ControllerBase): class NewsController(ControllerBase):

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 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,11 +57,32 @@ 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
# 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_moderated=True,
end_date__gte=timezone.now() - (relativedelta(months=6)),
)
)
)
return cls._INTERNAL_CALENDAR
@classmethod
def get_unmoderated(cls, user: User) -> bytes:
return cls.ics_from_queryset(
NewsDate.objects.viewable_by(user).filter(
news__is_moderated=False,
end_date__gte=timezone.now() - (relativedelta(months=6)),
),
)
@classmethod
def ics_from_queryset(cls, queryset: QuerySet) -> bytes:
calendar = Calendar() calendar = Calendar()
for news_date in NewsDate.objects.filter( for news_date in queryset.prefetch_related("news"):
news__is_moderated=True,
end_date__gte=timezone.now() - (relativedelta(months=6)),
).prefetch_related("news"):
event = Event( event = Event(
summary=news_date.news.title, summary=news_date.news.title,
start=news_date.start_date, start=news_date.start_date,
@ -68,8 +91,4 @@ class IcsCalendar:
) )
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

@ -7,20 +7,32 @@ 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,
calendarCalendarUnmoderated,
newsDeleteNews,
newsModerateNews,
} 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;
}
if (name === "can_moderate") {
this.canModerate = newValue.toLowerCase() === "true";
}
if (name === "can_delete") {
this.canDelete = newValue.toLowerCase() === "true";
} }
this.locale = newValue;
} }
isMobile() { isMobile() {
@ -54,6 +66,68 @@ export class IcsCalendar extends inheritHtmlElement("div") {
}).format(date); }).format(date);
} }
getNewsId(event: EventImpl) {
// return Number.parseInt(event.url.split("/").pop());
return Number.parseInt(
event.url
.toString()
.split("/")
.filter((s) => s) // Remove blank characters
.pop(),
);
}
async refreshEvents() {
this.click(); // Remove focus from popup
this.calendar.removeAllEventSources();
for (const source of await this.getEventSources()) {
this.calendar.addEventSource(source);
}
this.calendar.refetchEvents();
}
async moderate(id: number) {
await newsModerateNews({
path: {
// biome-ignore lint/style/useNamingConvention: python API
news_id: id,
},
});
await this.refreshEvents();
}
async delete(id: number) {
await newsDeleteNews({
path: {
// biome-ignore lint/style/useNamingConvention: python API
news_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(calendarCalendarUnmoderated)}${cacheInvalidate}`,
format: "ics",
color: "red",
className: "unmoderated",
},
];
}
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 +186,40 @@ 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.indexOf("external") >= 0) {
return null;
}
if (!(this.canDelete || this.canModerate)) {
return null;
}
const newsId = this.getNewsId(event);
const div = document.createElement("div");
if (
this.canModerate &&
event.source.internalEventSource.ui.classNames.indexOf("unmoderated") >= 0
) {
const button = document.createElement("button");
button.innerHTML = `<i class="fa fa-check"></i>${gettext("Moderate")}`;
button.setAttribute("class", "btn btn-green");
button.onclick = () => {
this.moderate(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.delete(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 +239,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 +265,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 +272,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

@ -179,7 +179,11 @@
{% 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
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">