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 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):

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 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")

View File

@ -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 = `<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
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());

View File

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