diff --git a/pgcommitfest/commitfest/feeds.py b/pgcommitfest/commitfest/feeds.py index 8a1250df..83c47bfc 100644 --- a/pgcommitfest/commitfest/feeds.py +++ b/pgcommitfest/commitfest/feeds.py @@ -1,5 +1,17 @@ +from __future__ import annotations + from django.contrib.syndication.views import Feed +from datetime import timedelta +from typing import TYPE_CHECKING + +from icalendar import Calendar, Event + +if TYPE_CHECKING: + from collections.abc import Iterable + + from .models import CommitFest + class ActivityFeed(Feed): title = description = "Commitfest Activity Log" @@ -34,3 +46,31 @@ def item_link(self, item): def item_pubdate(self, item): return item["date"] + + +def calendar(commitfests: Iterable[CommitFest]) -> Calendar: + """ + Build an RFC 5545 iCalendar document with one all-day VEVENT per + commitfest, covering its start and end date. + """ + calendar = Calendar.new( + prodid="-//PostgreSQL Commitfest//commitfest.postgresql.org//EN", + name="PostgreSQL Commitfests", + description="Start and end dates of PostgreSQL Commitfests", + method="PUBLISH", + ) + + for cf in commitfests: + event = Event.new( + uid=f"commitfest-{cf.name}@commitfest.postgresql.org", + summary=cf.title, + start=cf.startdate, + # DTEND is exclusive for all-day events, so add a day to make the + # last day of the commitfest show up as included on the calendar. + end=cf.enddate + timedelta(days=1), + description=f"Status: {cf.statusstring}", + url=f"https://commitfest.postgresql.org/{cf.id}/", + ) + calendar.add_component(event) + + return calendar diff --git a/pgcommitfest/commitfest/templates/archive.html b/pgcommitfest/commitfest/templates/archive.html index 6d983df1..7f9087f2 100644 --- a/pgcommitfest/commitfest/templates/archive.html +++ b/pgcommitfest/commitfest/templates/archive.html @@ -1,5 +1,6 @@ {%extends "base.html"%} {%block contents%} +

Subscribe to Commitfest dates (ICS)


{%endblock%} - diff --git a/pgcommitfest/commitfest/tests/test_ics_feed.py b/pgcommitfest/commitfest/tests/test_ics_feed.py new file mode 100644 index 00000000..b0382450 --- /dev/null +++ b/pgcommitfest/commitfest/tests/test_ics_feed.py @@ -0,0 +1,40 @@ +""" +Tests for the /commitfests.ics calendar feed. +""" + +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING + +import pytest +from icalendar import Calendar + +if TYPE_CHECKING: + from pgcommitfest.commitfest.models import CommitFest + +pytestmark = pytest.mark.django_db + + +def test_commitfests_ics(client, commitfests: dict[str, CommitFest]): + """ + Every non-draft commitfest, regardless of status, shows up as an all-day + VEVENT with DTEND being the day after enddate (exclusive). + """ + commitfests = {k: cf for k, cf in commitfests.items() if not cf.draft} + + response = client.get("/commitfests.ics") + assert response.status_code == 200 + assert response["Content-Type"] == "text/calendar; charset=utf-8" + + events = Calendar.from_ical(response.content).walk("VEVENT") + assert len(events) == len(commitfests) + events_by_uid = {str(event.uid): event for event in events} + + for cf in commitfests.values(): + event = events_by_uid[f"commitfest-{cf.name}@commitfest.postgresql.org"] + assert event.summary == f"Commitfest {cf.name}" + assert event.url == f"https://commitfest.postgresql.org/{cf.pk}/" + assert event.description == f"Status: {cf.statusstring}" + assert event.start == cf.startdate + assert event.end == cf.enddate + timedelta(days=1) diff --git a/pgcommitfest/commitfest/views.py b/pgcommitfest/commitfest/views.py index b6659ac8..a484d49b 100644 --- a/pgcommitfest/commitfest/views.py +++ b/pgcommitfest/commitfest/views.py @@ -6,6 +6,7 @@ from django.db.models import Count, Q from django.http import ( Http404, + HttpRequest, HttpResponse, HttpResponseForbidden, HttpResponsePermanentRedirect, @@ -27,7 +28,7 @@ from pgcommitfest.userprofile.util import UserWrapper from .ajax import _archivesAPI, doAttachThread, refresh_single_thread -from .feeds import ActivityFeed +from .feeds import ActivityFeed, calendar from .forms import ( BulkEmailForm, CommitFestFilterForm, @@ -228,6 +229,19 @@ def help(request): ) +def commitfests_ics(_: HttpRequest) -> HttpResponse: + """ + Calendar feed of all non-draft commitfests in iCalendar format (RFC 5545). + """ + commitfests = CommitFest.objects.filter(draft=False).order_by("startdate") + + response = HttpResponse( + calendar(commitfests).to_ical(), content_type="text/calendar; charset=utf-8" + ) + response["Content-Disposition"] = 'inline; filename="commitfests.ics"' + return response + + def activity(request, cfid=None, rss=None): # Number of notes to fetch if rss: diff --git a/pgcommitfest/urls.py b/pgcommitfest/urls.py index 3e0aaa46..de80e32c 100644 --- a/pgcommitfest/urls.py +++ b/pgcommitfest/urls.py @@ -24,6 +24,7 @@ re_path(r"^commitfest_history/$", views.commitfest_history_legacy_redirect), re_path(r"^me/$", views.me_legacy_redirect), re_path(r"^archive/$", views.archive), + re_path(r"^commitfests\.ics$", views.commitfests_ics), re_path(r"^activity(?P\.rss)?/", views.activity), re_path(r"^(\d+)/$", views.commitfest), re_path(r"^(open|inprogress|current|draft)/(.*)$", views.redir), diff --git a/pyproject.toml b/pyproject.toml index 4ca7a0f6..c3a6a178 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" license = "PostgreSQL" dependencies = [ "django>=5.2,<6.0", + "icalendar", "psycopg2", "simplejson", "requests",