paul@1234 | 1 | #!/usr/bin/env python |
paul@1234 | 2 | |
paul@1234 | 3 | """ |
paul@1234 | 4 | Managing free/busy periods. |
paul@1234 | 5 | |
paul@1234 | 6 | Copyright (C) 2014, 2015, 2016, 2017 Paul Boddie <paul@boddie.org.uk> |
paul@1234 | 7 | |
paul@1234 | 8 | This program is free software; you can redistribute it and/or modify it under |
paul@1234 | 9 | the terms of the GNU General Public License as published by the Free Software |
paul@1234 | 10 | Foundation; either version 3 of the License, or (at your option) any later |
paul@1234 | 11 | version. |
paul@1234 | 12 | |
paul@1234 | 13 | This program is distributed in the hope that it will be useful, but WITHOUT |
paul@1234 | 14 | ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS |
paul@1234 | 15 | FOR A PARTICULAR PURPOSE. See the GNU General Public License for more |
paul@1234 | 16 | details. |
paul@1234 | 17 | |
paul@1234 | 18 | You should have received a copy of the GNU General Public License along with |
paul@1234 | 19 | this program. If not, see <http://www.gnu.org/licenses/>. |
paul@1234 | 20 | """ |
paul@1234 | 21 | |
paul@1234 | 22 | from bisect import bisect_left, bisect_right |
paul@1234 | 23 | from imiptools.dates import format_datetime |
paul@1234 | 24 | from imiptools.period import get_overlapping, Period, PeriodBase |
paul@1234 | 25 | |
paul@1234 | 26 | # Conversion functions. |
paul@1234 | 27 | |
paul@1234 | 28 | def from_string(s, encoding): |
paul@1234 | 29 | |
paul@1234 | 30 | "Interpret 's' using 'encoding', preserving None." |
paul@1234 | 31 | |
paul@1234 | 32 | if s: |
paul@1236 | 33 | if isinstance(s, unicode): |
paul@1236 | 34 | return s |
paul@1236 | 35 | else: |
paul@1236 | 36 | return unicode(s, encoding) |
paul@1234 | 37 | else: |
paul@1234 | 38 | return s |
paul@1234 | 39 | |
paul@1234 | 40 | def to_string(s, encoding): |
paul@1234 | 41 | |
paul@1234 | 42 | "Encode 's' using 'encoding', preserving None." |
paul@1234 | 43 | |
paul@1234 | 44 | if s: |
paul@1234 | 45 | return s.encode(encoding) |
paul@1234 | 46 | else: |
paul@1234 | 47 | return s |
paul@1234 | 48 | |
paul@1236 | 49 | class period_from_tuple: |
paul@1236 | 50 | |
paul@1236 | 51 | "Convert a tuple to an instance of the given 'period_class'." |
paul@1236 | 52 | |
paul@1236 | 53 | def __init__(self, period_class): |
paul@1236 | 54 | self.period_class = period_class |
paul@1236 | 55 | def __call__(self, t): |
paul@1236 | 56 | return make_period(t, self.period_class) |
paul@1236 | 57 | |
paul@1236 | 58 | def period_to_tuple(p): |
paul@1236 | 59 | |
paul@1236 | 60 | "Convert period 'p' to a tuple for serialisation." |
paul@1236 | 61 | |
paul@1236 | 62 | return p.as_tuple(strings_only=True) |
paul@1236 | 63 | |
paul@1236 | 64 | def make_period(t, period_class): |
paul@1236 | 65 | |
paul@1236 | 66 | "Convert tuple 't' to an instance of the given 'period_class'." |
paul@1236 | 67 | |
paul@1236 | 68 | args = [] |
paul@1236 | 69 | for arg, column in zip(t, period_class.period_columns): |
paul@1236 | 70 | args.append(from_string(arg, "utf-8")) |
paul@1236 | 71 | return period_class(*args) |
paul@1236 | 72 | |
paul@1236 | 73 | def make_tuple(t, period_class): |
paul@1236 | 74 | |
paul@1236 | 75 | "Restrict tuple 't' to the columns appropriate for 'period_class'." |
paul@1236 | 76 | |
paul@1236 | 77 | args = [] |
paul@1236 | 78 | for arg, column in zip(t, period_class.period_columns): |
paul@1236 | 79 | args.append(arg) |
paul@1236 | 80 | return tuple(args) |
paul@1236 | 81 | |
paul@1234 | 82 | |
paul@1234 | 83 | |
paul@1234 | 84 | # Period abstractions. |
paul@1234 | 85 | |
paul@1234 | 86 | class FreeBusyPeriod(PeriodBase): |
paul@1234 | 87 | |
paul@1234 | 88 | "A free/busy record abstraction." |
paul@1234 | 89 | |
paul@1236 | 90 | period_columns = [ |
paul@1236 | 91 | "start", "end", "object_uid", "transp", "object_recurrenceid", |
paul@1236 | 92 | "summary", "organiser" |
paul@1236 | 93 | ] |
paul@1236 | 94 | |
paul@1234 | 95 | def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, |
paul@1234 | 96 | summary=None, organiser=None): |
paul@1234 | 97 | |
paul@1234 | 98 | """ |
paul@1234 | 99 | Initialise a free/busy period with the given 'start' and 'end' points, |
paul@1234 | 100 | plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser' |
paul@1234 | 101 | details. |
paul@1234 | 102 | """ |
paul@1234 | 103 | |
paul@1234 | 104 | PeriodBase.__init__(self, start, end) |
paul@1236 | 105 | self.uid = uid or None |
paul@1234 | 106 | self.transp = transp or None |
paul@1234 | 107 | self.recurrenceid = recurrenceid or None |
paul@1234 | 108 | self.summary = summary or None |
paul@1234 | 109 | self.organiser = organiser or None |
paul@1234 | 110 | |
paul@1234 | 111 | def as_tuple(self, strings_only=False, string_datetimes=False): |
paul@1234 | 112 | |
paul@1234 | 113 | """ |
paul@1234 | 114 | Return the initialisation parameter tuple, converting datetimes and |
paul@1234 | 115 | false value parameters to strings if 'strings_only' is set to a true |
paul@1234 | 116 | value. Otherwise, if 'string_datetimes' is set to a true value, only the |
paul@1234 | 117 | datetime values are converted to strings. |
paul@1234 | 118 | """ |
paul@1234 | 119 | |
paul@1234 | 120 | null = lambda x: (strings_only and [""] or [x])[0] |
paul@1234 | 121 | return ( |
paul@1234 | 122 | (strings_only or string_datetimes) and format_datetime(self.get_start_point()) or self.start, |
paul@1234 | 123 | (strings_only or string_datetimes) and format_datetime(self.get_end_point()) or self.end, |
paul@1234 | 124 | self.uid or null(self.uid), |
paul@1234 | 125 | self.transp or strings_only and "OPAQUE" or None, |
paul@1234 | 126 | self.recurrenceid or null(self.recurrenceid), |
paul@1234 | 127 | self.summary or null(self.summary), |
paul@1234 | 128 | self.organiser or null(self.organiser) |
paul@1234 | 129 | ) |
paul@1234 | 130 | |
paul@1234 | 131 | def __cmp__(self, other): |
paul@1234 | 132 | |
paul@1234 | 133 | """ |
paul@1234 | 134 | Compare this object to 'other', employing the uid if the periods |
paul@1234 | 135 | involved are the same. |
paul@1234 | 136 | """ |
paul@1234 | 137 | |
paul@1234 | 138 | result = PeriodBase.__cmp__(self, other) |
paul@1234 | 139 | if result == 0 and isinstance(other, FreeBusyPeriod): |
paul@1234 | 140 | return cmp((self.uid, self.recurrenceid), (other.uid, other.recurrenceid)) |
paul@1234 | 141 | else: |
paul@1234 | 142 | return result |
paul@1234 | 143 | |
paul@1234 | 144 | def get_key(self): |
paul@1234 | 145 | return self.uid, self.recurrenceid, self.get_start() |
paul@1234 | 146 | |
paul@1234 | 147 | def __repr__(self): |
paul@1234 | 148 | return "FreeBusyPeriod%r" % (self.as_tuple(),) |
paul@1234 | 149 | |
paul@1234 | 150 | def get_tzid(self): |
paul@1234 | 151 | return "UTC" |
paul@1234 | 152 | |
paul@1234 | 153 | # Period and event recurrence logic. |
paul@1234 | 154 | |
paul@1234 | 155 | def is_replaced(self, recurrences): |
paul@1234 | 156 | |
paul@1234 | 157 | """ |
paul@1234 | 158 | Return whether this period refers to one of the 'recurrences'. |
paul@1234 | 159 | The 'recurrences' must be UTC datetimes corresponding to the start of |
paul@1234 | 160 | the period described by a recurrence. |
paul@1234 | 161 | """ |
paul@1234 | 162 | |
paul@1234 | 163 | for recurrence in recurrences: |
paul@1234 | 164 | if self.is_affected(recurrence): |
paul@1234 | 165 | return True |
paul@1234 | 166 | return False |
paul@1234 | 167 | |
paul@1234 | 168 | def is_affected(self, recurrence): |
paul@1234 | 169 | |
paul@1234 | 170 | """ |
paul@1234 | 171 | Return whether this period refers to 'recurrence'. The 'recurrence' must |
paul@1234 | 172 | be a UTC datetime corresponding to the start of the period described by |
paul@1234 | 173 | a recurrence. |
paul@1234 | 174 | """ |
paul@1234 | 175 | |
paul@1234 | 176 | return recurrence and self.get_start_point() == recurrence |
paul@1234 | 177 | |
paul@1234 | 178 | # Value correction methods. |
paul@1234 | 179 | |
paul@1234 | 180 | def make_corrected(self, start, end): |
paul@1234 | 181 | return self.__class__(start, end) |
paul@1234 | 182 | |
paul@1234 | 183 | class FreeBusyOfferPeriod(FreeBusyPeriod): |
paul@1234 | 184 | |
paul@1234 | 185 | "A free/busy record abstraction for an offer period." |
paul@1234 | 186 | |
paul@1236 | 187 | period_columns = FreeBusyPeriod.period_columns + ["expires"] |
paul@1236 | 188 | |
paul@1234 | 189 | def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, |
paul@1234 | 190 | summary=None, organiser=None, expires=None): |
paul@1234 | 191 | |
paul@1234 | 192 | """ |
paul@1234 | 193 | Initialise a free/busy period with the given 'start' and 'end' points, |
paul@1234 | 194 | plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser' |
paul@1234 | 195 | details. |
paul@1234 | 196 | |
paul@1234 | 197 | An additional 'expires' parameter can be used to indicate an expiry |
paul@1234 | 198 | datetime in conjunction with free/busy offers made when countering |
paul@1234 | 199 | event proposals. |
paul@1234 | 200 | """ |
paul@1234 | 201 | |
paul@1234 | 202 | FreeBusyPeriod.__init__(self, start, end, uid, transp, recurrenceid, |
paul@1234 | 203 | summary, organiser) |
paul@1234 | 204 | self.expires = expires or None |
paul@1234 | 205 | |
paul@1234 | 206 | def as_tuple(self, strings_only=False, string_datetimes=False): |
paul@1234 | 207 | |
paul@1234 | 208 | """ |
paul@1234 | 209 | Return the initialisation parameter tuple, converting datetimes and |
paul@1234 | 210 | false value parameters to strings if 'strings_only' is set to a true |
paul@1234 | 211 | value. Otherwise, if 'string_datetimes' is set to a true value, only the |
paul@1234 | 212 | datetime values are converted to strings. |
paul@1234 | 213 | """ |
paul@1234 | 214 | |
paul@1234 | 215 | null = lambda x: (strings_only and [""] or [x])[0] |
paul@1234 | 216 | return FreeBusyPeriod.as_tuple(self, strings_only, string_datetimes) + ( |
paul@1234 | 217 | self.expires or null(self.expires),) |
paul@1234 | 218 | |
paul@1234 | 219 | def __repr__(self): |
paul@1234 | 220 | return "FreeBusyOfferPeriod%r" % (self.as_tuple(),) |
paul@1234 | 221 | |
paul@1234 | 222 | class FreeBusyGroupPeriod(FreeBusyPeriod): |
paul@1234 | 223 | |
paul@1234 | 224 | "A free/busy record abstraction for a quota group period." |
paul@1234 | 225 | |
paul@1236 | 226 | period_columns = FreeBusyPeriod.period_columns + ["attendee"] |
paul@1236 | 227 | |
paul@1234 | 228 | def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, |
paul@1234 | 229 | summary=None, organiser=None, attendee=None): |
paul@1234 | 230 | |
paul@1234 | 231 | """ |
paul@1234 | 232 | Initialise a free/busy period with the given 'start' and 'end' points, |
paul@1234 | 233 | plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser' |
paul@1234 | 234 | details. |
paul@1234 | 235 | |
paul@1234 | 236 | An additional 'attendee' parameter can be used to indicate the identity |
paul@1234 | 237 | of the attendee recording the period. |
paul@1234 | 238 | """ |
paul@1234 | 239 | |
paul@1234 | 240 | FreeBusyPeriod.__init__(self, start, end, uid, transp, recurrenceid, |
paul@1234 | 241 | summary, organiser) |
paul@1234 | 242 | self.attendee = attendee or None |
paul@1234 | 243 | |
paul@1234 | 244 | def as_tuple(self, strings_only=False, string_datetimes=False): |
paul@1234 | 245 | |
paul@1234 | 246 | """ |
paul@1234 | 247 | Return the initialisation parameter tuple, converting datetimes and |
paul@1234 | 248 | false value parameters to strings if 'strings_only' is set to a true |
paul@1234 | 249 | value. Otherwise, if 'string_datetimes' is set to a true value, only the |
paul@1234 | 250 | datetime values are converted to strings. |
paul@1234 | 251 | """ |
paul@1234 | 252 | |
paul@1234 | 253 | null = lambda x: (strings_only and [""] or [x])[0] |
paul@1234 | 254 | return FreeBusyPeriod.as_tuple(self, strings_only, string_datetimes) + ( |
paul@1234 | 255 | self.attendee or null(self.attendee),) |
paul@1234 | 256 | |
paul@1234 | 257 | def __cmp__(self, other): |
paul@1234 | 258 | |
paul@1234 | 259 | """ |
paul@1234 | 260 | Compare this object to 'other', employing the uid if the periods |
paul@1234 | 261 | involved are the same. |
paul@1234 | 262 | """ |
paul@1234 | 263 | |
paul@1234 | 264 | result = FreeBusyPeriod.__cmp__(self, other) |
paul@1234 | 265 | if isinstance(other, FreeBusyGroupPeriod) and result == 0: |
paul@1234 | 266 | return cmp(self.attendee, other.attendee) |
paul@1234 | 267 | else: |
paul@1234 | 268 | return result |
paul@1234 | 269 | |
paul@1234 | 270 | def __repr__(self): |
paul@1234 | 271 | return "FreeBusyGroupPeriod%r" % (self.as_tuple(),) |
paul@1234 | 272 | |
paul@1236 | 273 | |
paul@1236 | 274 | |
paul@1236 | 275 | # Collection abstractions. |
paul@1236 | 276 | |
paul@1234 | 277 | class FreeBusyCollectionBase: |
paul@1234 | 278 | |
paul@1234 | 279 | "Common operations on free/busy period collections." |
paul@1234 | 280 | |
paul@1234 | 281 | period_class = FreeBusyPeriod |
paul@1234 | 282 | |
paul@1234 | 283 | def __init__(self, mutable=True): |
paul@1234 | 284 | self.mutable = mutable |
paul@1234 | 285 | |
paul@1234 | 286 | def _check_mutable(self): |
paul@1234 | 287 | if not self.mutable: |
paul@1234 | 288 | raise TypeError, "Cannot mutate this collection." |
paul@1234 | 289 | |
paul@1236 | 290 | def close(self): |
paul@1236 | 291 | |
paul@1236 | 292 | "Close the collection." |
paul@1236 | 293 | |
paul@1236 | 294 | pass |
paul@1236 | 295 | |
paul@1234 | 296 | def copy(self): |
paul@1234 | 297 | |
paul@1234 | 298 | "Make an independent mutable copy of the collection." |
paul@1234 | 299 | |
paul@1234 | 300 | return FreeBusyCollection(list(self), True) |
paul@1234 | 301 | |
paul@1234 | 302 | def make_period(self, t): |
paul@1234 | 303 | |
paul@1234 | 304 | """ |
paul@1234 | 305 | Make a period using the given tuple of arguments and the collection's |
paul@1234 | 306 | column details. |
paul@1234 | 307 | """ |
paul@1234 | 308 | |
paul@1236 | 309 | return make_period(t, self.period_class) |
paul@1234 | 310 | |
paul@1234 | 311 | def make_tuple(self, t): |
paul@1234 | 312 | |
paul@1234 | 313 | """ |
paul@1234 | 314 | Return a tuple from the given tuple 't' conforming to the collection's |
paul@1234 | 315 | column details. |
paul@1234 | 316 | """ |
paul@1234 | 317 | |
paul@1236 | 318 | return make_tuple(t, self.period_class) |
paul@1234 | 319 | |
paul@1234 | 320 | # List emulation methods. |
paul@1234 | 321 | |
paul@1234 | 322 | def __iadd__(self, periods): |
paul@1234 | 323 | self.insert_periods(periods) |
paul@1234 | 324 | return self |
paul@1234 | 325 | |
paul@1234 | 326 | def append(self, period): |
paul@1234 | 327 | self.insert_period(period) |
paul@1234 | 328 | |
paul@1234 | 329 | # Operations. |
paul@1234 | 330 | |
paul@1236 | 331 | def insert_period(self, period): |
paul@1236 | 332 | |
paul@1236 | 333 | """ |
paul@1236 | 334 | Insert the given 'period' into the collection. |
paul@1236 | 335 | |
paul@1236 | 336 | This should be implemented in subclasses. |
paul@1236 | 337 | """ |
paul@1236 | 338 | |
paul@1236 | 339 | pass |
paul@1236 | 340 | |
paul@1234 | 341 | def insert_periods(self, periods): |
paul@1234 | 342 | |
paul@1234 | 343 | "Insert the given 'periods' into the collection." |
paul@1234 | 344 | |
paul@1234 | 345 | for p in periods: |
paul@1234 | 346 | self.insert_period(p) |
paul@1234 | 347 | |
paul@1234 | 348 | def can_schedule(self, periods, uid, recurrenceid): |
paul@1234 | 349 | |
paul@1234 | 350 | """ |
paul@1234 | 351 | Return whether the collection can accommodate the given 'periods' |
paul@1234 | 352 | employing the specified 'uid' and 'recurrenceid'. |
paul@1234 | 353 | """ |
paul@1234 | 354 | |
paul@1234 | 355 | for conflict in self.have_conflict(periods, True): |
paul@1234 | 356 | if conflict.uid != uid or conflict.recurrenceid != recurrenceid: |
paul@1234 | 357 | return False |
paul@1234 | 358 | |
paul@1234 | 359 | return True |
paul@1234 | 360 | |
paul@1234 | 361 | def have_conflict(self, periods, get_conflicts=False): |
paul@1234 | 362 | |
paul@1234 | 363 | """ |
paul@1234 | 364 | Return whether any period in the collection overlaps with the given |
paul@1234 | 365 | 'periods', returning a collection of such overlapping periods if |
paul@1234 | 366 | 'get_conflicts' is set to a true value. |
paul@1234 | 367 | """ |
paul@1234 | 368 | |
paul@1234 | 369 | conflicts = set() |
paul@1234 | 370 | for p in periods: |
paul@1234 | 371 | overlapping = self.period_overlaps(p, get_conflicts) |
paul@1234 | 372 | if overlapping: |
paul@1234 | 373 | if get_conflicts: |
paul@1234 | 374 | conflicts.update(overlapping) |
paul@1234 | 375 | else: |
paul@1234 | 376 | return True |
paul@1234 | 377 | |
paul@1234 | 378 | if get_conflicts: |
paul@1234 | 379 | return conflicts |
paul@1234 | 380 | else: |
paul@1234 | 381 | return False |
paul@1234 | 382 | |
paul@1234 | 383 | def period_overlaps(self, period, get_periods=False): |
paul@1234 | 384 | |
paul@1234 | 385 | """ |
paul@1234 | 386 | Return whether any period in the collection overlaps with the given |
paul@1234 | 387 | 'period', returning a collection of overlapping periods if 'get_periods' |
paul@1234 | 388 | is set to a true value. |
paul@1234 | 389 | """ |
paul@1234 | 390 | |
paul@1234 | 391 | overlapping = self.get_overlapping([period]) |
paul@1234 | 392 | |
paul@1234 | 393 | if get_periods: |
paul@1234 | 394 | return overlapping |
paul@1234 | 395 | else: |
paul@1234 | 396 | return len(overlapping) != 0 |
paul@1234 | 397 | |
paul@1234 | 398 | def replace_overlapping(self, period, replacements): |
paul@1234 | 399 | |
paul@1234 | 400 | """ |
paul@1234 | 401 | Replace existing periods in the collection within the given 'period', |
paul@1234 | 402 | using the given 'replacements'. |
paul@1234 | 403 | """ |
paul@1234 | 404 | |
paul@1234 | 405 | self._check_mutable() |
paul@1234 | 406 | |
paul@1234 | 407 | self.remove_overlapping(period) |
paul@1234 | 408 | for replacement in replacements: |
paul@1234 | 409 | self.insert_period(replacement) |
paul@1234 | 410 | |
paul@1234 | 411 | def coalesce_freebusy(self): |
paul@1234 | 412 | |
paul@1234 | 413 | "Coalesce the periods in the collection, returning a new collection." |
paul@1234 | 414 | |
paul@1234 | 415 | if not self: |
paul@1234 | 416 | return FreeBusyCollection() |
paul@1234 | 417 | |
paul@1234 | 418 | fb = [] |
paul@1234 | 419 | |
paul@1234 | 420 | it = iter(self) |
paul@1234 | 421 | period = it.next() |
paul@1234 | 422 | |
paul@1234 | 423 | start = period.get_start_point() |
paul@1234 | 424 | end = period.get_end_point() |
paul@1234 | 425 | |
paul@1234 | 426 | try: |
paul@1234 | 427 | while True: |
paul@1234 | 428 | period = it.next() |
paul@1234 | 429 | if period.get_start_point() > end: |
paul@1234 | 430 | fb.append(self.period_class(start, end)) |
paul@1234 | 431 | start = period.get_start_point() |
paul@1234 | 432 | end = period.get_end_point() |
paul@1234 | 433 | else: |
paul@1234 | 434 | end = max(end, period.get_end_point()) |
paul@1234 | 435 | except StopIteration: |
paul@1234 | 436 | pass |
paul@1234 | 437 | |
paul@1234 | 438 | fb.append(self.period_class(start, end)) |
paul@1234 | 439 | return FreeBusyCollection(fb) |
paul@1234 | 440 | |
paul@1234 | 441 | def invert_freebusy(self): |
paul@1234 | 442 | |
paul@1234 | 443 | "Return the free periods from the collection as a new collection." |
paul@1234 | 444 | |
paul@1234 | 445 | if not self: |
paul@1234 | 446 | return FreeBusyCollection([self.period_class(None, None)]) |
paul@1234 | 447 | |
paul@1234 | 448 | # Coalesce periods that overlap or are adjacent. |
paul@1234 | 449 | |
paul@1234 | 450 | fb = self.coalesce_freebusy() |
paul@1234 | 451 | free = [] |
paul@1234 | 452 | |
paul@1234 | 453 | # Add a start-of-time period if appropriate. |
paul@1234 | 454 | |
paul@1234 | 455 | first = fb[0].get_start_point() |
paul@1234 | 456 | if first: |
paul@1234 | 457 | free.append(self.period_class(None, first)) |
paul@1234 | 458 | |
paul@1234 | 459 | start = fb[0].get_end_point() |
paul@1234 | 460 | |
paul@1234 | 461 | for period in fb[1:]: |
paul@1234 | 462 | free.append(self.period_class(start, period.get_start_point())) |
paul@1234 | 463 | start = period.get_end_point() |
paul@1234 | 464 | |
paul@1234 | 465 | # Add an end-of-time period if appropriate. |
paul@1234 | 466 | |
paul@1234 | 467 | if start: |
paul@1234 | 468 | free.append(self.period_class(start, None)) |
paul@1234 | 469 | |
paul@1234 | 470 | return FreeBusyCollection(free) |
paul@1234 | 471 | |
paul@1234 | 472 | def _update_freebusy(self, periods, uid, recurrenceid): |
paul@1234 | 473 | |
paul@1234 | 474 | """ |
paul@1234 | 475 | Update the free/busy details with the given 'periods', using the given |
paul@1234 | 476 | 'uid' plus 'recurrenceid' to remove existing periods. |
paul@1234 | 477 | """ |
paul@1234 | 478 | |
paul@1234 | 479 | self._check_mutable() |
paul@1234 | 480 | |
paul@1234 | 481 | self.remove_specific_event_periods(uid, recurrenceid) |
paul@1234 | 482 | |
paul@1234 | 483 | self.insert_periods(periods) |
paul@1234 | 484 | |
paul@1234 | 485 | def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser): |
paul@1234 | 486 | |
paul@1234 | 487 | """ |
paul@1234 | 488 | Update the free/busy details with the given 'periods', 'transp' setting, |
paul@1234 | 489 | 'uid' plus 'recurrenceid' and 'summary' and 'organiser' details. |
paul@1234 | 490 | """ |
paul@1234 | 491 | |
paul@1234 | 492 | new_periods = [] |
paul@1234 | 493 | |
paul@1234 | 494 | for p in periods: |
paul@1234 | 495 | new_periods.append( |
paul@1234 | 496 | self.period_class(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser) |
paul@1234 | 497 | ) |
paul@1234 | 498 | |
paul@1234 | 499 | self._update_freebusy(new_periods, uid, recurrenceid) |
paul@1234 | 500 | |
paul@1234 | 501 | class SupportAttendee: |
paul@1234 | 502 | |
paul@1234 | 503 | "A mix-in that supports the affected attendee in free/busy periods." |
paul@1234 | 504 | |
paul@1234 | 505 | period_class = FreeBusyGroupPeriod |
paul@1234 | 506 | |
paul@1234 | 507 | def _update_freebusy(self, periods, uid, recurrenceid, attendee=None): |
paul@1234 | 508 | |
paul@1234 | 509 | """ |
paul@1234 | 510 | Update the free/busy details with the given 'periods', using the given |
paul@1234 | 511 | 'uid' plus 'recurrenceid' and 'attendee' to remove existing periods. |
paul@1234 | 512 | """ |
paul@1234 | 513 | |
paul@1234 | 514 | self._check_mutable() |
paul@1234 | 515 | |
paul@1234 | 516 | self.remove_specific_event_periods(uid, recurrenceid, attendee) |
paul@1234 | 517 | |
paul@1234 | 518 | self.insert_periods(periods) |
paul@1234 | 519 | |
paul@1234 | 520 | def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser, attendee=None): |
paul@1234 | 521 | |
paul@1234 | 522 | """ |
paul@1234 | 523 | Update the free/busy details with the given 'periods', 'transp' setting, |
paul@1234 | 524 | 'uid' plus 'recurrenceid' and 'summary' and 'organiser' details. |
paul@1234 | 525 | |
paul@1234 | 526 | An optional 'attendee' indicates the attendee affected by the period. |
paul@1234 | 527 | """ |
paul@1234 | 528 | |
paul@1234 | 529 | new_periods = [] |
paul@1234 | 530 | |
paul@1234 | 531 | for p in periods: |
paul@1234 | 532 | new_periods.append( |
paul@1234 | 533 | self.period_class(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, attendee) |
paul@1234 | 534 | ) |
paul@1234 | 535 | |
paul@1234 | 536 | self._update_freebusy(new_periods, uid, recurrenceid, attendee) |
paul@1234 | 537 | |
paul@1234 | 538 | class SupportExpires: |
paul@1234 | 539 | |
paul@1234 | 540 | "A mix-in that supports the expiry datetime in free/busy periods." |
paul@1234 | 541 | |
paul@1234 | 542 | period_class = FreeBusyOfferPeriod |
paul@1234 | 543 | |
paul@1234 | 544 | def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser, expires=None): |
paul@1234 | 545 | |
paul@1234 | 546 | """ |
paul@1234 | 547 | Update the free/busy details with the given 'periods', 'transp' setting, |
paul@1234 | 548 | 'uid' plus 'recurrenceid' and 'summary' and 'organiser' details. |
paul@1234 | 549 | |
paul@1234 | 550 | An optional 'expires' datetime string indicates the expiry time of any |
paul@1234 | 551 | free/busy offer. |
paul@1234 | 552 | """ |
paul@1234 | 553 | |
paul@1234 | 554 | new_periods = [] |
paul@1234 | 555 | |
paul@1234 | 556 | for p in periods: |
paul@1234 | 557 | new_periods.append( |
paul@1234 | 558 | self.period_class(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, expires) |
paul@1234 | 559 | ) |
paul@1234 | 560 | |
paul@1234 | 561 | self._update_freebusy(new_periods, uid, recurrenceid) |
paul@1234 | 562 | |
paul@1234 | 563 | |
paul@1234 | 564 | |
paul@1234 | 565 | # Simple abstractions suitable for use with file-based representations and as |
paul@1234 | 566 | # general copies of collections. |
paul@1234 | 567 | |
paul@1234 | 568 | class FreeBusyCollection(FreeBusyCollectionBase): |
paul@1234 | 569 | |
paul@1234 | 570 | "An abstraction for a collection of free/busy periods." |
paul@1234 | 571 | |
paul@1234 | 572 | def __init__(self, periods=None, mutable=True): |
paul@1234 | 573 | |
paul@1234 | 574 | """ |
paul@1234 | 575 | Initialise the collection with the given list of 'periods', or start an |
paul@1234 | 576 | empty collection if no list is given. If 'mutable' is indicated, the |
paul@1234 | 577 | collection may be changed; otherwise, an exception will be raised. |
paul@1234 | 578 | """ |
paul@1234 | 579 | |
paul@1234 | 580 | FreeBusyCollectionBase.__init__(self, mutable) |
paul@1236 | 581 | |
paul@1236 | 582 | if periods is not None: |
paul@1236 | 583 | self.periods = periods |
paul@1236 | 584 | else: |
paul@1236 | 585 | self.periods = [] |
paul@1236 | 586 | |
paul@1236 | 587 | def get_filename(self): |
paul@1236 | 588 | |
paul@1236 | 589 | "Return any filename for the periods collection." |
paul@1236 | 590 | |
paul@1236 | 591 | if hasattr(self.periods, "filename"): |
paul@1236 | 592 | return self.periods.filename |
paul@1236 | 593 | else: |
paul@1236 | 594 | return None |
paul@1236 | 595 | |
paul@1236 | 596 | def close(self): |
paul@1236 | 597 | |
paul@1236 | 598 | "Close the collection." |
paul@1236 | 599 | |
paul@1236 | 600 | if hasattr(self.periods, "close"): |
paul@1236 | 601 | self.periods.close() |
paul@1234 | 602 | |
paul@1234 | 603 | # List emulation methods. |
paul@1234 | 604 | |
paul@1234 | 605 | def __nonzero__(self): |
paul@1234 | 606 | return bool(self.periods) |
paul@1234 | 607 | |
paul@1234 | 608 | def __iter__(self): |
paul@1234 | 609 | return iter(self.periods) |
paul@1234 | 610 | |
paul@1234 | 611 | def __len__(self): |
paul@1234 | 612 | return len(self.periods) |
paul@1234 | 613 | |
paul@1234 | 614 | def __getitem__(self, i): |
paul@1234 | 615 | return self.periods[i] |
paul@1234 | 616 | |
paul@1236 | 617 | # Dictionary emulation methods (even though this is not a mapping). |
paul@1236 | 618 | |
paul@1236 | 619 | def clear(self): |
paul@1236 | 620 | del self.periods[:] |
paul@1236 | 621 | |
paul@1234 | 622 | # Operations. |
paul@1234 | 623 | |
paul@1234 | 624 | def insert_period(self, period): |
paul@1234 | 625 | |
paul@1234 | 626 | "Insert the given 'period' into the collection." |
paul@1234 | 627 | |
paul@1234 | 628 | self._check_mutable() |
paul@1234 | 629 | |
paul@1234 | 630 | i = bisect_left(self.periods, period) |
paul@1234 | 631 | if i == len(self.periods): |
paul@1234 | 632 | self.periods.append(period) |
paul@1234 | 633 | elif self.periods[i] != period: |
paul@1234 | 634 | self.periods.insert(i, period) |
paul@1234 | 635 | |
paul@1234 | 636 | def remove_periods(self, periods): |
paul@1234 | 637 | |
paul@1234 | 638 | "Remove the given 'periods' from the collection." |
paul@1234 | 639 | |
paul@1234 | 640 | self._check_mutable() |
paul@1234 | 641 | |
paul@1234 | 642 | for period in periods: |
paul@1234 | 643 | i = bisect_left(self.periods, period) |
paul@1234 | 644 | if i < len(self.periods) and self.periods[i] == period: |
paul@1234 | 645 | del self.periods[i] |
paul@1234 | 646 | |
paul@1243 | 647 | def remove_periods_before(self, period): |
paul@1243 | 648 | |
paul@1243 | 649 | "Remove the entries in the collection before 'period'." |
paul@1243 | 650 | |
paul@1243 | 651 | last = bisect_right(self.periods, period) |
paul@1243 | 652 | self.remove_periods(self.periods[:last]) |
paul@1243 | 653 | |
paul@1234 | 654 | def remove_event_periods(self, uid, recurrenceid=None, participant=None): |
paul@1234 | 655 | |
paul@1234 | 656 | """ |
paul@1234 | 657 | Remove from the collection all periods associated with 'uid' and |
paul@1234 | 658 | 'recurrenceid' (which if omitted causes the "parent" object's periods to |
paul@1234 | 659 | be referenced). |
paul@1234 | 660 | |
paul@1234 | 661 | If 'participant' is specified, only remove periods for which the |
paul@1234 | 662 | participant is given as attending. |
paul@1234 | 663 | |
paul@1234 | 664 | Return the removed periods. |
paul@1234 | 665 | """ |
paul@1234 | 666 | |
paul@1234 | 667 | self._check_mutable() |
paul@1234 | 668 | |
paul@1234 | 669 | removed = [] |
paul@1234 | 670 | i = 0 |
paul@1234 | 671 | while i < len(self.periods): |
paul@1234 | 672 | fb = self.periods[i] |
paul@1234 | 673 | |
paul@1234 | 674 | if fb.uid == uid and fb.recurrenceid == recurrenceid and \ |
paul@1234 | 675 | (not participant or participant == fb.attendee): |
paul@1234 | 676 | |
paul@1234 | 677 | removed.append(self.periods[i]) |
paul@1234 | 678 | del self.periods[i] |
paul@1234 | 679 | else: |
paul@1234 | 680 | i += 1 |
paul@1234 | 681 | |
paul@1234 | 682 | return removed |
paul@1234 | 683 | |
paul@1234 | 684 | # Specific period removal when updating event details. |
paul@1234 | 685 | |
paul@1234 | 686 | remove_specific_event_periods = remove_event_periods |
paul@1234 | 687 | |
paul@1234 | 688 | def remove_additional_periods(self, uid, recurrenceids=None): |
paul@1234 | 689 | |
paul@1234 | 690 | """ |
paul@1234 | 691 | Remove from the collection all periods associated with 'uid' having a |
paul@1234 | 692 | recurrence identifier indicating an additional or modified period. |
paul@1234 | 693 | |
paul@1234 | 694 | If 'recurrenceids' is specified, remove all periods associated with |
paul@1234 | 695 | 'uid' that do not have a recurrence identifier in the given list. |
paul@1234 | 696 | |
paul@1234 | 697 | Return the removed periods. |
paul@1234 | 698 | """ |
paul@1234 | 699 | |
paul@1234 | 700 | self._check_mutable() |
paul@1234 | 701 | |
paul@1234 | 702 | removed = [] |
paul@1234 | 703 | i = 0 |
paul@1234 | 704 | while i < len(self.periods): |
paul@1234 | 705 | fb = self.periods[i] |
paul@1234 | 706 | if fb.uid == uid and fb.recurrenceid and ( |
paul@1234 | 707 | recurrenceids is None or |
paul@1234 | 708 | recurrenceids is not None and fb.recurrenceid not in recurrenceids |
paul@1234 | 709 | ): |
paul@1234 | 710 | removed.append(self.periods[i]) |
paul@1234 | 711 | del self.periods[i] |
paul@1234 | 712 | else: |
paul@1234 | 713 | i += 1 |
paul@1234 | 714 | |
paul@1234 | 715 | return removed |
paul@1234 | 716 | |
paul@1234 | 717 | def remove_affected_period(self, uid, start, participant=None): |
paul@1234 | 718 | |
paul@1234 | 719 | """ |
paul@1234 | 720 | Remove from the collection the period associated with 'uid' that |
paul@1234 | 721 | provides an occurrence starting at the given 'start' (provided by a |
paul@1234 | 722 | recurrence identifier, converted to a datetime). A recurrence identifier |
paul@1234 | 723 | is used to provide an alternative time period whilst also acting as a |
paul@1234 | 724 | reference to the originally-defined occurrence. |
paul@1234 | 725 | |
paul@1234 | 726 | If 'participant' is specified, only remove periods for which the |
paul@1234 | 727 | participant is given as attending. |
paul@1234 | 728 | |
paul@1234 | 729 | Return any removed period in a list. |
paul@1234 | 730 | """ |
paul@1234 | 731 | |
paul@1234 | 732 | self._check_mutable() |
paul@1234 | 733 | |
paul@1234 | 734 | removed = [] |
paul@1234 | 735 | |
paul@1234 | 736 | search = Period(start, start) |
paul@1234 | 737 | found = bisect_left(self.periods, search) |
paul@1234 | 738 | |
paul@1234 | 739 | while found < len(self.periods): |
paul@1234 | 740 | fb = self.periods[found] |
paul@1234 | 741 | |
paul@1234 | 742 | # Stop looking if the start no longer matches the recurrence identifier. |
paul@1234 | 743 | |
paul@1234 | 744 | if fb.get_start_point() != search.get_start_point(): |
paul@1234 | 745 | break |
paul@1234 | 746 | |
paul@1234 | 747 | # If the period belongs to the parent object, remove it and return. |
paul@1234 | 748 | |
paul@1234 | 749 | if not fb.recurrenceid and uid == fb.uid and \ |
paul@1234 | 750 | (not participant or participant == fb.attendee): |
paul@1234 | 751 | |
paul@1234 | 752 | removed.append(self.periods[found]) |
paul@1234 | 753 | del self.periods[found] |
paul@1234 | 754 | break |
paul@1234 | 755 | |
paul@1234 | 756 | # Otherwise, keep looking for a matching period. |
paul@1234 | 757 | |
paul@1234 | 758 | found += 1 |
paul@1234 | 759 | |
paul@1234 | 760 | return removed |
paul@1234 | 761 | |
paul@1234 | 762 | def periods_from(self, period): |
paul@1234 | 763 | |
paul@1234 | 764 | "Return the entries in the collection at or after 'period'." |
paul@1234 | 765 | |
paul@1234 | 766 | first = bisect_left(self.periods, period) |
paul@1234 | 767 | return self.periods[first:] |
paul@1234 | 768 | |
paul@1234 | 769 | def periods_until(self, period): |
paul@1234 | 770 | |
paul@1234 | 771 | "Return the entries in the collection before 'period'." |
paul@1234 | 772 | |
paul@1234 | 773 | last = bisect_right(self.periods, Period(period.get_end(), period.get_end(), period.get_tzid())) |
paul@1234 | 774 | return self.periods[:last] |
paul@1234 | 775 | |
paul@1234 | 776 | def get_overlapping(self, periods): |
paul@1234 | 777 | |
paul@1234 | 778 | """ |
paul@1234 | 779 | Return the entries in the collection providing periods overlapping with |
paul@1234 | 780 | the given sorted collection of 'periods'. |
paul@1234 | 781 | """ |
paul@1234 | 782 | |
paul@1234 | 783 | return get_overlapping(self.periods, periods) |
paul@1234 | 784 | |
paul@1234 | 785 | def remove_overlapping(self, period): |
paul@1234 | 786 | |
paul@1234 | 787 | "Remove all periods overlapping with 'period' from the collection." |
paul@1234 | 788 | |
paul@1234 | 789 | self._check_mutable() |
paul@1234 | 790 | |
paul@1234 | 791 | overlapping = self.get_overlapping([period]) |
paul@1234 | 792 | |
paul@1234 | 793 | if overlapping: |
paul@1234 | 794 | for fb in overlapping: |
paul@1234 | 795 | self.periods.remove(fb) |
paul@1234 | 796 | |
paul@1234 | 797 | class FreeBusyGroupCollection(SupportAttendee, FreeBusyCollection): |
paul@1234 | 798 | |
paul@1234 | 799 | "A collection of quota group free/busy objects." |
paul@1234 | 800 | |
paul@1234 | 801 | def remove_specific_event_periods(self, uid, recurrenceid=None, attendee=None): |
paul@1234 | 802 | |
paul@1234 | 803 | """ |
paul@1234 | 804 | Remove from the collection all periods associated with 'uid' and |
paul@1234 | 805 | 'recurrenceid' (which if omitted causes the "parent" object's periods to |
paul@1234 | 806 | be referenced) and any 'attendee'. |
paul@1234 | 807 | |
paul@1234 | 808 | Return the removed periods. |
paul@1234 | 809 | """ |
paul@1234 | 810 | |
paul@1234 | 811 | self._check_mutable() |
paul@1234 | 812 | |
paul@1234 | 813 | removed = [] |
paul@1234 | 814 | i = 0 |
paul@1234 | 815 | while i < len(self.periods): |
paul@1234 | 816 | fb = self.periods[i] |
paul@1234 | 817 | if fb.uid == uid and fb.recurrenceid == recurrenceid and fb.attendee == attendee: |
paul@1234 | 818 | removed.append(self.periods[i]) |
paul@1234 | 819 | del self.periods[i] |
paul@1234 | 820 | else: |
paul@1234 | 821 | i += 1 |
paul@1234 | 822 | |
paul@1234 | 823 | return removed |
paul@1234 | 824 | |
paul@1234 | 825 | class FreeBusyOffersCollection(SupportExpires, FreeBusyCollection): |
paul@1234 | 826 | |
paul@1234 | 827 | "A collection of offered free/busy objects." |
paul@1234 | 828 | |
paul@1234 | 829 | pass |
paul@1234 | 830 | |
paul@1234 | 831 | # vim: tabstop=4 expandtab shiftwidth=4 |