From a1bf86dabff55ca195e7cadef51499d9dde259b6 Mon Sep 17 00:00:00 2001 From: Sli Date: Thu, 20 Feb 2025 17:47:51 +0100 Subject: [PATCH] Add moderation through calendar widget --- com/api.py | 14 +- com/calendar.py | 37 +++-- .../com/components/ics-calendar-index.ts | 137 +++++++++++++++--- com/templates/com/news_list.jinja | 6 +- 4 files changed, 166 insertions(+), 28 deletions(-) diff --git a/com/api.py b/com/api.py index 5a3eef10..c624e093 100644 --- a/com/api.py +++ b/com/api.py @@ -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,6 +39,17 @@ class CalendarController(ControllerBase): def calendar_internal(self): 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") class NewsController(ControllerBase): diff --git a/com/calendar.py b/com/calendar.py index f3c612e1..dfe8fe6a 100644 --- a/com/calendar.py +++ b/com/calendar.py @@ -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 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,11 +57,32 @@ 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_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() - 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.prefetch_related("news"): event = Event( summary=news_date.news.title, start=news_date.start_date, @@ -68,8 +91,4 @@ class IcsCalendar: ) 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") diff --git a/com/static/bundled/com/components/ics-calendar-index.ts b/com/static/bundled/com/components/ics-calendar-index.ts index 3c78f98f..5e6818bf 100644 --- a/com/static/bundled/com/components/ics-calendar-index.ts +++ b/com/static/bundled/com/components/ics-calendar-index.ts @@ -7,20 +7,32 @@ 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, + calendarCalendarUnmoderated, + newsDeleteNews, + newsModerateNews, +} 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 +66,68 @@ export class IcsCalendar extends inheritHtmlElement("div") { }).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) { // Delete previous popup const oldPopup = document.getElementById("event-details"); @@ -112,6 +186,40 @@ export class IcsCalendar extends inheritHtmlElement("div") { 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 = `${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 = `${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 const popup = document.createElement("div"); const popupContainer = document.createElement("div"); @@ -131,6 +239,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 +265,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 +272,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()); diff --git a/com/templates/com/news_list.jinja b/com/templates/com/news_list.jinja index a3d39f25..620e2916 100644 --- a/com/templates/com/news_list.jinja +++ b/com/templates/com/news_list.jinja @@ -179,7 +179,11 @@ {% trans %}All coming events{% endtrans %} - +