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
26 changes: 19 additions & 7 deletions calendarium/fasting.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,21 @@ def resolve(season, weekday, feast_level, rows, no_fish=False,
return allowance, (winner if winner is not None else CANONICAL[allowance])


def fast_level(days, pdist, pyear):
"""The fast level of a day, from its merged `Day` rows. `apply` sets it;
year_of_feasts reads it for a whole year without building each day."""

if FAST_FREE in (d.fast_exception for d in days):
return FastLevels.NoFast

# The Apostles' fast cannot be read off the database: the feast of Sts Peter
# and Paul belongs to the festal cycle while the fast's start is Paschal.
if 56 < pdist < pyear.peter_and_paul:
return FastLevels.ApostlesFast

return max((d.fast for d in days), default=FastLevels.NoFast)


def apply(day, season_for):
"""Set `day.fast_level` and `day.fast_exception` for one day.

Expand All @@ -336,17 +351,14 @@ def apply(day, season_for):
festal = {d.fast_exception: bool(d.month) for d in day.days}
cap_exempt_rows = any(d.fast_cap_exempt for d in day.days)

day.fast_level = fast_level(day.days, day.pdist, day.pyear)

if FAST_FREE in rows:
day.fast_level = FastLevels.NoFast
day.fast_exception = FAST_FREE
return

# The Apostles' fast cannot be read off the database: the feast of Sts Peter
# and Paul belongs to the festal cycle while the fast's start is Paschal.
if 56 < day.pdist < day.pyear.peter_and_paul:
day.fast_level = FastLevels.ApostlesFast
if day.pdist == 57:
day.service_notes.append("Beginning of Apostles' Fast")
if day.pdist == 57 and day.fast_level == FastLevels.ApostlesFast:
day.service_notes.append("Beginning of Apostles' Fast")

season = season_for(day)
no_fish = bool(season and season.no_fish_before_nativity
Expand Down
34 changes: 19 additions & 15 deletions calendarium/liturgics/day.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

from django.db.models import Q
from django.utils.functional import cached_property
from django.utils.html import strip_tags

from .. import datetools, models
from .. import fasting
Expand Down Expand Up @@ -44,6 +43,19 @@
Tradition.Greek: GreekYear,
}

def church_date(gregorian_date, calendar):
"""Return the church-calendar date for a civil date, with its distance
from Pascha, the year of that Pascha, and its Julian day number."""

if calendar == Calendar.Gregorian:
dt = gregorian_date
pdist, pyear = datetools.compute_pascha_distance(dt)
return dt, pdist, pyear, datetools.gregorian_to_jdn(dt)

dt = datetools.gregorian_to_julian(gregorian_date.year, gregorian_date.month, gregorian_date.day)
pdist, pyear = datetools.compute_julian_pascha_distance(dt)
return dt, pdist, pyear, datetools.julian_to_jdn(dt)

# How many commemorations Day.minimal_saints keeps -- a display-space
# constraint (the monthly calendar grid cell, summary_title's fallback).
# Which ones it keeps is _display_priority's job. Adjust freely if the
Expand Down Expand Up @@ -88,12 +100,12 @@ def _prefer_tradition(rows, tradition):


def _has_story(dc):
"""Whether dc.story has actual visible text, not just empty markup like
'<p></p>' -- a handful of rows have exactly that, which is truthy as a
plain Python string but renders as nothing, incorrectly earning a
clickable title and an empty entry in the story panel."""
"""Whether dc.story has any text. Every story in the data has visible text
once its markup is stripped -- nine that were only '<p></p>' were cleared
in #172, and TestHasStory keeps it that way -- so this doesn't strip the
markup itself, which cost a fifth of building a month calendar."""

return bool(strip_tags(dc.story or '').strip())
return bool(dc.story and dc.story.strip())


def _speech_worthy(dc):
Expand Down Expand Up @@ -211,15 +223,7 @@ def __new__(cls, year, month, day, calendar=Calendar.Gregorian, tradition=Tradit

def __init__(self, year, month, day, calendar=Calendar.Gregorian, tradition=Tradition.Slavic, language='en', translation=None):
self.gregorian_date = date(year, month, day)

if calendar == Calendar.Gregorian:
dt = self.gregorian_date
pdist, pyear = datetools.compute_pascha_distance(dt)
self.jdn = datetools.gregorian_to_jdn(dt)
else:
dt = datetools.gregorian_to_julian(year, month, day)
pdist, pyear = datetools.compute_julian_pascha_distance(dt)
self.jdn = datetools.julian_to_jdn(dt)
dt, pdist, pyear, self.jdn = church_date(self.gregorian_date, calendar)

self.date = dt
self.year = dt.year
Expand Down
151 changes: 111 additions & 40 deletions calendarium/liturgics/feasts.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
"""The feasts and fasting seasons of a civil year, for planning a year around
them."""

from calendar import isleap
from dataclasses import dataclass
from datetime import date, timedelta

from ..datetools import FastLevelDesc, FastLevels
from .day import Day
from django.db.models import Q

from .. import fasting, models
from ..datetools import Calendar, FastLevelDesc, FastLevels, julian_to_gregorian
from .day import _YEAR_CLASSES, _merge_tradition_days, church_date

# 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
Expand Down Expand Up @@ -42,72 +46,139 @@ def name(self):
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)
class _Rows:
"""The `Day` rows that can make a date a feast or put it in a fasting
season, fetched in one query.

Building a liturgical `Day` for every date of the year costs two to five
queries each, mostly for saints this page never shows. Instead, `dates`
finds the dates these rows fall on, and each one's rows are picked out the
way `Day._collect_commemorations` selects them and merged by the same
`_merge_tradition_days`, so no `Day` needs building at all. Every tradition
row comes along whatever its values, since an override can lower a common
row's feast level or fast."""

def __init__(self):
rows = models.Day.objects.filter(
Q(feast_level__gte=FEAST_LEVEL) | Q(fast__gte=FastLevels.LentenFast) |
Q(fast_exception=fasting.FAST_FREE) | ~Q(tradition='common'))

self.by_pdist = {}
self.by_date = {}
for row in rows:
if row.pdist == 999:
self.by_date.setdefault((row.month, row.day), []).append(row)
else:
self.by_pdist.setdefault(row.pdist, []).append(row)

def dates(self, year, calendar, tradition):
"""Return, in order, the civil dates in the year that a row falls on,
or that are in the Apostles' Fast, which no row marks."""

dates = set()

# A fixed-date row's date is on the church calendar. On the Julian
# calendar the civil year starts on church December 19 of the year
# before.
for month, day in self.by_date:
if calendar == Calendar.Gregorian:
if (month, day) != (2, 29) or isleap(year):
dates.add(date(year, month, day))
continue
for church_year in (year - 1, year):
if (month, day) != (2, 29) or church_year % 4 == 0:
dates.add(julian_to_gregorian(church_year, month, day))

# A civil year holds the end of one Paschal year and the start of the
# next.
for paschal_year in (year - 1, year):
pyear = _YEAR_CLASSES[tradition](paschal_year, calendar)
pdists = [pdist for pdist in self.by_pdist if pdist < 1000]
pdists += [pdist for pdist, index in pyear.floats.items() if index in self.by_pdist]
pdists += range(57, pyear.peter_and_paul)
dates.update(_jdn_to_date(pyear.pascha + pdist) for pdist in pdists)

return sorted(dt for dt in dates if dt.year == year)

def levels(self, dt, calendar, tradition):
"""Return the date's Pascha distance, merged rows, feast level and
fast level."""

church, pdist, pyear, _ = church_date(dt, calendar)
pyear = _YEAR_CLASSES[tradition](pyear, calendar)

rows = self.by_pdist.get(pdist, []) + self.by_date.get((church.month, church.day), [])
if float_index := pyear.floats.get(pdist):
rows += self.by_pdist.get(float_index, [])

# Leaving out rows below both thresholds can't change whether a date
# reaches them. A date with none of these rows has neither.
days = _merge_tradition_days(rows, tradition)
feast_level = max((d.feast_level for d in days), default=0)
return pdist, days, feast_level, fasting.fast_level(days, pdist, pyear)

def _jdn_to_date(jdn):
"""The civil date of a Julian day number."""

return date.fromordinal(jdn - 1721425)

def _season_level(fast_level):
"""The fasting season a fast level belongs to, or None. The weekly
Wednesday and Friday fast (FastLevels.Fast) isn't a season."""

level = FastLevels(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
def _feast(dt, pdist, days, feast_level):
"""The date's feast, 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)."""

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)
top = max(days, key=lambda d: d.feast_level)
return Feast(dt, top.feast_name or top.title, feast_level, 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}
rows = _Rows()
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 = []
seasons = []
for dt in rows.dates(year, calendar, tradition):
pdist, days, feast_level, fast_level = rows.levels(dt, calendar, tradition)

feasts = [feast for day in days if (feast := _feast(day))]
if feast_level >= FEAST_LEVEL:
feasts.append(_feast(dt, pdist, days, feast_level))

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))
if (level := _season_level(fast_level)) is not None:
if seasons and seasons[-1].level == level and seasons[-1].end == dt - timedelta(days=1):
seasons[-1].end = dt
else:
seasons.append(FastSeason(level, dt, dt))

