From 6b28ce3438c5f8c9c42a5ae0542d64299223d6d7 Mon Sep 17 00:00:00 2001 From: Tristan Partin Date: Wed, 23 Sep 2026 16:28:03 -0500 Subject: [PATCH] Add a /commitfests.ics feed This feed is a calendar feed of all commitfests in iCalendar format (RFC 5545[0]). Calendaring software can subscribe to this feed to show commitfests. Link: https://icalendar.org/RFC-Specifications/iCalendar-RFC-5545/ [0] Signed-off-by: Tristan Partin --- pgcommitfest/commitfest/feeds.py | 41 +++++++++++++ .../commitfest/templates/all_commitfests.html | 2 +- .../commitfest/templates/archive.html | 2 +- .../commitfest/tests/test_ics_feed.py | 57 +++++++++++++++++++ pgcommitfest/commitfest/views.py | 17 +++++- pgcommitfest/urls.py | 1 + pyproject.toml | 1 + 7 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 pgcommitfest/commitfest/tests/test_ics_feed.py diff --git a/pgcommitfest/commitfest/feeds.py b/pgcommitfest/commitfest/feeds.py index 8a1250df..e66d4073 100644 --- a/pgcommitfest/commitfest/feeds.py +++ b/pgcommitfest/commitfest/feeds.py @@ -1,5 +1,18 @@ +from __future__ import annotations + from django.contrib.syndication.views import Feed +from collections.abc import Iterable +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 +47,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/all_commitfests.html b/pgcommitfest/commitfest/templates/all_commitfests.html index 6d983df1..7f9087f2 100644 --- a/pgcommitfest/commitfest/templates/all_commitfests.html +++ b/pgcommitfest/commitfest/templates/all_commitfests.html @@ -1,5 +1,6 @@ {%extends "base.html"%} {%block contents%} +

Subscribe to Commitfest dates (ICS)


{%endblock%} - diff --git a/pgcommitfest/commitfest/templates/archive.html b/pgcommitfest/commitfest/templates/archive.html index f22c6161..093de175 100644 --- a/pgcommitfest/commitfest/templates/archive.html +++ b/pgcommitfest/commitfest/templates/archive.html @@ -1,9 +1,9 @@ {%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..dd3b4292 --- /dev/null +++ b/pgcommitfest/commitfest/tests/test_ics_feed.py @@ -0,0 +1,57 @@ +""" +Tests for the /commitfests.ics calendar feed. +""" + +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING, cast + +import pytest +from icalendar import Calendar, Event + +if TYPE_CHECKING: + from pgcommitfest.commitfest.models import CommitFest + +pytestmark = pytest.mark.django_db + + +def _get_calendar(client) -> Calendar: + response = client.get("/commitfests.ics") + assert response.status_code == 200 + assert response["Content-Type"] == "text/calendar; charset=utf-8" + return Calendar.from_ical(response.content) + + +def _get_events(calendar: Calendar) -> list[Event]: + return cast("list[Event]", calendar.walk("VEVENT")) + + +def test_commitfests_ics_empty(client): + """ + With no commitfests, we still get a valid, empty calendar. + """ + calendar = _get_calendar(client) + + assert len(_get_events(calendar)) == 0 + + +def test_commitfests_ics_contains_all_commitfests(client, commitfests: dict[str, CommitFest]): + """ + Every commitfest, regardless of status, shows up as a VEVENT with the right + dates, DTEND being the day after enddate (exclusive). + """ + calendar = _get_calendar(client) + + events = _get_events(calendar) + assert len(events) == len(commitfests) + + events_by_uid = {cast("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 34553217..20dcab7c 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, HttpResponseRedirect, @@ -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, CommentForm, @@ -195,6 +196,20 @@ def archive(request): ) +def commitfests_ics(_: HttpRequest) -> HttpResponse: + """ + Calendar feed of all commitfests in iCalendar format (RFC 5545). + """ + commitfests = CommitFest.objects.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 f6953347..edcbc944 100644 --- a/pgcommitfest/urls.py +++ b/pgcommitfest/urls.py @@ -24,6 +24,7 @@ re_path(r"^commitfest_history/$", views.commitfest_history), 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 d9716f6e..25e7cf2d 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",