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 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_update(self, user, name=None): 558 559 """ 560 Get free/busy details for the given 'user', locking the table. Dependent 561 code must release this lock regardless of it completing successfully. 562 """ 563 564 self.acquire_lock(user) 565 return self.get_freebusy(user, name, self._get_table) 566 567 def get_freebusy_for_other(self, user, other, get_table=None): 568 569 "For the given 'user', get free/busy details for the 'other' user." 570 571 filename = self.get_object_in_store(user, "freebusy-other", other) 572 if not filename or not exists(filename): 573 return [] 574 else: 575 return map(lambda t: FreeBusyPeriod(*t), 576 (get_table or self._get_table_atomic)(user, filename, [(4, None)])) 577 578 def get_freebusy_for_other_for_update(self, user, other): 579 580 """ 581 For the given 'user', get free/busy details for the 'other' user, 582 locking the table. Dependent code must release this lock regardless of 583 it completing successfully. 584 """ 585 586 self.acquire_lock(user) 587 return self.get_freebusy_for_other(user, other, self._get_table) 588 589 def set_freebusy(self, user, freebusy, name=None, set_table=None): 590 591 "For the given 'user', set 'freebusy' details." 592 593 filename = self.get_object_in_store(user, name or "freebusy") 594 if not filename: 595 return False 596 597 (set_table or self._set_table_atomic)(user, filename, 598 map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) 599 return True 600 601 def set_freebusy_in_update(self, user, freebusy, name=None): 602 603 "For the given 'user', set 'freebusy' details during a compound update." 604 605 return self.set_freebusy(user, freebusy, name, self._set_table) 606 607 def set_freebusy_for_other(self, user, freebusy, other, set_table=None): 608 609 "For the given 'user', set 'freebusy' details for the 'other' user." 610 611 filename = self.get_object_in_store(user, "freebusy-other", other) 612 if not filename: 613 return False 614 615 (set_table or self._set_table_atomic)(user, filename, 616 map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) 617 return True 618 619 def set_freebusy_for_other_in_update(self, user, freebusy, other): 620 621 """ 622 For the given 'user', set 'freebusy' details for the 'other' user during 623 a compound update. 624 """ 625 626 return self.set_freebusy_for_other(user, freebusy, other, self._set_table) 627 628 # Release methods. 629 630 release_freebusy = release_lock 631 632 # Tentative free/busy periods related to countering. 633 634 def get_freebusy_offers(self, user): 635 636 "Get free/busy offers for the given 'user'." 637 638 offers = [] 639 expired = [] 640 now = datetime.now() 641 642 # Expire old offers and save the collection if modified. 643 644 l = self.get_freebusy_for_update(user, "freebusy-offers") 645 try: 646 for fb in l: 647 if fb.expires and get_datetime(fb.expires) <= now: 648 expired.append(fb) 649 else: 650 offers.append(fb) 651 652 if expired: 653 self.set_freebusy_offers_in_update(user, offers) 654 655 finally: 656 self.release_freebusy(user) 657 658 return offers 659 660 def get_freebusy_offers_for_update(self, user): 661 662 """ 663 Get free/busy offers for the given 'user', locking the table. Dependent 664 code must release this lock regardless of it completing successfully. 665 """ 666 667 self.acquire_lock(user) 668 return self.get_freebusy_offers(user) 669 670 def set_freebusy_offers(self, user, freebusy): 671 672 "For the given 'user', set 'freebusy' offers." 673 674 return self.set_freebusy(user, freebusy, "freebusy-offers") 675 676 def set_freebusy_offers_in_update(self, user, freebusy): 677 678 "For the given 'user', set 'freebusy' offers during a compound update." 679 680 return self.set_freebusy_in_update(user, freebusy, "freebusy-offers") 681 682 # Object status details access. 683 684 def _get_requests(self, user, queue): 685 686 "Get requests for the given 'user' from the given 'queue'." 687 688 filename = self.get_object_in_store(user, queue) 689 if not filename or not exists(filename): 690 return None 691 692 return self._get_table_atomic(user, filename, [(1, None)]) 693 694 def get_requests(self, user): 695 696 "Get requests for the given 'user'." 697 698 return self._get_requests(user, "requests") 699 700 def _set_requests(self, user, requests, queue): 701 702 """ 703 For the given 'user', set the list of queued 'requests' in the given 704 'queue'. 705 """ 706 707 filename = self.get_object_in_store(user, queue) 708 if not filename: 709 return False 710 711 self.acquire_lock(user) 712 try: 713 f = open(filename, "w") 714 try: 715 for request in requests: 716 print >>f, "\t".join([value or "" for value in request]) 717 finally: 718 f.close() 719 fix_permissions(filename) 720 finally: 721 self.release_lock(user) 722 723 return True 724 725 def set_requests(self, user, requests): 726 727 "For the given 'user', set the list of queued 'requests'." 728 729 return self._set_requests(user, requests, "requests") 730 731 def _set_request(self, user, uid, recurrenceid, queue): 732 733 """ 734 For the given 'user', set the queued 'uid' and 'recurrenceid' in the 735 given 'queue'. 736 """ 737 738 filename = self.get_object_in_store(user, queue) 739 if not filename: 740 return False 741 742 self.acquire_lock(user) 743 try: 744 f = open(filename, "a") 745 try: 746 print >>f, "\t".join([uid, recurrenceid or ""]) 747 finally: 748 f.close() 749 fix_permissions(filename) 750 finally: 751 self.release_lock(user) 752 753 return True 754 755 def set_request(self, user, uid, recurrenceid=None): 756 757 "For the given 'user', set the queued 'uid' and 'recurrenceid'." 758 759 return self._set_request(user, uid, recurrenceid, "requests") 760 761 def queue_request(self, user, uid, recurrenceid=None): 762 763 """ 764 Queue a request for 'user' having the given 'uid'. If the optional 765 'recurrenceid' is specified, the request refers to a specific instance 766 or occurrence of an event. 767 """ 768 769 requests = self.get_requests(user) or [] 770 771 if (uid, recurrenceid) not in requests: 772 return self.set_request(user, uid, recurrenceid) 773 774 return False 775 776 def dequeue_request(self, user, uid, recurrenceid=None): 777 778 """ 779 Dequeue a request for 'user' having the given 'uid'. If the optional 780 'recurrenceid' is specified, the request refers to a specific instance 781 or occurrence of an event. 782 """ 783 784 requests = self.get_requests(user) or [] 785 786 try: 787 requests.remove((uid, recurrenceid)) 788 self.set_requests(user, requests) 789 except ValueError: 790 return False 791 else: 792 return True 793 794 def cancel_event(self, user, uid, recurrenceid=None): 795 796 """ 797 Cancel an event for 'user' having the given 'uid'. If the optional 798 'recurrenceid' is specified, a specific instance or occurrence of an 799 event is cancelled. 800 """ 801 802 filename = self.get_event_filename(user, uid, recurrenceid) 803 cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") 804 805 if filename and cancelled_filename and exists(filename): 806 return self.move_object(filename, cancelled_filename) 807 808 return False 809 810 class FilePublisher(FileBase): 811 812 "A publisher of objects." 813 814 def __init__(self, store_dir=None): 815 FileBase.__init__(self, store_dir or PUBLISH_DIR) 816 817 def set_freebusy(self, user, freebusy): 818 819 "For the given 'user', set 'freebusy' details." 820 821 filename = self.get_object_in_store(user, "freebusy") 822 if not filename: 823 return False 824 825 record = [] 826 rwrite = record.append 827 828 rwrite(("ORGANIZER", {}, user)) 829 rwrite(("UID", {}, user)) 830 rwrite(("DTSTAMP", {}, datetime.utcnow().strftime("%Y%m%dT%H%M%SZ"))) 831 832 for fb in freebusy: 833 if not fb.transp or fb.transp == "OPAQUE": 834 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join( 835 map(format_datetime, [fb.get_start_point(), fb.get_end_point()])))) 836 837 f = open(filename, "wb") 838 try: 839 to_stream(f, make_calendar([("VFREEBUSY", {}, record)], "PUBLISH")) 840 finally: 841 f.close() 842 fix_permissions(filename) 843 844 return True 845 846 # vim: tabstop=4 expandtab shiftwidth=4