# 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)
seasons[0].start = _extend(rows, seasons[0].start, -1, seasons[0].level, calendar, tradition)
if seasons and seasons[-1].end == last:
seasons[-1].end = _extend(seasons[-1].end, 1, seasons[-1].level, kwargs)
seasons[-1].end = _extend(rows, seasons[-1].end, 1, seasons[-1].level, calendar, tradition)

return feasts, seasons

def _extend(dt, step, level, kwargs):
def _extend(rows, dt, step, level, calendar, tradition):
"""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
*_, fast_level = rows.levels(dt + delta, calendar, tradition)
except ValueError:
return dt
if _season_level(fast_level) != level:
return dt
dt += delta
27 changes: 17 additions & 10 deletions calendarium/tests/test_liturgics.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
from types import SimpleNamespace

from django.test import TestCase
from django.utils.html import strip_tags

from .. import datetools, liturgics, models
from ..datetools import Tradition, Translation
from ..liturgics.day import _display_priority, _has_story
from bible.models import Verse
from commemorations.models import DayCommemoration


class TestYear(TestCase):
Expand Down Expand Up @@ -951,23 +953,28 @@ def test_slavic_weekdays_still_count_from_pentecost(self):
self.assertIn('Thursday of the 21st week after Pentecost', pday.titles)

class TestHasStory(TestCase):
"""A handful of DayCommemoration.story rows are exactly '<p></p>' --
non-empty and non-None, so truthy as a plain Python string, but with no
visible text once rendered. _has_story exists so those rows don't earn a
clickable commemoration title or an empty entry in the story panel (see
day.saint_links and day.stories)."""
"""_has_story decides whether a commemoration earns a clickable title and
an entry in the story panel (see day.saint_links and day.stories)."""

fixtures = ['calendarium.json', 'commemorations.json']

def test_none_and_empty_are_not_a_story(self):
self.assertFalse(_has_story(SimpleNamespace(story=None)))
self.assertFalse(_has_story(SimpleNamespace(story='')))

def test_empty_markup_is_not_a_story(self):
self.assertFalse(_has_story(SimpleNamespace(story='<p></p>')))
self.assertFalse(_has_story(SimpleNamespace(story='<p> </p>\n')))
self.assertFalse(_has_story(SimpleNamespace(story=' \n')))

def test_real_content_is_a_story(self):
self.assertTrue(_has_story(SimpleNamespace(story='<p>He was a deacon.</p>')))

def test_every_story_has_visible_text(self):
"""_has_story doesn't strip markup, so a story that is only markup,
like the nine '<p></p>' rows cleared in #172, would earn an empty
entry. Clear such a story to null instead."""

for dc in DayCommemoration.objects.exclude(story=None).exclude(story=''):
with self.subTest(dc.id):
self.assertTrue(strip_tags(dc.story).strip(), f'{dc.title!r} has a story with no text')


class TestDay(TestCase):
fixtures = ['calendarium.json', 'commemorations.json']
Expand Down Expand Up @@ -1002,7 +1009,7 @@ def test_display_priority(self):
self.assertEqual(_display_priority(row(rank=2)), 0)
self.assertEqual(_display_priority(row(high_rank=True)), 0)
self.assertEqual(_display_priority(row(story='<p>He was a deacon.</p>')), 1)
self.assertEqual(_display_priority(row(story='<p></p>')), 2)
self.assertEqual(_display_priority(row(story='')), 2)
self.assertEqual(_display_priority(row()), 2)

def test_sixth_council_date_differs_by_tradition(self):
Expand Down
Loading