Skip to content

Commit

Permalink
Merge pull request #975 from ae-utbm/unified-calendar
Browse files Browse the repository at this point in the history
Unified calendar widget on main com page with external and internal events
  • Loading branch information
klmp200 authored Jan 5, 2025
2 parents c627944 + 2749a88 commit d1e604e
Show file tree
Hide file tree
Showing 32 changed files with 1,417 additions and 766 deletions.
32 changes: 32 additions & 0 deletions com/api.py
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())
9 changes: 9 additions & 0 deletions com/apps.py
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
76 changes: 76 additions & 0 deletions com/calendar.py
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
3 changes: 2 additions & 1 deletion com/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#


from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.mail import EmailMultiAlternatives
Expand Down
10 changes: 10 additions & 0 deletions com/signals.py
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 com/static/bundled/com/components/ics-calendar-index.ts
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();
}
});
}
}
Loading

0 comments on commit d1e604e

Please sign in to comment.