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