From aeb8ef26d262cbd2094143129e97b43fb58f6bfd Mon Sep 17 00:00:00 2001 From: Oliver Ni Date: Thu, 12 Oct 2023 18:46:39 -0700 Subject: [PATCH] Expose personal calendar in iCal format instead of provisioning Google Calendars (#558) --- hknweb/events/admin/__init__.py | 14 ++--- hknweb/events/admin/ical_view.py | 14 +++++ hknweb/events/migrations/0012_icalview.py | 39 ++++++++++++ hknweb/events/models/__init__.py | 1 + hknweb/events/models/event.py | 33 ++++++++-- hknweb/events/models/ical_view.py | 63 +++++++++++++++++++ hknweb/events/models/rsvp.py | 12 ++-- hknweb/events/urls.py | 3 +- hknweb/events/utils.py | 31 +++++++++ hknweb/events/views/__init__.py | 1 + .../views/aggregate_displays/__init__.py | 2 +- .../views/aggregate_displays/calendar.py | 39 +++++++----- hknweb/settings/prod.py | 3 + poetry.lock | 31 ++++++++- pyproject.toml | 1 + 15 files changed, 252 insertions(+), 35 deletions(-) create mode 100644 hknweb/events/admin/ical_view.py create mode 100644 hknweb/events/migrations/0012_icalview.py create mode 100644 hknweb/events/models/ical_view.py diff --git a/hknweb/events/admin/__init__.py b/hknweb/events/admin/__init__.py index 3ffe5345..a20255a0 100644 --- a/hknweb/events/admin/__init__.py +++ b/hknweb/events/admin/__init__.py @@ -1,16 +1,14 @@ -from hknweb.events.admin.attendance import ( - AttendanceFormAdmin, - AttendanceResponseAdmin, -) +from django.contrib import admin + +from hknweb.events.admin.attendance import AttendanceFormAdmin, AttendanceResponseAdmin +from hknweb.events.admin.event import EventAdmin +from hknweb.events.admin.event_type import EventTypeAdmin from hknweb.events.admin.google_calendar import ( GCalAccessLevelMappingAdmin, GoogleCalendarCredentialsAdmin, ) -from hknweb.events.admin.event import EventAdmin -from hknweb.events.admin.event_type import EventTypeAdmin +from hknweb.events.admin.ical_view import ICalViewAdmin from hknweb.events.admin.rsvp import RsvpAdmin - -from django.contrib import admin from hknweb.events.models import EventPhoto admin.site.register(EventPhoto) diff --git a/hknweb/events/admin/ical_view.py b/hknweb/events/admin/ical_view.py new file mode 100644 index 00000000..66801f81 --- /dev/null +++ b/hknweb/events/admin/ical_view.py @@ -0,0 +1,14 @@ +from django.contrib import admin + +from hknweb.events.models import ICalView + + +@admin.register(ICalView) +class ICalViewAdmin(admin.ModelAdmin): + fields = ["user", "show_rsvpd", "show_not_rsvpd"] + list_display = ["id", "user"] + search_fields = [ + "user__username", + "user__first_name", + "user__last_name", + ] diff --git a/hknweb/events/migrations/0012_icalview.py b/hknweb/events/migrations/0012_icalview.py new file mode 100644 index 00000000..0c18b515 --- /dev/null +++ b/hknweb/events/migrations/0012_icalview.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.5 on 2023-10-05 05:03 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("events", "0011_eventphoto"), + ] + + operations = [ + migrations.CreateModel( + name="ICalView", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("show_rsvpd", models.BooleanField(default=True)), + ("show_not_rsvpd", models.BooleanField(default=False)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/hknweb/events/models/__init__.py b/hknweb/events/models/__init__.py index 12a6d77b..74b925d6 100644 --- a/hknweb/events/models/__init__.py +++ b/hknweb/events/models/__init__.py @@ -7,3 +7,4 @@ ) from hknweb.events.models.attendance import AttendanceForm, AttendanceResponse from hknweb.events.models.event_photo import EventPhoto +from hknweb.events.models.ical_view import ICalView diff --git a/hknweb/events/models/event.py b/hknweb/events/models/event.py index f55e63bd..41e893e0 100644 --- a/hknweb/events/models/event.py +++ b/hknweb/events/models/event.py @@ -1,14 +1,14 @@ -from django.db import models +import icalendar from django.contrib.auth.models import User - +from django.db import models +from icalendar import vCalAddress, vText from markdownx.models import MarkdownxField -from hknweb.utils import get_semester import hknweb.events.google_calendar_utils as gcal - +from hknweb.events.models.constants import ACCESS_LEVELS from hknweb.events.models.event_type import EventType from hknweb.events.models.google_calendar import GCalAccessLevelMapping -from hknweb.events.models.constants import ACCESS_LEVELS +from hknweb.utils import get_semester class Event(models.Model): @@ -41,6 +41,29 @@ def semester(self): Example: "Spring 2020" """ return get_semester(self.start_time) + def to_ical_obj(self): + event = icalendar.Event() + event.add("uid", self.id) + event.add("summary", self.name) + event.add("location", self.location) + event.add("description", self.description) + event.add("dtstart", self.start_time) + event.add("dtend", self.end_time) + event.add("dtstamp", self.created_at) + + def make_attendee(user, status): + attendee = vCalAddress(f"MAILTO:{user.email}") + attendee.params["PARTSTAT"] = vText(status) + attendee.params["CN"] = vText(f"{user.first_name} {user.last_name}") + return attendee + + for rsvp in self.admitted_set(): + event.add("attendee", make_attendee(rsvp.user, "ACCEPTED"), encode=0) + for rsvp in self.waitlist_set(): + event.add("attendee", make_attendee(rsvp.user, "TENTATIVE"), encode=0) + + return event + def get_absolute_url(self): return "/events/{}".format(self.id) diff --git a/hknweb/events/models/ical_view.py b/hknweb/events/models/ical_view.py new file mode 100644 index 00000000..5739c6f1 --- /dev/null +++ b/hknweb/events/models/ical_view.py @@ -0,0 +1,63 @@ +import random +import uuid +from datetime import datetime, timedelta + +import icalendar +from django.conf import settings +from django.db import models +from django.urls import reverse + +from hknweb.events.models import Event +from hknweb.events.utils import get_events + + +class ICalView(models.Model): + class Meta: + verbose_name = "iCal view" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + show_rsvpd = models.BooleanField(default=True) + show_not_rsvpd = models.BooleanField(default=False) + + @property + def url(self): + return reverse("events:ical", args=[self.id]) + + def to_ical_obj(self): + cal = icalendar.Calendar() + cal.add("prodid", "-//Eta Kappa Nu, Mu Chapter//Calendar//EN") + cal.add("version", "2.0") + cal.add("summary", f"HKN Personal Calendar for {self.user}") + + events = get_events(self.user, self.show_rsvpd, self.show_not_rsvpd) + for event in events: + cal.add_component(event.to_ical_obj()) + + cal.add_component(self.dummy_event()) + return cal + + def dummy_event(self): + # Google Calendar doesn't let you configure how often to sync iCal feeds + # like Apple's Calendar app does. They say this can take up to 24 hours. + + # According to https://webapps.stackexchange.com/a/66686, they probably + # look at how often the iCal feed itself changes and syncs more or less + # frequently based on that. + + # So we add a dummy event in the far future that's randomized every time + # the feed is requested in hopes of making Google Calendar sync faster. + + dt = datetime(3000, 1, 1) + timedelta(days=random.randrange(365)) + + event = icalendar.Event() + event.add("uid", "dummy") + event.add("summary", "Dummy Event") + event.add( + "description", + "Randomized dummy event to make Google Calendar sync faster", + ) + event.add("dtstart", dt) + event.add("dtend", dt) + event.add("dtstamp", dt) + return event diff --git a/hknweb/events/models/rsvp.py b/hknweb/events/models/rsvp.py index b87a1b82..717e63c6 100644 --- a/hknweb/events/models/rsvp.py +++ b/hknweb/events/models/rsvp.py @@ -1,9 +1,9 @@ -from django.db import models from django.contrib.auth.models import User +from django.db import models -from hknweb.models import Profile -from hknweb.events.models.event import Event import hknweb.events.google_calendar_utils as gcal +from hknweb.events.models.event import Event +from hknweb.models import Profile class Rsvp(models.Model): @@ -30,8 +30,10 @@ def has_not_rsvpd(cls, user, event): def save(self, *args, **kwargs): profile = Profile.objects.filter(user=self.user).first() if not profile.google_calendar_id: - profile.google_calendar_id = gcal.create_personal_calendar() - profile.save() + # we no longer provision new personal google calendars + # instead, we generate a ICalView and a route to view it + # so they can add it to any calendar app + return super().save(*args, **kwargs) if self.google_calendar_event_id is None: self.google_calendar_event_id = gcal.create_event( diff --git a/hknweb/events/urls.py b/hknweb/events/urls.py index 1c4244d4..c5cfc5f9 100644 --- a/hknweb/events/urls.py +++ b/hknweb/events/urls.py @@ -1,11 +1,12 @@ from django.urls import path -import hknweb.events.views as views +import hknweb.events.views as views app_name = "events" aggregate_display_urls = [ path("", views.index, name="index"), + path("ical/.ics", views.ical, name="ical"), path("leaderboard", views.get_leaderboard, name="leaderboard"), path("photos", views.photos, name="photos"), ] diff --git a/hknweb/events/utils.py b/hknweb/events/utils.py index 6278fd85..0a66b406 100644 --- a/hknweb/events/utils.py +++ b/hknweb/events/utils.py @@ -7,6 +7,37 @@ from hknweb.events.constants import ATTR from hknweb.events.models import Event +from hknweb.utils import get_access_level + + +def get_events(user, show_rsvpd, show_not_rsvpd): + """Retrieves the events a user can see. + + Parameters + ---------- + user: django.contrib.auth.models.User + The user authenticating the request (can be anonymous) + show_rsvpd: bool + Whether to include events the user has RSVP'd for + show_not_rsvpd: bool + Whether to include events the user has not RSVP'd for + + Returns + ------- + QuerySet of Event objects + """ + + events = Event.objects.order_by("-start_time").filter( + access_level__gte=get_access_level(user) + ) + + if user.is_authenticated: + if not show_rsvpd: + events = events.exclude(rsvp__user=user) + if not show_not_rsvpd: + events = events.filter(rsvp__user=user) + + return events def create_event(data, start_time, end_time, user): diff --git a/hknweb/events/views/__init__.py b/hknweb/events/views/__init__.py index 284892fb..488d2ea2 100644 --- a/hknweb/events/views/__init__.py +++ b/hknweb/events/views/__init__.py @@ -1,5 +1,6 @@ from hknweb.events.views.aggregate_displays import ( index, + ical, get_leaderboard, photos, ) diff --git a/hknweb/events/views/aggregate_displays/__init__.py b/hknweb/events/views/aggregate_displays/__init__.py index 8a51e531..710f8df3 100644 --- a/hknweb/events/views/aggregate_displays/__init__.py +++ b/hknweb/events/views/aggregate_displays/__init__.py @@ -1,3 +1,3 @@ -from hknweb.events.views.aggregate_displays.calendar import index +from hknweb.events.views.aggregate_displays.calendar import ical, index from hknweb.events.views.aggregate_displays.leaderboard import get_leaderboard from hknweb.events.views.aggregate_displays.photos import photos diff --git a/hknweb/events/views/aggregate_displays/calendar.py b/hknweb/events/views/aggregate_displays/calendar.py index 58b8bb88..e25ee6e2 100644 --- a/hknweb/events/views/aggregate_displays/calendar.py +++ b/hknweb/events/views/aggregate_displays/calendar.py @@ -1,12 +1,15 @@ +import uuid from typing import List -from django.shortcuts import render +from django.http import HttpResponse +from django.shortcuts import get_object_or_404, render -from hknweb.models import Profile -from hknweb.events.models import Event, EventType, GCalAccessLevelMapping +from hknweb.events.google_calendar_utils import SHARE_LINK_TEMPLATE, get_calendar_link +from hknweb.events.models import Event, EventType, GCalAccessLevelMapping, ICalView from hknweb.events.models.constants import ACCESS_LEVELS +from hknweb.events.utils import get_events +from hknweb.models import Profile from hknweb.utils import allow_public_access, get_access_level -from hknweb.events.google_calendar_utils import get_calendar_link @allow_public_access @@ -28,6 +31,12 @@ def index(request): ) +@allow_public_access +def ical(request, *, id: uuid.UUID): + ical_view = get_object_or_404(ICalView, pk=id) + return HttpResponse(ical_view.to_ical_obj().to_ical(), content_type="text/calendar") + + def calendar_helper( request, title, @@ -37,15 +46,7 @@ def calendar_helper( show_sidebar=False, ): user_access_level = get_access_level(request.user) - - events = Event.objects.order_by("-start_time").filter( - access_level__gte=user_access_level - ) - if request.user.is_authenticated: - if not rsvpd_display: - events = events.exclude(rsvp__user=request.user) - if not not_rsvpd_display: - events = events.filter(rsvp__user=request.user) + events = get_events(request.user, rsvpd_display, not_rsvpd_display) all_event_types = event_types = EventType.objects.order_by("type") if event_type_types: @@ -85,11 +86,21 @@ def get_calendars(request, user_access_level: int): if profile.google_calendar_id: calendars.append( { - "name": "personal", + "name": "personal (gcal)", "link": get_calendar_link(calendar_id=profile.google_calendar_id), } ) + ical_view, _ = ICalView.objects.get_or_create(user=request.user) + ical_url = request.build_absolute_uri(ical_view.url) + ical_url = ical_url.replace("https://", "webcal://") + calendars.append( + { + "name": "personal (ics)", + "link": SHARE_LINK_TEMPLATE.format(cid=ical_url), + } + ) + for calendar in calendars[:-1]: calendar["separator"] = "/" if len(calendars) > 0: diff --git a/hknweb/settings/prod.py b/hknweb/settings/prod.py index 86b07062..dc41bdcb 100644 --- a/hknweb/settings/prod.py +++ b/hknweb/settings/prod.py @@ -25,3 +25,6 @@ # https://docs.djangoproject.com/en/2.1/howto/static-files/ STATIC_URL = "https://www.ocf.berkeley.edu/~hkn/hknweb/static/" STATIC_ROOT = "/home/h/hk/hkn/public_html/hknweb/static/" + +USE_X_FORWARDED_HOST = True +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") diff --git a/poetry.lock b/poetry.lock index f7dfe39d..7c0c451f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -684,6 +684,21 @@ files = [ [package.dependencies] pyparsing = {version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0.2,<3.0.3 || >3.0.3,<4", markers = "python_version > \"3.0\""} +[[package]] +name = "icalendar" +version = "5.0.10" +description = "iCalendar parser/generator" +optional = false +python-versions = ">=3.7" +files = [ + {file = "icalendar-5.0.10-py3-none-any.whl", hash = "sha256:6e392c2f301b6b5f49433e14c905db3de444b12876f3345f1856a75e9cd8be6f"}, + {file = "icalendar-5.0.10.tar.gz", hash = "sha256:34f0ca020b804758ddf316eb70d1d46f769bce64638d5a080cb65dd46cfee642"}, +] + +[package.dependencies] +python-dateutil = "*" +pytz = "*" + [[package]] name = "idna" version = "3.4" @@ -993,6 +1008,20 @@ files = [ [package.extras] diagrams = ["jinja2", "railroad-diagrams"] +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "pytz" version = "2023.3.post1" @@ -1257,4 +1286,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "~3.9" -content-hash = "97fc202265ce2631e4dfff957dbf0e5bb325823aa9d44aa8561c912c4ffbd415" +content-hash = "72c98c5ebe3db16f613c40c9ef8e80ac30f7e48aeb69ff86d63fb7583adb9dc3" diff --git a/pyproject.toml b/pyproject.toml index 1fe9d715..ffb34b44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ fabric = "^3.2.2" django-autocomplete-light = "^3.9.7" djangorestframework = "^3.14.0" google-api-python-client = "^2.99.0" +icalendar = "^5.0.10" [tool.poetry.group.prod] optional = true