feat: handle timezone and recurring events

pull/95/head
Martin Eigenmann 2021-11-11 23:03:02 +01:00
rodzic c368ad8ec4
commit eaaa26320e
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 069D1EE3806CA368
8 zmienionych plików z 2576 dodań i 41 usunięć

Wyświetl plik

@ -14,6 +14,7 @@ from icalendar import Calendar
from icalendar.windows_to_olson import WINDOWS_TO_OLSON
from icalendar.prop import vDDDLists, vText
from pytz import timezone
from uuid import uuid4
def now():
@ -47,6 +48,7 @@ class Event:
self.created = None
self.last_modified = None
self.sequence = None
self.recurrence_id = None
self.attendee = None
self.organizer = None
self.categories = None
@ -199,6 +201,8 @@ def create_event(component, tz=UTC):
if component.get("uid"):
event.uid = component.get("uid").encode("utf-8").decode("ascii")
else:
event.uid = str(uuid4()) # Be nice - treat every event as unique
if component.get("organizer"):
event.organizer = component.get("organizer").encode("utf-8").decode("ascii")
@ -215,6 +219,9 @@ def create_event(component, tz=UTC):
if component.get("created"):
event.created = normalize(component.get("created").dt, tz)
if component.get("RECURRENCE-ID"):
event.recurrence_id = normalize(component.get("RECURRENCE-ID").dt, tz)
if component.get("last-modified"):
event.last_modified = normalize(component.get("last-modified").dt, tz)
elif event.created:
@ -342,19 +349,13 @@ def parse_events(content, start=None, end=None, default_span=timedelta(days=7)):
end = normalize(end, cal_tz)
found = []
recurrence_ids = []
# Skip dates that are stored as exceptions.
exceptions = {}
for component in calendar.walk():
# Skip dates that are stored as exceptions.
exceptions = {}
if component.name == "VEVENT":
e = create_event(component, cal_tz)
if "RECURRENCE-ID" in component:
recurrence_ids.append(
(e.uid, component["RECURRENCE-ID"].dt, e.sequence)
)
if "EXDATE" in component:
# Deal with the fact that sometimes it's a list and
# sometimes it's a singleton
@ -371,7 +372,6 @@ def parse_events(content, start=None, end=None, default_span=timedelta(days=7)):
# and end times. If the timezone is defined in the calendar,
# use it; otherwise, attempt to load the rules from pytz.
start_tz = None
end_tz = None
if e.start.tzinfo != UTC:
if str(e.start.tzinfo) in timezones:
@ -379,12 +379,6 @@ def parse_events(content, start=None, end=None, default_span=timedelta(days=7)):
else:
start_tz = e.start.tzinfo
if e.end.tzinfo != UTC:
if str(e.end.tzinfo) in timezones:
end_tz = timezones[str(e.end.tzinfo)]
else:
end_tz = e.end.tzinfo
# If we've been passed or constructed start/end values
# that are timezone naive, but the actual appointment
# start and end times are in a timezone, convert start
@ -397,12 +391,16 @@ def parse_events(content, start=None, end=None, default_span=timedelta(days=7)):
end = normalize(end, e.start.tzinfo)
duration = e.end - e.start
event_start = component.get("dtstart").dt
rule_start_time_zone = cal_tz
if type(event_start) is datetime and event_start.tzinfo:
rule_start_time_zone = component.get("dtstart").dt.tzinfo
if e.recurring:
# Unfold recurring events according to their rrule
rule = parse_rrule(component, cal_tz)
[after] = adjust_timezone(component, [start - duration], start_tz)
rule = parse_rrule(component, rule_start_time_zone)
[after] = adjust_timezone(component, [start], start_tz)
[end] = adjust_timezone(component, [end], start_tz)
for dt in rule.between(after, end, inc=True):
if start_tz is None:
# Shrug. If we couldn't work out the timezone, it is what it is.
@ -415,7 +413,6 @@ def parse_events(content, start=None, end=None, default_span=timedelta(days=7)):
dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second
)
dtstart = normalize(naive, tz=start_tz)
ecopy = e.copy_to(dtstart, e.uid)
# We're effectively looping over the start time, we might need
@ -430,19 +427,23 @@ def parse_events(content, start=None, end=None, default_span=timedelta(days=7)):
ecopy.start.month,
ecopy.start.day,
)
if exdate not in exceptions:
found.append(ecopy)
elif e.end >= start and e.start <= end:
exdate = "%04d%02d%02d" % (e.start.year, e.start.month, e.start.day)
if exdate not in exceptions:
found.append(e)
# Filter out all events that are moved as indicated by the recurrence-id prop
return [
event
for event in found
if e.sequence is None
or not (event.uid, event.start, e.sequence) in recurrence_ids
]
result = found.copy()
for event in found:
if not event.recurrence_id and (event.uid, event.start) in [
(f.uid, f.recurrence_id) for f in found
]:
result.remove(event)
return result
def parse_rrule(component, tz=UTC):
@ -469,25 +470,37 @@ def parse_rrule(component, tz=UTC):
# Remove/add timezone to rrule until dates depending on component
for index, rru in enumerate(rrules):
if "UNTIL" in rru:
rrules[index]["UNTIL"] = adjust_timezone(component, rru["UNTIL"], tz)
if type(rdtstart) is date:
rrules[index]["UNTIL"] = [
normalize(until, tz).date() for until in rrules[index]["UNTIL"]
]
else:
# Handle summer/winter time
rrules[index]["UNTIL"] = [
normalize(until, UTC)
+ tz.utcoffset(component["dtstart"].dt, is_dst=True)
for until in rrules[index]["UNTIL"]
]
# Parse the rrules, might return a rruleset instance, instead of rrule
rule = rrulestr(
"\n".join(x.to_ical().decode() for x in rrules), dtstart=rdtstart
"\n".join(x.to_ical().decode() for x in rrules),
dtstart=rdtstart,
forceset=True,
unfold=True,
)
if component.get("exdate"):
# Make sure, to work with a rruleset
if isinstance(rule, rrule):
rules = rruleset()
rules.rrule(rule)
rule = rules
# Add exdates to the rruleset
for exd in extract_exdates(component):
rule.exdate(exd)
# TODO: What about rdates and exrules?
if component.get("exrule"):
pass
if component.get("rdate"):
pass
# You really want an rrule for a component without rrule? Here you are.
else:

