Skip to content

Commit

Permalink
Merge pull request #2029 from digitalfabrik/feature/combine-recurring…
Browse files Browse the repository at this point in the history
…-events

Combine recurring events and add rrule
  • Loading branch information
ztefanie authored Feb 21, 2023
2 parents fa69ef0 + ff1518b commit 2bfdda5
Show file tree
Hide file tree
Showing 14 changed files with 314 additions and 90 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 14 additions & 10 deletions integreat_cms/api/v3/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}


Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
65 changes: 20 additions & 45 deletions integreat_cms/cms/models/events/event.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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
Expand Down
44 changes: 43 additions & 1 deletion integreat_cms/cms/models/events/recurrence_rule.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -201,6 +205,44 @@ def get_repr(self):
"""
return f"<RecurrenceRule (id: {self.id}, event: {self.event.best_translation.slug})>"

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")
Expand Down
1 change: 1 addition & 0 deletions sphinx/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": (
Expand Down
6 changes: 6 additions & 0 deletions tests/api/api_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
18 changes: 12 additions & 6 deletions tests/api/expected-outputs/augsburg_ar_events.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
}
]
18 changes: 12 additions & 6 deletions tests/api/expected-outputs/augsburg_de_events.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
}
]
Loading

0 comments on commit 2bfdda5

Please sign in to comment.