diff --git a/integreat_cms/cms/constants/weeks.py b/integreat_cms/cms/constants/weeks.py index 84bb56b1b3..5a5220ef61 100644 --- a/integreat_cms/cms/constants/weeks.py +++ b/integreat_cms/cms/constants/weeks.py @@ -22,8 +22,10 @@ THIRD: Final = 3 #: Fourth week of the month FOURTH: Final = 4 -#: Last week of the month (either 4th or 5th) -LAST: Final = 5 +#: Last week of the month +LAST: Final = -1 +#: Second Last week of the month +SECOND_LAST: Final = -2 #: Choices to use these constants in a database field CHOICES: Final[list[tuple[int, Promise]]] = [ @@ -32,13 +34,5 @@ (THIRD, _("Third week")), (FOURTH, _("Fourth week")), (LAST, _("Last week")), + (SECOND_LAST, _("Second last week")), ] - -#: A mapping from our week constants to the expected rrule values -WEEK_TO_RRULE_WEEK = { - FIRST: 1, - SECOND: 2, - THIRD: 3, - FOURTH: 4, - LAST: -1, -} diff --git a/integreat_cms/cms/migrations/0099_alter_recurrencerule_week_for_monthly.py b/integreat_cms/cms/migrations/0099_alter_recurrencerule_week_for_monthly.py new file mode 100644 index 0000000000..796fb8169d --- /dev/null +++ b/integreat_cms/cms/migrations/0099_alter_recurrencerule_week_for_monthly.py @@ -0,0 +1,82 @@ +# Generated by Django 4.2.13 on 2024-08-27 09:08 + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.db import migrations, models + +from ..constants import frequency + +if TYPE_CHECKING: + from django.apps.registry import Apps + from django.db.backends.base.schema import BaseDatabaseSchemaEditor as SchemaEditor + + +# pylint: disable=unused-argument +def forwards_func(apps: Apps, schema_editor: SchemaEditor) -> None: + """ + Adopting the old data when applying this migration + :param apps: The configuration of installed applications + :type apps: ~django.apps.registry.Apps + :param schema_editor: The database abstraction layer that creates actual SQL code + :type schema_editor: ~django.db.backends.base.schema.BaseDatabaseSchemaEditor + """ + RecurrenceRule = apps.get_model("cms", "RecurrenceRule") + for rr in RecurrenceRule.objects.filter( + frequency=frequency.MONTHLY, week_for_monthly=5 + ): + rr.week_for_monthly = -1 + rr.save() + + +# pylint: disable=unused-argument +def reverse_func(apps: Apps, schema_editor: SchemaEditor) -> None: + """ + Reverting (most of the) newer data when reverting this migration + :param apps: The configuration of installed applications + :type apps: ~django.apps.registry.Apps + :param schema_editor: The database abstraction layer that creates actual SQL code + :type schema_editor: ~django.db.backends.base.schema.BaseDatabaseSchemaEditor + """ + RecurrenceRule = apps.get_model("cms", "RecurrenceRule") + for rr in RecurrenceRule.objects.filter( + frequency=frequency.MONTHLY, week_for_monthly__lt=0 + ): + if rr.week_for_monthly == -1: + rr.week_for_monthly = 5 + else: + rr.week_for_monthly = 3 + rr.save() + + +class Migration(migrations.Migration): + """ + Remove 5 as designation for last week, use -1 (and -2) instead. + """ + + dependencies = [ + ("cms", "0098_add_contact"), + ] + + operations = [ + migrations.AlterField( + model_name="recurrencerule", + name="week_for_monthly", + field=models.IntegerField( + blank=True, + choices=[ + (1, "First week"), + (2, "Second week"), + (3, "Third week"), + (4, "Fourth week"), + (-1, "Last week"), + (-2, "Second last week"), + ], + help_text="If the frequency is monthly, this field determines on which week of the month the event takes place", + null=True, + verbose_name="week", + ), + ), + migrations.RunPython(forwards_func, reverse_func), + ] diff --git a/integreat_cms/cms/models/events/recurrence_rule.py b/integreat_cms/cms/models/events/recurrence_rule.py index ed6c39e132..cc70c82f5f 100644 --- a/integreat_cms/cms/models/events/recurrence_rule.py +++ b/integreat_cms/cms/models/events/recurrence_rule.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import date, datetime, time, timedelta +from functools import reduce from typing import TYPE_CHECKING from dateutil import rrule @@ -86,7 +87,7 @@ def iter_after(self, start_date: date) -> Iterator[date]: """ next_recurrence = start_date - def get_nth_weekday(month_date: date, weekday: int, n: int) -> date: + def get_nth_weekday(month_date: date, weekday: int, n: int) -> date | None: """ Get the nth occurrence of a given weekday in a specific month @@ -97,10 +98,18 @@ def get_nth_weekday(month_date: date, weekday: int, n: int) -> date: """ month_date = month_date.replace(day=1) month_date += timedelta((weekday - month_date.weekday()) % 7) - n_th_occurrence = month_date + timedelta(weeks=n - 1) - # If the occurrence is not in the desired month (because the last week is 4 and not 5), retry with 4 + if n < 0: + # Move past last occurence of date within month to properly count from the end + n_th_occurrence = month_date + timedelta(weeks=3) + while n_th_occurrence.month == month_date.month: + n_th_occurrence += timedelta(weeks=1) + # We add the timedelta since n is already negative + n_th_occurrence += timedelta(weeks=n) + else: + n_th_occurrence = month_date + timedelta(weeks=n - 1) + # If the occurrence is not in the desired month we have no valid result if n_th_occurrence.month != month_date.month: - n_th_occurrence = month_date + timedelta(weeks=n - 2) + return None return n_th_occurrence def next_month(month_date: date) -> date: @@ -139,15 +148,21 @@ def advance() -> Iterator[date]: # advance to the next monday next_recurrence += timedelta(days=7 - next_recurrence.weekday()) elif self.frequency == frequency.MONTHLY: - next_recurrence = get_nth_weekday( - next_recurrence, self.weekday_for_monthly, self.week_for_monthly - ) - if next_recurrence < start_date: - next_recurrence = get_nth_weekday( - next_month(next_recurrence), + candidate = None + month_dif = 0 + while candidate is None or candidate < start_date: + candidate = get_nth_weekday( + # Apply next_month() to original date month_dif times + reduce( + lambda x, _: next_month(x), + range(month_dif), + next_recurrence, + ), self.weekday_for_monthly, self.week_for_monthly, ) + month_dif += 1 + next_recurrence = candidate yield next_recurrence next_recurrence = next_month(next_recurrence) elif self.frequency == frequency.YEARLY: @@ -207,7 +222,7 @@ def to_ical_rrule(self) -> rrule.rrule: elif self.frequency == frequency.MONTHLY: kwargs["byweekday"] = rrule.weekday( self.weekday_for_monthly, - weeks.WEEK_TO_RRULE_WEEK[self.week_for_monthly], + self.week_for_monthly, ) if self.recurrence_end_date: kwargs["until"] = make_aware( diff --git a/integreat_cms/locale/de/LC_MESSAGES/django.po b/integreat_cms/locale/de/LC_MESSAGES/django.po index 27a840cf1e..9e1b1a897a 100644 --- a/integreat_cms/locale/de/LC_MESSAGES/django.po +++ b/integreat_cms/locale/de/LC_MESSAGES/django.po @@ -1798,6 +1798,10 @@ msgstr "Vierte Woche" msgid "Last week" msgstr "Letzte Woche" +#: cms/constants/weeks.py +msgid "Second last week" +msgstr "Vorletzte Woche" + #: cms/forms/custom_content_model_form.py msgid "" "Could not update because this content because it is already being edited by " diff --git a/tests/cms/models/events/test_recurrence_rule.py b/tests/cms/models/events/test_recurrence_rule.py index 18d682f0a4..f8a579fbe9 100644 --- a/tests/cms/models/events/test_recurrence_rule.py +++ b/tests/cms/models/events/test_recurrence_rule.py @@ -8,16 +8,21 @@ from typing import TYPE_CHECKING from zoneinfo import ZoneInfo +import pytest + from integreat_cms.cms.constants import weekdays, weeks from integreat_cms.cms.models import Event, RecurrenceRule if TYPE_CHECKING: + from datetime import date + from typing import Iterable + from rrule import rrule class TestCreatingIcalRule: """ - Test whether to_ical_rrule_string function is calculating the rrule correctly + Test whether :fun:`~integreat_cms.cms.models.events.recurrence_rule.RecurrenceRule.to_ical_rrule_string` function is calculating the rrule correctly """ test_event = Event( @@ -99,6 +104,19 @@ def test_api_rrule_last_week_in_month(self) -> None: recurrence_rule, "DTSTART:20300101T113000\nRRULE:FREQ=MONTHLY;BYDAY=-1WE" ) + def test_api_rrule_second_last_week_in_month(self) -> None: + recurrence_rule = RecurrenceRule( + frequency="MONTHLY", + interval=1, + weekdays_for_weekly=None, + weekday_for_monthly=weekdays.WEDNESDAY, + week_for_monthly=weeks.SECOND_LAST, + recurrence_end_date=None, + ) + self.check_rrule( + recurrence_rule, "DTSTART:20300101T113000\nRRULE:FREQ=MONTHLY;BYDAY=-2WE" + ) + def test_api_rrule_bimonthly_until(self) -> None: recurrence_rule = RecurrenceRule( frequency="MONTHLY", @@ -123,3 +141,255 @@ def test_api_rrule_yearly(self) -> None: recurrence_end_date=None, ) self.check_rrule(recurrence_rule, "DTSTART:20300101T113000\nRRULE:FREQ=YEARLY") + + +class TestGeneratingEventsInPython: + """ + Test whether :fun:`~integreat_cms.cms.models.events.recurrence_rule.RecurrenceRule.iter_after` is generating event instances correctly + """ + + test_event = Event( + start=datetime.datetime(2030, 1, 1, 11, 30, 0, 0, ZoneInfo("UTC")), + end=datetime.datetime(2030, 1, 1, 12, 30, 0, 0, ZoneInfo("UTC")), + ) + + def expect_first_items_start( + self, + recurrence_rule: RecurrenceRule, + start_times: Iterable[date], + event: Event | None = None, + ) -> None: + if event is None: + event = self.test_event + event.recurrence_rule = recurrence_rule + gen = recurrence_rule.iter_after(event.start.date()) + for start_time in start_times: + assert next(gen) == start_time + + def expect_finite_items_start( + self, + recurrence_rule: RecurrenceRule, + start_times: Iterable[date], + event: Event | None = None, + ) -> None: + if event is None: + event = self.test_event + event.recurrence_rule = recurrence_rule + gen = recurrence_rule.iter_after(event.start.date()) + for start_time in start_times: + assert next(gen) == start_time + with pytest.raises(StopIteration): + assert next(gen) is None + + def test_api_rrule_every_year_start_date_in_the_past(self) -> None: + recurrence_rule = RecurrenceRule( + frequency="YEARLY", + interval=1, + weekdays_for_weekly=None, + weekday_for_monthly=None, + week_for_monthly=None, + recurrence_end_date=None, + ) + test_event = Event( + start=datetime.datetime(2020, 1, 1, 11, 30, 0, 0, ZoneInfo("UTC")), + end=datetime.datetime(2030, 1, 1, 12, 30, 0, 0, ZoneInfo("UTC")), + ) + self.expect_first_items_start( + recurrence_rule, + [ + datetime.date(2020, 1, 1), + datetime.date(2021, 1, 1), + datetime.date(2022, 1, 1), + datetime.date(2023, 1, 1), + datetime.date(2024, 1, 1), + datetime.date(2025, 1, 1), + datetime.date(2026, 1, 1), + datetime.date(2027, 1, 1), + datetime.date(2028, 1, 1), + datetime.date(2029, 1, 1), + datetime.date(2030, 1, 1), + ], + event=test_event, + ) + + def test_api_rrule_every_three_days(self) -> None: + recurrence_rule = RecurrenceRule( + frequency="DAILY", + interval=3, + weekdays_for_weekly=None, + weekday_for_monthly=None, + week_for_monthly=None, + recurrence_end_date=None, + ) + self.expect_first_items_start( + recurrence_rule, + [ + datetime.date(2030, 1, 1), + datetime.date(2030, 1, 4), + datetime.date(2030, 1, 7), + datetime.date(2030, 1, 10), + datetime.date(2030, 1, 13), + datetime.date(2030, 1, 16), + datetime.date(2030, 1, 19), + datetime.date(2030, 1, 22), + datetime.date(2030, 1, 25), + datetime.date(2030, 1, 28), + datetime.date(2030, 1, 31), + datetime.date(2030, 2, 3), + datetime.date(2030, 2, 6), + datetime.date(2030, 2, 9), + datetime.date(2030, 2, 12), + ], + ) + + def test_api_rrule_weekly(self) -> None: + recurrence_rule = RecurrenceRule( + frequency="WEEKLY", + interval=1, + weekdays_for_weekly=[weekdays.MONDAY, weekdays.TUESDAY], + weekday_for_monthly=None, + week_for_monthly=None, + recurrence_end_date=None, + ) + self.expect_first_items_start( + recurrence_rule, + [ + datetime.date(2030, 1, 1), + datetime.date(2030, 1, 7), + datetime.date(2030, 1, 8), + datetime.date(2030, 1, 14), + datetime.date(2030, 1, 15), + datetime.date(2030, 1, 21), + datetime.date(2030, 1, 22), + datetime.date(2030, 1, 28), + datetime.date(2030, 1, 29), + datetime.date(2030, 2, 4), + datetime.date(2030, 2, 5), + datetime.date(2030, 2, 11), + datetime.date(2030, 2, 12), + ], + ) + + def test_api_rrule_monthly(self) -> None: + recurrence_rule = RecurrenceRule( + frequency="MONTHLY", + interval=1, + weekdays_for_weekly=None, + weekday_for_monthly=weekdays.FRIDAY, + week_for_monthly=weeks.FIRST, + recurrence_end_date=None, + ) + self.expect_first_items_start( + recurrence_rule, + [ + datetime.date(2030, 1, 4), + datetime.date(2030, 2, 1), + datetime.date(2030, 3, 1), + datetime.date(2030, 4, 5), + datetime.date(2030, 5, 3), + datetime.date(2030, 6, 7), + datetime.date(2030, 7, 5), + datetime.date(2030, 8, 2), + datetime.date(2030, 9, 6), + datetime.date(2030, 10, 4), + datetime.date(2030, 11, 1), + datetime.date(2030, 12, 6), + ], + ) + + def test_api_rrule_last_week_in_month(self) -> None: + recurrence_rule = RecurrenceRule( + frequency="MONTHLY", + interval=1, + weekdays_for_weekly=None, + weekday_for_monthly=weekdays.WEDNESDAY, + week_for_monthly=weeks.LAST, + recurrence_end_date=None, + ) + self.expect_first_items_start( + recurrence_rule, + [ + datetime.date(2030, 1, 30), + datetime.date(2030, 2, 27), + datetime.date(2030, 3, 27), + datetime.date(2030, 4, 24), + datetime.date(2030, 5, 29), + datetime.date(2030, 6, 26), + datetime.date(2030, 7, 31), + datetime.date(2030, 8, 28), + datetime.date(2030, 9, 25), + datetime.date(2030, 10, 30), + ], + ) + + def test_api_rrule_second_last_week_in_month(self) -> None: + recurrence_rule = RecurrenceRule( + frequency="MONTHLY", + interval=1, + weekdays_for_weekly=None, + weekday_for_monthly=weekdays.WEDNESDAY, + week_for_monthly=weeks.SECOND_LAST, + recurrence_end_date=None, + ) + self.expect_first_items_start( + recurrence_rule, + [ + datetime.date(2030, 1, 23), + datetime.date(2030, 2, 20), + datetime.date(2030, 3, 20), + datetime.date(2030, 4, 17), + datetime.date(2030, 5, 22), + datetime.date(2030, 6, 19), + datetime.date(2030, 7, 24), + datetime.date(2030, 8, 21), + datetime.date(2030, 9, 18), + datetime.date(2030, 10, 23), + ], + ) + + def test_api_rrule_bimonthly_until(self) -> None: + recurrence_rule = RecurrenceRule( + frequency="MONTHLY", + interval=2, + weekdays_for_weekly=None, + weekday_for_monthly=weekdays.SUNDAY, + week_for_monthly=weeks.FIRST, + recurrence_end_date=datetime.date(2030, 10, 19), + ) + self.expect_finite_items_start( + recurrence_rule, + [ + datetime.date(2030, 1, 6), + datetime.date(2030, 3, 3), + datetime.date(2030, 5, 5), + datetime.date(2030, 7, 7), + datetime.date(2030, 9, 1), + ], + ) + + def test_api_rrule_yearly(self) -> None: + recurrence_rule = RecurrenceRule( + frequency="YEARLY", + interval=1, + weekdays_for_weekly=None, + weekday_for_monthly=None, + week_for_monthly=None, + recurrence_end_date=None, + ) + self.expect_first_items_start( + recurrence_rule, + [ + datetime.date(2030, 1, 1), + datetime.date(2031, 1, 1), + datetime.date(2032, 1, 1), + datetime.date(2033, 1, 1), + datetime.date(2034, 1, 1), + datetime.date(2035, 1, 1), + datetime.date(2036, 1, 1), + datetime.date(2037, 1, 1), + datetime.date(2038, 1, 1), + datetime.date(2039, 1, 1), + datetime.date(2040, 1, 1), + datetime.date(2041, 1, 1), + ], + )