Plik diff jest za duży Load Diff

Wyświetl plik

@ -0,0 +1,43 @@
BEGIN:VCALENDAR
METHOD:PUBLISH
PRODID:Microsoft Exchange Server 2010
VERSION:2.0
X-WR-CALNAME:test
BEGIN:VTIMEZONE
TZID:W. Europe Standard Time
BEGIN:STANDARD
DTSTART:16010101T030000
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:16010101T020000
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
RRULE:FREQ=DAILY;UNTIL=20221111T093000Z;INTERVAL=1
UID:040000008200E00074C5B7101A82E00800000000B47AD3E5FED6D701000000000000000
01000000025C7E472027AAB44AD1F2F048390CB09
SUMMARY:Busy
DTSTART;TZID=W. Europe Standard Time:20211111T103000
DTEND;TZID=W. Europe Standard Time:20211111T110000
CLASS:PUBLIC
PRIORITY:5
DTSTAMP:20211111T132438Z
TRANSP:OPAQUE
STATUS:CONFIRMED
SEQUENCE:0
X-MICROSOFT-CDO-APPT-SEQUENCE:0
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
X-MICROSOFT-CDO-IMPORTANCE:1
X-MICROSOFT-CDO-INSTTYPE:1
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
X-MICROSOFT-DISALLOW-COUNTER:FALSE
END:VEVENT
END:VCALENDAR

Wyświetl plik

@ -0,0 +1,43 @@
BEGIN:VCALENDAR
METHOD:PUBLISH
PRODID:Microsoft Exchange Server 2010
VERSION:2.0
X-WR-CALNAME:test2
BEGIN:VTIMEZONE
TZID:W. Europe Standard Time
BEGIN:STANDARD
DTSTART:16010101T030000
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:16010101T020000
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
RRULE:FREQ=DAILY;UNTIL=20221110T230000Z;INTERVAL=2
UID:040000008200E00074C5B7101A82E0080000000072B908E2FFD6D701000000000000000
010000000F1F9FF2029BC6F438B081675570BC889
SUMMARY:Free
DTSTART;VALUE=DATE:20211111
DTEND;VALUE=DATE:20211112
CLASS:PUBLIC
PRIORITY:5
DTSTAMP:20211111T132808Z
TRANSP:TRANSPARENT
STATUS:CONFIRMED
SEQUENCE:0
X-MICROSOFT-CDO-APPT-SEQUENCE:0
X-MICROSOFT-CDO-BUSYSTATUS:FREE
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
X-MICROSOFT-CDO-ALLDAYEVENT:TRUE
X-MICROSOFT-CDO-IMPORTANCE:1
X-MICROSOFT-CDO-INSTTYPE:1
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
X-MICROSOFT-DISALLOW-COUNTER:FALSE
END:VEVENT
END:VCALENDAR

