Package flumotion :: Package common :: Module eventcalendar
[hide private]

Source Code for Module flumotion.common.eventcalendar

  1  # -*- Mode:Python; test-case-name:flumotion.test.test_common_eventcalendar -*- 
  2  # vi:si:et:sw=4:sts=4:ts=4 
  3  # 
  4  # Flumotion - a streaming media server 
  5  # Copyright (C) 2004,2005,2006,2007,2008 Fluendo, S.L. (www.fluendo.com). 
  6  # All rights reserved. 
  7   
  8  # This file may be distributed and/or modified under the terms of 
  9  # the GNU General Public License version 2 as published by 
 10  # the Free Software Foundation. 
 11  # This file is distributed without any warranty; without even the implied 
 12  # warranty of merchantability or fitness for a particular purpose. 
 13  # See "LICENSE.GPL" in the source distribution for more information. 
 14   
 15  # Licensees having purchased or holding a valid Flumotion Advanced 
 16  # Streaming Server license and using this file together with a Flumotion 
 17  # Advanced Streaming Server may only use this file in accordance with the 
 18  # Flumotion Advanced Streaming Server Commercial License Agreement. 
 19  # See "LICENSE.Flumotion" in the source distribution for more information. 
 20   
 21  # Headers in this file shall remain intact. 
 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  # for documentation on dateutil, see http://labix.org/python-dateutil 
 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   
