1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23 import datetime
24 import time
25
26 HAS_ICALENDAR = False
27 try:
28 import icalendar
29 HAS_ICALENDAR = True
30 except ImportError:
31 pass
32
33
34 HAS_DATEUTIL = False
35 try:
36 from dateutil import rrule, tz
37 HAS_DATEUTIL = True
38 except ImportError:
39 pass
40
41 from flumotion.extern.log import log
42
43 """
44 Implementation of a calendar that can inform about events beginning and
45 ending, as well as active event instances at a given time.
46
47 This uses iCalendar as defined in
48 http://www.ietf.org/rfc/rfc2445.txt
49
50 The users of this module should check if it has both HAS_ICALENDAR
51 and HAS_DATEUTIL properties and if any of them is False, they should
52 withhold from further using the module.
53 """
54
55
57 """
58 If d is a L{datetime.date}, convert it to L{datetime.datetime}.
59
60 @type d: anything
61
62 @rtype: L{datetime.datetime} or anything
63 @returns: The equivalent datetime.datetime if d is a datetime.date;
64 d if not
65 """
66 if isinstance(d, datetime.date) and not isinstance(d, datetime.datetime):
67 return datetime.datetime(d.year, d.month, d.day, tzinfo=UTC)
68 return d
69
70
72 """
73 Looks for the closest last sunday in the month
74
75 @param dt: Reference date
76 @type dt: L{datetime.datetime}
77
78 @rtype: L{datetime.datetime} or None
79 @returns: Last sunday of the month
80 """
81
82 days_to_go = 6 - dt.weekday()
83 if days_to_go:
84 dt += datetime.timedelta(days_to_go)
85 return dt
86
87
89 """ A tzinfo class representing a DST timezone """
90
91 ZERO = datetime.timedelta(0)
92
93 - def __init__(self, tzid, stdname, dstname, stdoffset, dstoffset,
94 stdoffsetfrom, dstoffsetfrom, dststart, dstend):
95 '''
96 @param tzid: Timezone unique ID
97 @type tzid: str
98 @param stdname: Name of the Standard observance
99 @type stdname: str
100 @param dstname: Name of the DST observance
101 @type dstname: str
102 @param stdoffset: UTC offset for the standard observance
103 @type stdoffset: L{datetime.timedelta}
104 @param dstoffset: UTC offset for the DST observance
105 @type dstoffset: L{datetime.timedelta}
106 @param stdoffsetfrom: UTC offset which is in use when the onset of
107 Standard observance begins
108 @type stdoffsetfrom: l{datetime.timedelta}
109 @param dstoffsetfrom: UTC offset which is in use when the onset of
110 DST observance begins
111 @type stdoffsetfrom: L{datetime.timedelta}
112 @param dststart: Start of the DST observance
113 @type dststart: L{datetime.datetime}
114 @param dstend: End of the DST observance
115 @type dstend: L{datetime.datetime}
116 '''
117
118 self._tzid = str(tzid)
119 self._stdname = str(stdname)
120 self._dstname = str(dstname)
121 self._stdoffset = stdoffset
122 self._dstoffset = dstoffset
123 self._stdoffsetfrom = stdoffsetfrom
124 self._dstoffsetfrom = dstoffsetfrom
125 self._dststart = dststart
126 self._dstend = dstend
127
130
132 return self._isdst(dt) and self._dstname or self._stdname
133
135 return self._isdst(dt) and self._dstoffset or self._stdoffset
136
138 dt = dt.replace(tzinfo=None)
139 return self._isdst(dt) and \
140 dt + self._dstoffsetfrom or dt + self._stdoffsetfrom
141
143
144
145
146
147 if dt is None or dt.tzinfo is None:
148 return self.ZERO
149 assert dt.tzinfo is self
150 return self._isdst(dt) and self._dstoffset - self._stdoffset or \
151 self.ZERO
152
154 return DSTTimezone(self._tzid, self._stdname, self._dstname,
155 self._stdoffset, self._dstoffset, self._stdoffsetfrom,
156 self._dstoffsetfrom, self._dststart, self._dstend)
157
164
165
167 """Fixed offset in hours from UTC."""
168
170 self.__offset = offset
171 self.__name = name
172
175
178
180 return datetime.deltatime(0)
181
184
185
187 """A tzinfo class representing the system's idea of the local timezone"""
188 STDOFFSET = datetime.timedelta(seconds=-time.timezone)
189 if time.daylight:
190 DSTOFFSET = datetime.timedelta(seconds=-time.altzone)
191 else:
192 DSTOFFSET = STDOFFSET
193 DSTDIFF = DSTOFFSET - STDOFFSET
194 ZERO = datetime.timedelta(0)
195
201
207
210
212 tt = (dt.year, dt.month, dt.day,
213 dt.hour, dt.minute, dt.second,
214 dt.weekday(), 0, -1)
215 return time.localtime(time.mktime(tt)).tm_isdst > 0
216 LOCAL = LocalTimezone()
217
218
219
220
222 """A tzinfo class representing UTC"""
223 ZERO = datetime.timedelta(0)
224
227
230
233 UTC = UTCTimezone()
234
235
236 -class Point(log.Loggable):
237 """
238 I represent a start or an end point linked to an event instance
239 of an event.
240
241 @type eventInstance: L{EventInstance}
242 @type which: str
243 @type dt: L{datetime.datetime}
244 """
245
246 - def __init__(self, eventInstance, which, dt):
247 """
248 @param eventInstance: An instance of an event.
249 @type eventInstance: L{EventInstance}
250 @param which: 'start' or 'end'
251 @type which: str
252 @param dt: Timestamp of this point. It will
253 be used when comparing Points.
254 @type dt: L{datetime.datetime}
255 """
256 self.which = which
257 self.dt = dt
258 self.eventInstance = eventInstance
259
261 return "Point '%s' at %r for %r" % (
262 self.which, self.dt, self.eventInstance)
263
265
266
267 return cmp(self.dt, other.dt) \
268 or cmp(self.which, other.which)
269
270
272 """
273 I represent one event instance of an event.
274
275 @type event: L{Event}
276 @type start: L{datetime.datetime}
277 @type end: L{datetime.datetime}
278 """
279
281 """
282 @type event: L{Event}
283 @type start: L{datetime.datetime}
284 @type end: L{datetime.datetime}
285 """
286 self.event = event
287 self.start = start
288 self.end = end
289
291 """
292 Get a list of start and end points.
293
294 @rtype: list of L{Point}
295 """
296 ret = []
297
298 ret.append(Point(self, 'start', self.start))
299 ret.append(Point(self, 'end', self.end))
300
301 return ret
302
306
308 return not self.__eq__(other)
309
310
311 -class Event(log.Loggable):
312 """
313 I represent a VEVENT entry in a calendar for our purposes.
314 I can have recurrence.
315 I can be scheduled between a start time and an end time,
316 returning a list of start and end points.
317 I can have exception dates.
318 """
319
320 - def __init__(self, uid, start, end, content, rrules=None,
321 recurrenceid=None, exdates=None):
322 """
323 @param uid: identifier of the event
324 @type uid: str
325 @param start: start time of the event
326 @type start: L{datetime.datetime}
327 @param end: end time of the event
328 @type end: L{datetime.datetime}
329 @param content: label to describe the content
330 @type content: unicode
331 @param rrules: a list of RRULE string
332 @type rrules: list of str
333 @param recurrenceid: a RECURRENCE-ID, used with
334 recurrence events
335 @type recurrenceid: L{datetime.datetime}
336 @param exdates: list of exceptions to the recurrence rule
337 @type exdates: list of L{datetime.datetime} or None
338 """
339
340 self.start = self._ensureTimeZone(start)
341 self.end = self._ensureTimeZone(end)
342 self.content = content
343 self.uid = uid
344 self.rrules = rrules
345 if rrules and len(rrules) > 1:
346 raise NotImplementedError(
347 "Events with multiple RRULE are not yet supported")
348 self.recurrenceid = recurrenceid
349 if exdates:
350 self.exdates = []
351 for exdate in exdates:
352 exdate = self._ensureTimeZone(exdate)
353 self.exdates.append(exdate)
354 else:
355 self.exdates = None
356
358
359 if dateTime.tzinfo:
360 return dateTime
361
362 return datetime.datetime(dateTime.year, dateTime.month, dateTime.day,
363 dateTime.hour, dateTime.minute, dateTime.second,
364 dateTime.microsecond, tz)
365
367 return "<Event %r >" % (self.toTuple(), )
368
370 return (self.uid, self.start, self.end, self.content, self.rrules,
371 self.exdates)
372
373
374
375
378
381
382
383
384
387
389 return not self.__eq__(other)
390
391
393 """
394 I represent a set of VEVENT entries in a calendar sharing the same uid.
395 I can have recurrence.
396 I can be scheduled between a start time and an end time,
397 returning a list of start and end points in UTC.
398 I can have exception dates.
399 """
400
402 """
403 @param uid: the uid shared among the events on this set
404 @type uid: str
405 """
406 self.uid = uid
407 self._events = []
408
410 return "<EventSet for uid %r >" % (
411 self.uid)
412
414 """
415 Add an event to the set. The event must have the same uid as the set.
416
417 @param event: the event to add.
418 @type event: L{Event}
419 """
420 assert self.uid == event.uid, \
421 "my uid %s does not match Event uid %s" % (self.uid, event.uid)
422 assert event not in self._events, "event %r already in set %r" % (
423 event, self._events)
424
425 self._events.append(event)
426
428 """
429 Remove an event from the set.
430
431 @param event: the event to add.
432 @type event: L{Event}
433 """
434 assert self.uid == event.uid, \
435 "my uid %s does not match Event uid %s" % (self.uid, event.uid)
436 self._events.remove(event)
437
438 - def getPoints(self, start=None, delta=None, clip=True):
439 """
440 Get an ordered list of start and end points from the given start
441 point, with the given delta, in this set of Events.
442
443 start defaults to now.
444 delta defaults to 0, effectively returning all points at this time.
445 the returned list includes the extremes (start and start + delta)
446
447 @param start: the start time
448 @type start: L{datetime.datetime}
449 @param delta: the delta
450 @type delta: L{datetime.timedelta}
451 @param clip: whether to clip all event instances to the given
452 start and end
453 """
454 if start is None:
455 start = datetime.datetime.now(UTC)
456
457 if delta is None:
458 delta = datetime.timedelta(seconds=0)
459
460 points = []
461
462 eventInstances = self._getEventInstances(start, start + delta, clip)
463 for i in eventInstances:
464 for p in i.getPoints():
465 if p.dt >= start and p.dt <= start + delta:
466 points.append(p)
467 points.sort()
468
469 return points
470
472 recurring = None
473
474
475 for v in self._events:
476 if v.rrules:
477 assert not recurring, \
478 "Cannot have two RRULE VEVENTs with UID %s" % self.uid
479 recurring = v
480 else:
481 if len(self._events) > 1:
482 assert v.recurrenceid, \
483 "With multiple VEVENTs with UID %s, " \
484 "each VEVENT should either have a " \
485 "reccurrence rule or have a recurrence id" % self.uid
486
487 return recurring
488
490
491
492
493
494
495
496 eventInstances = []
497
498 recurring = self._getRecurringEvent()
499
500
501 if recurring:
502 eventInstances = self._getEventInstancesRecur(
503 recurring, start, end)
504
505
506
507
508 for event in self._events:
509
510 if event is recurring:
511 continue
512
513 if event.recurrenceid:
514
515 for i in eventInstances[:]:
516 if i.start == event.recurrenceid:
517 eventInstances.remove(i)
518 break
519
520 i = self._getEventInstanceSingle(event, start, end)
521 if i:
522 eventInstances.append(i)
523
524 if clip:
525
526
527 for i in eventInstances[:]:
528 if i.start < start:
529 i.start = start
530 if start >= i.end:
531 eventInstances.remove(i)
532 if i.end > end:
533 i.end = end
534
535 return eventInstances
536
545
547
548
549
550
551
552
553
554 ret = []
555
556
557
558
559 delta = event.end - event.start
560
561
562 r = None
563 if event.rrules:
564 r = event.rrules[0]
565 startRecurRule = rrule.rrulestr(r, dtstart=event.start)
566
567 for startTime in startRecurRule:
568
569 if startTime + delta < start:
570 continue
571
572
573 if startTime >= end:
574 break
575
576
577 if event.exdates:
578 if startTime in event.exdates:
579 self.debug("startTime %r is listed as EXDATE, skipping",
580 startTime)
581 continue
582
583 endTime = startTime + delta
584
585 i = EventInstance(event, startTime, endTime)
586
587 ret.append(i)
588
589 return ret
590
592 """
593 Get all event instances active at the given dt.
594
595 @type dt: L{datetime.datetime}
596
597 @rtype: list of L{EventInstance}
598 """
599 if not dt:
600 dt = datetime.datetime.now(tz=UTC)
601
602 result = []
603
604
605 recurring = self._getRecurringEvent()
606 if recurring:
607
608 startRecurRule = rrule.rrulestr(recurring.rrules[0],
609 dtstart=recurring.start)
610 dtstart = startRecurRule.before(dt)
611
612 if dtstart:
613 skip = False
614
615 for event in self._events:
616 if event.recurrenceid:
617 if event.recurrenceid == dtstart:
618 self.log(
619 'event %r, recurrenceid %r matches dtstart %r',
620 event, event.recurrenceid, dtstart)
621 skip = True
622
623
624 if recurring.exdates and dtstart in recurring.exdates:
625 self.log('recurring event %r has exdate for %r',
626 recurring, dtstart)
627 skip = True
628
629 if not skip:
630 delta = recurring.end - recurring.start
631 dtend = dtstart + delta
632 if dtend >= dt:
633
634 result.append(EventInstance(recurring, dtstart, dtend))
635
636
637 for event in self._events:
638 if event is recurring:
639 continue
640
641 if event.start < dt < event.end:
642 result.append(EventInstance(event, event.start, event.end))
643
644 self.log('events active at %s: %r', str(dt), result)
645
646 return result
647
649 """
650 Return the list of events.
651
652 @rtype: list of L{Event}
653 """
654 return self._events
655
656
658 """
659 I represent a parsed iCalendar resource.
660 I have a list of VEVENT sets from which I can be asked to schedule
661 points marking the start or end of event instances.
662 """
663
664 logCategory = 'calendar'
665
668
670 """
671 Add a parsed VEVENT definition.
672
673 @type event: L{Event}
674 """
675 uid = event.uid
676 self.log("adding event %s with content %r", uid, event.content)
677 if uid not in self._eventSets:
678 self._eventSets[uid] = EventSet(uid)
679 self._eventSets[uid].addEvent(event)
680
681 - def getPoints(self, start=None, delta=None):
682 """
683 Get all points from the given start time within the given delta.
684 End Points will be ordered before Start Points with the same time.
685
686 All points have a dt in the timezone as specified in the calendar.
687
688 start defaults to now.
689 delta defaults to 0, effectively returning all points at this time.
690
691 @type start: L{datetime.datetime}
692 @type delta: L{datetime.timedelta}
693
694 @rtype: list of L{Point}
695 """
696 result = []
697
698 for eventSet in self._eventSets.values():
699 points = eventSet.getPoints(start, delta=delta, clip=False)
700 result.extend(points)
701
702 result.sort()
703
704 return result
705
707 """
708 Get a list of active event instances at the given time.
709
710 @param when: the time to check; defaults to right now
711 @type when: L{datetime.datetime}
712
713 @rtype: list of L{EventInstance}
714 """
715 result = []
716
717 if not when:
718 when = datetime.datetime.now(UTC)
719
720 for eventSet in self._eventSets.values():
721 result.extend(eventSet.getActiveEventInstances(when))
722
723 self.debug('%d active event instances at %s', len(result), str(when))
724 return result
725
726
728
731
733 return "The calendar is not compilant. " + repr(self.value)
734
735
737 """
738 Convert a vDDDType to a datetime, respecting timezones.
739
740 @param v: the time to convert
741 @type v: L{icalendar.prop.vDDDTypes}
742
743 @param timezones: Defined timezones in the calendar
744
745 """
746 if v is None:
747 return None
748 dt = _toDateTime(v.dt)
749 if dt.tzinfo is None:
750
751
752
753 tzid = v.params.get('TZID')
754 if tzid is None:
755 timezone = tz.gettz(None)
756 else:
757
758
759 timezone = timezones.get(tzid, tz.gettz(tzid))
760 if timezone is None:
761 raise NotCompilantError("You are trying to use a timezone\
762 that is not defined in this calendar")
763 elif timezone != UTC:
764 timezone = timezone.copy()
765 dt = datetime.datetime(dt.year, dt.month, dt.day,
766 dt.hour, dt.minute, dt.second,
767 dt.microsecond, timezone)
768 return dt
769
770
772 """
773 Convert a vDDDType (vDuration) to a timedelta.
774
775 @param v: the duration to convert
776 @type v: L{icalendar.prop.vDDDTypes}
777
778 @rtype : L{datetime.timedelta}
779 """
780 if v is None or not isinstance(v.dt, datetime.timedelta):
781 return None
782 return v.dt
783
784
786 """
787 Parse an icalendar Calendar object into our Calendar object.
788
789 @param iCalendar: The calendar to parse
790 @type iCalendar: L{icalendar.Calendar}
791
792 @rtype: L{Calendar}
793 """
794 calendar = Calendar()
795 timezones = {'UTC': UTC}
796
797 for vtimezone in iCalendar.walk('vtimezone'):
798
799 def parseObservance(observance):
800 try:
801 return (observance['TZNAME'], observance['TZOFFSETFROM'],
802 observance['TZOFFSETTO'], observance['DTSTART'])
803 except:
804 raise NotCompilantError(
805 "VTIMEZONE does not define one of the following required "
806 "elements: TZNAME, TZOFFSETFROM, TZOFFSETTO or DTSTART")
807
808
809 tzid = vtimezone.get('tzid')
810 standard = vtimezone.walk('standard')[0]
811 stdname, stdoffsetfrom, stdoffset, dstend = parseObservance(standard)
812 try:
813 daylight = vtimezone.walk('daylight')[0]
814 except:
815 timezone = FixedOffsetTimezone(stdoffset.td, stdname)
816 else:
817 dstname, dstoffsetfrom, dstoffset, dststart = parseObservance(
818 daylight)
819 timezone = DSTTimezone(tzid, stdname, dstname,
820 stdoffset.td, dstoffset.td,
821 stdoffsetfrom.td, dstoffsetfrom.td,
822 dststart.dt, dstend.dt)
823 if tzid not in timezones:
824 timezones[tzid] = timezone
825 else:
826 raise NotCompilantError("Timezones must have a unique TZID")
827
828 for event in iCalendar.walk('vevent'):
829
830
831
832 start = vDDDToDatetime(event.get('dtstart'), timezones)
833
834 end = vDDDToDatetime(event.get('dtend', None), timezones)
835
836 if not end:
837 duration = vDDDToTimedelta(event.get('duration', None))
838 end = duration and start + duration or None
839
840
841
842 if not end:
843 continue
844
845 if end == start:
846 continue
847
848 assert end > start, "end %r should not be before start %r" % (
849 end, start)
850
851 summary = event.decoded('SUMMARY', None)
852 uid = event['UID']
853
854
855 recur = event.get('RRULE', [])
856 if not isinstance(recur, list):
857 recur = [recur, ]
858 recur = [r.ical() for r in recur]
859
860 recurrenceid = event.get('RECURRENCE-ID', None)
861 if recurrenceid:
862 recurrenceid = vDDDToDatetime(recurrenceid, timezones)
863
864 exdates = event.get('EXDATE', [])
865
866
867 if not isinstance(exdates, list):
868 exdates = [exdates, ]
869
870
871
872 exdates = [vDDDToDatetime(i, timezones) for i in exdates]
873
874 if event.get('RDATE'):
875 raise NotImplementedError("We don't handle RDATE yet")
876
877 if event.get('EXRULE'):
878 raise NotImplementedError("We don't handle EXRULE yet")
879
880
881
882
883
884 e = Event(uid, start, end, summary, recur, recurrenceid, exdates)
885
886 calendar.addEvent(e)
887
888 return calendar
889
890
892 """
893 Create a new calendar from an open file object.
894
895 @type file: file object
896
897 @rtype: L{Calendar}
898 """
899 data = file.read()
900
901
902
903
904 data = data.replace('\nCREATED:0000', '\nCREATED:2008')
905 cal = icalendar.Calendar.from_string(data)
906 return fromICalendar(cal)
907