diff --git a/com/api.py b/com/api.py index 5a3eef10..99186f36 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,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() diff --git a/com/calendar.py b/com/calendar.py index f3c612e1..1c95a2b3 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 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") diff --git a/com/forms.py b/com/forms.py index 471e6632..8b81a3f9 100644 --- a/com/forms.py +++ b/com/forms.py @@ -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 diff --git a/com/migrations/0009_remove_news_is_moderated_news_is_published.py b/com/migrations/0009_remove_news_is_moderated_news_is_published.py new file mode 100644 index 00000000..dd633820 --- /dev/null +++ b/com/migrations/0009_remove_news_is_moderated_news_is_published.py @@ -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"), + ), + ] diff --git a/com/models.py b/com/models.py index 229d83c8..2b3a76c4 100644 --- a/com/models.py +++ b/com/models.py @@ -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) ) diff --git a/com/schemas.py b/com/schemas.py index 967076ad..93ee5315 100644 --- a/com/schemas.py +++ b/com/schemas.py @@ -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 diff --git a/com/static/bundled/com/components/ics-calendar-index.ts b/com/static/bundled/com/components/ics-calendar-index.ts index 3c78f98f..0b4976b0 100644 --- a/com/static/bundled/com/components/ics-calendar-index.ts +++ b/com/static/bundled/com/components/ics-calendar-index.ts @@ -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 = `${gettext("Publish")}`; + button.setAttribute("class", "btn btn-green"); + button.onclick = () => { + this.publishNews(newsId); + }; + div.appendChild(button); + } else { + const button = document.createElement("button"); + button.innerHTML = `${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 = `${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()); diff --git a/com/static/bundled/com/components/moderation-alert-index.ts b/com/static/bundled/com/components/moderation-alert-index.ts index f4ecc4d1..f2ff1806 100644 --- a/com/static/bundled/com/components/moderation-alert-index.ts +++ b/com/static/bundled/com/components/moderation-alert-index.ts @@ -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 { + async nbToPublish(): Promise { // 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], ); diff --git a/com/templates/com/macros.jinja b/com/templates/com/macros.jinja index dbff9188..1e3fb853 100644 --- a/com/templates/com/macros.jinja +++ b/com/templates/com/macros.jinja @@ -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 %} > -