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