1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 import datetime
19
20 HAS_ICALENDAR = False
21 try:
22 import icalendar
23 HAS_ICALENDAR = True
24 except ImportError:
25 pass
26
27
28 HAS_DATEUTIL = False
29 try:
30 from dateutil import rrule
31 HAS_DATEUTIL = True
32 except ImportError:
33 pass
34
35 from flumotion.common import tz
36 from flumotion.extern.log import log
37
38 """
39 Implementation of a calendar that can inform about events beginning and
40 ending, as well as active event instances at a given time.
41
42 This uses iCalendar as defined in
43 http://www.ietf.org/rfc/rfc2445.txt
44
45 The users of this module should check if it has both HAS_ICALENDAR
46 and HAS_DATEUTIL properties and if any of them is False, they should
47 withhold from further using the module.
48 """
49
50
52 """
53 If d is a L{datetime.date}, convert it to L{datetime.datetime}.
54
55 @type d: anything
56
57 @rtype: L{datetime.datetime} or anything
58 @returns: The equivalent datetime.datetime if d is a datetime.date;
59 d if not
60 """
61 if isinstance(d, datetime.date) and not isinstance(d, datetime.datetime):
62 return datetime.datetime(d.year, d.month, d.day, tzinfo=tz.UTC)
63 return d
64
65
66 -class Point(log.Loggable):
67 """
68 I represent a start or an end point linked to an event instance
69 of an event.
70
71 @type eventInstance: L{EventInstance}
72 @type which: str
73 @type dt: L{datetime.datetime}
74 """
75
76 - def __init__(self, eventInstance, which, dt):
77 """
78 @param eventInstance: An instance of an event.
79 @type eventInstance: L{EventInstance}
80 @param which: 'start' or 'end'
81 @type which: str
82 @param dt: Timestamp of this point. It will
83 be used when comparing Points.
84 @type dt: L{datetime.datetime}
85 """
86 self.which = which
87 self.dt = dt
88 self.eventInstance = eventInstance
89
91 return "Point '%s' at %r for %r" % (
92 self.which, self.dt, self.eventInstance)
93
95
96
97 return cmp(self.dt, other.dt) \
98 or cmp(self.which, other.which)
99
100
102 """
103 I represent one event instance of an event.
104
105 @type event: L{Event}
106 @type start: L{datetime.datetime}
107 @type end: L{datetime.datetime}
108 """
109
111 """
112 @type event: L{Event}
113 @type start: L{datetime.datetime}
114 @type end: L{datetime.datetime}
115 """
116 self.event = event
117 self.start = start
118 self.end = end
119
121 """
122 Get a list of start and end points.
123
124 @rtype: list of L{Point}
125 """
126 ret = []
127
128 ret.append(Point(self, 'start', self.start))
129 ret.append(Point(self, 'end', self.end))
130
131 return ret
132
136
138 return not self.__eq__(other)
139
140
141 -class Event(log.Loggable):
142 """
143 I represent a VEVENT entry in a calendar for our purposes.
144 I can have recurrence.
145 I can be scheduled between a start time and an end time,
146 returning a list of start and end points.
147 I can have exception dates.
148 """
149
150 - def __init__(self, uid, start, end, content, rrules=None,
151 recurrenceid=None, exdates=None):
152 """
153 @param uid: identifier of the event
154 @type uid: str
155 @param start: start time of the event
156 @type start: L{datetime.datetime}
157 @param end: end time of the event
158 @type end: L{datetime.datetime}
159 @param content: label to describe the content
160 @type content: unicode
161 @param rrules: a list of RRULE string
162 @type rrules: list of str
163 @param recurrenceid: a RECURRENCE-ID, used with
164 recurrence events
165 @type recurrenceid: L{datetime.datetime}
166 @param exdates: list of exceptions to the recurrence rule
167 @type exdates: list of L{datetime.datetime} or None
168 """
169
170 self.start = self._ensureTimeZone(start)
171 self.end = self._ensureTimeZone(end)
172 self.content = content
173 self.uid = uid
174 self.rrules = rrules
175 if rrules and len(rrules) > 1:
176 raise NotImplementedError(
177 "Events with multiple RRULE are not yet supported")
178 self.recurrenceid = recurrenceid
179 if exdates:
180 self.exdates = []
181 for exdate in exdates:
182 exdate = self._ensureTimeZone(exdate)
183 self.exdates.append(exdate)
184 else:
185 self.exdates = None
186
188
189 if dateTime.tzinfo:
190 return dateTime
191
192 return datetime.datetime(dateTime.year, dateTime.month, dateTime.day,
193 dateTime.hour, dateTime.minute, dateTime.second,
194 dateTime.microsecond, tz)
195
197 return "<Event %r >" % (self.toTuple(), )
198
200 return (self.uid, self.start, self.end, self.content, self.rrules,
201 self.exdates)
202
203
204
205
208
211
212
213
214
217
219 return not self.__eq__(other)
220
221
223 """
224 I represent a set of VEVENT entries in a calendar sharing the same uid.
225 I can have recurrence.
226 I can be scheduled between a start time and an end time,
227 returning a list of start and end points in UTC.
228 I can have exception dates.
229 """
230
232 """
233 @param uid: the uid shared among the events on this set
234 @type uid: str
235 """
236 self.uid = uid
237 self._events = []
238
240 return "<EventSet for uid %r >" % (
241 self.uid)
242
244 """
245 Add an event to the set. The event must have the same uid as the set.
246
247 @param event: the event to add.
248 @type event: L{Event}
249 """
250 assert self.uid == event.uid, \
251 "my uid %s does not match Event uid %s" % (self.uid, event.uid)
252 assert event not in self._events, "event %r already in set %r" % (
253 event, self._events)
254
255 self._events.append(event)
256
258 """
259 Remove an event from the set.
260
261 @param event: the event to add.
262 @type event: L{Event}
263 """
264 assert self.uid == event.uid, \
265 "my uid %s does not match Event uid %s" % (self.uid, event.uid)
266 self._events.remove(event)
267
268 - def getPoints(self, start=None, delta=None, clip=True):
269 """
270 Get an ordered list of start and end points from the given start
271 point, with the given delta, in this set of Events.
272
273 start defaults to now.
274 delta defaults to 0, effectively returning all points at this time.
275 the returned list includes the extremes (start and start + delta)
276
277 @param start: the start time
278 @type start: L{datetime.datetime}
279 @param delta: the delta
280 @type delta: L{datetime.timedelta}
281 @param clip: whether to clip all event instances to the given
282 start and end
283 """
284 if start is None:
285 start = datetime.datetime.now(tz.UTC)
286
287 if delta is None:
288 delta = datetime.timedelta(seconds=0)
289
290 points = []
291
292 eventInstances = self._getEventInstances(start, start + delta, clip)
293 for i in eventInstances:
294 for p in i.getPoints():
295 if p.dt >= start and p.dt <= start + delta:
296 points.append(p)
297 points.sort()
298
299 return points
300
302 recurring = None
303
304
305 for v in self._events:
306 if v.rrules:
307 assert not recurring, \
308 "Cannot have two RRULE VEVENTs with UID %s" % self.uid
309 recurring = v
310 else:
311 if len(self._events) > 1:
312 assert v.recurrenceid, \
313 "With multiple VEVENTs with UID %s, " \
314 "each VEVENT should either have a " \
315 "reccurrence rule or have a recurrence id" % self.uid
316
317 return recurring
318
320
321
322
323
324
325
326 eventInstances = []
327
328 recurring = self._getRecurringEvent()
329
330
331 if recurring:
332 eventInstances = self._getEventInstancesRecur(
333 recurring, start, end)
334
335
336
337
338 for event in self._events:
339
340 if event is recurring:
341 continue
342
343 if event.recurrenceid:
344
345 for i in eventInstances[:]:
346 if i.start == event.recurrenceid:
347 eventInstances.remove(i)
348 break
349
350 i = self._getEventInstanceSingle(event, start, end)
351 if i:
352 eventInstances.append(i)
353
354 if clip:
355
356
357 for i in eventInstances[:]:
358 if i.start < start:
359 i.start = start
360 if start >= i.end:
361 eventInstances.remove(i)
362 if i.end > end:
363 i.end = end
364
365 return eventInstances
366
375
377
378
379
380
381
382
383
384 ret = []
385
386
387
388
389 delta = event.end - event.start
390
391
392 r = None
393 if event.rrules:
394 r = event.rrules[0]
395 startRecurRule = rrule.rrulestr(r, dtstart=event.start)
396
397 for startTime in startRecurRule:
398
399 if startTime + delta < start:
400 continue
401
402
403 if startTime >= end:
404 break
405
406
407 if event.exdates:
408 if startTime in event.exdates:
409 self.debug("startTime %r is listed as EXDATE, skipping",
410 startTime)
411 continue
412
413 endTime = startTime + delta
414
415 i = EventInstance(event, startTime, endTime)
416
417 ret.append(i)
418
419 return ret
420
422 """
423 Get all event instances active at the given dt.
424
425 @type dt: L{datetime.datetime}
426
427 @rtype: list of L{EventInstance}
428 """
429 if not dt:
430 dt = datetime.datetime.now(tz=tz.UTC)
431
432 result = []
433
434
435 recurring = self._getRecurringEvent()
436 if recurring:
437
438 startRecurRule = rrule.rrulestr(recurring.rrules[0],
439 dtstart=recurring.start)
440 dtstart = startRecurRule.before(dt)
441
442 if dtstart:
443 skip = False
444
445 for event in self._events:
446 if event.recurrenceid:
447 if event.recurrenceid == dtstart:
448 self.log(
449 'event %r, recurrenceid %r matches dtstart %r',
450 event, event.recurrenceid, dtstart)
451 skip = True
452
453
454 if recurring.exdates and dtstart in recurring.exdates:
455 self.log('recurring event %r has exdate for %r',
456 recurring, dtstart)
457 skip = True
458
459 if not skip:
460 delta = recurring.end - recurring.start
461 dtend = dtstart + delta
462 if dtend >= dt:
463
464 result.append(EventInstance(recurring, dtstart, dtend))
465
466
467 for event in self._events:
468 if event is recurring:
469 continue
470
471 if event.start < dt < event.end:
472 result.append(EventInstance(event, event.start, event.end))
473
474 self.log('events active at %s: %r', str(dt), result)
475
476 return result
477
479 """
480 Return the list of events.
481
482 @rtype: list of L{Event}
483 """
484 return self._events
485
486
488 """
489 I represent a parsed iCalendar resource.
490 I have a list of VEVENT sets from which I can be asked to schedule
491 points marking the start or end of event instances.
492 """
493
494 logCategory = 'calendar'
495
498
500 """
501 Add a parsed VEVENT definition.
502
503 @type event: L{Event}
504 """
505 uid = event.uid
506 self.log("adding event %s with content %r", uid, event.content)
507 if uid not in self._eventSets:
508 self._eventSets[uid] = EventSet(uid)
509 self._eventSets[uid].addEvent(event)
510
511 - def getPoints(self, start=None, delta=None):
512 """
513 Get all points from the given start time within the given delta.
514 End Points will be ordered before Start Points with the same time.
515
516 All points have a dt in the timezone as specified in the calendar.
517
518 start defaults to now.
519 delta defaults to 0, effectively returning all points at this time.
520
521 @type start: L{datetime.datetime}
522 @type delta: L{datetime.timedelta}
523
524 @rtype: list of L{Point}
525 """
526 result = []
527
528 for eventSet in self._eventSets.values():
529 points = eventSet.getPoints(start, delta=delta, clip=False)
530 result.extend(points)
531
532 result.sort()
533
534 return result
535
537 """
538 Get a list of active event instances at the given time.
539
540 @param when: the time to check; defaults to right now
541 @type when: L{datetime.datetime}
542
543 @rtype: list of L{EventInstance}
544 """
545 result = []
546
547 if not when:
548 when = datetime.datetime.now(tz.UTC)
549
550 for eventSet in self._eventSets.values():
551 result.extend(eventSet.getActiveEventInstances(when))
552
553 self.debug('%d active event instances at %s', len(result), str(when))
554 return result
555
556
558
561
563 return "The calendar is not compilant. " + repr(self.value)
564
565
567 """
568 Convert a vDDDType to a datetime, respecting timezones.
569
570 @param v: the time to convert
571 @type v: L{icalendar.prop.vDDDTypes}
572
573 @param timezones: Defined timezones in the calendar
574
575 """
576 if v is None:
577 return None
578 dt = _toDateTime(v.dt)
579 if dt.tzinfo is None:
580
581
582
583 tzid = v.params.get('TZID')
584 if tzid is None:
585 timezone = tz.LOCAL
586 else:
587
588
589 timezone = timezones.get(tzid, tz.gettz(tzid))
590 if timezone is None:
591 raise NotCompilantError("You are trying to use a timezone\
592 that is not defined in this calendar")
593 elif isinstance(timezone, tz.DSTTimezone):
594 timezone = timezone.copy()
595 dt = datetime.datetime(dt.year, dt.month, dt.day,
596 dt.hour, dt.minute, dt.second,
597 dt.microsecond, timezone)
598 return dt
599
600
602 """
603 Convert a vDDDType (vDuration) to a timedelta.
604
605 @param v: the duration to convert
606 @type v: L{icalendar.prop.vDDDTypes}
607
608 @rtype : L{datetime.timedelta}
609 """
610 if v is None or not isinstance(v.dt, datetime.timedelta):
611 return None
612 return v.dt
613
614
616 """
617 Parses a VTIMEZONE section and returns a tzinfo
618 """
619
620 def getRecurrence(observance, dtstart):
621 if 'RRULE' in observance:
622 return rrule.rrulestr(str(observance['RRULE']), dtstart=dtstart,
623 cache=True)
624 if 'RDATE' in observance:
625 return rrule.rrule('YEARLY', str(observance['RDATE']), cache=True)
626 return None
627
628 def parseObservance(observance, tzname):
629 try:
630 required = (observance['DTSTART'].dt,
631 observance['TZOFFSETFROM'].td,
632 observance['TZOFFSETTO'].td)
633 except KeyError:
634 raise NotCompilantError(
635 "VTIMEZONE does not define one of the following required "
636 "elements: TZOFFSETFROM, TZOFFSETTO or DTSTART")
637 rr = getRecurrence(observance, required[0])
638 return required + (observance.get('TZNAME', tzname), rr)
639
640 tzid = vtimezone.get('tzid')
641 try:
642 standard = vtimezone.walk('standard')[0]
643 dstend, stdoffsetfrom, stdoffset, stdname, stdrrule = \
644 parseObservance(standard, 'Standard')
645 except KeyError:
646 standard = None
647
648 daylight_w = vtimezone.walk('daylight')
649 if len(daylight_w) != 0:
650 dststart, dstoffsetfrom, dstoffset, dstname, dstrrule = \
651 parseObservance(daylight_w[0], 'Daylight')
652 if standard is None:
653 return tz.FixedOffsetTimezone(dstoffset, dstname)
654 else:
655 return tz.DSTTimezone(tzid, stdname, dstname, stdoffset, dstoffset,
656 stdoffsetfrom, dstoffsetfrom, dststart,
657 dstend, stdrrule, dstrrule)
658 else:
659 if standard is None:
660 raise NotCompilantError("One of DAYLIGHT or STANDARD must be "
661 "defined")
662 return tz.FixedOffsetTimezone(stdoffset, stdname)
663
664
666 """
667 Parse an icalendar Calendar object into our Calendar object.
668
669 @param iCalendar: The calendar to parse
670 @type iCalendar: L{icalendar.Calendar}
671
672 @rtype: L{Calendar}
673 """
674 calendar = Calendar()
675 timezones = {'UTC': tz.UTC}
676
677
678 for vtimezone in iCalendar.walk('vtimezone'):
679 tzinfo = parseTimezone(vtimezone)
680 tzid = str(tzinfo)
681 if tzid not in timezones:
682 timezones[tzid] = tzinfo
683 else:
684 raise NotCompilantError("Timezones must have a unique TZID")
685
686 for event in iCalendar.walk('vevent'):
687
688
689
690 start = vDDDToDatetime(event.get('dtstart'), timezones)
691
692 end = vDDDToDatetime(event.get('dtend', None), timezones)
693
694 if not end:
695 duration = vDDDToTimedelta(event.get('duration', None))
696 end = duration and start + duration or None
697
698
699
700 if not end:
701 continue
702
703 if end == start:
704 continue
705
706 assert end > start, "end %r should not be before start %r" % (
707 end, start)
708
709 summary = event.decoded('SUMMARY', None)
710 uid = event['UID']
711
712
713 recur = event.get('RRULE', [])
714 if not isinstance(recur, list):
715 recur = [recur, ]
716 recur = [r.ical() for r in recur]
717
718 recurrenceid = event.get('RECURRENCE-ID', None)
719 if recurrenceid:
720 recurrenceid = vDDDToDatetime(recurrenceid, timezones)
721
722 exdates = event.get('EXDATE', [])
723
724
725 if not isinstance(exdates, list):
726 exdates = [exdates, ]
727
728
729
730 exdates = [vDDDToDatetime(i, timezones) for i in exdates]
731
732 if event.get('RDATE'):
733 raise NotImplementedError("We don't handle RDATE yet")
734
735 if event.get('EXRULE'):
736 raise NotImplementedError("We don't handle EXRULE yet")
737
738
739
740
741
742 e = Event(uid, start, end, summary, recur, recurrenceid, exdates)
743
744 calendar.addEvent(e)
745
746 return calendar
747
748
750 """
751 Create a new calendar from an open file object.
752
753 @type file: file object
754
755 @rtype: L{Calendar}
756 """
757 data = file.read()
758
759
760
761
762 data = data.replace('\nCREATED:0000', '\nCREATED:2008')
763 try:
764 cal = icalendar.Calendar.from_string(data)
765 except ValueError:
766 log.warning("icalendar", "The ics file we've received is not encoded "
767 "in UTF-8. We'll decode it but some characters may be "
768 "missing")
769 data = data.decode('utf-8', 'ignore')
770 cal = icalendar.Calendar.from_string(data)
771 return fromICalendar(cal)
772