Wyświetl plik

@ -0,0 +1,44 @@
BEGIN:VCALENDAR
METHOD:PUBLISH
PRODID:Microsoft Exchange Server 2010
VERSION:2.0
X-WR-CALNAME:test3
BEGIN:VTIMEZONE
TZID:W. Europe Standard Time
BEGIN:STANDARD
DTSTART:16010101T030000
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:16010101T020000
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
RRULE:FREQ=DAILY;UNTIL=20211115T070000Z;INTERVAL=1
EXDATE;TZID=W. Europe Standard Time:20211112T080000,20211114T080000
UID:040000008200E00074C5B7101A82E0080000000008EA7A7A00D7D701000000000000000
010000000943ACF1BD16B5145AAD2954EF56FF236
SUMMARY:Busy
DTSTART;TZID=W. Europe Standard Time:20211111T080000
DTEND;TZID=W. Europe Standard Time:20211111T083000
CLASS:PUBLIC
PRIORITY:5
DTSTAMP:20211111T133243Z
TRANSP:OPAQUE
STATUS:CONFIRMED
SEQUENCE:0
X-MICROSOFT-CDO-APPT-SEQUENCE:0
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
X-MICROSOFT-CDO-IMPORTANCE:1
X-MICROSOFT-CDO-INSTTYPE:1
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
X-MICROSOFT-DISALLOW-COUNTER:FALSE
END:VEVENT
END:VCALENDAR

Wyświetl plik

@ -41,7 +41,7 @@ BEGIN:VEVENT
DTSTART;VALUE=DATE:20170713
DTEND;VALUE=DATE:20170714
DTSTAMP:20170711T171222Z
UID:0eedefedba891fcbb49dcfa4279d9d93
UID:0eedefedba891fcbb49dcfa4279d9d94
CREATED:20170104T080401Z
DESCRIPTION:graue Restmülltonne nicht vergessen!
LOCATION:Luitpoldstraße\, Treuchtlingen
@ -54,7 +54,7 @@ BEGIN:VEVENT
DTSTART;VALUE=DATE:20170714
DTEND;VALUE=DATE:20170715
DTSTAMP:20170711T171222Z
UID:0eedefedba891fcbb49dcfa4279d9d93
UID:0eedefedba891fcbb49dcfa4279d9d95
DESCRIPTION:graue Restmülltonne nicht vergessen!
LOCATION:Luitpoldstraße\, Treuchtlingen
SEQUENCE:0

Wyświetl plik

@ -0,0 +1,60 @@
BEGIN:VCALENDAR
PRODID:-//Google Inc//Google Calendar 70.9054//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:email@example.com
X-WR-TIMEZONE:Australia/Sydney
BEGIN:VTIMEZONE
TZID:Australia/Sydney
X-LIC-LOCATION:Australia/Sydney
BEGIN:STANDARD
TZOFFSETFROM:+1100
TZOFFSETTO:+1000
TZNAME:AEST
DTSTART:19700405T030000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETFROM:+1000
TZOFFSETTO:+1100
TZNAME:AEDT
DTSTART:19701004T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VTIMEZONE
TZID:Australia/Melbourne
X-LIC-LOCATION:Australia/Melbourne
BEGIN:STANDARD
TZOFFSETFROM:+1100
TZOFFSETTO:+1000
TZNAME:AEST
DTSTART:19700405T030000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU
END:STANDARD
BEGIN:DAYLIGHT
TZOFFSETFROM:+1000
TZOFFSETTO:+1100
TZNAME:AEDT
DTSTART:19701004T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;TZID=Australia/Sydney:20211017T090000
DTEND;TZID=Australia/Sydney:20211017T095000
RRULE:FREQ=WEEKLY;BYDAY=SU
DTSTAMP:20211025T060625Z
UID:random_string@google.com
CLASS:PRIVATE
CREATED:20211020T015841Z
DESCRIPTION:
LAST-MODIFIED:20211020T015856Z
LOCATION:
SEQUENCE:1
STATUS:CONFIRMED
SUMMARY:Event Name
TRANSP:TRANSPARENT
END:VEVENT
END:VCALENDAR

