diff --git a/CHANGELOG.md b/CHANGELOG.md index 162a0684ff..f88c15941e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ UNRELEASED * [ [#1989](https://github.com/digitalfabrik/integreat-cms/issues/1989) ] Improve load time of locations endpoint * [ [#1578](https://github.com/digitalfabrik/integreat-cms/issues/1578) ] Add option to hide files in global media library * [ [#1752](https://github.com/digitalfabrik/integreat-cms/issues/1752) ] Show chapter for internal link suggestions +* [ [#1965](https://github.com/digitalfabrik/integreat-cms/issues/1965) ] Add ical rrule for recurring events 2023.2.0 diff --git a/integreat_cms/api/v3/events.py b/integreat_cms/api/v3/events.py index bbc19487e5..837a7d0458 100644 --- a/integreat_cms/api/v3/events.py +++ b/integreat_cms/api/v3/events.py @@ -89,6 +89,9 @@ def transform_event_translation(event_translation, recurrence_date=None): "location": transform_poi(event.location), "event": transform_event(event, recurrence_date), "hash": None, + "recurrence_rule": event.recurrence_rule.to_ical_rrule_string() + if event.recurrence_rule + else None, } @@ -136,17 +139,17 @@ def transform_event_recurrences(event_translation, today): :return: An iterator over all future recurrences up to ``settings.API_EVENTS_MAX_TIME_SPAN_DAYS`` :rtype: Iterator[:class:`~datetime.date`] """ + event = event_translation.event recurrence_rule = event.recurrence_rule if not recurrence_rule: return - # In order to avoid unnecessary computations, check if any future event - # may be valid and return early if that is not the case - if ( + event_is_invalid = ( recurrence_rule.recurrence_end_date and recurrence_rule.recurrence_end_date < today - ): + ) + if event_is_invalid: return start_date = event.start_local.date() @@ -185,16 +188,17 @@ def events(request, region_slug, language_slug): region = request.region # Throw a 404 error when the language does not exist or is disabled region.get_language_or_404(language_slug, only_active=True) + result = [] now = timezone.now().date() for event in region.events.prefetch_public_translations().filter(archived=False): - event_translation = event.get_public_translation(language_slug) - if event_translation: - if event.end_local.date() >= now: + if event_translation := event.get_public_translation(language_slug): + # Either it's in the future or it's recurring and the last recurrence is >= now + if not event.is_past: result.append(transform_event_translation(event_translation)) - - for future_event in transform_event_recurrences(event_translation, now): - result.append(future_event) + if "combine_recurring" not in request.GET: + for future_event in transform_event_recurrences(event_translation, now): + result.append(future_event) return JsonResponse( result, safe=False diff --git a/integreat_cms/cms/models/events/event.py b/integreat_cms/cms/models/events/event.py index bbac1ad01e..c1b8eca49c 100644 --- a/integreat_cms/cms/models/events/event.py +++ b/integreat_cms/cms/models/events/event.py @@ -1,5 +1,4 @@ -from datetime import datetime, time, date -from dateutil.rrule import weekday, rrule +from datetime import time from django.db import models from django.db.models import Q @@ -9,7 +8,7 @@ from linkcheck.models import Link -from ...constants import frequency, status +from ...constants import status from ...utils.slug_utils import generate_unique_slug from ..abstract_content_model import AbstractContentModel, ContentQuerySet from ..media.media_file import MediaFile @@ -141,6 +140,22 @@ def is_recurring(self): """ return bool(self.recurrence_rule) + @cached_property + def is_past(self): + """ + This property checks whether an event lies in the past, including potential future recurrences. + + :return: Whether event lies in the past + :rtype: bool + """ + now = timezone.now() + duration = self.end_local - self.start_local + future_recurrence = ( + self.is_recurring + and self.recurrence_rule.to_ical_rrule().after(now - duration, True) + ) + return self.end_local.date() < now.date() and not future_recurrence + @cached_property def is_all_day(self): """ @@ -214,48 +229,8 @@ def get_occurrences(self, start, end): """ event_start = self.start event_end = self.end - event_span = event_end - event_start - recurrence = self.recurrence_rule - if recurrence is not None: - until = min( - end, - datetime.combine( - recurrence.recurrence_end_date - if recurrence.recurrence_end_date - else date.max, - time.max, - ), - ) - if recurrence.frequency in (frequency.DAILY, frequency.YEARLY): - occurrences = rrule( - recurrence.frequency, - dtstart=event_start, - interval=recurrence.interval, - until=until, - ) - elif recurrence.frequency == frequency.WEEKLY: - occurrences = rrule( - recurrence.frequency, - dtstart=event_start, - interval=recurrence.interval, - byweekday=recurrence.weekdays_for_weekly, - until=until, - ) - else: - occurrences = rrule( - recurrence.frequency, - dtstart=event_start, - interval=recurrence.interval, - byweekday=weekday( - recurrence.weekday_for_monthly, recurrence.week_for_monthly - ), - until=until, - ) - return [ - x - for x in occurrences - if start <= x <= end or start <= x + event_span <= end - ] + if self.is_recurring is not None: + return self.recurrence_rule.to_ical_rrule().between(start, end) return ( [event_start] if start <= event_start <= end or start <= event_end <= end diff --git a/integreat_cms/cms/models/events/recurrence_rule.py b/integreat_cms/cms/models/events/recurrence_rule.py index afe0e66bd1..9d110f9837 100644 --- a/integreat_cms/cms/models/events/recurrence_rule.py +++ b/integreat_cms/cms/models/events/recurrence_rule.py @@ -1,9 +1,13 @@ -from datetime import date, timedelta +import datetime +from datetime import date, datetime, time, timedelta +from dateutil import rrule + from django.contrib.postgres.fields import ArrayField from django.core.validators import MinValueValidator from django.db import models from django.utils.translation import gettext from django.utils.translation import gettext_lazy as _ +from django.utils.timezone import make_aware from ..abstract_base_model import AbstractBaseModel from ...constants import frequency, weekdays, weeks @@ -201,6 +205,44 @@ def get_repr(self): """ return f"" + def to_ical_rrule(self): + """ + Calculates the ical standardized rrule for a recurring rule. See details of the rrule here: + https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html + + :return: The ical rrule for the recurrence rule + :rtype: dateutil.rrule.rrule + """ + kwargs = {} + if self.frequency == frequency.WEEKLY: + kwargs["byweekday"] = self.weekdays_for_weekly + elif self.frequency == frequency.MONTHLY: + kwargs["byweekday"] = rrule.weekday( + self.weekday_for_monthly, self.week_for_monthly + ) + if self.recurrence_end_date: + kwargs["until"] = make_aware( + datetime.combine(self.recurrence_end_date, time.max), + self.event.start.tzinfo, + ) + + ical_rrule = rrule.rrule( + getattr(rrule, self.frequency), + dtstart=self.event.start, + interval=self.interval, + **kwargs, + ) + return ical_rrule + + def to_ical_rrule_string(self): + """ + Gets the iCal rrule as a string + + :return: The ical rrule for the recurrence rule as a string + :rtype: str + """ + return str(self.to_ical_rrule()) + class Meta: #: The verbose name of the model verbose_name = _("recurrence rule") diff --git a/sphinx/conf.py b/sphinx/conf.py index 7d1131374d..7e9ab2bb1a 100644 --- a/sphinx/conf.py +++ b/sphinx/conf.py @@ -67,6 +67,7 @@ #: Enable cross-references to other documentations intersphinx_mapping = { "aiohttp": ("https://docs.aiohttp.org/en/stable/", None), + "dateutil": ("https://dateutil.readthedocs.io/en/stable/", None), "geopy": ("https://geopy.readthedocs.io/en/stable/", None), "lxml": ("https://lxml.de/apidoc/", None), "python": ( diff --git a/tests/api/api_config.py b/tests/api/api_config.py index bd3ee9691c..547c895105 100644 --- a/tests/api/api_config.py +++ b/tests/api/api_config.py @@ -106,6 +106,12 @@ "tests/api/expected-outputs/augsburg_de_events.json", 200, ), + ( + "/api/augsburg/de/events/?combine_recurring=True", + None, + "tests/api/expected-outputs/augsburg_de_events_combine_recurring.json", + 200, + ), ( "/api/augsburg/en/events/", "/augsburg/en/wp-json/extensions/v3/events/", diff --git a/tests/api/expected-outputs/augsburg_ar_events.json b/tests/api/expected-outputs/augsburg_ar_events.json index b330d98ddc..77c0f5f2a8 100644 --- a/tests/api/expected-outputs/augsburg_ar_events.json +++ b/tests/api/expected-outputs/augsburg_ar_events.json @@ -50,7 +50,8 @@ "recurrence_id": 1, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20300101T120000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20310101T235959;BYDAY=TU,TH" }, { "id": null, @@ -103,7 +104,8 @@ "recurrence_id": 1, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20300101T120000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20310101T235959;BYDAY=TU,TH" }, { "id": null, @@ -156,7 +158,8 @@ "recurrence_id": 1, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20300101T120000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20310101T235959;BYDAY=TU,TH" }, { "id": null, @@ -209,7 +212,8 @@ "recurrence_id": 1, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20300101T120000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20310101T235959;BYDAY=TU,TH" }, { "id": null, @@ -262,7 +266,8 @@ "recurrence_id": 1, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20300101T120000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20310101T235959;BYDAY=TU,TH" }, { "id": null, @@ -315,6 +320,7 @@ "recurrence_id": 1, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20300101T120000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20310101T235959;BYDAY=TU,TH" } ] diff --git a/tests/api/expected-outputs/augsburg_de_events.json b/tests/api/expected-outputs/augsburg_de_events.json index 4d8a0df9d0..617e15ca0f 100644 --- a/tests/api/expected-outputs/augsburg_de_events.json +++ b/tests/api/expected-outputs/augsburg_de_events.json @@ -50,7 +50,8 @@ "recurrence_id": 1, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20300101T120000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20310101T235959;BYDAY=TU,TH" }, { "id": null, @@ -103,7 +104,8 @@ "recurrence_id": 1, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20300101T120000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20310101T235959;BYDAY=TU,TH" }, { "id": null, @@ -156,7 +158,8 @@ "recurrence_id": 1, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20300101T120000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20310101T235959;BYDAY=TU,TH" }, { "id": null, @@ -209,7 +212,8 @@ "recurrence_id": 1, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20300101T120000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20310101T235959;BYDAY=TU,TH" }, { "id": null, @@ -262,7 +266,8 @@ "recurrence_id": 1, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20300101T120000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20310101T235959;BYDAY=TU,TH" }, { "id": null, @@ -315,6 +320,7 @@ "recurrence_id": 1, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20300101T120000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20310101T235959;BYDAY=TU,TH" } ] \ No newline at end of file diff --git a/tests/api/expected-outputs/augsburg_de_events_combine_recurring.json b/tests/api/expected-outputs/augsburg_de_events_combine_recurring.json new file mode 100644 index 0000000000..e6363d2d38 --- /dev/null +++ b/tests/api/expected-outputs/augsburg_de_events_combine_recurring.json @@ -0,0 +1,56 @@ +[ + { + "id": 2, + "url": "http://localhost:8000/augsburg/de/events/test-veranstaltung/", + "path": "/augsburg/de/events/test-veranstaltung/", + "title": "Test-Veranstaltung", + "modified_gmt": "2020-01-22T12:46:33.967Z", + "last_updated": "2020-01-22T13:46:33.967+01:00", + "excerpt": "Dies ist die Beschreibung einer Test-Veranstaltung.", + "content": "

Dies ist die Beschreibung einer Test-Veranstaltung.

", + "available_languages": { + "en": { + "id": 3, + "url": "http://localhost:8000/augsburg/en/events/test-event/", + "path": "/augsburg/en/events/test-event/" + }, + "ar": { + "id": null, + "url": "http://localhost:8000/augsburg/ar/events/test-veranstaltung/", + "path": "/augsburg/ar/events/test-veranstaltung/" + }, + "fa": { + "id": null, + "url": "http://localhost:8000/augsburg/fa/events/test-veranstaltung/", + "path": "/augsburg/fa/events/test-veranstaltung/" + } + }, + "thumbnail": null, + "location": { + "id": null, + "name": null, + "address": null, + "town": null, + "state": null, + "postcode": null, + "region": null, + "country": null, + "latitude": null, + "longitude": null + }, + "event": { + "id": 1, + "start": "2030-01-01T13:00:00+01:00", + "start_date": "2030-01-01", + "start_time": "13:00:00", + "end": "2030-01-01T15:00:00+01:00", + "end_date": "2030-01-01", + "end_time": "15:00:00", + "all_day": false, + "recurrence_id": 1, + "timezone": "Europe/Berlin" + }, + "hash": null, + "recurrence_rule": "DTSTART:20300101T120000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20310101T235959;BYDAY=TU,TH" + } +] \ No newline at end of file diff --git a/tests/api/expected-outputs/augsburg_en_events.json b/tests/api/expected-outputs/augsburg_en_events.json index 7e68b28ab4..f59037466f 100644 --- a/tests/api/expected-outputs/augsburg_en_events.json +++ b/tests/api/expected-outputs/augsburg_en_events.json @@ -50,7 +50,8 @@ "recurrence_id": 1, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20300101T120000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20310101T235959;BYDAY=TU,TH" }, { "id": null, @@ -103,7 +104,8 @@ "recurrence_id": 1, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20300101T120000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20310101T235959;BYDAY=TU,TH" }, { "id": null, @@ -156,7 +158,8 @@ "recurrence_id": 1, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20300101T120000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20310101T235959;BYDAY=TU,TH" }, { "id": null, @@ -209,7 +212,8 @@ "recurrence_id": 1, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20300101T120000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20310101T235959;BYDAY=TU,TH" }, { "id": null, @@ -262,7 +266,8 @@ "recurrence_id": 1, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20300101T120000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20310101T235959;BYDAY=TU,TH" }, { "id": null, @@ -315,6 +320,7 @@ "recurrence_id": 1, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20300101T120000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20310101T235959;BYDAY=TU,TH" } ] \ No newline at end of file diff --git a/tests/api/expected-outputs/nurnberg_de_events.json b/tests/api/expected-outputs/nurnberg_de_events.json index 773cb1583d..309b2e7bf0 100644 --- a/tests/api/expected-outputs/nurnberg_de_events.json +++ b/tests/api/expected-outputs/nurnberg_de_events.json @@ -40,7 +40,8 @@ "recurrence_id": 2, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20310101T140000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20320101T235959;BYDAY=WE,FR" }, { "id": null, @@ -83,7 +84,8 @@ "recurrence_id": 2, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20310101T140000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20320101T235959;BYDAY=WE,FR" }, { "id": null, @@ -126,7 +128,8 @@ "recurrence_id": 2, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20310101T140000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20320101T235959;BYDAY=WE,FR" }, { "id": null, @@ -169,7 +172,8 @@ "recurrence_id": 2, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20310101T140000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20320101T235959;BYDAY=WE,FR" }, { "id": null, @@ -212,7 +216,8 @@ "recurrence_id": 2, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20310101T140000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20320101T235959;BYDAY=WE,FR" }, { "id": null, @@ -255,6 +260,7 @@ "recurrence_id": 2, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20310101T140000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20320101T235959;BYDAY=WE,FR" } ] \ No newline at end of file diff --git a/tests/api/expected-outputs/nurnberg_en_events.json b/tests/api/expected-outputs/nurnberg_en_events.json index 5a6df5385f..1b7c008faf 100644 --- a/tests/api/expected-outputs/nurnberg_en_events.json +++ b/tests/api/expected-outputs/nurnberg_en_events.json @@ -40,7 +40,8 @@ "recurrence_id": 2, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20310101T140000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20320101T235959;BYDAY=WE,FR" }, { "id": null, @@ -83,7 +84,8 @@ "recurrence_id": 2, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20310101T140000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20320101T235959;BYDAY=WE,FR" }, { "id": null, @@ -126,7 +128,8 @@ "recurrence_id": 2, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20310101T140000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20320101T235959;BYDAY=WE,FR" }, { "id": null, @@ -169,7 +172,8 @@ "recurrence_id": 2, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20310101T140000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20320101T235959;BYDAY=WE,FR" }, { "id": null, @@ -212,7 +216,8 @@ "recurrence_id": 2, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20310101T140000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20320101T235959;BYDAY=WE,FR" }, { "id": null, @@ -255,6 +260,7 @@ "recurrence_id": 2, "timezone": "Europe/Berlin" }, - "hash": null + "hash": null, + "recurrence_rule": "DTSTART:20310101T140000\nRRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20320101T235959;BYDAY=WE,FR" } ] \ No newline at end of file diff --git a/tests/api/test_api_result.py b/tests/api/test_api_result.py index c041555195..b2f82655e5 100644 --- a/tests/api/test_api_result.py +++ b/tests/api/test_api_result.py @@ -38,10 +38,12 @@ def test_api_result( response = client.get(endpoint, format="json") print(response.headers) assert response.status_code == expected_code - response_wp = client.get(wp_endpoint, format="json") - print(response_wp.headers) - assert response_wp.status_code == expected_code + if wp_endpoint: + response_wp = client.get(wp_endpoint, format="json") + print(response_wp.headers) + assert response_wp.status_code == expected_code with open(expected_result, encoding="utf-8") as f: result = json.load(f) assert result == response.json() - assert result == response_wp.json() + if wp_endpoint: + assert result == response_wp.json() diff --git a/tests/cms/models/events/test_recurrence_rule.py b/tests/cms/models/events/test_recurrence_rule.py new file mode 100644 index 0000000000..d4b8ab8ceb --- /dev/null +++ b/tests/cms/models/events/test_recurrence_rule.py @@ -0,0 +1,107 @@ +""" +Test module for RecurrenceRule class +""" +import datetime +import pytz + +from integreat_cms.cms.models import RecurrenceRule, Event + + +# pylint: disable=missing-docstring + + +class TestCreatingIcalRule: + """ + Test whether to_ical_rrule_string function is calculating the rrule correctly + """ + + test_event = Event( + start=datetime.datetime(2030, 1, 1, 11, 30, 0, 0, pytz.UTC), + end=datetime.datetime(2030, 1, 1, 12, 30, 0, 0, pytz.UTC), + ) + + def test_api_rrule_every_year_start_date_in_the_past(self): + 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, pytz.UTC), + end=datetime.datetime(2030, 1, 1, 12, 30, 0, 0, pytz.UTC), + ) + test_event.recurrence_rule = recurrence_rule + ical_rrule = recurrence_rule.to_ical_rrule_string() + assert ical_rrule == "DTSTART:20200101T113000\nRRULE:FREQ=YEARLY" + + def check_rrule(self, recurrence_rule, expected): + self.test_event.recurrence_rule = recurrence_rule + ical_rrule = recurrence_rule.to_ical_rrule_string() + assert ical_rrule == expected + + def test_api_rrule_every_three_days(self): + recurrence_rule = RecurrenceRule( + frequency="DAILY", + interval=3, + weekdays_for_weekly=None, + weekday_for_monthly=None, + week_for_monthly=None, + recurrence_end_date=None, + ) + self.check_rrule( + recurrence_rule, "DTSTART:20300101T113000\nRRULE:FREQ=DAILY;INTERVAL=3" + ) + + def test_api_rrule_weekly(self): + recurrence_rule = RecurrenceRule( + frequency="WEEKLY", + interval=1, + weekdays_for_weekly=[0, 1], + weekday_for_monthly=None, + week_for_monthly=None, + recurrence_end_date=None, + ) + self.check_rrule( + recurrence_rule, "DTSTART:20300101T113000\nRRULE:FREQ=WEEKLY;BYDAY=MO,TU" + ) + + def test_api_rrule_monthly(self): + recurrence_rule = RecurrenceRule( + frequency="MONTHLY", + interval=1, + weekdays_for_weekly=None, + weekday_for_monthly=4, + week_for_monthly=1, + recurrence_end_date=None, + ) + self.check_rrule( + recurrence_rule, "DTSTART:20300101T113000\nRRULE:FREQ=MONTHLY;BYDAY=+1FR" + ) + + def test_api_rrule_bimonthly_until(self): + recurrence_rule = RecurrenceRule( + frequency="MONTHLY", + interval=2, + weekdays_for_weekly=None, + weekday_for_monthly=6, + week_for_monthly=1, + recurrence_end_date=datetime.date(2030, 10, 19), + ) + self.check_rrule( + recurrence_rule, + "DTSTART:20300101T113000\nRRULE:FREQ=MONTHLY;INTERVAL=2;UNTIL=20301019T235959;BYDAY=+1SU", + ) + + def test_api_rrule_yearly(self): + recurrence_rule = RecurrenceRule( + frequency="YEARLY", + interval=1, + weekdays_for_weekly=None, + weekday_for_monthly=None, + week_for_monthly=None, + recurrence_end_date=None, + ) + self.check_rrule(recurrence_rule, "DTSTART:20300101T113000\nRRULE:FREQ=YEARLY")