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 %}
-