-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #975 from ae-utbm/unified-calendar
Unified calendar widget on main com page with external and internal events
- Loading branch information
Showing
32 changed files
with
1,417 additions
and
766 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
from pathlib import Path | ||
|
||
from django.conf import settings | ||
from django.http import Http404 | ||
from ninja_extra import ControllerBase, api_controller, route | ||
|
||
from com.calendar import IcsCalendar | ||
from core.views.files import send_raw_file | ||
|
||
|
||
@api_controller("/calendar") | ||
class CalendarController(ControllerBase): | ||
CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars" | ||
|
||
@route.get("/external.ics", url_name="calendar_external") | ||
def calendar_external(self): | ||
"""Return the ICS file of the AE Google Calendar | ||
Because of Google's cors rules, we can't just do a request to google ics | ||
from the frontend. Google is blocking CORS request in it's responses headers. | ||
The only way to do it from the frontend is to use Google Calendar API with an API key | ||
This is not especially desirable as your API key is going to be provided to the frontend. | ||
This is why we have this backend based solution. | ||
""" | ||
if (calendar := IcsCalendar.get_external()) is not None: | ||
return send_raw_file(calendar) | ||
raise Http404 | ||
|
||
@route.get("/internal.ics", url_name="calendar_internal") | ||
def calendar_internal(self): | ||
return send_raw_file(IcsCalendar.get_internal()) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
from django.apps import AppConfig | ||
|
||
|
||
class ComConfig(AppConfig): | ||
name = "com" | ||
verbose_name = "News and communication" | ||
|
||
def ready(self): | ||
import com.signals # noqa F401 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
from datetime import datetime, timedelta | ||
from pathlib import Path | ||
from typing import final | ||
|
||
import urllib3 | ||
from dateutil.relativedelta import relativedelta | ||
from django.conf import settings | ||
from django.urls import reverse | ||
from django.utils import timezone | ||
from ical.calendar import Calendar | ||
from ical.calendar_stream import IcsCalendarStream | ||
from ical.event import Event | ||
|
||
from com.models import NewsDate | ||
|
||
|
||
@final | ||
class IcsCalendar: | ||
_CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars" | ||
_EXTERNAL_CALENDAR = _CACHE_FOLDER / "external.ics" | ||
_INTERNAL_CALENDAR = _CACHE_FOLDER / "internal.ics" | ||
|
||
@classmethod | ||
def get_external(cls, expiration: timedelta = timedelta(hours=1)) -> Path | None: | ||
if ( | ||
cls._EXTERNAL_CALENDAR.exists() | ||
and timezone.make_aware( | ||
datetime.fromtimestamp(cls._EXTERNAL_CALENDAR.stat().st_mtime) | ||
) | ||
+ expiration | ||
> timezone.now() | ||
): | ||
return cls._EXTERNAL_CALENDAR | ||
return cls.make_external() | ||
|
||
@classmethod | ||
def make_external(cls) -> Path | None: | ||
calendar = urllib3.request( | ||
"GET", | ||
"https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics", | ||
) | ||
if calendar.status != 200: | ||
return None | ||
|
||
cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True) | ||
with open(cls._EXTERNAL_CALENDAR, "wb") as f: | ||
_ = f.write(calendar.data) | ||
return cls._EXTERNAL_CALENDAR | ||
|
||
@classmethod | ||
def get_internal(cls) -> Path: | ||
if not cls._INTERNAL_CALENDAR.exists(): | ||
return cls.make_internal() | ||
return cls._INTERNAL_CALENDAR | ||
|
||
@classmethod | ||
def make_internal(cls) -> Path: | ||
# Updated through a post_save signal on News in com.signals | ||
calendar = Calendar() | ||
for news_date in NewsDate.objects.filter( | ||
news__is_moderated=True, | ||
end_date__gte=timezone.now() - (relativedelta(months=6)), | ||
).prefetch_related("news"): | ||
event = Event( | ||
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
from django.db.models.base import post_save | ||
from django.dispatch import receiver | ||
|
||
from com.calendar import IcsCalendar | ||
from com.models import News | ||
|
||
|
||
@receiver(post_save, sender=News, dispatch_uid="update_internal_ics") | ||
def update_internal_ics(*args, **kwargs): | ||
_ = IcsCalendar.make_internal() |
197 changes: 197 additions & 0 deletions
197
com/static/bundled/com/components/ics-calendar-index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,197 @@ | ||
import { makeUrl } from "#core:utils/api"; | ||
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components"; | ||
import { Calendar, type EventClickArg } from "@fullcalendar/core"; | ||
import type { EventImpl } from "@fullcalendar/core/internal"; | ||
import enLocale from "@fullcalendar/core/locales/en-gb"; | ||
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"; | ||
|
||
@registerComponent("ics-calendar") | ||
export class IcsCalendar extends inheritHtmlElement("div") { | ||
static observedAttributes = ["locale"]; | ||
private calendar: Calendar; | ||
private locale = "en"; | ||
|
||
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) { | ||
if (name !== "locale") { | ||
return; | ||
} | ||
|
||
this.locale = newValue; | ||
} | ||
|
||
isMobile() { | ||
return window.innerWidth < 765; | ||
} | ||
|
||
currentView() { | ||
// Get view type based on viewport | ||
return this.isMobile() ? "listMonth" : "dayGridMonth"; | ||
} | ||
|
||
currentToolbar() { | ||
if (this.isMobile()) { | ||
return { | ||
left: "prev,next", | ||
center: "title", | ||
right: "", | ||
}; | ||
} | ||
return { | ||
left: "prev,next today", | ||
center: "title", | ||
right: "dayGridMonth,dayGridWeek,dayGridDay", | ||
}; | ||
} | ||
|
||
formatDate(date: Date) { | ||
return new Intl.DateTimeFormat(this.locale, { | ||
dateStyle: "medium", | ||
timeStyle: "short", | ||
}).format(date); | ||
} | ||
|
||
createEventDetailPopup(event: EventClickArg) { | ||
// Delete previous popup | ||
const oldPopup = document.getElementById("event-details"); | ||
if (oldPopup !== null) { | ||
oldPopup.remove(); | ||
} | ||
|
||
const makePopupInfo = (info: HTMLElement, iconClass: string) => { | ||
const row = document.createElement("div"); | ||
const icon = document.createElement("i"); | ||
|
||
row.setAttribute("class", "event-details-row"); | ||
|
||
icon.setAttribute("class", `event-detail-row-icon fa-xl ${iconClass}`); | ||
|
||
row.appendChild(icon); | ||
row.appendChild(info); | ||
|
||
return row; | ||
}; | ||
|
||
const makePopupTitle = (event: EventImpl) => { | ||
const row = document.createElement("div"); | ||
const title = document.createElement("h4"); | ||
const time = document.createElement("span"); | ||
|
||
title.setAttribute("class", "event-details-row-content"); | ||
title.textContent = event.title; | ||
|
||
time.setAttribute("class", "event-details-row-content"); | ||
time.textContent = `${this.formatDate(event.start)} - ${this.formatDate(event.end)}`; | ||
|
||
row.appendChild(title); | ||
row.appendChild(time); | ||
return makePopupInfo( | ||
row, | ||
"fa-solid fa-calendar-days fa-xl event-detail-row-icon", | ||
); | ||
}; | ||
|
||
const makePopupLocation = (event: EventImpl) => { | ||
if (event.extendedProps.location === null) { | ||
return null; | ||
} | ||
const info = document.createElement("div"); | ||
info.innerText = event.extendedProps.location; | ||
|
||
return makePopupInfo(info, "fa-solid fa-location-dot"); | ||
}; | ||
|
||
const makePopupUrl = (event: EventImpl) => { | ||
if (event.url === "") { | ||
return null; | ||
} | ||
const url = document.createElement("a"); | ||
url.href = event.url; | ||
url.textContent = gettext("More info"); | ||
|
||
return makePopupInfo(url, "fa-solid fa-link"); | ||
}; | ||
|
||
// Create new popup | ||
const popup = document.createElement("div"); | ||
const popupContainer = document.createElement("div"); | ||
|
||
popup.setAttribute("id", "event-details"); | ||
popupContainer.setAttribute("class", "event-details-container"); | ||
|
||
popupContainer.appendChild(makePopupTitle(event.event)); | ||
|
||
const location = makePopupLocation(event.event); | ||
if (location !== null) { | ||
popupContainer.appendChild(location); | ||
} | ||
|
||
const url = makePopupUrl(event.event); | ||
if (url !== null) { | ||
popupContainer.appendChild(url); | ||
} | ||
|
||
popup.appendChild(popupContainer); | ||
|
||
// We can't just add the element relative to the one we want to appear under | ||
// Otherwise, it either gets clipped by the boundaries of the calendar or resize cells | ||
// Here, we create a popup outside the calendar that follows the clicked element | ||
this.node.appendChild(popup); | ||
const follow = (node: HTMLElement) => { | ||
const rect = node.getBoundingClientRect(); | ||
popup.setAttribute( | ||
"style", | ||
`top: calc(${rect.top + window.scrollY}px + ${rect.height}px); left: ${rect.left + window.scrollX}px;`, | ||
); | ||
}; | ||
follow(event.el); | ||
window.addEventListener("resize", () => { | ||
follow(event.el); | ||
}); | ||
} | ||
|
||
async connectedCallback() { | ||
super.connectedCallback(); | ||
this.calendar = new Calendar(this.node, { | ||
plugins: [dayGridPlugin, iCalendarPlugin, listPlugin], | ||
locales: [frLocale, enLocale], | ||
height: "auto", | ||
locale: this.locale, | ||
initialView: this.currentView(), | ||
headerToolbar: this.currentToolbar(), | ||
eventSources: [ | ||
{ | ||
url: await makeUrl(calendarCalendarInternal), | ||
format: "ics", | ||
}, | ||
{ | ||
url: await makeUrl(calendarCalendarExternal), | ||
format: "ics", | ||
}, | ||
], | ||
windowResize: () => { | ||
this.calendar.changeView(this.currentView()); | ||
this.calendar.setOption("headerToolbar", this.currentToolbar()); | ||
}, | ||
eventClick: (event) => { | ||
// Avoid our popup to be deleted because we clicked outside of it | ||
event.jsEvent.stopPropagation(); | ||
// Don't auto-follow events URLs | ||
event.jsEvent.preventDefault(); | ||
this.createEventDetailPopup(event); | ||
}, | ||
}); | ||
this.calendar.render(); | ||
|
||
window.addEventListener("click", (event: MouseEvent) => { | ||
// Auto close popups when clicking outside of it | ||
const popup = document.getElementById("event-details"); | ||
if (popup !== null && !popup.contains(event.target as Node)) { | ||
popup.remove(); | ||
} | ||
}); | ||
} | ||
} |
Oops, something went wrong.