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, to_timezone 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 f = codecs.open(filename, "rb", encoding="utf-8") 68 try: 69 l = [] 70 for line in f.readlines(): 71 t = line.strip(" \r\n").split("\t") 72 if empty_defaults: 73 t = self._set_defaults(t, empty_defaults) 74 l.append(tuple(t)) 75 return l 76 finally: 77 f.close() 78 79 def _get_table_atomic(self, user, filename, empty_defaults=None): 80 81 """ 82 From the file for the given 'user' having the given 'filename', return 83 a list of tuples representing the file's contents. 84 85 The 'empty_defaults' is a list of (index, value) tuples indicating the 86 default value where a column either does not exist or provides an empty 87 value. 88 """ 89 90 self.acquire_lock(user) 91 try: 92 return self._get_table(user, filename, empty_defaults) 93 finally: 94 self.release_lock(user) 95 96 def _set_table(self, user, filename, items, empty_defaults=None): 97 98 """ 99 For the given 'user', write to the file having the given 'filename' the 100 'items'. 101 102 The 'empty_defaults' is a list of (index, value) tuples indicating the 103 default value where a column either does not exist or provides an empty 104 value. 105 """ 106 107 f = codecs.open(filename, "wb", encoding="utf-8") 108 try: 109 for item in items: 110 if empty_defaults: 111 item = self._set_defaults(list(item), empty_defaults) 112 f.write("\t".join(item) + "\n") 113 finally: 114 f.close() 115 fix_permissions(filename) 116 117 def _set_table_atomic(self, user, filename, items, empty_defaults=None): 118 119 """ 120 For the given 'user', write to the file having the given 'filename' the 121 'items'. 122 123 The 'empty_defaults' is a list of (index, value) tuples indicating the 124 default value where a column either does not exist or provides an empty 125 value. 126 """ 127 128 self.acquire_lock(user) 129 try: 130 self._set_table(user, filename, items, empty_defaults) 131 finally: 132 self.release_lock(user) 133 134 # Store object access. 135 136 def _get_object(self, user, filename): 137 138 """ 139 Return the parsed object for the given 'user' having the given 140 'filename'. 141 """ 142 143 self.acquire_lock(user) 144 try: 145 f = open(filename, "rb") 146 try: 147 return parse_object(f, "utf-8") 148 finally: 149 f.close() 150 finally: 151 self.release_lock(user) 152 153 def _set_object(self, user, filename, node): 154 155 """ 156 Set an object for the given 'user' having the given 'filename', using 157 'node' to define the object. 158 """ 159 160 self.acquire_lock(user) 161 try: 162 f = open(filename, "wb") 163 try: 164 to_stream(f, node) 165 finally: 166 f.close() 167 fix_permissions(filename) 168 finally: 169 self.release_lock(user) 170 171 return True 172 173 def _remove_object(self, filename): 174 175 "Remove the object with the given 'filename'." 176 177 try: 178 remove(filename) 179 except OSError: 180 return False 181 182 return True 183 184 def _remove_collection(self, filename): 185 186 "Remove the collection with the given 'filename'." 187 188 try: 189 rmdir(filename) 190 except OSError: 191 return False 192 193 return True 194 195 # User discovery. 196 197 def get_users(self): 198 199 "Return a list of users." 200 201 return listdir(self.store_dir) 202 203 # Event and event metadata access. 204 205 def get_events(self, user): 206 207 "Return a list of event identifiers." 208 209 filename = self.get_object_in_store(user, "objects") 210 if not filename or not exists(filename): 211 return None 212 213 return [name for name in listdir(filename) if isfile(join(filename, name))] 214 215 def get_all_events(self, user): 216 217 "Return a set of (uid, recurrenceid) tuples for all events." 218 219 uids = self.get_events(user) 220 if not uids: 221 return set() 222 223 all_events = set() 224 for uid in uids: 225 all_events.add((uid, None)) 226 all_events.update([(uid, recurrenceid) for recurrenceid in self.get_recurrences(user, uid)]) 227 228 return all_events 229 230 def get_event_filename(self, user, uid, recurrenceid=None, dirname=None): 231 232 """ 233 Get the filename providing the event for the given 'user' with the given 234 'uid'. If the optional 'recurrenceid' is specified, a specific instance 235 or occurrence of an event is returned. 236 237 Where 'dirname' is specified, the given directory name is used as the 238 base of the location within which any filename will reside. 239 """ 240 241 if recurrenceid: 242 return self.get_recurrence_filename(user, uid, recurrenceid, dirname) 243 else: 244 return self.get_complete_event_filename(user, uid, dirname) 245 246 def get_event(self, user, uid, recurrenceid=None): 247 248 """ 249 Get the event for the given 'user' with the given 'uid'. If 250 the optional 'recurrenceid' is specified, a specific instance or 251 occurrence of an event is returned. 252 """ 253 254 filename = self.get_event_filename(user, uid, recurrenceid) 255 if not filename or not exists(filename): 256 return None 257 258 return filename and self._get_object(user, filename) 259 260 def get_complete_event_filename(self, user, uid, dirname=None): 261 262 """ 263 Get the filename providing the event for the given 'user' with the given 264 'uid'. 265 266 Where 'dirname' is specified, the given directory name is used as the 267 base of the location within which any filename will reside. 268 """ 269 270 return self.get_object_in_store(user, dirname, "objects", uid) 271 272 def get_complete_event(self, user, uid): 273 274 "Get the event for the given 'user' with the given 'uid'." 275 276 filename = self.get_complete_event_filename(user, uid) 277 if not filename or not exists(filename): 278 return None 279 280 return filename and self._get_object(user, filename) 281 282 def set_event(self, user, uid, recurrenceid, node): 283 284 """ 285 Set an event for 'user' having the given 'uid' and 'recurrenceid' (which 286 if the latter is specified, a specific instance or occurrence of an 287 event is referenced), using the given 'node' description. 288 """ 289 290 if recurrenceid: 291 return self.set_recurrence(user, uid, recurrenceid, node) 292 else: 293 return self.set_complete_event(user, uid, node) 294 295 def set_complete_event(self, user, uid, node): 296 297 "Set an event for 'user' having the given 'uid' and 'node'." 298 299 filename = self.get_object_in_store(user, "objects", uid) 300 if not filename: 301 return False 302 303 return self._set_object(user, filename, node) 304 305 def remove_event(self, user, uid, recurrenceid=None): 306 307 """ 308 Remove an event for 'user' having the given 'uid'. If the optional 309 'recurrenceid' is specified, a specific instance or occurrence of an 310 event is removed. 311 """ 312 313 if recurrenceid: 314 return self.remove_recurrence(user, uid, recurrenceid) 315 else: 316 for recurrenceid in self.get_recurrences(user, uid) or []: 317 self.remove_recurrence(user, uid, recurrenceid) 318 return self.remove_complete_event(user, uid) 319 320 def remove_complete_event(self, user, uid): 321 322 "Remove an event for 'user' having the given 'uid'." 323 324 self.remove_recurrences(user, uid) 325 326 filename = self.get_object_in_store(user, "objects", uid) 327 if not filename: 328 return False 329 330 return self._remove_object(filename) 331 332 def get_recurrences(self, user, uid): 333 334 """ 335 Get additional event instances for an event of the given 'user' with the 336 indicated 'uid'. Both active and cancelled recurrences are returned. 337 """ 338 339 return self.get_active_recurrences(user, uid) + self.get_cancelled_recurrences(user, uid) 340 341 def get_active_recurrences(self, user, uid): 342 343 """ 344 Get additional event instances for an event of the given 'user' with the 345 indicated 'uid'. Cancelled recurrences are not returned. 346 """ 347 348 filename = self.get_object_in_store(user, "recurrences", uid) 349 if not filename or not exists(filename): 350 return [] 351 352 return [name for name in listdir(filename) if isfile(join(filename, name))] 353 354 def get_cancelled_recurrences(self, user, uid): 355 356 """ 357 Get additional event instances for an event of the given 'user' with the 358 indicated 'uid'. Only cancelled recurrences are returned. 359 """ 360 361 filename = self.get_object_in_store(user, "cancelled", "recurrences", uid) 362 if not filename or not exists(filename): 363 return [] 364 365 return [name for name in listdir(filename) if isfile(join(filename, name))] 366 367 def get_recurrence_filename(self, user, uid, recurrenceid, dirname=None): 368 369 """ 370 For the event of the given 'user' with the given 'uid', return the 371 filename providing the recurrence with the given 'recurrenceid'. 372 373 Where 'dirname' is specified, the given directory name is used as the 374 base of the location within which any filename will reside. 375 """ 376 377 return self.get_object_in_store(user, dirname, "recurrences", uid, recurrenceid) 378 379 def get_recurrence(self, user, uid, recurrenceid): 380 381 """ 382 For the event of the given 'user' with the given 'uid', return the 383 specific recurrence indicated by the 'recurrenceid'. 384 """ 385 386 filename = self.get_recurrence_filename(user, uid, recurrenceid) 387 if not filename or not exists(filename): 388 return None 389 390 return filename and self._get_object(user, filename) 391 392 def set_recurrence(self, user, uid, recurrenceid, node): 393 394 "Set an event for 'user' having the given 'uid' and 'node'." 395 396 filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) 397 if not filename: 398 return False 399 400 return self._set_object(user, filename, node) 401 402 def remove_recurrence(self, user, uid, recurrenceid): 403 404 """ 405 Remove a special recurrence from an event stored by 'user' having the 406 given 'uid' and 'recurrenceid'. 407 """ 408 409 filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) 410 if not filename: 411 return False 412 413 return self._remove_object(filename) 414 415 def remove_recurrences(self, user, uid): 416 417 """ 418 Remove all recurrences for an event stored by 'user' having the given 419 'uid'. 420 """ 421 422 for recurrenceid in self.get_recurrences(user, uid): 423 self.remove_recurrence(user, uid, recurrenceid) 424 425 recurrences = self.get_object_in_store(user, "recurrences", uid) 426 if recurrences: 427 return self._remove_collection(recurrences) 428 429 return True 430 431 # Free/busy period providers, upon extension of the free/busy records. 432 433 def _get_freebusy_providers(self, user): 434 435 """ 436 Return the free/busy providers for the given 'user'. 437 438 This function returns any stored datetime and a list of providers as a 439 2-tuple. Each provider is itself a (uid, recurrenceid) tuple. 440 """ 441 442 filename = self.get_object_in_store(user, "freebusy-providers") 443 if not filename or not exists(filename): 444 return None 445 446 # Attempt to read providers, with a declaration of the datetime 447 # from which such providers are considered as still being active. 448 449 t = self._get_table_atomic(user, filename, [(1, None)]) 450 try: 451 dt_string = t[0][0] 452 except IndexError: 453 return None 454 455 return dt_string, t[1:] 456 457 def get_freebusy_providers(self, user, dt=None): 458 459 """ 460 Return a set of uncancelled events of the form (uid, recurrenceid) 461 providing free/busy details beyond the given datetime 'dt'. 462 463 If 'dt' is not specified, all events previously found to provide 464 details will be returned. Otherwise, if 'dt' is earlier than the 465 datetime recorded for the known providers, None is returned, indicating 466 that the list of providers must be recomputed. 467 468 This function returns a list of (uid, recurrenceid) tuples upon success. 469 """ 470 471 t = self._get_freebusy_providers(user) 472 if not t: 473 return None 474 475 dt_string, t = t 476 477 # If the requested datetime is earlier than the stated datetime, the 478 # providers will need to be recomputed. 479 480 if dt: 481 providers_dt = get_datetime(dt_string) 482 if not providers_dt or providers_dt > dt: 483 return None 484 485 # Otherwise, return the providers. 486 487 return t[1:] 488 489 def _set_freebusy_providers(self, user, dt_string, t): 490 491 "Set the given provider timestamp 'dt_string' and table 't'." 492 493 filename = self.get_object_in_store(user, "freebusy-providers") 494 if not filename: 495 return False 496 497 t.insert(0, (dt_string,)) 498 self._set_table_atomic(user, filename, t, [(1, "")]) 499 return True 500 501 def set_freebusy_providers(self, user, dt, providers): 502 503 """ 504 Define the uncancelled events providing free/busy details beyond the 505 given datetime 'dt'. 506 """ 507 508 t = [] 509 510 for obj in providers: 511 t.append((obj.get_uid(), obj.get_recurrenceid())) 512 513 return self._set_freebusy_providers(user, format_datetime(dt), t) 514 515 def append_freebusy_provider(self, user, provider): 516 517 "For the given 'user', append the free/busy 'provider'." 518 519 t = self._get_freebusy_providers(user) 520 if not t: 521 return False 522 523 dt_string, t = t 524 t.append((provider.get_uid(), provider.get_recurrenceid())) 525 526 return self._set_freebusy_providers(user, dt_string, t) 527 528 def remove_freebusy_provider(self, user, provider): 529 530 "For the given 'user', remove the free/busy 'provider'." 531 532 t = self._get_freebusy_providers(user) 533 if not t: 534 return False 535 536 dt_string, t = t 537 try: 538 t.remove((provider.get_uid(), provider.get_recurrenceid())) 539 except ValueError: 540 return False 541 542 return self._set_freebusy_providers(user, dt_string, t) 543 544 # Free/busy period access. 545 546 def get_freebusy(self, user, name=None, get_table=None): 547 548 "Get free/busy details for the given 'user'." 549 550 filename = self.get_object_in_store(user, name or "freebusy") 551 if not filename or not exists(filename): 552 return [] 553 else: 554 return map(lambda t: FreeBusyPeriod(*t), 555 (get_table or self._get_table_atomic)(user, filename, [(4, None)])) 556 557 def get_freebusy_for_other(self, user, other, get_table=None): 558 559 "For the given 'user', get free/busy details for the 'other' user." 560 561 filename = self.get_object_in_store(user, "freebusy-other", other) 562 if not filename or not exists(filename): 563 return [] 564 else: 565 return map(lambda t: FreeBusyPeriod(*t), 566 (get_table or self._get_table_atomic)(user, filename, [(4, None)])) 567 568 def set_freebusy(self, user, freebusy, name=None, set_table=None): 569 570 "For the given 'user', set 'freebusy' details." 571 572 filename = self.get_object_in_store(user, name or "freebusy") 573 if not filename: 574 return False 575 576 (set_table or self._set_table_atomic)(user, filename, 577 map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) 578 return True 579 580 def set_freebusy_for_other(self, user, freebusy, other, set_table=None): 581 582 "For the given 'user', set 'freebusy' details for the 'other' user." 583 584 filename = self.get_object_in_store(user, "freebusy-other", other) 585 if not filename: 586 return False 587 588 (set_table or self._set_table_atomic)(user, filename, 589 map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) 590 return True 591 592 # Tentative free/busy periods related to countering. 593 594 def get_freebusy_offers(self, user): 595 596 "Get free/busy offers for the given 'user'." 597 598 offers = [] 599 expired = [] 600 now = to_timezone(datetime.utcnow(), "UTC") 601 602 # Expire old offers and save the collection if modified. 603 604 self.acquire_lock(user) 605 try: 606 l = self.get_freebusy(user, "freebusy-offers") 607 for fb in l: 608 if fb.expires and get_datetime(fb.expires) <= now: 609 expired.append(fb) 610 else: 611 offers.append(fb) 612 613 if expired: 614 self.set_freebusy_offers(user, offers) 615 finally: 616 self.release_lock(user) 617 618 return offers 619 620 def set_freebusy_offers(self, user, freebusy): 621 622 "For the given 'user', set 'freebusy' offers." 623 624 return self.set_freebusy(user, freebusy, "freebusy-offers") 625 626 # Object status details access. 627 628 def _get_requests(self, user, queue): 629 630 "Get requests for the given 'user' from the given 'queue'." 631 632 filename = self.get_object_in_store(user, queue) 633 if not filename or not exists(filename): 634 return None 635 636 return self._get_table_atomic(user, filename, [(1, None)]) 637 638 def get_requests(self, user): 639 640 "Get requests for the given 'user'." 641 642 return self._get_requests(user, "requests") 643 644 def _set_requests(self, user, requests, queue): 645 646 """ 647 For the given 'user', set the list of queued 'requests' in the given 648 'queue'. 649 """ 650 651 filename = self.get_object_in_store(user, queue) 652 if not filename: 653 return False 654 655 self.acquire_lock(user) 656 try: 657 f = open(filename, "w") 658 try: 659 for request in requests: 660 print >>f, "\t".join([value or "" for value in request]) 661 finally: 662 f.close() 663 fix_permissions(filename) 664 finally: 665 self.release_lock(user) 666 667 return True 668 669 def set_requests(self, user, requests): 670 671 "For the given 'user', set the list of queued 'requests'." 672 673 return self._set_requests(user, requests, "requests") 674 675 def _set_request(self, user, uid, recurrenceid, queue): 676 677 """ 678 For the given 'user', set the queued 'uid' and 'recurrenceid' in the 679 given 'queue'. 680 """ 681 682 filename = self.get_object_in_store(user, queue) 683 if not filename: 684 return False 685 686 self.acquire_lock(user) 687 try: 688 f = open(filename, "a") 689 try: 690 print >>f, "\t".join([uid, recurrenceid or ""]) 691 finally: 692 f.close() 693 fix_permissions(filename) 694 finally: 695 self.release_lock(user) 696 697 return True 698 699 def set_request(self, user, uid, recurrenceid=None): 700 701 "For the given 'user', set the queued 'uid' and 'recurrenceid'." 702 703 return self._set_request(user, uid, recurrenceid, "requests") 704 705 def queue_request(self, user, uid, recurrenceid=None): 706 707 """ 708 Queue a request for 'user' having the given 'uid'. If the optional 709 'recurrenceid' is specified, the request refers to a specific instance 710 or occurrence of an event. 711 """ 712 713 requests = self.get_requests(user) or [] 714 715 if (uid, recurrenceid) not in requests: 716 return self.set_request(user, uid, recurrenceid) 717 718 return False 719 720 def dequeue_request(self, user, uid, recurrenceid=None): 721 722 """ 723 Dequeue a request for 'user' having the given 'uid'. If the optional 724 'recurrenceid' is specified, the request refers to a specific instance 725 or occurrence of an event. 726 """ 727 728 requests = self.get_requests(user) or [] 729 730 try: 731 requests.remove((uid, recurrenceid)) 732 self.set_requests(user, requests) 733 except ValueError: 734 return False 735 else: 736 return True 737 738 def cancel_event(self, user, uid, recurrenceid=None): 739 740 """ 741 Cancel an event for 'user' having the given 'uid'. If the optional 742 'recurrenceid' is specified, a specific instance or occurrence of an 743 event is cancelled. 744 """ 745 746 filename = self.get_event_filename(user, uid, recurrenceid) 747 cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") 748 749 if filename and cancelled_filename and exists(filename): 750 return self.move_object(filename, cancelled_filename) 751 752 return False 753 754 class FilePublisher(FileBase): 755 756 "A publisher of objects." 757 758 def __init__(self, store_dir=None): 759 FileBase.__init__(self, store_dir or PUBLISH_DIR) 760 761 def set_freebusy(self, user, freebusy): 762 763 "For the given 'user', set 'freebusy' details." 764 765 filename = self.get_object_in_store(user, "freebusy") 766 if not filename: 767 return False 768 769 record = [] 770 rwrite = record.append 771 772 rwrite(("ORGANIZER", {}, user)) 773 rwrite(("UID", {}, user)) 774 rwrite(("DTSTAMP", {}, datetime.utcnow().strftime("%Y%m%dT%H%M%SZ"))) 775 776 for fb in freebusy: 777 if not fb.transp or fb.transp == "OPAQUE": 778 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join( 779 map(format_datetime, [fb.get_start_point(), fb.get_end_point()])))) 780 781 f = open(filename, "wb") 782 try: 783 to_stream(f, make_calendar([("VFREEBUSY", {}, record)], "PUBLISH")) 784 finally: 785 f.close() 786 fix_permissions(filename) 787 788 return True 789 790 # vim: tabstop=4 expandtab shiftwidth=4