diff --git a/calendarium/api.py b/calendarium/api.py
index 4f2a8bd..2df187c 100644
--- a/calendarium/api.py
+++ b/calendarium/api.py
@@ -165,6 +165,32 @@ class OembedSchema(Schema):
html: str
+
+# A field named date can't also be annotated with the name date.
+CivilDate = date
+
+class FeastSchema(Schema):
+ date: CivilDate = Field(..., description='The civil date of the feast.')
+ weekday: datetools.Weekday
+ name: str
+ feast_level: conint(ge=6, le=8) = Field(..., alias='level', description='7 and 8 are Pascha and the Twelve Great Feasts; 6 marks the other great feasts.')
+ great_feast: bool = Field(..., alias='is_great', description='Whether this is Pascha or one of the Twelve Great Feasts.')
+ pascha_distance: int = Field(..., alias='pdist')
+
+
+class FastSeasonSchema(Schema):
+ name: str
+ fast_level: datetools.FastLevels = Field(..., alias='level')
+ start: date = Field(..., description='The first day of the fast, which can be in the previous year.')
+ end: date = Field(..., description='The last day of the fast, which can be in the next year.')
+ days: int
+
+
+class FeastYearSchema(Schema):
+ year: year
+ feasts: List[FeastSchema]
+ fasts: List[FastSeasonSchema] = Field(..., description='The fasting seasons that overlap the year: Great Lent, and the Apostles, Dormition, and Nativity Fasts.')
+
@api.exception_handler(NotImplementedError)
def not_implemented_handler(request, exc):
return api.create_response(request, {'message': 'Not Implemented'}, status=501)
@@ -292,6 +318,31 @@ async def get_calendar_month_tradition(request, tradition: Tradition, cal: Calen
return await _get_calendar_month(request, cal, tradition, year, month)
+@in_thread_pool
+@cache_content('api-feasts-{tradition}-{cal}-{year}', JSON_CONTENT_TYPE, timeout=settings.ORTHOCAL_ICAL_TTL*60*60)
+def _get_feasts(request, cal, tradition, year):
+ feasts, fasts = liturgics.year_of_feasts(year, calendar=cal, tradition=tradition)
+ data = FeastYearSchema.model_validate({
+ 'year': year,
+ 'feasts': [{'weekday': datetools.weekday_from_pdist(feast.pdist), **vars(feast), 'is_great': feast.is_great} for feast in feasts],
+ 'fasts': [{'name': fast.name, 'days': fast.days, **vars(fast)} for fast in fasts],
+ }).model_dump()
+ return api.create_response(request, data, status=200).content
+
+@api.get('{tradition:tradition}/{cal:cal}/feasts/{year:year}/', response=FeastYearSchema)
+@instrument_endpoint
+@decorate_view(etag)
+async def get_feasts(request, tradition: Tradition, cal: Calendar, year: year) -> FeastYearSchema:
+ """Get the feasts and fasting seasons of a civil year, for the given
+ tradition and calendar: Pascha, the Twelve Great Feasts, the other great
+ feasts, and the four fasting seasons, all with civil dates.
+
+ The *tradition* path parameter should be `slavic` or `greek`. The *cal* path
+ parameter should be `gregorian` or `julian`.
+ """
+
+ return await _get_feasts(request, cal, tradition, year)
+
@api.get('{cal:cal}/', response=DaySchema, summary='Get Today')
@instrument_endpoint
@decorate_view(etag_date)
diff --git a/calendarium/fasting.py b/calendarium/fasting.py
index 988ccf6..f3c15a8 100644
--- a/calendarium/fasting.py
+++ b/calendarium/fasting.py
@@ -181,7 +181,7 @@ def cap_for(self, weekday, feast_level):
cap=_GREEK_FISH_CAP, default_cap=D.FishWineOil,
# 7, not 4: the Wednesday/Friday no-fish cap holds inside this fast exactly
# as it does in ordinary time, and holds against high-ranking saints.
- # St Matthew (6), St Andrew (4) and St Nicholas (5) all fall here and all
+ # St Matthew (4), St Andrew (4) and St Nicholas (5) all fall here and all
# get wine and oil rather than fish when they land on a Wednesday or Friday.
cap_exempt_rank=7)
NATIVITY_GREEK_STRICT = Season(
@@ -204,7 +204,7 @@ def cap_for(self, weekday, feast_level):
# Lord even during fasting seasons." GOA applies that as a *cap* -- a saint's
# fish grant falls back to wine and oil on a Wednesday or Friday, however highly
# ranked the saint. Ten years of data give fourteen such saints, at feast levels
-# 4, 5 and 6: Anthony the Great, Euthymius, the Three Hierarchs, John the
+# 4 and 5: Anthony the Great, Euthymius, the Three Hierarchs, John the
# Theologian (twice a year), Constantine and Helen, Elijah, Thomas, Demetrius,
# the Archangel Michael, Chrysostom, Matthew, Andrew and Nicholas.
#
diff --git a/calendarium/liturgics/__init__.py b/calendarium/liturgics/__init__.py
index b686819..1cdc75e 100644
--- a/calendarium/liturgics/__init__.py
+++ b/calendarium/liturgics/__init__.py
@@ -1,6 +1,7 @@
from datetime import datetime, timedelta
from .day import Day, SlavicDay, GreekDay
+from .feasts import year_of_feasts
from .year import ByzantineYear, SlavicYear, GreekYear
def month_of_days(year, month, **kwargs):
diff --git a/calendarium/liturgics/feasts.py b/calendarium/liturgics/feasts.py
new file mode 100644
index 0000000..2747ef0
--- /dev/null
+++ b/calendarium/liturgics/feasts.py
@@ -0,0 +1,113 @@
+"""The feasts and fasting seasons of a civil year, for planning a year around
+them."""
+
+from dataclasses import dataclass
+from datetime import date, timedelta
+
+from ..datetools import FastLevelDesc, FastLevels
+from .day import Day
+
+# Feast levels 7 and 8 are the major feasts of the Theotokos and of the Lord:
+# Pascha and the Twelve Great Feasts. Level 6 is the typikon's great-feast
+# symbol, which marks the other great feasts.
+GREAT_FEAST_LEVEL = 7
+FEAST_LEVEL = 6
+
+@dataclass
+class Feast:
+ date: date
+ name: str
+ level: int
+ pdist: int
+
+ @property
+ def is_pascha(self):
+ return self.pdist == 0
+
+ @property
+ def is_great(self):
+ return self.level >= GREAT_FEAST_LEVEL
+
+@dataclass
+class FastSeason:
+ level: FastLevels
+ start: date
+ end: date
+
+ @property
+ def name(self):
+ return FastLevelDesc[self.level]
+
+ @property
+ def days(self):
+ return (self.end - self.start).days + 1
+
+def _day(dt, **kwargs):
+ return Day(dt.year, dt.month, dt.day, **kwargs)
+
+def _season_level(day):
+ """The fasting season a day belongs to, or None. The weekly Wednesday and
+ Friday fast (FastLevels.Fast) isn't a season."""
+
+ level = FastLevels(day.fast_level)
+ return level if level >= FastLevels.LentenFast else None
+
+def _feast(day):
+ """The day's feast if it ranks as one, named after its highest-ranked
+ commemoration: a day's feast_name list can lead with a saint who shares
+ the day (Palm Sunday has no feast_name of its own; its title names it)."""
+
+ if day.feast_level < FEAST_LEVEL:
+ return None
+
+ top = max(day.days, key=lambda d: d.feast_level)
+ return Feast(day.gregorian_date, top.feast_name or top.title, day.feast_level, day.pdist)
+
+def year_of_feasts(year, calendar, tradition):
+ """Return the feasts that fall in the given civil year, in date order, and
+ the fasting seasons that overlap it. A season that runs across New Year
+ (on the Julian calendar the Nativity Fast ends on civil January 6) is
+ given in full, including its days in the neighboring year."""
+
+ kwargs = {'calendar': calendar, 'tradition': tradition}
+ first, last = date(year, 1, 1), date(year, 12, 31)
+
+ days = []
+ dt = first
+ while dt <= last:
+ days.append(_day(dt, **kwargs))
+ dt += timedelta(days=1)
+
+ feasts = [feast for day in days if (feast := _feast(day))]
+
+ seasons = []
+ for day in days:
+ level = _season_level(day)
+ if level is None:
+ continue
+ if seasons and seasons[-1].level == level and seasons[-1].end == day.gregorian_date - timedelta(days=1):
+ seasons[-1].end = day.gregorian_date
+ else:
+ seasons.append(FastSeason(level, day.gregorian_date, day.gregorian_date))
+
+ # Follow a season that is under way at either end of the year to its
+ # actual start or end.
+ if seasons and seasons[0].start == first:
+ seasons[0].start = _extend(seasons[0].start, -1, seasons[0].level, kwargs)
+ if seasons and seasons[-1].end == last:
+ seasons[-1].end = _extend(seasons[-1].end, 1, seasons[-1].level, kwargs)
+
+ return feasts, seasons
+
+def _extend(dt, step, level, kwargs):
+ """Step from dt a day at a time while the season continues, and return
+ the last date still in it. Stops at the edge of the supported years."""
+
+ delta = timedelta(days=step)
+ while True:
+ try:
+ if _season_level(_day(dt + delta, **kwargs)) != level:
+ return dt
+ except ValueError:
+ return dt
+ dt += delta
diff --git a/calendarium/models.py b/calendarium/models.py
index ff48512..46287a8 100644
--- a/calendarium/models.py
+++ b/calendarium/models.py
@@ -48,7 +48,7 @@ class Day(models.Model):
# fish away on a Wednesday means "no fish for a saint", and is not aimed at
# a feast of the Lord -- but `feast_level` cannot always tell the two apart,
# because it follows the Slavic reckoning. The Synaxis of the Forerunner is
- # level 3 here and keeps its fish in Greek practice; St Matthew is level 6
+ # level 3 here and keeps its fish in Greek practice; St Matthew is level 4
# and does not.
#
# The old scale said this positionally: indices 3 and 4 duplicate 1 and 2 in
diff --git a/calendarium/templates/feasts.html b/calendarium/templates/feasts.html
new file mode 100644
index 0000000..d849760
--- /dev/null
+++ b/calendarium/templates/feasts.html
@@ -0,0 +1,67 @@
+{% extends "content_base.html" %}
+{% load fullurl %}
+
+{% block title %}Orthodox Feasts and Fasts for {{ year }}{% endblock %}
+
+{% block head %}
+
+
+
+ {% if noindex %}
+
+ {% endif %}
+{% endblock %}
+
+{% block extra_nav %}
+