Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions calendarium/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions calendarium/fasting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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.
#
Expand Down
1 change: 1 addition & 0 deletions calendarium/liturgics/__init__.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
113 changes: 113 additions & 0 deletions calendarium/liturgics/feasts.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion calendarium/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions calendarium/templates/feasts.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{% extends "content_base.html" %}
{% load fullurl %}

{% block title %}Orthodox Feasts and Fasts for {{ year }}{% endblock %}

{% block head %}
<meta name="description" content="The dates of Pascha, the Twelve Great Feasts, the other great feasts, and the fasting seasons of the Orthodox Church in {{ year }}.">
<link rel="canonical" href="{% fullurl "feasts" tradition=tradition cal=cal year=year %}">

{% if noindex %}
<meta name="robots" content="noindex">
{% endif %}
{% endblock %}

{% block extra_nav %}
<div class="page-nav-row">
<div class="day-nav">
<a class="cal-nav" href="{% url "feasts" tradition=tradition cal=cal year=previous_year %}" {% if previous_nofollow %}rel="nofollow"{% endif %} hx-get="{% url "feasts" tradition=tradition cal=cal year=previous_year %}" hx-select="#orthocal-content" hx-target="#orthocal-content" hx-swap="outerHTML transition:true" hx-push-url="true">&larr; {{ previous_year }}</a>
<a class="cal-nav" href="{% url "feasts" tradition=tradition cal=cal year=next_year %}" {% if next_nofollow %}rel="nofollow"{% endif %} hx-get="{% url "feasts" tradition=tradition cal=cal year=next_year %}" hx-select="#orthocal-content" hx-target="#orthocal-content" hx-swap="outerHTML transition:true" hx-push-url="true">{{ next_year }} &rarr;</a>
</div>

<div class="toggle-row" hx-params="none">
<label class="toggle-picker" title="Liturgical tradition">
<select name="tradition" hx-get="" hx-on::config-request="event.detail.path = this.value" hx-select="#orthocal-content" hx-target="#orthocal-content" hx-swap="outerHTML transition:true" hx-push-url="true">
<option value="{% url "feasts" tradition="slavic" cal=cal year=year %}" {% if tradition == "slavic" %}selected{% endif %} title="OCA, ROCOR">Slavic</option>
<option value="{% url "feasts" tradition="greek" cal=cal year=year %}" {% if tradition == "greek" %}selected{% endif %} title="Antiochian, Greek Archdiocese">Greek</option>
</select>
</label>
<label class="toggle-picker" title="Calendar">
<select name="calendar" hx-get="" hx-on::config-request="event.detail.path = this.value" hx-select="#orthocal-content" hx-target="#orthocal-content" hx-swap="outerHTML transition:true" hx-push-url="true">
<option value="{% url "feasts" tradition=tradition cal="gregorian" year=year %}" {% if cal == "gregorian" %}selected{% endif %}>Gregorian</option>
<option value="{% url "feasts" tradition=tradition cal="julian" year=year %}" {% if cal == "julian" %}selected{% endif %}>Julian</option>
</select>
</label>
</div>
</div>
{% endblock extra_nav %}

{% block main %}
<header>
<h1>{{ year }}<span><br>Feasts and Fasts</span></h1>
</header>

<p class="feasts-key">Pascha and the Twelve Great Feasts are in red. Dates are civil dates{% if cal == "julian" %}, for the Julian (Old) calendar{% endif %}.</p>

