diff --git a/calendarium/fasting.py b/calendarium/fasting.py index f3c15a8..306979c 100644 --- a/calendarium/fasting.py +++ b/calendarium/fasting.py @@ -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. @@ -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 diff --git a/calendarium/liturgics/day.py b/calendarium/liturgics/day.py index 3eb1431..31fc254 100644 --- a/calendarium/liturgics/day.py +++ b/calendarium/liturgics/day.py @@ -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 @@ -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 @@ -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 - '

' -- 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 '

' 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): @@ -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 diff --git a/calendarium/liturgics/feasts.py b/calendarium/liturgics/feasts.py index 2747ef0..49d9ea9 100644 --- a/calendarium/liturgics/feasts.py +++ b/calendarium/liturgics/feasts.py @@ -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 @@ -42,26 +46,96 @@ 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 @@ -69,45 +143,42 @@ def year_of_feasts(year, calendar, tradition): (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 diff --git a/calendarium/tests/test_liturgics.py b/calendarium/tests/test_liturgics.py index 9d710b1..68e9ccb 100644 --- a/calendarium/tests/test_liturgics.py +++ b/calendarium/tests/test_liturgics.py @@ -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): @@ -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 '

' -- - 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='

'))) - self.assertFalse(_has_story(SimpleNamespace(story='

\n'))) + self.assertFalse(_has_story(SimpleNamespace(story=' \n'))) def test_real_content_is_a_story(self): self.assertTrue(_has_story(SimpleNamespace(story='

He was a deacon.

'))) + def test_every_story_has_visible_text(self): + """_has_story doesn't strip markup, so a story that is only markup, + like the nine '

' 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'] @@ -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='

He was a deacon.

')), 1) - self.assertEqual(_display_priority(row(story='

')), 2) + self.assertEqual(_display_priority(row(story='')), 2) self.assertEqual(_display_priority(row()), 2) def test_sixth_council_date_differs_by_tradition(self):