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)
{%for c in commitfests%}
- {{c}} ({{c.statusstring}} - {{c.periodstring}})
@@ -7,4 +8,3 @@
{%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)
{%for c in commitfests%}
- {{c}} ({{c.statusstring}}{%if c.startdate%} - {{c.periodstring}}{%endif%})
{%endfor%}
{%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",