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 %} + +{% endblock extra_nav %} + +{% block main %} +
+

{{ year }}
Feasts and Fasts

+
+ +

Pascha and the Twelve Great Feasts are in red. Dates are civil dates{% if cal == "julian" %}, for the Julian (Old) calendar{% endif %}.

+ +
    + {% for entry in entries %} + {% if entry.feast %} + {% with feast=entry.feast %} +
  1. + + {{ feast.date|date:"M j" }}{{ feast.date|date:"l" }} + {{ feast.name }} + +
  2. + {% endwith %} + {% else %} + {% with season=entry.season %} +
  3. + {{ season.start|date:"M j" }}{% if season.start.year != year %}, {{ season.start.year }}{% endif %} – {{ season.end|date:"M j" }}{% if season.end.year != year %}, {{ season.end.year }}{% endif %} + {{ season.name }} ({{ season.days }} day{{ season.days|pluralize }}) +
  4. + {% endwith %} + {% endif %} + {% endfor %} +
+{% endblock %} diff --git a/calendarium/tests/data/fasting-characterization.txt b/calendarium/tests/data/fasting-characterization.txt index a1a065e..b5a9e91 100644 --- a/calendarium/tests/data/fasting-characterization.txt +++ b/calendarium/tests/data/fasting-characterization.txt @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/calendarium/tests/test_feasts.py b/calendarium/tests/test_feasts.py new file mode 100644 index 0000000..b35c08a --- /dev/null +++ b/calendarium/tests/test_feasts.py @@ -0,0 +1,133 @@ +from datetime import date + +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from .. import liturgics +from ..datetools import Calendar, FastLevels, Tradition + + +class YearOfFeastsTestCase(TestCase): + fixtures = ['calendarium.json', 'commemorations.json'] + + def test_gregorian(self): + feasts, seasons = liturgics.year_of_feasts(2027, calendar=Calendar.Gregorian, tradition=Tradition.Slavic) + + self.assertEqual([f.date for f in feasts], sorted(f.date for f in feasts)) + self.assertEqual(feasts[0].date, date(2027, 1, 1)) + self.assertEqual(feasts[0].name, 'Circumcision of Our Lord') + + # Pascha and the Twelve Great Feasts. + self.assertEqual(len([f for f in feasts if f.is_great]), 13) + pascha, = [f for f in feasts if f.is_pascha] + self.assertEqual(pascha.date, date(2027, 5, 2)) + + # Named for the feast, not St Mark, who shares the day in 2027. + palm_sunday, = [f for f in feasts if f.pdist == -7] + self.assertEqual(palm_sunday.name, 'Palm Sunday') + + # St Matthew is a polyeleos feast, like the other apostles but Peter and Paul. + self.assertNotIn('Holy Apostle and Evangelist Matthew', [f.name for f in feasts]) + + self.assertEqual( + [(s.level, s.start, s.end) for s in seasons], + [ + (FastLevels.LentenFast, date(2027, 3, 15), date(2027, 5, 1)), + # Pascha is late in 2027, which leaves one day before the feast. + (FastLevels.ApostlesFast, date(2027, 6, 28), date(2027, 6, 28)), + (FastLevels.DormitionFast, date(2027, 8, 1), date(2027, 8, 14)), + (FastLevels.NativityFast, date(2027, 11, 15), date(2027, 12, 24)), + ], + ) + + def test_julian_seasons_cross_new_year(self): + """On the Julian calendar the Nativity Fast ends on civil January 6, + so the year opens and closes inside one, and each is given whole.""" + + feasts, seasons = liturgics.year_of_feasts(2027, calendar=Calendar.Julian, tradition=Tradition.Slavic) + + self.assertEqual((feasts[0].date, feasts[0].name), (date(2027, 1, 7), 'Nativity of Christ')) + self.assertEqual((seasons[0].start, seasons[0].end), (date(2026, 11, 28), date(2027, 1, 6))) + self.assertEqual((seasons[-1].start, seasons[-1].end), (date(2027, 11, 28), date(2028, 1, 6))) + self.assertEqual(seasons[-1].days, 40) + + def test_greek(self): + feasts, _ = liturgics.year_of_feasts(2027, calendar=Calendar.Gregorian, tradition=Tradition.Greek) + names = [f.name for f in feasts] + + self.assertIn('Nativity of Christ', names) + # The Protection is a Slavic-only great feast. + self.assertNotIn('Protection (Pokrov) of the Most-Holy Theotokos', names) + + def test_last_supported_year(self): + """The Nativity Fast running past the last supported year is cut off + there instead of failing.""" + + _, seasons = liturgics.year_of_feasts(4099, calendar=Calendar.Julian, tradition=Tradition.Slavic) + self.assertEqual(seasons[-1].end, date(4099, 12, 31)) + + +class FeastsViewTestCase(TestCase): + fixtures = ['calendarium.json', 'commemorations.json'] + + def test_default(self): + response = self.client.get(reverse('feasts-default')) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['year'], timezone.localtime().year) + + def test_year(self): + url = reverse('feasts', kwargs={'tradition': 'slavic', 'cal': 'gregorian', 'year': 2027}) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'The Resurrection of our Lord and Savior Jesus Christ') + self.assertContains(response, 'Nativity Fast') + pascha = reverse('readings', kwargs={'tradition': 'slavic', 'cal': 'gregorian', 'year': 2027, 'month': 5, 'day': 2}) + self.assertContains(response, f'href="{pascha}"') + + # Transfiguration falls in the Dormition Fast; Pascha in no fast. + entries = {entry['feast'].date: entry['in_fast'] for entry in response.context['entries'] if 'feast' in entry} + self.assertTrue(entries[date(2027, 8, 6)]) + self.assertFalse(entries[date(2027, 5, 2)]) + + def test_julian_shows_neighboring_years(self): + url = reverse('feasts', kwargs={'tradition': 'slavic', 'cal': 'julian', 'year': 2027}) + response = self.client.get(url) + + self.assertContains(response, 'Nov 28, 2026') + self.assertContains(response, 'Jan 6, 2028') + + def test_remembers_calendar(self): + url = reverse('feasts', kwargs={'tradition': 'slavic', 'cal': 'julian', 'year': 2027}) + self.client.get(f'{url}?foo') # We send an argument to bypass caching + response = self.client.get(reverse('feasts-default')) + + self.assertEqual(response.context['cal'], Calendar.Julian) + + +class FeastsAPITestCase(TestCase): + fixtures = ['calendarium.json', 'commemorations.json'] + + def test_feasts(self): + response = self.client.get('/api/slavic/julian/feasts/2027/') + self.assertEqual(response.status_code, 200) + data = response.json() + + self.assertEqual(data['year'], 2027) + self.assertEqual(data['feasts'][0], { + 'date': '2027-01-07', + 'weekday': 4, + 'name': 'Nativity of Christ', + 'feast_level': 8, + 'great_feast': True, + 'pascha_distance': 270, + }) + self.assertEqual(data['fasts'][0], { + 'name': 'Nativity Fast', + 'fast_level': 5, + 'start': '2026-11-28', + 'end': '2027-01-06', + 'days': 40, + }) diff --git a/calendarium/urls.py b/calendarium/urls.py index 6a3e634..bf93eb5 100644 --- a/calendarium/urls.py +++ b/calendarium/urls.py @@ -22,5 +22,7 @@ path('calendar-embed////', views.calendar_embed_view, name='calendar-embed'), path('calendar-embed/', etag_date(views.calendar_embed_view), name='calendar-embed-default'), path('calendar/', etag_date(views.calendar_view), name='calendar-default'), + path('feasts////', cache(etag(views.feasts_view)), name='feasts'), + path('feasts/', etag_date(views.feasts_view), name='feasts-default'), path('', etag_date(views.readings_view), name='index'), ] diff --git a/calendarium/views.py b/calendarium/views.py index 98af893..ca0c69a 100644 --- a/calendarium/views.py +++ b/calendarium/views.py @@ -84,6 +84,41 @@ def calendar_view(request, cal=None, tradition=None, year=None, month=None): 'next_nofollow': not is_indexable(next_month), }) +@in_thread_pool +def feasts_view(request, cal=None, tradition=None, year=None): + tradition = remember_tradition(request, tradition) + cal = remember_cal(request, cal, tradition) + + if not year: + year = timezone.localtime().date().year + + try: + feasts, seasons = liturgics.year_of_feasts(year, calendar=cal, tradition=tradition) + except ValueError: + raise Http404 + + # One timeline for the year, each fasting season placed at its start. + # A season that began the year before sorts first. + entries = sorted( + [{'date': feast.date, 'feast': feast, + 'in_fast': any(season.start <= feast.date <= season.end for season in seasons)} + for feast in feasts] + + [{'date': season.start, 'season': season} for season in seasons], + key=lambda entry: (entry['date'], 'feast' in entry), + ) + + return render(request, 'feasts.html', context={ + 'entries': entries, + 'year': year, + 'cal': cal, + 'tradition': tradition, + 'noindex': not is_indexable(date(year, 1, 1)), + 'previous_year': year - 1, + 'previous_nofollow': not is_indexable(date(year - 1, 1, 1)), + 'next_year': year + 1, + 'next_nofollow': not is_indexable(date(year + 1, 1, 1)), + }) + @in_thread_pool def calendar_embed_view(request, cal=Calendar.Gregorian, tradition=Tradition.Slavic, year=None, month=None): if not year or not month: diff --git a/fixtures/calendarium.json b/fixtures/calendarium.json index b153578..61b90a3 100644 --- a/fixtures/calendarium.json +++ b/fixtures/calendarium.json @@ -15821,8 +15821,8 @@ "day": 16, "title": "", "subtitle": "", - "feast_name": "Holy Apostle and Evangelist Mathew", - "feast_level": 6, + "feast_name": "Holy Apostle and Evangelist Matthew", + "feast_level": 4, "service": 0, "service_note": "", "story": null, diff --git a/orthocal/sitemaps.py b/orthocal/sitemaps.py index 9963c20..b98ee57 100644 --- a/orthocal/sitemaps.py +++ b/orthocal/sitemaps.py @@ -40,6 +40,24 @@ def location(self, item): return reverse('calendar', kwargs={'year': item.year, 'month': item.month, 'cal': 'julian',}) +class FeastsSitemap(sitemaps.Sitemap): + """This year's feasts and the next few, for people planning ahead.""" + + priority = 0.75 + changefreq = 'monthly' + + def items(self): + year = timezone.localtime().year + return [(tradition, cal, year + offset) + for offset in range(-1, 3) + for tradition in ('slavic', 'greek') + for cal in ('gregorian', 'julian')] + + def location(self, item): + tradition, cal, year = item + return reverse('feasts', kwargs={'tradition': tradition, 'cal': cal, 'year': year}) + + class ReadingsSitemap(sitemaps.Sitemap): priority = 0.75 changefreq = 'monthly' diff --git a/orthocal/static/main.css b/orthocal/static/main.css index ac0f762..be62b83 100644 --- a/orthocal/static/main.css +++ b/orthocal/static/main.css @@ -47,6 +47,7 @@ --color-bg-input: #fff; --color-bg-table-cell: #eee9e0; --color-bg-table-fast: #dfd9cd; + --color-bg-in-fast: #efeae2; --color-bg-table-fast-hover: #dbd4c5; --color-bg-table-hover: #e6e1d5; @@ -125,6 +126,7 @@ --color-bg-input: #222222; --color-bg-table-cell: #141414; --color-bg-table-fast: #2c2c2c; + --color-bg-in-fast: #212121; --color-bg-table-fast-hover: #363636; --color-bg-table-hover: #242424; @@ -395,6 +397,13 @@ h1, h2, h3, h4 { padding: 0.4em 1em; } +/* Just above the hamburger breakpoint the full menu only fits beside the + title with tighter spacing. 1280px screens are a seventh of desktop visits. */ +@media (min-width: 1201px) and (max-width: 1300px) { + .topbar-inner > nav > ul { + gap: 1em; + } +} /* Hamburger menu -- hidden until the site nav would wrap */ .nav-toggle { display: none; @@ -1151,6 +1160,85 @@ p.day-number { } } +/* ========================================================================== + Feasts page -- the year's feasts and fasting seasons as one timeline. + ========================================================================== */ +main#orthocal p.feasts-key { + text-indent: 0; + text-align: center; + margin: 1.5em auto; + font-size: 0.85em; + color: var(--color-text-secondary); +} +ol.feast-list { + list-style: none; + max-width: 36em; + margin: 0 auto 2em auto; + padding: 0; +} +ol.feast-list li { + border-bottom: 1px solid var(--color-border-light); +} +ol.feast-list li > a, +ol.feast-list li.fast-season { + display: grid; + grid-template-columns: 8em 1fr; + gap: 0 1em; + align-items: baseline; + padding: 0.55em 0.5em; +} +ol.feast-list li > a { + color: var(--color-text); + text-decoration: none; + transition-property: background-color; + transition-duration: 0.25s; +} +ol.feast-list li > a:hover { + background-color: var(--color-bg-table-hover); +} +.feast-date { + font-family: var(--font-sans); + font-size: 0.85em; + color: var(--color-text-secondary); +} +.feast-weekday { + display: block; + font-size: 0.85em; +} +/* A fasting season's range wraps only after its dash. */ +.feast-date .date-part { + white-space: nowrap; +} +ol.feast-list li.great .feast-name { + color: var(--color-accent); + font-weight: bold; +} +ol.feast-list li.pascha .feast-name { + font-size: 1.25em; +} +ol.feast-list li.fast-season { + background-color: var(--color-bg-table-fast); +} +/* Feasts that fall within a fasting season, lighter than the season itself. */ +ol.feast-list li.in-fast { + background-color: var(--color-bg-in-fast); +} +ol.feast-list li.fast-season .feast-name { + font-style: italic; +} +.fast-days { + font-style: normal; + font-size: 0.8em; + color: var(--color-text-secondary); +} +@media (max-width: 600px) { + ol.feast-list li > a, + ol.feast-list li.fast-season { + grid-template-columns: 6.5em 1fr; + gap: 0 0.75em; + } +} + /* ========================================================================== Saint search page ========================================================================== */ diff --git a/orthocal/templates/base.html b/orthocal/templates/base.html index 4b76a96..ce031e2 100644 --- a/orthocal/templates/base.html +++ b/orthocal/templates/base.html @@ -64,6 +64,7 @@
  • Readings
  • Calendar
  • +
  • Feasts
  • Saints
  • diff --git a/orthocal/urls.py b/orthocal/urls.py index 0b8e190..d40dca8 100644 --- a/orthocal/urls.py +++ b/orthocal/urls.py @@ -30,6 +30,7 @@ 'static': sitemaps.StaticViewSitemap, 'calendar': sitemaps.CalendarSitemap, 'calendar-julian': sitemaps.CalendarJulianSitemap, + 'feasts': sitemaps.FeastsSitemap, 'readings': sitemaps.ReadingsSitemap, 'readings-julian': sitemaps.ReadingsJulianSitemap, }