56 -def _toDateTime(d):
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
71 -class LocalTimezone(datetime.tzinfo):
72 """A tzinfo class representing the system's idea of the local timezone""" 73 STDOFFSET = datetime.timedelta(seconds=-time.timezone) 74 if time.daylight: 75 DSTOFFSET = datetime.timedelta(seconds=-time.altzone) 76 else: 77 DSTOFFSET = STDOFFSET 78 DSTDIFF = DSTOFFSET - STDOFFSET 79 ZERO = datetime.timedelta(0) 80
81 - def utcoffset(self, dt):
82 if self._isdst(dt): 83 return self.DSTOFFSET 84 else: 85 return self.STDOFFSET
86
87 - def dst(self, dt):
88 if self._isdst(dt): 89 return self.DSTDIFF 90 else: 91 return self.ZERO
92
93 - def tzname(self, dt):
94 return time.tzname[self._isdst(dt)]
95
96 - def _isdst(self, dt):
97 tt = (dt.year, dt.month, dt.day, 98 dt.hour, dt.minute, dt.second, 99 dt.weekday(), 0, -1) 100 return time.localtime(time.mktime(tt)).tm_isdst > 0
101 LOCAL = LocalTimezone() 102 103 # A UTC class; see datetime.tzinfo documentation 104 105
106 -class UTCTimezone(datetime.tzinfo):
107 """A tzinfo class representing UTC""" 108 ZERO = datetime.timedelta(0) 109
110 - def utcoffset(self, dt):
111 return self.ZERO
112
113 - def tzname(self, dt):
114 return "UTC"
115
116 - def dst(self, dt):
117 return self.ZERO
118 UTC = UTCTimezone() 119 120
121 -class Point(log.Loggable):
122 """ 123 I represent a start or an end point linked to an event instance 124 of an event. 125 126 @type eventInstance: L{EventInstance} 127 @type which: str 128 @type dt: L{datetime.datetime} 129 """ 130
131 - def __init__(self, eventInstance, which, dt):
132 """ 133 @param eventInstance: An instance of an event. 134 @type eventInstance: L{EventInstance} 135 @param which: 'start' or 'end' 136 @type which: str 137 @param dt: Timestamp of this point. It will 138 be used when comparing Points. 139 @type dt: L{datetime.datetime} 140 """ 141 self.which = which 142 self.dt = dt 143 self.eventInstance = eventInstance
144
145 - def __repr__(self):
146 return "Point '%s' at %r for %r" % ( 147 self.which, self.dt, self.eventInstance)
148
149 - def __cmp__(self, other):
150 # compare based on dt, then end before start 151 # relies on alphabetic order of end before start 152 return cmp(self.dt, other.dt) \ 153 or cmp(self.which, other.which)
154 155
156 -class EventInstance(log.Loggable):
157 """ 158 I represent one event instance of an event. 159 160 @type event: L{Event} 161 @type start: L{datetime.datetime} 162 @type end: L{datetime.datetime} 163 """ 164
165 - def __init__(self, event, start, end):
166 """ 167 @type event: L{Event} 168 @type start: L{datetime.datetime} 169 @type end: L{datetime.datetime} 170 """ 171 self.event = event 172 self.start = start 173 self.end = end
174
175 - def getPoints(self):
176 """ 177 Get a list of start and end points. 178 179 @rtype: list of L{Point} 180 """ 181 ret = [] 182 183 ret.append(Point(self, 'start', self.start)) 184 ret.append(Point(self, 'end', self.end)) 185 186 return ret
187
188 - def __eq__(self, other):
189 return self.start == other.start and self.end == other.end and \ 190 self.event == other.event
191
192 - def __ne__(self, other):
193 return not self.__eq__(other)
194 195
196 -class Event(log.Loggable):
197 """ 198 I represent a VEVENT entry in a calendar for our purposes. 199 I can have recurrence. 200 I can be scheduled between a start time and an end time, 201 returning a list of start and end points. 202 I can have exception dates. 203 """ 204
205 - def __init__(self, uid, start, end, content, rrules=None, 206 recurrenceid=None, exdates=None):
207 """ 208 @param uid: identifier of the event 209 @type uid: str 210 @param start: start time of the event 211 @type start: L{datetime.datetime} 212 @param end: end time of the event 213 @type end: L{datetime.datetime} 214 @param content: label to describe the content 215 @type content: unicode 216 @param rrules: a list of RRULE string 217 @type rrules: list of str 218 @param recurrenceid: a RECURRENCE-ID, used with 219 recurrence events 220 @type recurrenceid: L{datetime.datetime} 221 @param exdates: list of exceptions to the recurrence rule 222 @type exdates: list of L{datetime.datetime} or None 223 """ 224 225 self.start = self._ensureTimeZone(start) 226 self.end = self._ensureTimeZone(end) 227 self.content = content 228 self.uid = uid 229 self.rrules = rrules 230 if rrules and len(rrules) > 1: 231 raise NotImplementedError( 232 "Events with multiple RRULE are not yet supported") 233 self.recurrenceid = recurrenceid 234 if exdates: 235 self.exdates = [] 236 for exdate in exdates: 237 exdate = self._ensureTimeZone(exdate) 238 self.exdates.append(exdate) 239 else: 240 self.exdates = None
241
242 - def _ensureTimeZone(self, dateTime, tz=UTC):
243 # add timezone information if it is not specified for some reason 244 if dateTime.tzinfo: 245 return dateTime 246 247 return datetime.datetime(dateTime.year, dateTime.month, dateTime.day, 248 dateTime.hour, dateTime.minute, dateTime.second, 249 dateTime.microsecond, tz)
250
251 - def __repr__(self):
252 return "<Event %r >" % (self.toTuple(), )
253
254 - def toTuple(self):
255 return (self.uid, self.start, self.end, self.content, self.rrules, 256 self.exdates)
257 258 # FIXME: these are only here so the rrdmon stuff can use Event instances 259 # in an avltree 260
261 - def __lt__(self, other):
262 return self.toTuple() < other.toTuple()
263
264 - def __gt__(self, other):
265 return self.toTuple() > other.toTuple()
266 267 # FIXME: but these should be kept, so that events with different id 268 # but same properties are the same 269
270 - def __eq__(self, other):
271 return self.toTuple() == other.toTuple()
272
273 - def __ne__(self, other):
274 return not self.__eq__(other)
275 276
277 -class EventSet(log.Loggable):
278 """ 279 I represent a set of VEVENT entries in a calendar sharing the same uid. 280 I can have recurrence. 281 I can be scheduled between a start time and an end time, 282 returning a list of start and end points in UTC. 283 I can have exception dates. 284 """ 285
286 - def __init__(self, uid):
287 """ 288 @param uid: the uid shared among the events on this set 289 @type uid: str 290 """ 291 self.uid = uid 292 self._events = []
293
294 - def __repr__(self):
295 return "<EventSet for uid %r >" % ( 296 self.uid)
297
298 - def addEvent(self, event):
299 """ 300 Add an event to the set. The event must have the same uid as the set. 301 302 @param event: the event to add. 303 @type event: L{Event} 304 """ 305 assert self.uid == event.uid, \ 306 "my uid %s does not match Event uid %s" % (self.uid, event.uid) 307 assert event not in self._events, "event %r already in set %r" % ( 308 event, self._events) 309 310 self._events.append(event)
311
312 - def removeEvent(self, event):
313 """ 314 Remove an event from the set. 315 316 @param event: the event to add. 317 @type event: L{Event} 318 """ 319 assert self.uid == event.uid, \ 320 "my uid %s does not match Event uid %s" % (self.uid, event.uid) 321 self._events.remove(event)
322
323 - def getPoints(self, start=None, delta=None, clip=True):
324 """ 325 Get an ordered list of start and end points from the given start 326 point, with the given delta, in this set of Events. 327 328 start defaults to now. 329 delta defaults to 0, effectively returning all points at this time. 330 the returned list includes the extremes (start and start + delta) 331 332 @param start: the start time 333 @type start: L{datetime.datetime} 334 @param delta: the delta 335 @type delta: L{datetime.timedelta} 336 @param clip: whether to clip all event instances to the given 337 start and end 338 """ 339 if start is None: 340 start = datetime.datetime.now(UTC) 341 342 if delta is None: 343 delta = datetime.timedelta(seconds=0) 344 345 points = [] 346 347 eventInstances = self._getEventInstances(start, start + delta, clip) 348 for i in eventInstances: 349 for p in i.getPoints(): 350 if p.dt >= start and p.dt <= start + delta: 351 points.append(p) 352 points.sort() 353 354 return points
355
356 - def _getRecurringEvent(self):
357 recurring = None 358 359 # get the event in the event set that is recurring, if any 360 for v in self._events: 361 if v.rrules: 362 assert not recurring, \ 363 "Cannot have two RRULE VEVENTs with UID %s" % self.uid 364 recurring = v 365 else: 366 if len(self._events) > 1: 367 assert v.recurrenceid, \ 368 "With multiple VEVENTs with UID %s, " \ 369 "each VEVENT should either have a " \ 370 "reccurrence rule or have a recurrence id" % self.uid 371 372 return recurring
373
374 - def _getEventInstances(self, start, end, clip):
375 # get all instances whose start and/or end fall between the given 376 # datetimes 377 # clips the event to the given start and end if asked for 378 # FIXME: decide if clip is inclusive or exclusive; maybe compare 379 # to dateutil's solution 380 381 eventInstances = [] 382 383 recurring = self._getRecurringEvent() 384 385 # find all instances between the two given times 386 if recurring: 387 eventInstances = self._getEventInstancesRecur( 388 recurring, start, end) 389 390 # an event that has a recurrence id overrides the instance of the 391 # recurrence with a start time matching the recurrence id, so 392 # throw it out 393 for event in self._events: 394 # skip the main event 395 if event is recurring: 396 continue 397 398 if event.recurrenceid: 399 # Remove recurrent instance(s) that start at this recurrenceid 400 for i in eventInstances[:]: 401 if i.start == event.recurrenceid: 402 eventInstances.remove(i) 403 break 404 405 i = self._getEventInstanceSingle(event, start, end) 406 if i: 407 eventInstances.append(i) 408 409 if clip: 410 # fix all incidences that lie partly outside of the range 411 # to be in the range 412 for i in eventInstances[:]: 413 if i.start < start: 414 i.start = start 415 if start >= i.end: 416 eventInstances.remove(i) 417 if i.end > end: 418 i.end = end 419 420 return eventInstances
421
422 - def _getEventInstanceSingle(self, event, start, end):
423 # is this event within the range asked for ? 424 if start > event.end: 425 return None 426 if end < event.start: 427 return None 428 429 return EventInstance(event, event.start, event.end)
430
431 - def _getEventInstancesRecur(self, event, start, end):
432 # get all event instances for this recurring event that start before 433 # the given end time and end after the given start time. 434 # The UNTIL value applies to the start of a recurring event, 435 # not to the end. So if you would calculate based on the end for the 436 # recurrence rule, and there is a recurring instance that starts before 437 # UNTIL but ends after UNTIL, it would not be taken into account. 438 439 ret = [] 440 441 # don't calculate endPoint based on end recurrence rule, because 442 # if the next one after a start point is past UNTIL then the rrule 443 # returns None 444 delta = event.end - event.start 445 446 # FIXME: support multiple RRULE; see 4.8.5.4 Recurrence Rule 447 r = None 448 if event.rrules: 449 r = event.rrules[0] 450 startRecurRule = rrule.rrulestr(r, dtstart=event.start) 451 452 for startTime in startRecurRule: 453 # ignore everything stopping before our start time 454 if startTime + delta < start: 455 continue 456 457 # stop looping if it's past the requested end time 458 if startTime >= end: 459 break 460 461 # skip if it's on our list of exceptions 462 if event.exdates: 463 if startTime in event.exdates: 464 self.debug("startTime %r is listed as EXDATE, skipping", 465 startTime) 466 continue 467 468 endTime = startTime + delta 469 470 i = EventInstance(event, startTime, endTime) 471 472 ret.append(i) 473 474 return ret
475
476 - def getActiveEventInstances(self, dt=None):
477 """ 478 Get all event instances active at the given dt. 479 480 @type dt: L{datetime.datetime} 481 482 @rtype: list of L{EventInstance} 483 """ 484 if not dt: 485 dt = datetime.datetime.now(tz=UTC) 486 487 result = [] 488 489 # handle recurrence events first 490 recurring = self._getRecurringEvent() 491 if recurring: 492 # FIXME: support multiple RRULE; see 4.8.5.4 Recurrence Rule 493 startRecurRule = rrule.rrulestr(recurring.rrules[0], 494 dtstart=recurring.start) 495 dtstart = startRecurRule.before(dt) 496 497 if dtstart: 498 skip = False 499 # ignore if we have another event with this recurrence-id 500 for event in self._events: 501 if event.recurrenceid: 502 if event.recurrenceid == dtstart: 503 self.log( 504 'event %r, recurrenceid %r matches dtstart %r', 505 event, event.recurrenceid, dtstart) 506 skip = True 507 508 # add if it's not on our list of exceptions 509 if recurring.exdates and dtstart in recurring.exdates: 510 self.log('recurring event %r has exdate for %r', 511 recurring, dtstart) 512 skip = True 513 514 if not skip: 515 delta = recurring.end - recurring.start 516 dtend = dtstart + delta 517 if dtend >= dt: 518 # starts before our dt, and ends after, so add 519 result.append(EventInstance(recurring, dtstart, dtend)) 520 521 # handle all other events 522 for event in self._events: 523 if event is recurring: 524 continue 525 526 if event.start < dt < event.end: 527 result.append(EventInstance(event, event.start, event.end)) 528 529 self.log('events active at %s: %r', str(dt), result) 530 531 return result
532
533 - def getEvents(self):
534 """ 535 Return the list of events. 536 537 @rtype: list of L{Event} 538 """ 539 return self._events
540 541
542 -class Calendar(log.Loggable):
543 """ 544 I represent a parsed iCalendar resource. 545 I have a list of VEVENT sets from which I can be asked to schedule 546 points marking the start or end of event instances. 547 """ 548 549 logCategory = 'calendar' 550
551 - def __init__(self):
552 self._eventSets = {} # uid -> EventSet
553
554 - def addEvent(self, event):
555 """ 556 Add a parsed VEVENT definition. 557 558 @type event: L{Event} 559 """ 560 uid = event.uid 561 self.log("adding event %s with content %r", uid, event.content) 562 if uid not in self._eventSets: 563 self._eventSets[uid] = EventSet(uid) 564 self._eventSets[uid].addEvent(event)
565
566 - def getPoints(self, start=None, delta=None):
567 """ 568 Get all points from the given start time within the given delta. 569 End Points will be ordered before Start Points with the same time. 570 571 All points have a dt in the timezone as specified in the calendar. 572 573 start defaults to now. 574 delta defaults to 0, effectively returning all points at this time. 575 576 @type start: L{datetime.datetime} 577 @type delta: L{datetime.timedelta} 578 579 @rtype: list of L{Point} 580 """ 581 result = [] 582 583 for eventSet in self._eventSets.values(): 584 points = eventSet.getPoints(start, delta=delta, clip=False) 585 result.extend(points) 586 587 result.sort() 588 589 return result
590
591 - def getActiveEventInstances(self, when=None):
592 """ 593 Get a list of active event instances at the given time. 594 595 @param when: the time to check; defaults to right now 596 @type when: L{datetime.datetime} 597 598 @rtype: list of L{EventInstance} 599 """ 600 result = [] 601 602 if not when: 603 when = datetime.datetime.now(UTC) 604 605 for eventSet in self._eventSets.values(): 606 result.extend(eventSet.getActiveEventInstances(when)) 607 608 self.debug('%d active event instances at %s', len(result), str(when)) 609 return result
610 611
612 -def vDDDToDatetime(v):
613 """ 614 Convert a vDDDType to a datetime, respecting timezones. 615 616 @param v: the time to convert 617 @type v: L{icalendar.prop.vDDDTypes} 618 619 """ 620 dt = _toDateTime(v.dt) 621 if dt.tzinfo is None: 622 # We might have a "floating" DATE-TIME value here, in 623 # which case we will not have a TZID parameter; see 624 # 4.3.5, FORM #3 625 # Using None as the parameter for tz.gettz will create a 626 # tzinfo object representing local time, which is the 627 # Right Thing 628 tzinfo = tz.gettz(v.params.get('TZID', None)) 629 dt = datetime.datetime(dt.year, dt.month, dt.day, 630 dt.hour, dt.minute, dt.second, 631 dt.microsecond, tzinfo) 632 return dt
633 634
635 -def fromICalendar(iCalendar):
636 """ 637 Parse an icalendar Calendar object into our Calendar object. 638 639 @param iCalendar: The calendar to parse 640 @type iCalendar: L{icalendar.Calendar} 641 642 @rtype: L{Calendar} 643 """ 644 calendar = Calendar() 645 646 for event in iCalendar.walk('vevent'): 647 # extract to function ? 648 649 # DTSTART is REQUIRED in VEVENT; see 4.8.2.4 650 start = vDDDToDatetime(event.get('dtstart')) 651 # DTEND is optional; see 4.8.2.3 652 end = vDDDToDatetime(event.get('dtend', None)) 653 # FIXME: this implementation does not yet handle DURATION, which 654 # is an alternative to DTEND 655 656 # an event without DURATION or DTEND is defined to not consume any 657 # time; see 6; so we skip it 658 if not end: 659 continue 660 661 if end == start: 662 continue 663 664 assert end >= start, "end %r should not be before start %r" % ( 665 end, start) 666 667 summary = event.decoded('SUMMARY', None) 668 uid = event['UID'] 669 # When there is only one rrule, we don't get a list, but the 670 # single rrule Bad API 671 recur = event.get('RRULE', []) 672 if not isinstance(recur, list): 673 recur = [recur, ] 674 recur = [r.ical() for r in recur] 675 676 recurrenceid = event.get('RECURRENCE-ID', None) 677 if recurrenceid: 678 recurrenceid = vDDDToDatetime(recurrenceid) 679 680 exdates = event.get('EXDATE', []) 681 # When there is only one exdate, we don't get a list, but the 682 # single exdate. Bad API 683 if not isinstance(exdates, list): 684 exdates = [exdates, ] 685 686 # this is a list of icalendar.propvDDDTypes on which we can call 687 # .dt() or .ical() 688 exdates = [vDDDToDatetime(i) for i in exdates] 689 690 if event.get('RDATE'): 691 raise NotImplementedError("We don't handle RDATE yet") 692 693 if event.get('EXRULE'): 694 raise NotImplementedError("We don't handle EXRULE yet") 695 696 #if not start: 697 # raise AssertionError, "event %r does not have start" % event 698 #if not end: 699 # raise AssertionError, "event %r does not have end" % event 700 e = Event(uid, start, end, summary, recur, recurrenceid, exdates) 701 702 calendar.addEvent(e) 703 704 return calendar
705 706
707 -def fromFile(file):
708 """ 709 Create a new calendar from an open file object. 710 711 @type file: file object 712 713 @rtype: L{Calendar} 714 """ 715 data = file.read() 716 717 # FIXME Google calendar recently started introducing things like 718 # CREATED:0000XXXXTXXXXXXZ, which means: created in year 0000 719 # this breaks the icalendar parsing code. Guard against that. 720 data = data.replace('\nCREATED:0000', '\nCREATED:2008') 721 cal = icalendar.Calendar.from_string(data) 722 return fromICalendar(cal)
723