def _getNormalizedDateTimeProperties(self, component): # Basic time properties if component.name() in ("VEVENT", "VJOURNAL", "VPOLL"): dtstart = component.getProperty("DTSTART") dtend = component.getProperty("DTEND") duration = component.getProperty("DURATION") timeRange = Period( start=dtstart.value() if dtstart is not None else None, end=dtend.value() if dtend is not None else None, duration=duration.value() if duration is not None else None, ) newdue = None elif component.name() == "VTODO": dtstart = component.getProperty("DTSTART") duration = component.getProperty("DURATION") if dtstart or duration: timeRange = Period( start=dtstart.value() if dtstart is not None else None, duration=duration.value() if duration is not None else None, ) else: timeRange = Period() newdue = component.getProperty("DUE") if newdue is not None: newdue = newdue.value().duplicate().adjustToUTC() else: timeRange = Period() newdue = None # Recurrence rules - we need to normalize the order of the value parts newrrules = set() rrules = component.properties("RRULE") for rrule in rrules: indexedTokens = {} indexedTokens.update([valuePart.split("=") for valuePart in rrule.value().getText().split(";")]) sortedValue = ";".join(["%s=%s" % (key, value,) for key, value in sorted(indexedTokens.iteritems(), key=lambda x:x[0])]) newrrules.add(sortedValue) # RDATEs newrdates = set() rdates = component.properties("RDATE") for rdate in rdates: for value in rdate.value(): if isinstance(value, DateTime): value = value.duplicate().adjustToUTC() newrdates.add(value) # EXDATEs newexdates = set() exdates = component.properties("EXDATE") for exdate in exdates: newexdates.update([exvalue.getValue().duplicate().adjustToUTC() for exvalue in exdate.value()]) return timeRange.getStart(), timeRange.getEnd(), newdue, newrrules, newrdates, newexdates
def getVFreeBusyFB(self, period, fb): # First create expanded set # TODO: fix this # list = ExpandedComponents() self.getVEvents(period, list) if len(list) == 0: return # Get start/end list for each non-all-day expanded components dtstart = [] dtend = [] for dt in list: # Ignore if all-day if dt.getInstanceStart().isDateOnly(): continue # Ignore if transparent to free-busy transp = "" if dt.getOwner().getProperty( definitions.cICalProperty_TRANSP, transp) and (transp == definitions.cICalProperty_TRANSPARENT): continue # Add start/end to list dtstart.append(dt.getInstanceStart()) dtend.append(dt.getInstanceEnd()) # No longer need the expanded items list.clear() # Create non-overlapping periods as properties in the freebusy component temp = Period(dtstart.front(), dtend.front()) dtstart_iter = dtstart.iter() dtstart_iter.next() dtend_iter = dtend.iter() dtend_iter.next() for i in i: # Check for non-overlap if dtstart_iter > temp.getEnd(): # Current period is complete fb.addProperty( Property(definitions.cICalProperty_FREEBUSY, temp)) # Reset period to new range temp = Period(dtstart_iter, dtend_iter) # They overlap - check for extended end if dtend_iter > temp.getEnd(): # Extend the end temp = Period(temp.getStart(), dtend_iter) # Add remaining period as property fb.addProperty(Property(definitions.cICalProperty_FREEBUSY, temp))
def getFreeBusy(self, period, fb): # First create expanded set list = [] self.getVEvents(period, list) # Get start/end list for each non-all-day expanded components for comp in list: # Ignore if all-day if comp.getInstanceStart().isDateOnly(): continue # Ignore if transparent to free-busy transp = "" if comp.getOwner().getProperty( definitions.cICalProperty_TRANSP, transp) and (transp == definitions.cICalProperty_TRANSPARENT): continue # Add free busy item to list status = comp.getMaster().getStatus() if status in (definitions.eStatus_VEvent_None, definitions.eStatus_VEvent_Confirmed): fb.append( FreeBusy( FreeBusy.BUSY, Period(comp.getInstanceStart(), comp.getInstanceEnd()))) elif status == definitions.eStatus_VEvent_Tentative: fb.append( FreeBusy( FreeBusy.BUSYTENTATIVE, Period(comp.getInstanceStart(), comp.getInstanceEnd()))) break elif status == definitions.eStatus_VEvent_Cancelled: # Cancelled => does not contribute to busy time pass # Now get the VFREEBUSY info list2 = [] self.getVFreeBusy(period, list2) # Get start/end list for each free-busy for comp in list2: # Expand component and add free busy info to list comp.expandPeriod(period, fb) # Add remaining period as property FreeBusy.resolveOverlaps(fb)
def processAvailabilityFreeBusy(self, calendar, fbinfo): """ Extract free-busy data from a VAVAILABILITY component. @param calendar: the L{Component} that is the VCALENDAR containing the VAVAILABILITY's. @param fbinfo: the tuple used to store the three types of fb data. """ for vav in [ x for x in calendar.subcomponents() if x.name() == "VAVAILABILITY" ]: # Get overall start/end start = vav.getStartDateUTC() if start is None: start = DateTime(1900, 1, 1, 0, 0, 0, tzid=Timezone.UTCTimezone) end = vav.getEndDateUTC() if end is None: end = DateTime(2100, 1, 1, 0, 0, 0, tzid=Timezone.UTCTimezone) period = Period(start, end) overall = clipPeriod(period, self.timerange) if overall is None: continue # Now get periods for each instance of AVAILABLE sub-components periods = self.processAvailablePeriods(vav) # Now invert the periods and store in accumulator busyperiods = [] last_end = self.timerange.getStart() for period in periods: if last_end < period.getStart(): busyperiods.append(Period(last_end, period.getStart())) last_end = period.getEnd() if last_end < self.timerange.getEnd(): busyperiods.append(Period(last_end, self.timerange.getEnd())) # Add to actual results mapped by busy type fbtype = vav.propertyValue("BUSYTYPE") if fbtype is None: fbtype = "BUSY-UNAVAILABLE" getattr(fbinfo, self.FBInfo_mapper.get(fbtype, "unavailable")).extend(busyperiods)
def testSetUseDuration(self): p1 = Period( start=DateTime(2000, 1, 1, 0, 0, 0), end=DateTime(2000, 1, 1, 1, 0, 0), ) p1.setUseDuration(True) self.assertTrue(p1.getText(), "20000101T000000/PT1H") p2 = Period( start=DateTime(2000, 1, 1, 0, 0, 0), duration=Duration(hours=1), ) p2.setUseDuration(False) self.assertTrue(p2.getText(), "20000101T000000/20000101T010000")
def test_freebusy(self): """ Test that action=component works. """ yield self.createShare("user01", "puser01") calendar1 = yield self.calendarUnderTest(txn=self.theTransactionUnderTest(0), home="user01", name="calendar") yield calendar1.createCalendarObjectWithName("1.ics", Component.fromString(self.caldata1)) yield self.commitTransaction(0) fbstart = "{now:04d}0102T000000Z".format(**self.nowYear) fbend = "{now:04d}0103T000000Z".format(**self.nowYear) shared = yield self.calendarUnderTest(txn=self.theTransactionUnderTest(1), home="puser01", name="shared-calendar") fbinfo = FreebusyQuery.FBInfo([], [], []) timerange = Period(DateTime.parseText(fbstart), DateTime.parseText(fbend)) organizer = recipient = (yield calendarUserFromCalendarUserAddress("mailto:[email protected]", self.theTransactionUnderTest(1))) freebusy = FreebusyQuery(organizer=organizer, recipient=recipient, timerange=timerange) matchtotal = (yield freebusy.generateFreeBusyInfo([shared, ], fbinfo)) self.assertEqual(matchtotal, 1) self.assertEqual(fbinfo[0], [Period.parseText("{now:04d}0102T140000Z/PT1H".format(**self.nowYear)), ]) self.assertEqual(len(fbinfo[1]), 0) self.assertEqual(len(fbinfo[2]), 0) yield self.commitTransaction(1)
def testMonthlyInUTC(self): recur = Recurrence() recur.parse("FREQ=MONTHLY") start = DateTime(2014, 1, 1, 12, 0, 0, tzid=Timezone(utc=True)) end = DateTime(2015, 1, 1, 0, 0, 0, tzid=Timezone(utc=True)) items = [] range = Period(start, end) recur.expand(DateTime(2014, 1, 1, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), range, items) self.assertEqual( items, [ DateTime(2014, 1, 1, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 2, 1, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 3, 1, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 4, 1, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 5, 1, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 6, 1, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 7, 1, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 8, 1, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 9, 1, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 10, 1, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 11, 1, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 12, 1, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), ], )
def expandAll(self, start, end, with_name): if start is None: start = self.mStart # Ignore if there is no change in offset offsetto = self.loadValueInteger(definitions.cICalProperty_TZOFFSETTO, Value.VALUETYPE_UTC_OFFSET) offsetfrom = self.loadValueInteger(definitions.cICalProperty_TZOFFSETFROM, Value.VALUETYPE_UTC_OFFSET) # if offsetto == offsetfrom: # return () # Look for recurrences if self.mStart > end: # Return nothing return () elif not self.mRecurrences.hasRecurrence(): # Return DTSTART even if it is newer if self.mStart >= start: result = (self.mStart, offsetfrom, offsetto,) if with_name: result += (self.getTZName(),) return (result,) else: return () else: # We want to allow recurrence calculation caching to help us here # as this method # gets called a lot - most likely for ever increasing dt values # (which will therefore # invalidate the recurrence cache). # # What we will do is round up the date-time to the next year so # that the recurrence # cache is invalidated less frequently temp = DateTime(end.getYear(), 1, 1, 0, 0, 0) # Use cache of expansion if self.mCachedExpandBelowItems is None: self.mCachedExpandBelowItems = [] if self.mCachedExpandBelow is None: self.mCachedExpandBelow = self.mStart.duplicate() if temp > self.mCachedExpandBelow: self.mCachedExpandBelowItems = [] period = Period(self.mStart, end) self.mRecurrences.expand(self.mStart, period, self.mCachedExpandBelowItems, float_offset=self.mUTCOffsetFrom) self.mCachedExpandBelow = temp if len(self.mCachedExpandBelowItems) != 0: # Return them all within the range results = [] for dt in self.mCachedExpandBelowItems: if dt >= start and dt < end: result = (dt, offsetfrom, offsetto,) if with_name: result += (self.getTZName(),) results.append(result) return results return ()
def limitFreeBusy(self, calendar): """ Limit the range of any FREEBUSY properties in the calendar, returning a new calendar if limits were applied, or the same one if no limits were applied. @param calendar: the L{Component} for the calendar to operate on. @return: the L{Component} for the result. """ # First check for any VFREEBUSYs - can ignore limit if there are none if calendar.mainType() != "VFREEBUSY": return calendar # Create duplicate calendar and filter FREEBUSY properties calendar = calendar.duplicate() for component in calendar.subcomponents(): if component.name() != "VFREEBUSY": continue for property in component.properties("FREEBUSY"): newvalue = [] for period in property.value(): clipped = clipPeriod(period.getValue(), Period(self.calendardata.freebusy_set.start, self.calendardata.freebusy_set.end)) if clipped: newvalue.append(clipped) if len(newvalue): property.setValue(newvalue) else: component.removeProperty(property) return calendar
def clipPeriod(period, clipPeriod): """ Clip the start/end period so that it lies entirely within the clip period. @param period: the (start, end) tuple for the period to be clipped. @param clipPeriod: the (start, end) tuple for the period to clip to. @return: the (start, end) tuple for the clipped period, or None if the period is outside the clip period """ start = period.getStart() end = period.getEnd() clipStart = clipPeriod.getStart() clipEnd = clipPeriod.getEnd() if start < clipStart: start = clipStart if end > clipEnd: end = clipEnd if start >= end: return None else: # Try to preserve use of duration in period result = Period(start, end) result.setUseDuration(period.getUseDuration()) return result
def test_one_event_event_details(self): """ Test when the calendar is empty. """ data = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN BEGIN:VEVENT UID:1234-5678 DTSTAMP:20080601T000000Z DTSTART:%s DTEND:%s END:VEVENT END:VCALENDAR """ % ( self.now_12H.getText(), self.now_13H.getText(), ) yield self._createCalendarObject(data, "user01", "test.ics") calendar = (yield self.calendarUnderTest(home="user01", name="calendar_1")) fbinfo = FreebusyQuery.FBInfo([], [], []) timerange = Period(self.now, self.now_1D) event_details = [] organizer = recipient = yield calendarUserFromCalendarUserAddress( "mailto:[email protected]", self.transactionUnderTest()) freebusy = FreebusyQuery(organizer=organizer, recipient=recipient, timerange=timerange, event_details=event_details) freebusy.same_calendar_user = True result = yield freebusy.generateFreeBusyInfo([ calendar, ], fbinfo) self.assertEqual(result, 1) self.assertEqual(fbinfo.busy, [ Period(self.now_12H, self.now_13H), ]) self.assertEqual(len(fbinfo.tentative), 0) self.assertEqual(len(fbinfo.unavailable), 0) self.assertEqual(len(event_details), 1) self.assertEqual( str(event_details[0]), str(tuple(Component.fromString(data).subcomponents())[0]))
def processAvailablePeriods(calendar, timerange): """ Extract instance period data from an AVAILABLE component. @param calendar: the L{Component} that is the VAVAILABILITY containing the AVAILABLE's. @param timerange: the time range to restrict free busy data to. """ periods = [] # First we need to group all AVAILABLE sub-components by UID uidmap = {} for component in calendar.subcomponents(): if component.name() == "AVAILABLE": uid = component.propertyValue("UID") uidmap.setdefault(uid, []).append(component) # Then we expand each uid set separately for componentSet in uidmap.itervalues(): instances = InstanceList(ignoreInvalidInstances=True) instances.expandTimeRanges(componentSet, timerange.end) # Now convert instances into period list for key in instances: instance = instances[key] # Ignore any with floating times (which should not happen as the spec requires UTC or local # but we will try and be safe here). start = instance.start if start.floating(): continue end = instance.end if end.floating(): continue # Clip period for this instance - use duration for period end if that # is what original component used if instance.component.hasProperty("DURATION"): period = Period(start, duration=end - start) else: period = Period(start, end) clipped = clipPeriod(period, Period(timerange.start, timerange.end)) if clipped: periods.append(clipped) normalizePeriodList(periods) return periods
def report_urn_ietf_params_xml_ns_caldav_free_busy_query(self, request, freebusy): """ Generate a free-busy REPORT. (CalDAV-access-09, section 7.8) """ if not self.isCollection(): log.error("freebusy report is only allowed on collection resources {s!r}", s=self) raise HTTPError(StatusResponse(responsecode.FORBIDDEN, "Not a calendar collection")) if freebusy.qname() != (caldavxml.caldav_namespace, "free-busy-query"): raise ValueError("{CalDAV:}free-busy-query expected as root element, not %s." % (freebusy.sname(),)) timerange = freebusy.timerange if not timerange.valid(): raise HTTPError(StatusResponse(responsecode.BAD_REQUEST, "Invalid time-range specified")) fbset = [] accepted_type = bestAcceptType(request.headers.getHeader("accept"), Component.allowedTypes()) if accepted_type is None: raise HTTPError(StatusResponse(responsecode.NOT_ACCEPTABLE, "Cannot generate requested data type")) def getCalendarList(calresource, uri): # @UnusedVariable """ Store the calendars that match the query in L{fbset} which will then be used with the freebusy query. @param calresource: the L{CalDAVResource} for a calendar collection. @param uri: the uri for the calendar collection resource. """ fbset.append(calresource._newStoreObject) return succeed(True) # Run report taking depth into account depth = request.headers.getHeader("depth", "0") yield report_common.applyToCalendarCollections(self, request, request.uri, depth, getCalendarList, (caldavxml.ReadFreeBusy(),)) # Do the actual freebusy query against the set of matched calendars principal = yield self.resourceOwnerPrincipal(request) organizer = recipient = LocalCalendarUser(principal.canonicalCalendarUserAddress(), principal.record) timerange = Period(timerange.start, timerange.end) try: fbresult = yield FreebusyQuery(organizer=organizer, recipient=recipient, timerange=timerange).generateAttendeeFreeBusyResponse(fbset=fbset, method=None) except NumberOfMatchesWithinLimits: log.error("Too many matching components in free-busy report") raise HTTPError(ErrorResponse( responsecode.FORBIDDEN, davxml.NumberOfMatchesWithinLimits(), "Too many components" )) except TimeRangeLowerLimit, e: raise HTTPError(ErrorResponse( responsecode.FORBIDDEN, caldavxml.MinDateTime(), "Time-range value too far in the past. Must be on or after %s." % (str(e.limit),) ))
def normalizePeriodList(periods): """ Normalize the list of periods by merging overlapping or consecutive ranges and sorting the list by each periods start. @param list: a list of tuples of L{Period}. The list is changed in place. """ # First sort the list def sortPeriods(p1, p2): """ Compare two periods. Sort by their start and then end times. A period is a L{Period}. @param p1: first period @param p2: second period @return: 1 if p1>p2, 0 if p1==p2, -1 if p1<p2 """ assert isinstance(p1, Period), "Period is not a Period: %r" % (p1,) assert isinstance(p2, Period), "Period is not a Period: %r" % (p2,) if p1.getStart() == p2.getStart(): cmp1 = p1.getEnd() cmp2 = p2.getEnd() else: cmp1 = p1.getStart() cmp2 = p2.getStart() return compareDateTime(cmp1, cmp2) for period in periods: period.adjustToUTC() periods.sort(cmp=sortPeriods) # Now merge overlaps and consecutive periods index = None p = None pe = None for i in xrange(len(periods)): if p is None: index = i p = periods[i] pe = p.getEnd() continue ie = periods[i].getEnd() if (pe >= periods[i].getStart()): if ie > pe: periods[index] = Period(periods[index].getStart(), ie) pe = ie periods[i] = None else: index = i p = periods[i] pe = p.getEnd() periods[:] = [x for x in periods if x]
def testWeeklyTwice(self): recur = Recurrence() recur.parse("FREQ=WEEKLY") start = DateTime(2014, 1, 1, 12, 0, 0, tzid=Timezone(utc=True)) end = DateTime(2014, 2, 1, 0, 0, 0, tzid=Timezone(utc=True)) items = [] range = Period(start, end) recur.expand(DateTime(2014, 1, 1, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), range, items) self.assertEqual( items, [ DateTime(2014, 1, 1, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 1, 8, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 1, 15, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 1, 22, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 1, 29, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), ], ) start = DateTime(2014, 1, 1, 12, 0, 0, tzid=Timezone(utc=True)) end = DateTime(2014, 3, 1, 0, 0, 0, tzid=Timezone(utc=True)) items = [] range = Period(start, end) recur.expand(DateTime(2014, 1, 1, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), range, items) self.assertEqual( items, [ DateTime(2014, 1, 1, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 1, 8, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 1, 15, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 1, 22, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 1, 29, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 2, 5, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 2, 12, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 2, 19, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), DateTime(2014, 2, 26, 12, 0, 0, tzid=Timezone(tzid="America/New_York")), ], )
def instances(start, rrule): """ Expand an RRULE. """ recur = Recurrence() recur.parse(rrule) start = DateTime.parseText(start) end = start.duplicate() end.offsetYear(100) items = [] range = Period(start, end) recur.expand(start, range, items) print("DTSTART:{}".format(start)) print("RRULE:{}".format(rrule)) print("Instances: {}".format(", ".join(map(str, items))))
def expandBelow(self, below): # Look for recurrences if not self.mRecurrences.hasRecurrence() or self.mStart > below: # Return DTSTART even if it is newer return self.mStart else: # We want to allow recurrence calculation caching to help us here # as this method # gets called a lot - most likely for ever increasing dt values # (which will therefore # invalidate the recurrence cache). # # What we will do is round up the date-time to the next year so # that the recurrence # cache is invalidated less frequently temp = DateTime(below.getYear(), 1, 1, 0, 0, 0) # Use cache of expansion if self.mCachedExpandBelowItems is None: self.mCachedExpandBelowItems = [] if self.mCachedExpandBelow is None: self.mCachedExpandBelow = self.mStart.duplicate() if temp > self.mCachedExpandBelow: self.mCachedExpandBelowItems = [] period = Period(self.mStart, temp) self.mRecurrences.expand(self.mStart, period, self.mCachedExpandBelowItems, float_offset=self.mUTCOffsetFrom) self.mCachedExpandBelow = temp if len(self.mCachedExpandBelowItems) != 0: # List comes back sorted so we pick the element just less than # the dt value we want i = bisect_right(self.mCachedExpandBelowItems, below) if i != 0: return self.mCachedExpandBelowItems[i - 1] # The first one in the list is the one we want return self.mCachedExpandBelowItems[0] return self.mStart
def testClearOnChange(self): recur = Recurrence() recur.parse("FREQ=DAILY") start = DateTime(2013, 1, 1, 0, 0, 0) end = DateTime(2017, 1, 1, 0, 0, 0) range = Period(start, end) items = [] recur.expand(start, range, items) self.assertTrue(recur.mCached) self.assertTrue(len(items) > 100) recur.setUseCount(True) recur.setCount(10) self.assertFalse(recur.mCached) items = [] recur.expand(start, range, items) self.assertEqual(len(items), 10)
def testExampleRules(self): examples = os.path.join(os.path.dirname(__file__), "rrule_examples.json") with open(examples) as f: examples = json.loads(f.read()) for ctr, i in enumerate(examples): recur = Recurrence() recur.parse(i["rule"]) start = DateTime.parseText(i["start"]) end = DateTime.parseText(i["end"]) results = map(DateTime.parseText, i["results"]) items = [] range = Period(start, end) recur.expand(start, range, items) self.assertEqual(items, results, msg="Failed rule: #{} {}".format( ctr + 1, i["rule"]))
def test_no_events(self): """ Test when the calendar is empty. """ calendar = (yield self.calendarUnderTest(home="user01", name="calendar_1")) fbinfo = FreebusyQuery.FBInfo([], [], []) timerange = Period(self.now, self.now_1D) organizer = recipient = yield calendarUserFromCalendarUserAddress( "mailto:[email protected]", self.transactionUnderTest()) freebusy = FreebusyQuery(organizer=organizer, recipient=recipient, timerange=timerange) result = (yield freebusy.generateFreeBusyInfo([ calendar, ], fbinfo)) self.assertEqual(result, 0) self.assertEqual(len(fbinfo.busy), 0) self.assertEqual(len(fbinfo.tentative), 0) self.assertEqual(len(fbinfo.unavailable), 0)
def processFreeBusyFreeBusy(calendar, fbinfo, timerange): """ Extract FREEBUSY data from a VFREEBUSY component. @param calendar: the L{Component} that is the VCALENDAR containing the VFREEBUSY's. @param fbinfo: the tuple used to store the three types of fb data. @param timerange: the time range to restrict free busy data to. """ for vfb in [ x for x in calendar.subcomponents() if x.name() == "VFREEBUSY" ]: # First check any start/end in the actual component start = vfb.getStartDateUTC() end = vfb.getEndDateUTC() if start and end: if not timeRangesOverlap(start, end, timerange.start, timerange.end): continue # Now look at each FREEBUSY property for fb in vfb.properties("FREEBUSY"): # Check the type fbtype = fb.parameterValue("FBTYPE", default="BUSY") if fbtype == "FREE": continue # Look at each period in the property assert isinstance( fb.value(), list ), "FREEBUSY property does not contain a list of values: %r" % ( fb, ) for period in fb.value(): # Clip period for this instance clipped = clipPeriod(period.getValue(), Period(timerange.start, timerange.end)) if clipped: fbinfo[fbtype_mapper.get(fbtype, 0)].append(clipped)
def _addMasterComponent(self, component, lowerLimit, upperlimit, rulestart, start, end, duration): rrules = component.getRecurrenceSet() if rrules is not None and rulestart is not None: # Do recurrence set expansion expanded = [] # Begin expansion far in the past because there may be RDATEs earlier # than the master DTSTART, and if we exclude those, the associated # overridden instances will cause an InvalidOverriddenInstance. limited = rrules.expand(rulestart, Period(DateTime(1900, 1, 1), upperlimit), expanded) for startDate in expanded: startDate = self.normalizeFunction(startDate) endDate = startDate + duration if lowerLimit is None or endDate >= lowerLimit: self.addInstance(Instance(component, startDate, endDate)) else: self.lowerLimit = lowerLimit if limited: self.limit = upperlimit else: # Always add main instance if included in range. if start < upperlimit: if lowerLimit is None or end >= lowerLimit: start = self.normalizeFunction(start) end = self.normalizeFunction(end) self.addInstance(Instance(component, start, end)) else: self.lowerLimit = lowerLimit else: self.limit = upperlimit self.master_cancelled = component.propertyValue( "STATUS") == "CANCELLED"
def testGetVEvents(self): data = ( ( "Non-recurring match", """BEGIN:VCALENDAR VERSION:2.0 CALSCALE:GREGORIAN PRODID:-//mulberrymail.com//Mulberry v4.0//EN BEGIN:VEVENT UID:C3184A66-1ED0-11D9-A5E0-000A958A3252 DTSTART;VALUE=DATE:20110601 DURATION:P1D DTSTAMP:20020101T000000Z SUMMARY:New Year's Day END:VEVENT END:VCALENDAR """.replace("\n", "\r\n"), (DateTime(2011, 6, 1), ), ), ( "Non-recurring no-match", """BEGIN:VCALENDAR VERSION:2.0 CALSCALE:GREGORIAN PRODID:-//mulberrymail.com//Mulberry v4.0//EN BEGIN:VEVENT UID:C3184A66-1ED0-11D9-A5E0-000A958A3252 DTSTART;VALUE=DATE:20110501 DURATION:P1D DTSTAMP:20020101T000000Z SUMMARY:New Year's Day END:VEVENT END:VCALENDAR """.replace("\n", "\r\n"), (), ), ( "Recurring match", """BEGIN:VCALENDAR VERSION:2.0 CALSCALE:GREGORIAN PRODID:-//mulberrymail.com//Mulberry v4.0//EN BEGIN:VEVENT UID:C3184A66-1ED0-11D9-A5E0-000A958A3252 DTSTART;VALUE=DATE:20110601 DURATION:P1D DTSTAMP:20020101T000000Z RRULE:FREQ=DAILY;COUNT=2 SUMMARY:New Year's Day END:VEVENT END:VCALENDAR """.replace("\n", "\r\n"), ( DateTime(2011, 6, 1), DateTime(2011, 6, 2), ), ), ( "Recurring no match", """BEGIN:VCALENDAR VERSION:2.0 CALSCALE:GREGORIAN PRODID:-//mulberrymail.com//Mulberry v4.0//EN BEGIN:VEVENT UID:C3184A66-1ED0-11D9-A5E0-000A958A3252 DTSTART;VALUE=DATE:20110501 DURATION:P1D DTSTAMP:20020101T000000Z RRULE:FREQ=DAILY;COUNT=2 SUMMARY:New Year's Day END:VEVENT END:VCALENDAR """.replace("\n", "\r\n"), (), ), ( "Recurring with override match", """BEGIN:VCALENDAR VERSION:2.0 CALSCALE:GREGORIAN PRODID:-//mulberrymail.com//Mulberry v4.0//EN BEGIN:VEVENT UID:C3184A66-1ED0-11D9-A5E0-000A958A3252 DTSTART:20110601T120000 DURATION:P1D DTSTAMP:20020101T000000Z RRULE:FREQ=DAILY;COUNT=2 SUMMARY:New Year's Day END:VEVENT BEGIN:VEVENT UID:C3184A66-1ED0-11D9-A5E0-000A958A3252 RECURRENCE-ID;VALUE=DATE:20110602T120000 DTSTART;VALUE=DATE:20110602T130000 DURATION:P1D DTSTAMP:20020101T000000Z SUMMARY:New Year's Day END:VEVENT END:VCALENDAR """.replace("\n", "\r\n"), ( DateTime(2011, 6, 1, 12, 0, 0), DateTime(2011, 6, 2, 13, 0, 0), ), ), ( "Recurring with override no match", """BEGIN:VCALENDAR VERSION:2.0 CALSCALE:GREGORIAN PRODID:-//mulberrymail.com//Mulberry v4.0//EN BEGIN:VEVENT UID:C3184A66-1ED0-11D9-A5E0-000A958A3252 DTSTART:20110501T120000 DURATION:P1D DTSTAMP:20020101T000000Z RRULE:FREQ=DAILY;COUNT=2 SUMMARY:New Year's Day END:VEVENT BEGIN:VEVENT UID:C3184A66-1ED0-11D9-A5E0-000A958A3252 RECURRENCE-ID;VALUE=DATE:20110502T120000 DTSTART;VALUE=DATE:20110502T130000 DURATION:P1D DTSTAMP:20020101T000000Z SUMMARY:New Year's Day END:VEVENT END:VCALENDAR """.replace("\n", "\r\n"), (), ), ( "Recurring partial match", """BEGIN:VCALENDAR VERSION:2.0 CALSCALE:GREGORIAN PRODID:-//mulberrymail.com//Mulberry v4.0//EN BEGIN:VEVENT UID:C3184A66-1ED0-11D9-A5E0-000A958A3252 DTSTART;VALUE=DATE:20110531 DURATION:P1D DTSTAMP:20020101T000000Z RRULE:FREQ=DAILY;COUNT=2 SUMMARY:New Year's Day END:VEVENT END:VCALENDAR """.replace("\n", "\r\n"), (DateTime(2011, 6, 1), ), ), ( "Recurring with override partial match", """BEGIN:VCALENDAR VERSION:2.0 CALSCALE:GREGORIAN PRODID:-//mulberrymail.com//Mulberry v4.0//EN BEGIN:VEVENT UID:C3184A66-1ED0-11D9-A5E0-000A958A3252 DTSTART:20110531T120000 DURATION:P1D DTSTAMP:20020101T000000Z RRULE:FREQ=DAILY;COUNT=2 SUMMARY:New Year's Day END:VEVENT BEGIN:VEVENT UID:C3184A66-1ED0-11D9-A5E0-000A958A3252 RECURRENCE-ID;VALUE=DATE:20110601T120000 DTSTART;VALUE=DATE:20110601T130000 DURATION:P1D DTSTAMP:20020101T000000Z SUMMARY:New Year's Day END:VEVENT END:VCALENDAR """.replace("\n", "\r\n"), (DateTime(2011, 6, 1, 13, 0, 0), ), ), ) for title, caldata, result in data: calendar = Calendar.parseText(caldata) instances = [] calendar.getVEvents( Period( start=DateTime(2011, 6, 1), end=DateTime(2011, 7, 1), ), instances) instances = tuple( [instance.getInstanceStart() for instance in instances]) self.assertEqual( instances, result, "Failed in %s: got %s, expected %s" % (title, instances, result))
def processEventFreeBusy(self, calendar, fbinfo, tzinfo): """ Extract free busy data from a VEVENT component. @param calendar: the L{Component} that is the VCALENDAR containing the VEVENT's. @param fbinfo: the tuple used to store the three types of fb data. @param tzinfo: the L{Timezone} for the timezone to use for floating/all-day events. """ # Expand out the set of instances for the event with in the required range instances = calendar.expandTimeRanges(self.timerange.getEnd(), lowerLimit=self.timerange.getStart(), ignoreInvalidInstances=True) # Can only do timed events for key in instances: instance = instances[key] if instance.start.isDateOnly(): return break else: return for key in instances: instance = instances[key] # Apply a timezone to any floating times fbstart = instance.start if fbstart.floating(): fbstart.setTimezone(tzinfo) fbend = instance.end if fbend.floating(): fbend.setTimezone(tzinfo) # Check TRANSP property of underlying component if instance.component.hasProperty("TRANSP"): # If its TRANSPARENT we always ignore it if instance.component.propertyValue("TRANSP") == "TRANSPARENT": continue # Determine status if instance.component.hasProperty("STATUS"): status = instance.component.propertyValue("STATUS") else: status = "CONFIRMED" # Ignore cancelled if status == "CANCELLED": continue # Clip period for this instance - use duration for period end if that # is what original component used if instance.component.hasProperty("DURATION"): period = Period(fbstart, duration=fbend - fbstart) else: period = Period(fbstart, fbend) clipped = clipPeriod(period, self.timerange) # Double check for overlap if clipped: if status == "TENTATIVE": fbinfo.tentative.append(clipped) else: fbinfo.busy.append(clipped)
def _matchCalendarResources(self, calresource): # Get the timezone property from the collection. tz = calresource.getTimezone() # Try cache aggregated_resources = (yield FBCacheEntry.getCacheEntry(calresource, self.attendee_uid, self.timerange)) if config.EnableFreeBusyCache else None if aggregated_resources is None: if self.accountingItems is not None: self.accountingItems["fb-uncached"] = self.accountingItems.get("fb-uncached", 0) + 1 caching = False if config.EnableFreeBusyCache: # Log extended item if self.logItems is not None: self.logItems["fb-uncached"] = self.logItems.get("fb-uncached", 0) + 1 # We want to cache a large range of time based on the current date cache_start = normalizeToUTC(DateTime.getToday() + Duration(days=0 - config.FreeBusyCacheDaysBack)) cache_end = normalizeToUTC(DateTime.getToday() + Duration(days=config.FreeBusyCacheDaysForward)) # If the requested time range would fit in our allowed cache range, trigger the cache creation if compareDateTime(self.timerange.getStart(), cache_start) >= 0 and compareDateTime(self.timerange.getEnd(), cache_end) <= 0: cache_timerange = Period(cache_start, cache_end) caching = True # # What we do is a fake calendar-query for VEVENT/VFREEBUSYs in the specified time-range. # We then take those results and merge them into one VFREEBUSY component # with appropriate FREEBUSY properties, and return that single item as iCal data. # # Create fake filter element to match time-range tr = TimeRange( start=(cache_timerange if caching else self.timerange).getStart().getText(), end=(cache_timerange if caching else self.timerange).getEnd().getText(), ) filter = caldavxml.Filter( caldavxml.ComponentFilter( caldavxml.ComponentFilter( tr, name=("VEVENT", "VFREEBUSY", "VAVAILABILITY"), ), name="VCALENDAR", ) ) filter = Filter(filter) tzinfo = filter.settimezone(tz) if self.accountingItems is not None: self.accountingItems["fb-query-timerange"] = (str(tr.start), str(tr.end),) try: resources = yield calresource.search(filter, useruid=self.attendee_uid, fbtype=True) aggregated_resources = {} for name, uid, comptype, test_organizer, float, start, end, fbtype, transp in resources: if transp == 'T' and fbtype != '?': fbtype = 'F' aggregated_resources.setdefault((name, uid, comptype, test_organizer,), []).append(( float, tupleFromDateTime(parseSQLTimestampToPyCalendar(start)), tupleFromDateTime(parseSQLTimestampToPyCalendar(end)), fbtype, )) if caching: yield FBCacheEntry.makeCacheEntry(calresource, self.attendee_uid, cache_timerange, aggregated_resources) except IndexedSearchException: raise InternalDataStoreError("Invalid indexedSearch query") else: if self.accountingItems is not None: self.accountingItems["fb-cached"] = self.accountingItems.get("fb-cached", 0) + 1 # Log extended item if self.logItems is not None: self.logItems["fb-cached"] = self.logItems.get("fb-cached", 0) + 1 # Determine appropriate timezone (UTC is the default) tzinfo = tz.gettimezone() if tz is not None else Timezone.UTCTimezone filter = None returnValue((aggregated_resources, tzinfo, filter,))
def _internalGenerateFreeBusyInfo( self, fbset, fbinfo, matchtotal, ): """ Run a free busy report on the specified calendar collection accumulating the free busy info for later processing. @param calresource: the L{Calendar} for a calendar collection. @param fbinfo: the array of busy periods to update. @param matchtotal: the running total for the number of matches. """ yield self.checkRichOptions(fbset[0]._txn) calidmap = dict([(fbcalendar.id(), fbcalendar,) for fbcalendar in fbset]) directoryService = fbset[0].directoryService() results = yield self._matchResources(fbset) if self.accountingItems is not None: self.accountingItems["fb-resources"] = {} for calid, result in results.items(): aggregated_resources, tzinfo, filter = result for k, v in aggregated_resources.items(): name, uid, comptype, test_organizer = k self.accountingItems["fb-resources"][uid] = [] for float, start, end, fbtype in v: fbstart = tupleToDateTime(start, withTimezone=tzinfo if float == 'Y' else Timezone.UTCTimezone) fbend = tupleToDateTime(end, withTimezone=tzinfo if float == 'Y' else Timezone.UTCTimezone) self.accountingItems["fb-resources"][uid].append(( float, str(fbstart), str(fbend), fbtype, )) # Cache directory record lookup outside this loop as it is expensive and will likely # always end up being called with the same organizer address. recordUIDCache = {} for calid, result in results.items(): calresource = calidmap[calid] aggregated_resources, tzinfo, filter = result for key in aggregated_resources.iterkeys(): name, uid, comptype, test_organizer = key # Short-cut - if an fbtype exists we can use that if comptype == "VEVENT" and aggregated_resources[key][0][3] != '?': matchedResource = False # Look at each instance for float, start, end, fbtype in aggregated_resources[key]: # Ignore free time or unknown if fbtype in ('F', '?'): continue # Apply a timezone to any floating times fbstart = tupleToDateTime(start, withTimezone=tzinfo if float == 'Y' else Timezone.UTCTimezone) fbend = tupleToDateTime(end, withTimezone=tzinfo if float == 'Y' else Timezone.UTCTimezone) # Clip instance to time range clipped = clipPeriod(Period(fbstart, end=fbend), self.timerange) # Double check for overlap if clipped: # Ignore ones of this UID if not (yield self._testIgnoreExcludeUID(uid, test_organizer, recordUIDCache, directoryService)): clipped.setUseDuration(True) matchedResource = True getattr(fbinfo, self.FBInfo_index_mapper.get(fbtype, "busy")).append(clipped) if matchedResource: # Check size of results is within limit matchtotal += 1 if matchtotal > config.MaxQueryWithDataResults: raise QueryMaxResources(config.MaxQueryWithDataResults, matchtotal) # Add extended details if any(self.rich_options.values()): child = (yield calresource.calendarObjectWithName(name)) # Only add fully public events if not child.accessMode or child.accessMode == Component.ACCESS_PUBLIC: calendar = (yield child.componentForUser()) self._addEventDetails(calendar, self.rich_options, tzinfo) else: child = (yield calresource.calendarObjectWithName(name)) calendar = (yield child.componentForUser()) # The calendar may come back as None if the resource is being changed, or was deleted # between our initial index query and getting here. For now we will ignore this error, but in # the longer term we need to implement some form of locking, perhaps. if calendar is None: log.error("Calendar {name} is missing from calendar collection {coll!r}", name=name, coll=calresource) continue if self.accountingItems is not None: self.accountingItems.setdefault("fb-filter-match", []).append(uid) if filter.match(calendar, None): # Ignore ones of this UID if (yield self._testIgnoreExcludeUID(uid, calendar.getOrganizer(), recordUIDCache, calresource.directoryService())): continue if self.accountingItems is not None: self.accountingItems.setdefault("fb-filter-matched", []).append(uid) # Check size of results is within limit matchtotal += 1 if matchtotal > config.MaxQueryWithDataResults: raise QueryMaxResources(config.MaxQueryWithDataResults, matchtotal) if calendar.mainType() == "VEVENT": self.processEventFreeBusy(calendar, fbinfo, tzinfo) elif calendar.mainType() == "VFREEBUSY": self.processFreeBusyFreeBusy(calendar, fbinfo) elif calendar.mainType() == "VAVAILABILITY": self.processAvailabilityFreeBusy(calendar, fbinfo) else: assert "Free-busy query returned unwanted component: %s in %r", (name, calresource,) # Add extended details if calendar.mainType() == "VEVENT" and any(self.rich_options.values()): # Only add fully public events if not child.accessMode or child.accessMode == Component.ACCESS_PUBLIC: self._addEventDetails(calendar, self.rich_options, tzinfo) returnValue(matchtotal)
def generateFreeBusyInfo( request, calresource, fbinfo, timerange, matchtotal, excludeuid=None, organizer=None, organizerPrincipal=None, same_calendar_user=False, servertoserver=False, event_details=None, ): """ Run a free busy report on the specified calendar collection accumulating the free busy info for later processing. @param request: the L{IRequest} for the current request. @param calresource: the L{CalDAVResource} for a calendar collection. @param fbinfo: the array of busy periods to update. @param timerange: the L{TimeRange} for the query. @param matchtotal: the running total for the number of matches. @param excludeuid: a C{str} containing a UID value to exclude any components with that UID from contributing to free-busy. @param organizer: a C{str} containing the value of the ORGANIZER property in the VFREEBUSY request. This is used in conjunction with the UID value to process exclusions. @param same_calendar_user: a C{bool} indicating whether the calendar user requesting the free-busy information is the same as the calendar user being targeted. @param servertoserver: a C{bool} indicating whether we are doing a local or remote lookup request. @param event_details: a C{list} into which to store extended VEVENT details if not C{None} """ # First check the privilege on this collection # TODO: for server-to-server we bypass this right now as we have no way to authorize external users. if not servertoserver: try: yield calresource.checkPrivileges(request, (caldavxml.ReadFreeBusy(), ), principal=organizerPrincipal) except AccessDeniedError: returnValue(matchtotal) # May need organizer principal organizer_principal = (yield calresource.principalForCalendarUserAddress( organizer)) if organizer else None organizer_uid = organizer_principal.principalUID( ) if organizer_principal else "" # Free busy is per-user userPrincipal = (yield calresource.resourceOwnerPrincipal(request)) if userPrincipal: useruid = userPrincipal.principalUID() else: useruid = "" # Get the timezone property from the collection. has_prop = (yield calresource.hasProperty(CalendarTimeZone(), request)) if has_prop: tz = (yield calresource.readProperty(CalendarTimeZone(), request)) else: tz = None # Look for possible extended free busy information rich_options = { "organizer": False, "delegate": False, "resource": False, } do_event_details = False if event_details is not None and organizer_principal is not None and userPrincipal is not None: # Check if organizer is attendee if organizer_principal == userPrincipal: do_event_details = True rich_options["organizer"] = True # Check if organizer is a delegate of attendee proxy = (yield organizer_principal.isProxyFor(userPrincipal)) if config.Scheduling.Options.DelegeteRichFreeBusy and proxy: do_event_details = True rich_options["delegate"] = True # Check if attendee is room or resource if config.Scheduling.Options.RoomResourceRichFreeBusy and userPrincipal.getCUType( ) in ( "RESOURCE", "ROOM", ): do_event_details = True rich_options["resource"] = True # Try cache resources = (yield FBCacheEntry.getCacheEntry( calresource, useruid, timerange)) if config.EnableFreeBusyCache else None if resources is None: caching = False if config.EnableFreeBusyCache: # Log extended item if not hasattr(request, "extendedLogItems"): request.extendedLogItems = {} request.extendedLogItems[ "fb-uncached"] = request.extendedLogItems.get( "fb-uncached", 0) + 1 # We want to cache a large range of time based on the current date cache_start = normalizeToUTC(DateTime.getToday() + Duration( days=0 - config.FreeBusyCacheDaysBack)) cache_end = normalizeToUTC(DateTime.getToday() + Duration( days=config.FreeBusyCacheDaysForward)) # If the requested timerange would fit in our allowed cache range, trigger the cache creation if compareDateTime(timerange.start, cache_start) >= 0 and compareDateTime( timerange.end, cache_end) <= 0: cache_timerange = TimeRange(start=cache_start.getText(), end=cache_end.getText()) caching = True # # What we do is a fake calendar-query for VEVENT/VFREEBUSYs in the specified time-range. # We then take those results and merge them into one VFREEBUSY component # with appropriate FREEBUSY properties, and return that single item as iCal data. # # Create fake filter element to match time-range filter = caldavxml.Filter( caldavxml.ComponentFilter( caldavxml.ComponentFilter( cache_timerange if caching else timerange, name=("VEVENT", "VFREEBUSY", "VAVAILABILITY"), ), name="VCALENDAR", )) filter = Filter(filter) tzinfo = filter.settimezone(tz) try: resources = yield calresource.search(filter, useruid=useruid, fbtype=True) if caching: yield FBCacheEntry.makeCacheEntry(calresource, useruid, cache_timerange, resources) except IndexedSearchException: raise HTTPError( StatusResponse(responsecode.INTERNAL_SERVER_ERROR, "Failed freebusy query")) else: # Log extended item if not hasattr(request, "extendedLogItems"): request.extendedLogItems = {} request.extendedLogItems["fb-cached"] = request.extendedLogItems.get( "fb-cached", 0) + 1 # Determine appropriate timezone (UTC is the default) tzinfo = tz.gettimezone() if tz is not None else Timezone(utc=True) # We care about separate instances for VEVENTs only aggregated_resources = {} for name, uid, type, test_organizer, float, start, end, fbtype, transp in resources: if transp == 'T' and fbtype != '?': fbtype = 'F' aggregated_resources.setdefault(( name, uid, type, test_organizer, ), []).append(( float, start, end, fbtype, )) for key in aggregated_resources.iterkeys(): name, uid, type, test_organizer = key # Short-cut - if an fbtype exists we can use that if type == "VEVENT" and aggregated_resources[key][0][3] != '?': matchedResource = False # Look at each instance for float, start, end, fbtype in aggregated_resources[key]: # Ignore free time or unknown if fbtype in ('F', '?'): continue # Ignore ones of this UID if excludeuid: # See if we have a UID match if (excludeuid == uid): test_principal = ( yield calresource.principalForCalendarUserAddress( test_organizer)) if test_organizer else None test_uid = test_principal.principalUID( ) if test_principal else "" # Check that ORGANIZER's match (security requirement) if (organizer is None) or (organizer_uid == test_uid): continue # Check for no ORGANIZER and check by same calendar user elif (test_uid == "") and same_calendar_user: continue # Apply a timezone to any floating times fbstart = parseSQLTimestampToPyCalendar(start) if float == 'Y': fbstart.setTimezone(tzinfo) else: fbstart.setTimezone(Timezone(utc=True)) fbend = parseSQLTimestampToPyCalendar(end) if float == 'Y': fbend.setTimezone(tzinfo) else: fbend.setTimezone(Timezone(utc=True)) # Clip instance to time range clipped = clipPeriod(Period(fbstart, duration=fbend - fbstart), Period(timerange.start, timerange.end)) # Double check for overlap if clipped: matchedResource = True fbinfo[fbtype_index_mapper.get(fbtype, 0)].append(clipped) if matchedResource: # Check size of results is within limit matchtotal += 1 if matchtotal > max_number_of_matches: raise NumberOfMatchesWithinLimits(max_number_of_matches) # Add extended details if do_event_details: child = (yield request.locateChildResource(calresource, name)) calendar = (yield child.iCalendarForUser(request)) _addEventDetails(calendar, event_details, rich_options, timerange, tzinfo) else: child = (yield request.locateChildResource(calresource, name)) calendar = (yield child.iCalendarForUser(request)) # The calendar may come back as None if the resource is being changed, or was deleted # between our initial index query and getting here. For now we will ignore this error, but in # the longer term we need to implement some form of locking, perhaps. if calendar is None: log.error( "Calendar %s is missing from calendar collection %r" % (name, calresource)) continue # Ignore ones of this UID if excludeuid: # See if we have a UID match if (excludeuid == uid): test_organizer = calendar.getOrganizer() test_principal = ( yield calresource.principalForCalendarUserAddress( test_organizer)) if test_organizer else None test_uid = test_principal.principalUID( ) if test_principal else "" # Check that ORGANIZER's match (security requirement) if (organizer is None) or (organizer_uid == test_uid): continue # Check for no ORGANIZER and check by same calendar user elif (test_organizer is None) and same_calendar_user: continue if filter.match(calendar, None): # Check size of results is within limit matchtotal += 1 if matchtotal > max_number_of_matches: raise NumberOfMatchesWithinLimits(max_number_of_matches) if calendar.mainType() == "VEVENT": processEventFreeBusy(calendar, fbinfo, timerange, tzinfo) elif calendar.mainType() == "VFREEBUSY": processFreeBusyFreeBusy(calendar, fbinfo, timerange) elif calendar.mainType() == "VAVAILABILITY": processAvailabilityFreeBusy(calendar, fbinfo, timerange) else: assert "Free-busy query returned unwanted component: %s in %r", ( name, calresource, ) # Add extended details if calendar.mainType() == "VEVENT" and do_event_details: child = (yield request.locateChildResource(calresource, name)) calendar = (yield child.iCalendarForUser(request)) _addEventDetails(calendar, event_details, rich_options, timerange, tzinfo) returnValue(matchtotal)
def _processFBURL(self, request): # # Check authentication and access controls # yield self.authorize(request, (davxml.Read(),)) # Extract query parameters from the URL args = ('start', 'end', 'duration', 'token', 'format', 'user',) for arg in args: setattr(self, arg, request.args.get(arg, [None])[0]) # Some things we do not handle if self.token or self.user: raise HTTPError(ErrorResponse( responsecode.NOT_ACCEPTABLE, (calendarserver_namespace, "supported-query-parameter"), "Invalid query parameter", )) # Check format if self.format: self.format = self.format.split(";")[0] if self.format not in ("text/calendar", "text/plain"): raise HTTPError(ErrorResponse( responsecode.NOT_ACCEPTABLE, (calendarserver_namespace, "supported-format"), "Invalid return format requested", )) else: self.format = "text/calendar" # Start/end/duration must be valid iCalendar DATE-TIME UTC or DURATION values try: if self.start: self.start = DateTime.parseText(self.start) if not self.start.utc(): raise ValueError() if self.end: self.end = DateTime.parseText(self.end) if not self.end.utc(): raise ValueError() if self.duration: self.duration = Duration.parseText(self.duration) except ValueError: raise HTTPError(ErrorResponse( responsecode.BAD_REQUEST, (calendarserver_namespace, "valid-query-parameters"), "Invalid query parameters", )) # Sanity check start/end/duration # End and duration cannot both be present if self.end and self.duration: raise HTTPError(ErrorResponse( responsecode.NOT_ACCEPTABLE, (calendarserver_namespace, "valid-query-parameters"), "Invalid query parameters", )) # Duration must be positive if self.duration and self.duration.getTotalSeconds() < 0: raise HTTPError(ErrorResponse( responsecode.BAD_REQUEST, (calendarserver_namespace, "valid-query-parameters"), "Invalid query parameters", )) # Now fill in the missing pieces if self.start is None: self.start = DateTime.getNowUTC() self.start.setHHMMSS(0, 0, 0) if self.duration: self.end = self.start + self.duration if self.end is None: self.end = self.start + Duration(days=config.FreeBusyURL.TimePeriod) # End > start if self.end <= self.start: raise HTTPError(ErrorResponse( responsecode.BAD_REQUEST, (calendarserver_namespace, "valid-query-parameters"), "Invalid query parameters", )) # TODO: We should probably verify that the actual time-range is within sensible bounds (e.g. not too far in the past or future and not too long) # Now lookup the principal details for the targeted user principal = (yield self.parent.principalForRecord()) # Pick the first mailto cu address or the first other type cuaddr = None for item in principal.calendarUserAddresses(): if cuaddr is None: cuaddr = item if item.startswith("mailto:"): cuaddr = item break # Get inbox details inboxURL = principal.scheduleInboxURL() if inboxURL is None: raise HTTPError(StatusResponse(responsecode.INTERNAL_SERVER_ERROR, "No schedule inbox URL for principal: %s" % (principal,))) try: inbox = (yield request.locateResource(inboxURL)) except: log.error("No schedule inbox for principal: {p}", p=principal) inbox = None if inbox is None: raise HTTPError(StatusResponse(responsecode.INTERNAL_SERVER_ERROR, "No schedule inbox for principal: %s" % (principal,))) organizer = recipient = LocalCalendarUser(cuaddr, principal.record) recipient.inbox = inbox._newStoreObject attendeeProp = Property("ATTENDEE", recipient.cuaddr) timerange = Period(self.start, self.end) fbresult = yield FreebusyQuery( organizer=organizer, recipient=recipient, attendeeProp=attendeeProp, timerange=timerange, ).generateAttendeeFreeBusyResponse() response = Response() response.stream = MemoryStream(str(fbresult)) response.headers.setHeader("content-type", MimeType.fromString("%s; charset=utf-8" % (self.format,))) returnValue(response)
def _internalGenerateFreeBusyInfo( calresource, fbinfo, timerange, matchtotal, excludeuid=None, organizer=None, organizerPrincipal=None, same_calendar_user=False, servertoserver=False, event_details=None, logItems=None, accountingItems=None, ): """ Run a free busy report on the specified calendar collection accumulating the free busy info for later processing. @param calresource: the L{Calendar} for a calendar collection. @param fbinfo: the array of busy periods to update. @param timerange: the L{TimeRange} for the query. @param matchtotal: the running total for the number of matches. @param excludeuid: a C{str} containing a UID value to exclude any components with that UID from contributing to free-busy. @param organizer: a C{str} containing the value of the ORGANIZER property in the VFREEBUSY request. This is used in conjunction with the UID value to process exclusions. @param same_calendar_user: a C{bool} indicating whether the calendar user requesting the free-busy information is the same as the calendar user being targeted. @param servertoserver: a C{bool} indicating whether we are doing a local or remote lookup request. @param event_details: a C{list} into which to store extended VEVENT details if not C{None} @param logItems: a C{dict} to store logging info to @param accountingItems: a C{dict} to store accounting info to """ # First check the privilege on this collection # TODO: for server-to-server we bypass this right now as we have no way to authorize external users. # TODO: actually we by pass altogether by assuming anyone can check anyone else's freebusy # May need organizer principal organizer_record = (yield calresource.directoryService( ).recordWithCalendarUserAddress(organizer)) if organizer else None organizer_uid = organizer_record.uid if organizer_record else "" # Free busy is per-user attendee_uid = calresource.viewerHome().uid() attendee_record = yield calresource.directoryService().recordWithUID( attendee_uid.decode("utf-8")) # Get the timezone property from the collection. tz = calresource.getTimezone() # Look for possible extended free busy information rich_options = { "organizer": False, "delegate": False, "resource": False, } do_event_details = False if event_details is not None and organizer_record is not None and attendee_record is not None: # Get the principal of the authorized user which may be different from the organizer if a delegate of # the organizer is making the request authz_uid = organizer_uid authz_record = organizer_record if calresource._txn._authz_uid is not None and calresource._txn._authz_uid != organizer_uid: authz_uid = calresource._txn._authz_uid authz_record = yield calresource.directoryService().recordWithUID( authz_uid.decode("utf-8")) # Check if attendee is also the organizer or the delegate doing the request if attendee_uid in (organizer_uid, authz_uid): do_event_details = True rich_options["organizer"] = True # Check if authorized user is a delegate of attendee proxy = (yield authz_record.isProxyFor(attendee_record)) if config.Scheduling.Options.DelegeteRichFreeBusy and proxy: do_event_details = True rich_options["delegate"] = True # Check if attendee is room or resource if config.Scheduling.Options.RoomResourceRichFreeBusy and attendee_record.getCUType( ) in ( "RESOURCE", "ROOM", ): do_event_details = True rich_options["resource"] = True # Try cache resources = (yield FBCacheEntry.getCacheEntry( calresource, attendee_uid, timerange)) if config.EnableFreeBusyCache else None if resources is None: if accountingItems is not None: accountingItems["fb-uncached"] = accountingItems.get( "fb-uncached", 0) + 1 caching = False if config.EnableFreeBusyCache: # Log extended item if logItems is not None: logItems["fb-uncached"] = logItems.get("fb-uncached", 0) + 1 # We want to cache a large range of time based on the current date cache_start = normalizeToUTC(DateTime.getToday() + Duration( days=0 - config.FreeBusyCacheDaysBack)) cache_end = normalizeToUTC(DateTime.getToday() + Duration( days=config.FreeBusyCacheDaysForward)) # If the requested time range would fit in our allowed cache range, trigger the cache creation if compareDateTime(timerange.start, cache_start) >= 0 and compareDateTime( timerange.end, cache_end) <= 0: cache_timerange = TimeRange(start=cache_start.getText(), end=cache_end.getText()) caching = True # # What we do is a fake calendar-query for VEVENT/VFREEBUSYs in the specified time-range. # We then take those results and merge them into one VFREEBUSY component # with appropriate FREEBUSY properties, and return that single item as iCal data. # # Create fake filter element to match time-range filter = caldavxml.Filter( caldavxml.ComponentFilter( caldavxml.ComponentFilter( cache_timerange if caching else timerange, name=("VEVENT", "VFREEBUSY", "VAVAILABILITY"), ), name="VCALENDAR", )) filter = Filter(filter) tzinfo = filter.settimezone(tz) if accountingItems is not None: tr = cache_timerange if caching else timerange accountingItems["fb-query-timerange"] = ( str(tr.start), str(tr.end), ) try: resources = yield calresource.search(filter, useruid=attendee_uid, fbtype=True) if caching: yield FBCacheEntry.makeCacheEntry(calresource, attendee_uid, cache_timerange, resources) except IndexedSearchException: raise InternalDataStoreError("Invalid indexedSearch query") else: if accountingItems is not None: accountingItems["fb-cached"] = accountingItems.get("fb-cached", 0) + 1 # Log extended item if logItems is not None: logItems["fb-cached"] = logItems.get("fb-cached", 0) + 1 # Determine appropriate timezone (UTC is the default) tzinfo = tz.gettimezone() if tz is not None else Timezone(utc=True) # We care about separate instances for VEVENTs only aggregated_resources = {} for name, uid, type, test_organizer, float, start, end, fbtype, transp in resources: if transp == 'T' and fbtype != '?': fbtype = 'F' aggregated_resources.setdefault(( name, uid, type, test_organizer, ), []).append(( float, start, end, fbtype, )) if accountingItems is not None: accountingItems["fb-resources"] = {} for k, v in aggregated_resources.items(): name, uid, type, test_organizer = k accountingItems["fb-resources"][uid] = [] for float, start, end, fbtype in v: fbstart = parseSQLTimestampToPyCalendar(start) if float == 'Y': fbstart.setTimezone(tzinfo) else: fbstart.setTimezone(Timezone(utc=True)) fbend = parseSQLTimestampToPyCalendar(end) if float == 'Y': fbend.setTimezone(tzinfo) else: fbend.setTimezone(Timezone(utc=True)) accountingItems["fb-resources"][uid].append(( float, str(fbstart), str(fbend), fbtype, )) # Cache directory record lookup outside this loop as it is expensive and will likely # always end up being called with the same organizer address. recordUIDCache = {} for key in aggregated_resources.iterkeys(): name, uid, type, test_organizer = key # Short-cut - if an fbtype exists we can use that if type == "VEVENT" and aggregated_resources[key][0][3] != '?': matchedResource = False # Look at each instance for float, start, end, fbtype in aggregated_resources[key]: # Ignore free time or unknown if fbtype in ('F', '?'): continue # Ignore ones of this UID if excludeuid: # See if we have a UID match if (excludeuid == uid): if test_organizer: test_uid = recordUIDCache.get(test_organizer) if test_uid is None: test_record = (yield calresource.directoryService( ).recordWithCalendarUserAddress( test_organizer)) test_uid = test_record.uid if test_record else "" recordUIDCache[test_organizer] = test_uid else: test_uid = "" # Check that ORGANIZER's match (security requirement) if (organizer is None) or (organizer_uid == test_uid): continue # Check for no ORGANIZER and check by same calendar user elif (test_uid == "") and same_calendar_user: continue # Apply a timezone to any floating times fbstart = parseSQLTimestampToPyCalendar(start) if float == 'Y': fbstart.setTimezone(tzinfo) else: fbstart.setTimezone(Timezone(utc=True)) fbend = parseSQLTimestampToPyCalendar(end) if float == 'Y': fbend.setTimezone(tzinfo) else: fbend.setTimezone(Timezone(utc=True)) # Clip instance to time range clipped = clipPeriod(Period(fbstart, duration=fbend - fbstart), Period(timerange.start, timerange.end)) # Double check for overlap if clipped: matchedResource = True fbinfo[fbtype_index_mapper.get(fbtype, 0)].append(clipped) if matchedResource: # Check size of results is within limit matchtotal += 1 if matchtotal > config.MaxQueryWithDataResults: raise QueryMaxResources(config.MaxQueryWithDataResults, matchtotal) # Add extended details if do_event_details: child = (yield calresource.calendarObjectWithName(name)) # Only add fully public events if not child.accessMode or child.accessMode == Component.ACCESS_PUBLIC: calendar = (yield child.componentForUser()) _addEventDetails(calendar, event_details, rich_options, timerange, tzinfo) else: child = (yield calresource.calendarObjectWithName(name)) calendar = (yield child.componentForUser()) # The calendar may come back as None if the resource is being changed, or was deleted # between our initial index query and getting here. For now we will ignore this error, but in # the longer term we need to implement some form of locking, perhaps. if calendar is None: log.error( "Calendar %s is missing from calendar collection %r" % (name, calresource)) continue # Ignore ones of this UID if excludeuid: # See if we have a UID match if (excludeuid == uid): test_organizer = calendar.getOrganizer() if test_organizer: test_uid = recordUIDCache.get(test_organizer) if test_uid is None: test_record = (yield calresource.directoryService( ).recordWithCalendarUserAddress(test_organizer)) test_uid = test_record.uid if test_record else "" recordUIDCache[test_organizer] = test_uid else: test_uid = "" # Check that ORGANIZER's match (security requirement) if (organizer is None) or (organizer_uid == test_uid): continue # Check for no ORGANIZER and check by same calendar user elif (test_organizer is None) and same_calendar_user: continue if accountingItems is not None: accountingItems.setdefault("fb-filter-match", []).append(uid) if filter.match(calendar, None): if accountingItems is not None: accountingItems.setdefault("fb-filter-matched", []).append(uid) # Check size of results is within limit matchtotal += 1 if matchtotal > config.MaxQueryWithDataResults: raise QueryMaxResources(config.MaxQueryWithDataResults, matchtotal) if calendar.mainType() == "VEVENT": processEventFreeBusy(calendar, fbinfo, timerange, tzinfo) elif calendar.mainType() == "VFREEBUSY": processFreeBusyFreeBusy(calendar, fbinfo, timerange) elif calendar.mainType() == "VAVAILABILITY": processAvailabilityFreeBusy(calendar, fbinfo, timerange) else: assert "Free-busy query returned unwanted component: %s in %r", ( name, calresource, ) # Add extended details if calendar.mainType() == "VEVENT" and do_event_details: child = (yield calresource.calendarObjectWithName(name)) # Only add fully public events if not child.accessMode or child.accessMode == Component.ACCESS_PUBLIC: calendar = (yield child.componentForUser()) _addEventDetails(calendar, event_details, rich_options, timerange, tzinfo) returnValue(matchtotal)
def cacheBusyTime(self): # Clear out any existing cache self.mBusyTime = [] # Get all FREEBUSY items and add those that are BUSY min_start = DateTime() max_end = DateTime() props = self.getProperties() result = props.get(definitions.cICalProperty_FREEBUSY, ()) for iter in result: # Check the properties FBTYPE parameter type = 0 is_busy = False if iter.hasParameter(definitions.cICalParameter_FBTYPE): fbyype = iter.getParameterValue( definitions.cICalParameter_FBTYPE) if fbyype.upper() == definitions.cICalParameter_FBTYPE_BUSY: is_busy = True type = FreeBusy.BUSY elif fbyype.upper( ) == definitions.cICalParameter_FBTYPE_BUSYUNAVAILABLE: is_busy = True type = FreeBusy.BUSYUNAVAILABLE elif fbyype.upper( ) == definitions.cICalParameter_FBTYPE_BUSYTENTATIVE: is_busy = True type = FreeBusy.BUSYTENTATIVE else: is_busy = False type = FreeBusy.FREE else: # Default is busy when no parameter is_busy = True type = FreeBusy.BUSY # Add this period if is_busy: multi = iter.getMultiValue() if (multi is not None) and (multi.getType() == Value.VALUETYPE_PERIOD): for o in multi.getValues(): # Double-check type period = None if isinstance(o, PeriodValue): period = o # Double-check type if period is not None: self.mBusyTime.append( FreeBusy(type, period.getValue())) if len(self.mBusyTime) == 1: min_start = period.getValue().getStart() max_end = period.getValue().getEnd() else: if min_start > period.getValue().getStart(): min_start = period.getValue().getStart() if max_end < period.getValue().getEnd(): max_end = period.getValue().getEnd() # If nothing present, empty the list if len(self.mBusyTime) == 0: self.mBusyTime = None else: # Sort the list by period self.mBusyTime.sort(cmp=lambda x, y: x.getPeriod().getStart(). compareDateTime(y.getPeriod().getStart())) # Determine range start = DateTime() end = DateTime() if self.mHasStart: start = self.mStart else: start = min_start if self.mHasEnd: end = self.mEnd else: end = max_end self.mSpanPeriod = Period(start, end) self.mCachedBusyTime = True