1 #!/usr/bin/env python 2 3 """ 4 A simple filesystem-based store of calendar data. 5 6 Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk> 7 8 This program is free software; you can redistribute it and/or modify it under 9 the terms of the GNU General Public License as published by the Free Software 10 Foundation; either version 3 of the License, or (at your option) any later 11 version. 12 13 This program is distributed in the hope that it will be useful, but WITHOUT 14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 15 FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 16 details. 17 18 You should have received a copy of the GNU General Public License along with 19 this program. If not, see <http://www.gnu.org/licenses/>. 20 """ 21 22 from datetime import datetime 23 from imiptools.config import STORE_DIR, PUBLISH_DIR 24 from imiptools.data import make_calendar, parse_object, to_stream 25 from imiptools.dates import format_datetime, get_datetime 26 from imiptools.filesys import fix_permissions, FileBase 27 from imiptools.period import FreeBusyPeriod 28 from os.path import exists, isfile, join 29 from os import listdir, remove, rmdir 30 from time import sleep 31 import codecs 32 33 class FileStore(FileBase): 34 35 "A file store of tabular free/busy data and objects." 36 37 def __init__(self, store_dir=None): 38 FileBase.__init__(self, store_dir or STORE_DIR) 39 40 def acquire_lock(self, user, timeout=None): 41 FileBase.acquire_lock(self, timeout, user) 42 43 def release_lock(self, user): 44 FileBase.release_lock(self, user) 45 46 # Utility methods. 47 48 def _set_defaults(self, t, empty_defaults): 49 for i, default in empty_defaults: 50 if i >= len(t): 51 t += [None] * (i - len(t) + 1) 52 if not t[i]: 53 t[i] = default 54 return t 55 56 def _get_table(self, user, filename, empty_defaults=None): 57 58 """ 59 From the file for the given 'user' having the given 'filename', return 60 a list of tuples representing the file's contents. 61 62 The 'empty_defaults' is a list of (index, value) tuples indicating the 63 default value where a column either does not exist or provides an empty 64 value. 65 """ 66 67 self.acquire_lock(user) 68 try: 69 f = codecs.open(filename, "rb", encoding="utf-8") 70 try: 71 l = [] 72 for line in f.readlines(): 73 t = line.strip(" \r\n").split("\t") 74 if empty_defaults: 75 t = self._set_defaults(t, empty_defaults) 76 l.append(tuple(t)) 77 return l 78 finally: 79 f.close() 80 finally: 81 self.release_lock(user) 82 83 def _set_table(self, user, filename, items, empty_defaults=None): 84 85 """ 86 For the given 'user', write to the file having the given 'filename' the 87 'items'. 88 89 The 'empty_defaults' is a list of (index, value) tuples indicating the 90 default value where a column either does not exist or provides an empty 91 value. 92 """ 93 94 self.acquire_lock(user) 95 try: 96 f = codecs.open(filename, "wb", encoding="utf-8") 97 try: 98 for item in items: 99 if empty_defaults: 100 item = self._set_defaults(list(item), empty_defaults) 101 f.write("\t".join(item) + "\n") 102 finally: 103 f.close() 104 fix_permissions(filename) 105 finally: 106 self.release_lock(user) 107 108 # Store object access. 109 110 def _get_object(self, user, filename): 111 112 """ 113 Return the parsed object for the given 'user' having the given 114 'filename'. 115 """ 116 117 self.acquire_lock(user) 118 try: 119 f = open(filename, "rb") 120 try: 121 return parse_object(f, "utf-8") 122 finally: 123 f.close() 124 finally: 125 self.release_lock(user) 126 127 def _set_object(self, user, filename, node): 128 129 """ 130 Set an object for the given 'user' having the given 'filename', using 131 'node' to define the object. 132 """ 133 134 self.acquire_lock(user) 135 try: 136 f = open(filename, "wb") 137 try: 138 to_stream(f, node) 139 finally: 140 f.close() 141 fix_permissions(filename) 142 finally: 143 self.release_lock(user) 144 145 return True 146 147 def _remove_object(self, filename): 148 149 "Remove the object with the given 'filename'." 150 151 try: 152 remove(filename) 153 except OSError: 154 return False 155 156 return True 157 158 def _remove_collection(self, filename): 159 160 "Remove the collection with the given 'filename'." 161 162 try: 163 rmdir(filename) 164 except OSError: 165 return False 166 167 return True 168 169 # User discovery. 170 171 def get_users(self): 172 173 "Return a list of users." 174 175 return listdir(self.store_dir) 176 177 # Event and event metadata access. 178 179 def get_events(self, user): 180 181 "Return a list of event identifiers." 182 183 filename = self.get_object_in_store(user, "objects") 184 if not filename or not exists(filename): 185 return None 186 187 return [name for name in listdir(filename) if isfile(join(filename, name))] 188 189 def get_all_events(self, user): 190 191 "Return a set of (uid, recurrenceid) tuples for all events." 192 193 uids = self.get_events(user) 194 195 all_events = set() 196 for uid in uids: 197 all_events.add((uid, None)) 198 all_events.update([(uid, recurrenceid) for recurrenceid in self.get_recurrences(user, uid)]) 199 200 return all_events 201 202 def get_active_events(self, user): 203 204 "Return a set of uncancelled events of the form (uid, recurrenceid)." 205 206 all_events = self.get_all_events(user) 207 208 # Filter out cancelled events. 209 210 cancelled = self.get_cancellations(user) or [] 211 all_events.difference_update(cancelled) 212 return all_events 213 214 def get_event(self, user, uid, recurrenceid=None): 215 216 """ 217 Get the event for the given 'user' with the given 'uid'. If 218 the optional 'recurrenceid' is specified, a specific instance or 219 occurrence of an event is returned. 220 """ 221 222 if recurrenceid: 223 return self.get_recurrence(user, uid, recurrenceid) 224 else: 225 return self.get_complete_event(user, uid) 226 227 def get_complete_event(self, user, uid): 228 229 "Get the event for the given 'user' with the given 'uid'." 230 231 filename = self.get_object_in_store(user, "objects", uid) 232 if not filename or not exists(filename): 233 return None 234 235 return self._get_object(user, filename) 236 237 def set_event(self, user, uid, recurrenceid, node): 238 239 """ 240 Set an event for 'user' having the given 'uid' and 'recurrenceid' (which 241 if the latter is specified, a specific instance or occurrence of an 242 event is referenced), using the given 'node' description. 243 """ 244 245 if recurrenceid: 246 return self.set_recurrence(user, uid, recurrenceid, node) 247 else: 248 return self.set_complete_event(user, uid, node) 249 250 def set_complete_event(self, user, uid, node): 251 252 "Set an event for 'user' having the given 'uid' and 'node'." 253 254 filename = self.get_object_in_store(user, "objects", uid) 255 if not filename: 256 return False 257 258 return self._set_object(user, filename, node) 259 260 def remove_event(self, user, uid, recurrenceid=None): 261 262 """ 263 Remove an event for 'user' having the given 'uid'. If the optional 264 'recurrenceid' is specified, a specific instance or occurrence of an 265 event is removed. 266 """ 267 268 if recurrenceid: 269 return self.remove_recurrence(user, uid, recurrenceid) 270 else: 271 for recurrenceid in self.get_recurrences(user, uid) or []: 272 self.remove_recurrence(user, uid, recurrenceid) 273 return self.remove_complete_event(user, uid) 274 275 def remove_complete_event(self, user, uid): 276 277 "Remove an event for 'user' having the given 'uid'." 278 279 self.remove_recurrences(user, uid) 280 281 filename = self.get_object_in_store(user, "objects", uid) 282 if not filename: 283 return False 284 285 return self._remove_object(filename) 286 287 def get_recurrences(self, user, uid): 288 289 """ 290 Get additional event instances for an event of the given 'user' with the 291 indicated 'uid'. 292 """ 293 294 filename = self.get_object_in_store(user, "recurrences", uid) 295 if not filename or not exists(filename): 296 return [] 297 298 return [name for name in listdir(filename) if isfile(join(filename, name))] 299 300 def get_recurrence(self, user, uid, recurrenceid): 301 302 """ 303 For the event of the given 'user' with the given 'uid', return the 304 specific recurrence indicated by the 'recurrenceid'. 305 """ 306 307 filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) 308 if not filename or not exists(filename): 309 return None 310 311 return self._get_object(user, filename) 312 313 def set_recurrence(self, user, uid, recurrenceid, node): 314 315 "Set an event for 'user' having the given 'uid' and 'node'." 316 317 filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) 318 if not filename: 319 return False 320 321 return self._set_object(user, filename, node) 322 323 def remove_recurrence(self, user, uid, recurrenceid): 324 325 """ 326 Remove a special recurrence from an event stored by 'user' having the 327 given 'uid' and 'recurrenceid'. 328 """ 329 330 filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) 331 if not filename: 332 return False 333 334 return self._remove_object(filename) 335 336 def remove_recurrences(self, user, uid): 337 338 """ 339 Remove all recurrences for an event stored by 'user' having the given 340 'uid'. 341 """ 342 343 for recurrenceid in self.get_recurrences(user, uid): 344 self.remove_recurrence(user, uid, recurrenceid) 345 346 recurrences = self.get_object_in_store(user, "recurrences", uid) 347 if recurrences: 348 return self._remove_collection(recurrences) 349 350 return True 351 352 # Free/busy period providers, upon extension of the free/busy records. 353 354 def get_freebusy_providers(self, user, dt=None): 355 356 """ 357 Return a set of uncancelled events of the form (uid, recurrenceid) 358 providing free/busy details beyond the given datetime 'dt'. 359 360 If 'dt' is not specified, all events previously found to provide 361 details will be returned. Otherwise, if 'dt' is earlier than the 362 datetime recorded for the known providers, None is returned, indicating 363 that the list of providers must be recomputed. 364 """ 365 366 filename = self.get_object_in_store(user, "freebusy-providers") 367 if not filename or not exists(filename): 368 return None 369 else: 370 # Attempt to read providers, with a declaration of the datetime 371 # from which such providers are considered as still being active. 372 373 t = self._get_table(user, filename, [(1, None)]) 374 try: 375 dt_string = t[0][0] 376 except IndexError: 377 return None 378 379 # If the requested datetime is earlier than the stated datetime, the 380 # providers will need to be recomputed. 381 382 if dt: 383 providers_dt = get_datetime(dt_string) 384 if not providers_dt or providers_dt > dt: 385 return None 386 387 # Otherwise, return the providers. 388 389 return t[1:] 390 391 def set_freebusy_providers(self, user, dt, providers): 392 393 """ 394 Define the uncancelled events providing free/busy details beyond the 395 given datetime 'dt'. 396 """ 397 398 t = [(format_datetime(dt),)] 399 400 for obj in providers: 401 t.append((obj.get_uid(), obj.get_recurrenceid() or "")) 402 403 filename = self.get_object_in_store(user, "freebusy-providers") 404 if not filename: 405 return False 406 407 self._set_table(user, filename, t) 408 return True 409 410 # Free/busy period access. 411 412 def get_freebusy(self, user): 413 414 "Get free/busy details for the given 'user'." 415 416 filename = self.get_object_in_store(user, "freebusy") 417 if not filename or not exists(filename): 418 return [] 419 else: 420 return map(lambda t: FreeBusyPeriod(*t), self._get_table(user, filename, [(4, None)])) 421 422 def get_freebusy_for_other(self, user, other): 423 424 "For the given 'user', get free/busy details for the 'other' user." 425 426 filename = self.get_object_in_store(user, "freebusy-other", other) 427 if not filename or not exists(filename): 428 return [] 429 else: 430 return map(lambda t: FreeBusyPeriod(*t), self._get_table(user, filename, [(4, None)])) 431 432 def set_freebusy(self, user, freebusy): 433 434 "For the given 'user', set 'freebusy' details." 435 436 filename = self.get_object_in_store(user, "freebusy") 437 if not filename: 438 return False 439 440 self._set_table(user, filename, map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) 441 return True 442 443 def set_freebusy_for_other(self, user, freebusy, other): 444 445 "For the given 'user', set 'freebusy' details for the 'other' user." 446 447 filename = self.get_object_in_store(user, "freebusy-other", other) 448 if not filename: 449 return False 450 451 self._set_table(user, filename, map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) 452 return True 453 454 # Object status details access. 455 456 def _get_requests(self, user, queue): 457 458 "Get requests for the given 'user' from the given 'queue'." 459 460 filename = self.get_object_in_store(user, queue) 461 if not filename or not exists(filename): 462 return None 463 464 return self._get_table(user, filename, [(1, None)]) 465 466 def get_requests(self, user): 467 468 "Get requests for the given 'user'." 469 470 return self._get_requests(user, "requests") 471 472 def get_cancellations(self, user): 473 474 "Get cancellations for the given 'user'." 475 476 return self._get_requests(user, "cancellations") 477 478 def _set_requests(self, user, requests, queue): 479 480 """ 481 For the given 'user', set the list of queued 'requests' in the given 482 'queue'. 483 """ 484 485 filename = self.get_object_in_store(user, queue) 486 if not filename: 487 return False 488 489 self.acquire_lock(user) 490 try: 491 f = open(filename, "w") 492 try: 493 for request in requests: 494 print >>f, "\t".join([value or "" for value in request]) 495 finally: 496 f.close() 497 fix_permissions(filename) 498 finally: 499 self.release_lock(user) 500 501 return True 502 503 def set_requests(self, user, requests): 504 505 "For the given 'user', set the list of queued 'requests'." 506 507 return self._set_requests(user, requests, "requests") 508 509 def set_cancellations(self, user, cancellations): 510 511 "For the given 'user', set the list of queued 'cancellations'." 512 513 return self._set_requests(user, cancellations, "cancellations") 514 515 def _set_request(self, user, uid, recurrenceid, queue): 516 517 """ 518 For the given 'user', set the queued 'uid' and 'recurrenceid' in the 519 given 'queue'. 520 """ 521 522 filename = self.get_object_in_store(user, queue) 523 if not filename: 524 return False 525 526 self.acquire_lock(user) 527 try: 528 f = open(filename, "a") 529 try: 530 print >>f, "\t".join([uid, recurrenceid or ""]) 531 finally: 532 f.close() 533 fix_permissions(filename) 534 finally: 535 self.release_lock(user) 536 537 return True 538 539 def set_request(self, user, uid, recurrenceid=None): 540 541 "For the given 'user', set the queued 'uid' and 'recurrenceid'." 542 543 return self._set_request(user, uid, recurrenceid, "requests") 544 545 def set_cancellation(self, user, uid, recurrenceid=None): 546 547 "For the given 'user', set the queued 'uid' and 'recurrenceid'." 548 549 return self._set_request(user, uid, recurrenceid, "cancellations") 550 551 def queue_request(self, user, uid, recurrenceid=None): 552 553 """ 554 Queue a request for 'user' having the given 'uid'. If the optional 555 'recurrenceid' is specified, the request refers to a specific instance 556 or occurrence of an event. 557 """ 558 559 requests = self.get_requests(user) or [] 560 561 if (uid, recurrenceid) not in requests: 562 return self.set_request(user, uid, recurrenceid) 563 564 return False 565 566 def dequeue_request(self, user, uid, recurrenceid=None): 567 568 """ 569 Dequeue a request for 'user' having the given 'uid'. If the optional 570 'recurrenceid' is specified, the request refers to a specific instance 571 or occurrence of an event. 572 """ 573 574 requests = self.get_requests(user) or [] 575 576 try: 577 requests.remove((uid, recurrenceid)) 578 self.set_requests(user, requests) 579 except ValueError: 580 return False 581 else: 582 return True 583 584 def cancel_event(self, user, uid, recurrenceid=None): 585 586 """ 587 Queue an event for cancellation for 'user' having the given 'uid'. If 588 the optional 'recurrenceid' is specified, a specific instance or 589 occurrence of an event is cancelled. 590 """ 591 592 cancellations = self.get_cancellations(user) or [] 593 594 if (uid, recurrenceid) not in cancellations: 595 return self.set_cancellation(user, uid, recurrenceid) 596 597 return False 598 599 class FilePublisher(FileBase): 600 601 "A publisher of objects." 602 603 def __init__(self, store_dir=None): 604 FileBase.__init__(self, store_dir or PUBLISH_DIR) 605 606 def set_freebusy(self, user, freebusy): 607 608 "For the given 'user', set 'freebusy' details." 609 610 filename = self.get_object_in_store(user, "freebusy") 611 if not filename: 612 return False 613 614 record = [] 615 rwrite = record.append 616 617 rwrite(("ORGANIZER", {}, user)) 618 rwrite(("UID", {}, user)) 619 rwrite(("DTSTAMP", {}, datetime.utcnow().strftime("%Y%m%dT%H%M%SZ"))) 620 621 for fb in freebusy: 622 if not fb.transp or fb.transp == "OPAQUE": 623 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join( 624 map(format_datetime, [fb.get_start_point(), fb.get_end_point()])))) 625 626 f = open(filename, "wb") 627 try: 628 to_stream(f, make_calendar([("VFREEBUSY", {}, record)], "PUBLISH")) 629 finally: 630 f.close() 631 fix_permissions(filename) 632 633 return True 634 635 # vim: tabstop=4 expandtab shiftwidth=4