Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unified calendar #975

Open
wants to merge 15 commits into
base: taiste
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.models 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")
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")
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
71 changes: 70 additions & 1 deletion com/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@
# 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 datetime import datetime, timedelta
from pathlib import Path
from typing import final

import urllib3
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.mail import EmailMultiAlternatives
Expand All @@ -32,11 +37,75 @@
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from ics import Calendar, Event

from club.models import Club
from core.models import Notification, Preferences, User


@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()
- (timedelta(days=30) * 60), # Roughly get the last 6 months
).prefetch_related("news"):
event = Event(
name=news_date.news.title,
begin=news_date.start_date,
end=news_date.end_date,
url=reverse("com:news_detail", kwargs={"news_id": news_date.news.id}),
)
calendar.events.add(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(calendar.serialize().encode("utf-8"))
return cls._INTERNAL_CALENDAR


class Sith(models.Model):
"""A one instance class storing all the modifiable infos."""

Expand Down
9 changes: 9 additions & 0 deletions com/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.db.models.base import post_save
from django.dispatch import receiver

from com.models import IcsCalendar, 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
Loading