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