Wyświetl plik

@ -407,7 +407,7 @@ class ICalEventsTests(unittest.TestCase):
evs = icalevents.events(file=ical, start=start, end=end)
self.assertEqual(len(evs), 41, "41 events in total - one was moved")
self.assertEqual(len(evs), 42, "42 events in total - one was moved")
def test_recurence_id_google(self):
ical = "test/test_data/recurrenceid_google.ics"
@ -420,12 +420,71 @@ class ICalEventsTests(unittest.TestCase):
def test_cest(self):
ical = "test/test_data/cest.ics"
start = date(2021, 1, 1)
end = date(2021, 12, 31)
start = date(2010, 1, 1)
end = date(2023, 12, 31)
evs = icalevents.events(file=ical, start=start, end=end)
self.assertEqual(len(evs), 115, "4 events in total")
self.assertEqual(
len(evs), 239, "239 events in total"
) # 102 events / 91 + 11 recurring
def test_cest_2021_03(self):
ical = "test/test_data/cest.ics"
start = date(2021, 3, 1)
end = date(2021, 3, 31)
evs = icalevents.events(file=ical, start=start, end=end)
self.assertEqual(len(evs), 30, "30 in march")
def test_cest_2021_04(self):
ical = "test/test_data/cest.ics"
start = date(2021, 4, 1)
end = date(2021, 5, 1)
evs = icalevents.events(file=ical, start=start, end=end)
self.assertEqual(len(evs), 27, "27 in april")
def test_cest_2021_05(self):
ical = "test/test_data/cest.ics"
start = date(2021, 5, 1)
end = date(2021, 6, 1)
evs = icalevents.events(file=ical, start=start, end=end)
self.assertEqual(len(evs), 20, "20 in mai")
def test_cest_1(self):
ical = "test/test_data/cest_every_day_for_one_year.ics"
start = date(2020, 1, 1)
end = date(2024, 12, 31)
evs = icalevents.events(file=ical, start=start, end=end)
self.assertEqual(
len(evs),
366,
"366 events in total - one year + 1 (2021-11-11 to 2022-11-11)",
)
def test_cest_2(self):
ical = "test/test_data/cest_every_second_day_for_one_year.ics"
start = date(2020, 1, 1)
end = date(2024, 12, 31)
evs = icalevents.events(file=ical, start=start, end=end)
self.assertEqual(
len(evs), 183, "183 events in total - one year (2021-11-11 to 2022-11-11)"
)
def test_cest_3(self):
ical = "test/test_data/cest_with_deleted.ics"
start = date(2020, 1, 1)
end = date(2024, 12, 31)
evs = icalevents.events(file=ical, start=start, end=end)
self.assertEqual(
len(evs), 3, "3 events in total - 5 events in rrule but 2 deleted"
)
def test_transparent(self):
ical = "test/test_data/transparent.ics"
@ -450,3 +509,14 @@ class ICalEventsTests(unittest.TestCase):
self.assertEqual(ev3.status, "CANCELLED")
self.assertEqual(ev4.status, "CANCELLED")
self.assertEqual(ev5.status, None)
def test_recurrence_tz(self):
ical = "test/test_data/recurrence_tz.ics"
start = datetime(2021, 10, 24, 00, 0, 0, tzinfo=gettz("Australia/Sydney"))
end = datetime(2021, 10, 26, 00, 0, 0, tzinfo=gettz("Australia/Sydney"))
[e1] = icalevents.events(file=ical, start=start, end=end)
expect = datetime(2021, 10, 24, 9, 0, 0, tzinfo=gettz("Australia/Sydney"))
self.assertEqual(
e1.start, expect, "Recurring event matches event in ical (Issue #89)"
)