<ol class="feast-list">
{% for entry in entries %}
{% if entry.feast %}
{% with feast=entry.feast %}
<li class="feast{% if feast.is_great %} great{% endif %}{% if feast.is_pascha %} pascha{% endif %}{% if entry.in_fast %} in-fast{% endif %}">
<a href="{% url "readings" tradition=tradition cal=cal year=feast.date.year month=feast.date.month day=feast.date.day %}">
<span class="feast-date">{{ feast.date|date:"M j" }}<span class="feast-weekday">{{ feast.date|date:"l" }}</span></span>
<span class="feast-name">{{ feast.name }}</span>
</a>
</li>
{% endwith %}
{% else %}
{% with season=entry.season %}
<li class="fast-season">
<span class="feast-date"><span class="date-part">{{ season.start|date:"M j" }}{% if season.start.year != year %}, {{ season.start.year }}{% endif %}&thinsp;&ndash;</span>&thinsp;<span class="date-part">{{ season.end|date:"M j" }}{% if season.end.year != year %}, {{ season.end.year }}{% endif %}</span></span>
<span class="feast-name">{{ season.name }} <span class="fast-days">({{ season.days }} day{{ season.days|pluralize }})</span></span>
</li>
{% endwith %}
{% endif %}
{% endfor %}
</ol>
{% endblock %}
20 changes: 10 additions & 10 deletions calendarium/tests/data/fasting-characterization.txt
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@
2025-11-13 slavic w4 feast5 fast0 exc2 rows=0,2 -> -
2025-11-14 slavic w5 feast4 fast1 exc1 rows=0,1 -> meat,fish,dairy,eggs
2025-11-15 slavic w6 feast0 fast5 exc2 rows=0,0 -> meat,dairy,eggs
2025-11-16 slavic w0 feast6 fast5 exc2 rows=0,2 -> meat,dairy,eggs
2025-11-16 slavic w0 feast4 fast5 exc2 rows=0,2 -> meat,dairy,eggs
2025-11-17 slavic w1 feast3 fast5 exc0 rows=0,0 -> meat,fish,dairy,eggs,wine,oil
2025-11-18 slavic w2 feast0 fast5 exc1 rows=0,0 -> meat,fish,dairy,eggs
2025-11-19 slavic w3 feast3 fast5 exc0 rows=0,0 -> meat,fish,dairy,eggs,wine,oil
Expand Down Expand Up @@ -682,7 +682,7 @@
2026-11-13 slavic w5 feast5 fast1 exc2 rows=0,2 -> meat,dairy,eggs
2026-11-14 slavic w6 feast4 fast0 exc1 rows=0,1 -> -
2026-11-15 slavic w0 feast0 fast5 exc2 rows=0,0 -> meat,dairy,eggs
2026-11-16 slavic w1 feast6 fast5 exc2 rows=0,2 -> meat,dairy,eggs
2026-11-16 slavic w1 feast4 fast5 exc2 rows=0,2 -> meat,dairy,eggs
2026-11-17 slavic w2 feast3 fast5 exc1 rows=0,0 -> meat,fish,dairy,eggs
2026-11-18 slavic w3 feast0 fast5 exc0 rows=0,0 -> meat,fish,dairy,eggs,wine,oil
2026-11-19 slavic w4 feast3 fast5 exc1 rows=0,0 -> meat,fish,dairy,eggs
Expand Down Expand Up @@ -1047,7 +1047,7 @@
2027-11-13 slavic w6 feast5 fast0 exc2 rows=0,2 -> -
2027-11-14 slavic w0 feast4 fast0 exc1 rows=0,1 -> -
2027-11-15 slavic w1 feast0 fast5 exc0 rows=0,0 -> meat,fish,dairy,eggs,wine,oil
2027-11-16 slavic w2 feast6 fast5 exc2 rows=0,2 -> meat,dairy,eggs
2027-11-16 slavic w2 feast4 fast5 exc2 rows=0,2 -> meat,dairy,eggs
2027-11-17 slavic w3 feast3 fast5 exc0 rows=0,0 -> meat,fish,dairy,eggs,wine,oil
2027-11-18 slavic w4 feast0 fast5 exc1 rows=0,0 -> meat,fish,dairy,eggs
2027-11-19 slavic w5 feast3 fast5 exc0 rows=0,0 -> meat,fish,dairy,eggs,wine,oil
Expand Down Expand Up @@ -1413,7 +1413,7 @@
2028-11-13 slavic w1 feast5 fast0 exc2 rows=0,2 -> -
2028-11-14 slavic w2 feast4 fast0 exc1 rows=0,1 -> -
2028-11-15 slavic w3 feast0 fast5 exc0 rows=0,0 -> meat,fish,dairy,eggs,wine,oil
2028-11-16 slavic w4 feast6 fast5 exc2 rows=0,2 -> meat,dairy,eggs
2028-11-16 slavic w4 feast4 fast5 exc2 rows=0,2 -> meat,dairy,eggs
2028-11-17 slavic w5 feast3 fast5 exc0 rows=0,0 -> meat,fish,dairy,eggs,wine,oil
2028-11-18 slavic w6 feast0 fast5 exc2 rows=0,0 -> meat,dairy,eggs
2028-11-19 slavic w0 feast3 fast5 exc2 rows=0,0 -> meat,dairy,eggs
Expand Down Expand Up @@ -1778,7 +1778,7 @@
2029-11-13 slavic w2 feast5 fast0 exc2 rows=0,2 -> -
2029-11-14 slavic w3 feast4 fast1 exc1 rows=0,1 -> meat,fish,dairy,eggs
2029-11-15 slavic w4 feast0 fast5 exc1 rows=0,0 -> meat,fish,dairy,eggs
2029-11-16 slavic w5 feast6 fast5 exc2 rows=0,2 -> meat,dairy,eggs
2029-11-16 slavic w5 feast4 fast5 exc2 rows=0,2 -> meat,dairy,eggs
2029-11-17 slavic w6 feast3 fast5 exc2 rows=0,0 -> meat,dairy,eggs
2029-11-18 slavic w0 feast0 fast5 exc2 rows=0,0 -> meat,dairy,eggs
2029-11-19 slavic w1 feast3 fast5 exc0 rows=0,0 -> meat,fish,dairy,eggs,wine,oil
Expand Down Expand Up @@ -2143,7 +2143,7 @@
2025-11-13 greek w4 feast5 fast0 exc2 rows=0,2 -> -
2025-11-14 greek w5 feast4 fast1 exc2 rows=0,2 -> meat,dairy,eggs
2025-11-15 greek w6 feast0 fast5 exc2 rows=0,0 -> meat,dairy,eggs
2025-11-16 greek w0 feast6 fast5 exc2 rows=0,2 -> meat,dairy,eggs
2025-11-16 greek w0 feast4 fast5 exc2 rows=0,2 -> meat,dairy,eggs
2025-11-17 greek w1 feast3 fast5 exc2 rows=0,0 -> meat,dairy,eggs
2025-11-18 greek w2 feast0 fast5 exc2 rows=0,0 -> meat,dairy,eggs
2025-11-19 greek w3 feast3 fast5 exc0 rows=0,0 -> meat,fish,dairy,eggs,wine,oil
Expand Down Expand Up @@ -2508,7 +2508,7 @@
2026-11-13 greek w5 feast5 fast1 exc1 rows=0,2 -> meat,fish,dairy,eggs
2026-11-14 greek w6 feast4 fast0 exc2 rows=0,2 -> -
2026-11-15 greek w0 feast0 fast5 exc2 rows=0,0 -> meat,dairy,eggs
2026-11-16 greek w1 feast6 fast5 exc2 rows=0,2 -> meat,dairy,eggs
2026-11-16 greek w1 feast4 fast5 exc2 rows=0,2 -> meat,dairy,eggs
2026-11-17 greek w2 feast3 fast5 exc2 rows=0,0 -> meat,dairy,eggs
2026-11-18 greek w3 feast0 fast5 exc0 rows=0,0 -> meat,fish,dairy,eggs,wine,oil
2026-11-19 greek w4 feast3 fast5 exc2 rows=0,0 -> meat,dairy,eggs
Expand Down Expand Up @@ -2873,7 +2873,7 @@
2027-11-13 greek w6 feast5 fast0 exc2 rows=0,2 -> -
2027-11-14 greek w0 feast4 fast0 exc2 rows=0,2 -> -
2027-11-15 greek w1 feast0 fast5 exc2 rows=0,0 -> meat,dairy,eggs
2027-11-16 greek w2 feast6 fast5 exc2 rows=0,2 -> meat,dairy,eggs
2027-11-16 greek w2 feast4 fast5 exc2 rows=0,2 -> meat,dairy,eggs
2027-11-17 greek w3 feast3 fast5 exc0 rows=0,0 -> meat,fish,dairy,eggs,wine,oil
2027-11-18 greek w4 feast0 fast5 exc2 rows=0,0 -> meat,dairy,eggs
2027-11-19 greek w5 feast3 fast5 exc0 rows=0,0 -> meat,fish,dairy,eggs,wine,oil
Expand Down Expand Up @@ -3239,7 +3239,7 @@
2028-11-13 greek w1 feast5 fast0 exc2 rows=0,2 -> -
2028-11-14 greek w2 feast4 fast0 exc2 rows=0,2 -> -
2028-11-15 greek w3 feast0 fast5 exc0 rows=0,0 -> meat,fish,dairy,eggs,wine,oil
2028-11-16 greek w4 feast6 fast5 exc2 rows=0,2 -> meat,dairy,eggs
2028-11-16 greek w4 feast4 fast5 exc2 rows=0,2 -> meat,dairy,eggs
2028-11-17 greek w5 feast3 fast5 exc0 rows=0,0 -> meat,fish,dairy,eggs,wine,oil
2028-11-18 greek w6 feast0 fast5 exc2 rows=0,0 -> meat,dairy,eggs
2028-11-19 greek w0 feast3 fast5 exc2 rows=0,0 -> meat,dairy,eggs
Expand Down Expand Up @@ -3604,7 +3604,7 @@
2029-11-13 greek w2 feast5 fast0 exc2 rows=0,2 -> -
2029-11-14 greek w3 feast4 fast1 exc2 rows=0,2 -> meat,dairy,eggs
2029-11-15 greek w4 feast0 fast5 exc2 rows=0,0 -> meat,dairy,eggs
2029-11-16 greek w5 feast6 fast5 exc1 rows=0,2 -> meat,fish,dairy,eggs
2029-11-16 greek w5 feast4 fast5 exc1 rows=0,2 -> meat,fish,dairy,eggs
2029-11-17 greek w6 feast3 fast5 exc2 rows=0,0 -> meat,dairy,eggs
2029-11-18 greek w0 feast0 fast5 exc2 rows=0,0 -> meat,dairy,eggs
2029-11-19 greek w1 feast3 fast5 exc2 rows=0,0 -> meat,dairy,eggs
Expand Down
Loading
Loading