From 6b28ce3438c5f8c9c42a5ae0542d64299223d6d7 Mon Sep 17 00:00:00 2001 From: Tristan Partin Date: Wed, 23 Sep 2026 16:28:03 -0500 Subject: [PATCH 1/3] 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", From 79ab0af5b9694772d8aa6a3c1775c757dfeafa04 Mon Sep 17 00:00:00 2001 From: Jelterminator FennemAI-Neo Date: Sun, 18 Oct 2026 09:59:21 +0200 Subject: [PATCH 2/3] Trim the ICS feed tests and tidy imports The separate empty-calendar test added little over the test that checks every commitfest, so it is dropped and the small helpers are inlined into the remaining test. Also remove the duplicate runtime import of `Iterable` in `feeds.py`, which is only needed for type checking, and apply `ruff format` to the new view. --- pgcommitfest/commitfest/feeds.py | 1 - .../commitfest/tests/test_ics_feed.py | 37 +++++-------------- pgcommitfest/commitfest/views.py | 3 +- 3 files changed, 10 insertions(+), 31 deletions(-) diff --git a/pgcommitfest/commitfest/feeds.py b/pgcommitfest/commitfest/feeds.py index e66d4073..83c47bfc 100644 --- a/pgcommitfest/commitfest/feeds.py +++ b/pgcommitfest/commitfest/feeds.py @@ -2,7 +2,6 @@ from django.contrib.syndication.views import Feed -from collections.abc import Iterable from datetime import timedelta from typing import TYPE_CHECKING diff --git a/pgcommitfest/commitfest/tests/test_ics_feed.py b/pgcommitfest/commitfest/tests/test_ics_feed.py index dd3b4292..182faef8 100644 --- a/pgcommitfest/commitfest/tests/test_ics_feed.py +++ b/pgcommitfest/commitfest/tests/test_ics_feed.py @@ -5,10 +5,10 @@ from __future__ import annotations from datetime import timedelta -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING import pytest -from icalendar import Calendar, Event +from icalendar import Calendar if TYPE_CHECKING: from pgcommitfest.commitfest.models import CommitFest @@ -16,37 +16,18 @@ pytestmark = pytest.mark.django_db -def _get_calendar(client) -> Calendar: +def test_commitfests_ics(client, commitfests: dict[str, CommitFest]): + """ + Every commitfest, regardless of status, shows up as an all-day VEVENT with + DTEND being the day after enddate (exclusive). + """ 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) + events = Calendar.from_ical(response.content).walk("VEVENT") assert len(events) == len(commitfests) - - events_by_uid = {cast("str", event.uid): event for event in events} + 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"] diff --git a/pgcommitfest/commitfest/views.py b/pgcommitfest/commitfest/views.py index 20dcab7c..8be5405c 100644 --- a/pgcommitfest/commitfest/views.py +++ b/pgcommitfest/commitfest/views.py @@ -203,8 +203,7 @@ def commitfests_ics(_: HttpRequest) -> HttpResponse: commitfests = CommitFest.objects.order_by("startdate") response = HttpResponse( - calendar(commitfests).to_ical(), - content_type="text/calendar; charset=utf-8" + calendar(commitfests).to_ical(), content_type="text/calendar; charset=utf-8" ) response["Content-Disposition"] = 'inline; filename="commitfests.ics"' return response From c41bb93959fa4650fe4333e4951fd3983fa76f5d Mon Sep 17 00:00:00 2001 From: Jelterminator FennemAI-Neo Date: Sun, 18 Oct 2026 10:15:45 +0200 Subject: [PATCH 3/3] Leave draft commitfests out of the ICS feed Draft commitfests aren't announced yet and their dates can still change, so putting them in people's calendars would be misleading. --- pgcommitfest/commitfest/tests/test_ics_feed.py | 6 ++++-- pgcommitfest/commitfest/views.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pgcommitfest/commitfest/tests/test_ics_feed.py b/pgcommitfest/commitfest/tests/test_ics_feed.py index 182faef8..b0382450 100644 --- a/pgcommitfest/commitfest/tests/test_ics_feed.py +++ b/pgcommitfest/commitfest/tests/test_ics_feed.py @@ -18,9 +18,11 @@ def test_commitfests_ics(client, commitfests: dict[str, CommitFest]): """ - Every commitfest, regardless of status, shows up as an all-day VEVENT with - DTEND being the day after enddate (exclusive). + 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" diff --git a/pgcommitfest/commitfest/views.py b/pgcommitfest/commitfest/views.py index 8be5405c..c3c865be 100644 --- a/pgcommitfest/commitfest/views.py +++ b/pgcommitfest/commitfest/views.py @@ -198,9 +198,9 @@ def archive(request): def commitfests_ics(_: HttpRequest) -> HttpResponse: """ - Calendar feed of all commitfests in iCalendar format (RFC 5545). + Calendar feed of all non-draft commitfests in iCalendar format (RFC 5545). """ - commitfests = CommitFest.objects.order_by("startdate") + commitfests = CommitFest.objects.filter(draft=False).order_by("startdate") response = HttpResponse( calendar(commitfests).to_ical(), content_type="text/calendar; charset=utf-8"