1 #!/usr/bin/env python 2 3 """ 4 A simple filesystem-based store of calendar data. 5 6 Copyright (C) 2014, 2015, 2016 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 import StoreBase, PublisherBase, JournalBase 23 24 from datetime import datetime 25 from imiptools.config import STORE_DIR, PUBLISH_DIR, JOURNAL_DIR 26 from imiptools.data import 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 from imiptools.period import FreeBusyPeriod, FreeBusyCollection 30 from imiptools.text import parse_line 31 from os.path import isdir, isfile, join 32 from os import listdir, remove, rmdir 33 import codecs 34 35 class FileStoreBase(FileBase): 36 37 "A file store supporting user-specific locking and tabular data." 38 39 def acquire_lock(self, user, timeout=None): 40 FileBase.acquire_lock(self, timeout, user) 41 42 def release_lock(self, user): 43 FileBase.release_lock(self, user) 44 45 # Utility methods. 46 47 def _set_defaults(self, t, empty_defaults): 48 for i, default in empty_defaults: 49 if i >= len(t): 50 t += [None] * (i - len(t) + 1) 51 if not t[i]: 52 t[i] = default 53 return t 54 55 def _get_table(self, user, filename, empty_defaults=None, tab_separated=True): 56 57 """ 58 From the file for the given 'user' having the given 'filename', return 59 a list of tuples representing the file's contents. 60 61 The 'empty_defaults' is a list of (index, value) tuples indicating the 62 default value where a column either does not exist or provides an empty 63 value. 64 65 If 'tab_separated' is specified and is a false value, line parsing using 66 the imiptools.text.parse_line function will be performed instead of 67 splitting each line of the file using tab characters as separators. 68 """ 69 70 f = codecs.open(filename, "rb", encoding="utf-8") 71 try: 72 l = [] 73 for line in f.readlines(): 74 line = line.strip(" \r\n") 75 if tab_separated: 76 t = line.split("\t") 77 else: 78 t = parse_line(line) 79 if empty_defaults: 80 t = self._set_defaults(t, empty_defaults) 81 l.append(tuple(t)) 82 return l 83 finally: 84 f.close() 85 86 def _get_table_atomic(self, user, filename, empty_defaults=None, tab_separated=True): 87 88 """ 89 From the file for the given 'user' having the given 'filename', return 90 a list of tuples representing the file's contents. 91 92 The 'empty_defaults' is a list of (index, value) tuples indicating the 93 default value where a column either does not exist or provides an empty 94 value. 95 96 If 'tab_separated' is specified and is a false value, line parsing using 97 the imiptools.text.parse_line function will be performed instead of 98 splitting each line of the file using tab characters as separators. 99 """ 100 101 self.acquire_lock(user) 102 try: 103 return self._get_table(user, filename, empty_defaults, tab_separated) 104 finally: 105 self.release_lock(user) 106 107 def _set_table(self, user, filename, items, empty_defaults=None): 108 109 """ 110 For the given 'user', write to the file having the given 'filename' the 111 'items'. 112 113 The 'empty_defaults' is a list of (index, value) tuples indicating the 114 default value where a column either does not exist or provides an empty 115 value. 116 """ 117 118 f = codecs.open(filename, "wb", encoding="utf-8") 119 try: 120 for item in items: 121 self._set_table_item(f, item, empty_defaults) 122 finally: 123 f.close() 124 fix_permissions(filename) 125 126 def _set_table_item(self, f, item, empty_defaults=None): 127 128 "Set in table 'f' the given 'item', using any 'empty_defaults'." 129 130 if empty_defaults: 131 item = self._set_defaults(list(item), empty_defaults) 132 f.write("\t".join(item) + "\n") 133 134 def _set_table_atomic(self, user, filename, items, empty_defaults=None): 135 136 """ 137 For the given 'user', write to the file having the given 'filename' the 138 'items'. 139 140 The 'empty_defaults' is a list of (index, value) tuples indicating the 141 default value where a column either does not exist or provides an empty 142 value. 143 """ 144 145 self.acquire_lock(user) 146 try: 147 self._set_table(user, filename, items, empty_defaults) 148 finally: 149 self.release_lock(user) 150 151 class FileStore(FileStoreBase, StoreBase): 152 153 "A file store of tabular free/busy data and objects." 154 155 def __init__(self, store_dir=None): 156 FileBase.__init__(self, store_dir or STORE_DIR) 157 158 # Store object access. 159 160 def _get_object(self, user, filename): 161 162 """ 163 Return the parsed object for the given 'user' having the given 164 'filename'. 165 """ 166 167 self.acquire_lock(user) 168 try: 169 f = open(filename, "rb") 170 try: 171 return parse_object(f, "utf-8") 172 finally: 173 f.close() 174 finally: 175 self.release_lock(user) 176 177 def _set_object(self, user, filename, node): 178 179 """ 180 Set an object for the given 'user' having the given 'filename', using 181 'node' to define the object. 182 """ 183 184 self.acquire_lock(user) 185 try: 186 f = open(filename, "wb") 187 try: 188 to_stream(f, node) 189 finally: 190 f.close() 191 fix_permissions(filename) 192 finally: 193 self.release_lock(user) 194 195 return True 196 197 def _remove_object(self, filename): 198 199 "Remove the object with the given 'filename'." 200 201 try: 202 remove(filename) 203 except OSError: 204 return False 205 206 return True 207 208 def _remove_collection(self, filename): 209 210 "Remove the collection with the given 'filename'." 211 212 try: 213 rmdir(filename) 214 except OSError: 215 return False 216 217 return True 218 219 # User discovery. 220 221 def get_users(self): 222 223 "Return a list of users." 224 225 return listdir(self.store_dir) 226 227 # Event and event metadata access. 228 229 def get_events(self, user): 230 231 "Return a list of event identifiers." 232 233 filename = self.get_object_in_store(user, "objects") 234 if not filename or not isdir(filename): 235 return None 236 237 return [name for name in listdir(filename) if isfile(join(filename, name))] 238 239 def get_event_filename(self, user, uid, recurrenceid=None, dirname=None, username=None): 240 241 """ 242 Get the filename providing the event for the given 'user' with the given 243 'uid'. If the optional 'recurrenceid' is specified, a specific instance 244 or occurrence of an event is returned. 245 246 Where 'dirname' is specified, the given directory name is used as the 247 base of the location within which any filename will reside. 248 """ 249 250 if recurrenceid: 251 return self.get_recurrence_filename(user, uid, recurrenceid, dirname, username) 252 else: 253 return self.get_complete_event_filename(user, uid, dirname, username) 254 255 def get_event(self, user, uid, recurrenceid=None, dirname=None): 256 257 """ 258 Get the event for the given 'user' with the given 'uid'. If 259 the optional 'recurrenceid' is specified, a specific instance or 260 occurrence of an event is returned. 261 """ 262 263 filename = self.get_event_filename(user, uid, recurrenceid, dirname) 264 if not filename or not isfile(filename): 265 return None 266 267 return filename and self._get_object(user, filename) 268 269 def get_complete_event_filename(self, user, uid, dirname=None, username=None): 270 271 """ 272 Get the filename providing the event for the given 'user' with the given 273 'uid'. 274 275 Where 'dirname' is specified, the given directory name is used as the 276 base of the location within which any filename will reside. 277 278 Where 'username' is specified, the event details will reside in a file 279 bearing that name within a directory having 'uid' as its name. 280 """ 281 282 return self.get_object_in_store(user, dirname, "objects", uid, username) 283 284 def get_complete_event(self, user, uid): 285 286 "Get the event for the given 'user' with the given 'uid'." 287 288 filename = self.get_complete_event_filename(user, uid) 289 if not filename or not isfile(filename): 290 return None 291 292 return filename and self._get_object(user, filename) 293 294 def set_complete_event(self, user, uid, node): 295 296 "Set an event for 'user' having the given 'uid' and 'node'." 297 298 filename = self.get_object_in_store(user, "objects", uid) 299 if not filename: 300 return False 301 302 return self._set_object(user, filename, node) 303 304 def remove_parent_event(self, user, uid): 305 306 "Remove the parent event for 'user' having the given 'uid'." 307 308 filename = self.get_object_in_store(user, "objects", uid) 309 if not filename: 310 return False 311 312 return self._remove_object(filename) 313 314 def get_recurrences(self, user, uid): 315 316 """ 317 Get additional event instances for an event of the given 'user' with the 318 indicated 'uid'. Both active and cancelled recurrences are returned. 319 """ 320 321 return self.get_active_recurrences(user, uid) + self.get_cancelled_recurrences(user, uid) 322 323 def get_active_recurrences(self, user, uid): 324 325 """ 326 Get additional event instances for an event of the given 'user' with the 327 indicated 'uid'. Cancelled recurrences are not returned. 328 """ 329 330 filename = self.get_object_in_store(user, "recurrences", uid) 331 if not filename or not isdir(filename): 332 return [] 333 334 return [name for name in listdir(filename) if isfile(join(filename, name))] 335 336 def get_cancelled_recurrences(self, user, uid): 337 338 """ 339 Get additional event instances for an event of the given 'user' with the 340 indicated 'uid'. Only cancelled recurrences are returned. 341 """ 342 343 filename = self.get_object_in_store(user, "cancellations", "recurrences", uid) 344 if not filename or not isdir(filename): 345 return [] 346 347 return [name for name in listdir(filename) if isfile(join(filename, name))] 348 349 def get_recurrence_filename(self, user, uid, recurrenceid, dirname=None, username=None): 350 351 """ 352 For the event of the given 'user' with the given 'uid', return the 353 filename providing the recurrence with the given 'recurrenceid'. 354 355 Where 'dirname' is specified, the given directory name is used as the 356 base of the location within which any filename will reside. 357 358 Where 'username' is specified, the event details will reside in a file 359 bearing that name within a directory having 'uid' as its name. 360 """ 361 362 return self.get_object_in_store(user, dirname, "recurrences", uid, recurrenceid, username) 363 364 def get_recurrence(self, user, uid, recurrenceid): 365 366 """ 367 For the event of the given 'user' with the given 'uid', return the 368 specific recurrence indicated by the 'recurrenceid'. 369 """ 370 371 filename = self.get_recurrence_filename(user, uid, recurrenceid) 372 if not filename or not isfile(filename): 373 return None 374 375 return filename and self._get_object(user, filename) 376 377 def set_recurrence(self, user, uid, recurrenceid, node): 378 379 "Set an event for 'user' having the given 'uid' and 'node'." 380 381 filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) 382 if not filename: 383 return False 384 385 return self._set_object(user, filename, node) 386 387 def remove_recurrence(self, user, uid, recurrenceid): 388 389 """ 390 Remove a special recurrence from an event stored by 'user' having the 391 given 'uid' and 'recurrenceid'. 392 """ 393 394 filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) 395 if not filename: 396 return False 397 398 return self._remove_object(filename) 399 400 def remove_recurrence_collection(self, user, uid): 401 402 """ 403 Remove the collection of recurrences stored by 'user' having the given 404 'uid'. 405 """ 406 407 recurrences = self.get_object_in_store(user, "recurrences", uid) 408 if recurrences: 409 return self._remove_collection(recurrences) 410 411 return True 412 413 # Free/busy period providers, upon extension of the free/busy records. 414 415 def _get_freebusy_providers(self, user): 416 417 """ 418 Return the free/busy providers for the given 'user'. 419 420 This function returns any stored datetime and a list of providers as a 421 2-tuple. Each provider is itself a (uid, recurrenceid) tuple. 422 """ 423 424 filename = self.get_object_in_store(user, "freebusy-providers") 425 if not filename or not isfile(filename): 426 return None 427 428 # Attempt to read providers, with a declaration of the datetime 429 # from which such providers are considered as still being active. 430 431 t = self._get_table_atomic(user, filename, [(1, None)]) 432 try: 433 dt_string = t[0][0] 434 except IndexError: 435 return None 436 437 return dt_string, t[1:] 438 439 def get_freebusy_providers(self, user, dt=None): 440 441 """ 442 Return a set of uncancelled events of the form (uid, recurrenceid) 443 providing free/busy details beyond the given datetime 'dt'. 444 445 If 'dt' is not specified, all events previously found to provide 446 details will be returned. Otherwise, if 'dt' is earlier than the 447 datetime recorded for the known providers, None is returned, indicating 448 that the list of providers must be recomputed. 449 450 This function returns a list of (uid, recurrenceid) tuples upon success. 451 """ 452 453 t = self._get_freebusy_providers(user) 454 if not t: 455 return None 456 457 dt_string, t = t 458 459 # If the requested datetime is earlier than the stated datetime, the 460 # providers will need to be recomputed. 461 462 if dt: 463 providers_dt = get_datetime(dt_string) 464 if not providers_dt or providers_dt > dt: 465 return None 466 467 # Otherwise, return the providers. 468 469 return t[1:] 470 471 def _set_freebusy_providers(self, user, dt_string, t): 472 473 "Set the given provider timestamp 'dt_string' and table 't'." 474 475 filename = self.get_object_in_store(user, "freebusy-providers") 476 if not filename: 477 return False 478 479 t.insert(0, (dt_string,)) 480 self._set_table_atomic(user, filename, t, [(1, "")]) 481 return True 482 483 def set_freebusy_providers(self, user, dt, providers): 484 485 """ 486 Define the uncancelled events providing free/busy details beyond the 487 given datetime 'dt'. 488 """ 489 490 t = [] 491 492 for obj in providers: 493 t.append((obj.get_uid(), obj.get_recurrenceid())) 494 495 return self._set_freebusy_providers(user, format_datetime(dt), t) 496 497 def append_freebusy_provider(self, user, provider): 498 499 "For the given 'user', append the free/busy 'provider'." 500 501 t = self._get_freebusy_providers(user) 502 if not t: 503 return False 504 505 dt_string, t = t 506 t.append((provider.get_uid(), provider.get_recurrenceid())) 507 508 return self._set_freebusy_providers(user, dt_string, t) 509 510 def remove_freebusy_provider(self, user, provider): 511 512 "For the given 'user', remove the free/busy 'provider'." 513 514 t = self._get_freebusy_providers(user) 515 if not t: 516 return False 517 518 dt_string, t = t 519 try: 520 t.remove((provider.get_uid(), provider.get_recurrenceid())) 521 except ValueError: 522 return False 523 524 return self._set_freebusy_providers(user, dt_string, t) 525 526 # Free/busy period access. 527 528 def get_freebusy(self, user, name=None, mutable=False): 529 530 "Get free/busy details for the given 'user'." 531 532 filename = self.get_object_in_store(user, name or "freebusy") 533 534 if not filename or not isfile(filename): 535 periods = [] 536 else: 537 periods = map(lambda t: FreeBusyPeriod(*t), 538 self._get_table_atomic(user, filename)) 539 540 return FreeBusyCollection(periods, mutable) 541 542 def get_freebusy_for_update(self, user, name=None): 543 544 "Get free/busy details for the given 'user'." 545 546 return self.get_freebusy(user, name, True) 547 548 def get_freebusy_for_other(self, user, other, mutable=False): 549 550 "For the given 'user', get free/busy details for the 'other' user." 551 552 filename = self.get_object_in_store(user, "freebusy-other", other) 553 554 if not filename or not isfile(filename): 555 periods = [] 556 else: 557 periods = map(lambda t: FreeBusyPeriod(*t), 558 self._get_table_atomic(user, filename)) 559 560 return FreeBusyCollection(periods, mutable) 561 562 def get_freebusy_for_other_for_update(self, user, other): 563 564 "For the given 'user', get free/busy details for the 'other' user." 565 566 return self.get_freebusy_for_other(user, other, True) 567 568 def set_freebusy(self, user, freebusy, name=None): 569 570 "For the given 'user', set 'freebusy' details." 571 572 filename = self.get_object_in_store(user, name or "freebusy") 573 if not filename: 574 return False 575 576 self._set_table_atomic(user, filename, 577 map(lambda fb: fb.as_tuple(strings_only=True), freebusy.periods)) 578 return True 579 580 def set_freebusy_for_other(self, user, freebusy, other): 581 582 "For the given 'user', set 'freebusy' details for the 'other' user." 583 584 filename = self.get_object_in_store(user, "freebusy-other", other) 585 if not filename: 586 return False 587 588 self._set_table_atomic(user, filename, 589 map(lambda fb: fb.as_tuple(strings_only=True), freebusy.periods)) 590 return True 591 592 # Tentative free/busy periods related to countering. 593 594 def get_freebusy_offers(self, user, mutable=False): 595 596 "Get free/busy offers for the given 'user'." 597 598 offers = [] 599 expired = [] 600 now = to_timezone(datetime.utcnow(), "UTC") 601 602 # Expire old offers and save the collection if modified. 603 604 self.acquire_lock(user) 605 try: 606 l = self.get_freebusy(user, "freebusy-offers") 607 for fb in l: 608 if fb.expires and get_datetime(fb.expires) <= now: 609 expired.append(fb) 610 else: 611 offers.append(fb) 612 613 if expired: 614 self.set_freebusy_offers(user, offers) 615 finally: 616 self.release_lock(user) 617 618 return FreeBusyCollection(offers, mutable) 619 620 def get_freebusy_offers_for_update(self, user): 621 622 "Get free/busy offers for the given 'user'." 623 624 return self.get_freebusy_offers(user, True) 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 isfile(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 get_counters(self, user, uid, recurrenceid=None): 703 704 """ 705 For the given 'user', return a list of users from whom counter-proposals 706 have been received for the given 'uid' and optional 'recurrenceid'. 707 """ 708 709 filename = self.get_event_filename(user, uid, recurrenceid, "counters") 710 if not filename or not isdir(filename): 711 return False 712 713 return [name for name in listdir(filename) if isfile(join(filename, name))] 714 715 def get_counter(self, user, other, uid, recurrenceid=None): 716 717 """ 718 For the given 'user', return the counter-proposal from 'other' for the 719 given 'uid' and optional 'recurrenceid'. 720 """ 721 722 filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) 723 if not filename: 724 return False 725 726 return self._get_object(user, filename) 727 728 def set_counter(self, user, other, node, uid, recurrenceid=None): 729 730 """ 731 For the given 'user', store a counter-proposal received from 'other' the 732 given 'node' representing that proposal for the given 'uid' and 733 'recurrenceid'. 734 """ 735 736 filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) 737 if not filename: 738 return False 739 740 return self._set_object(user, filename, node) 741 742 def remove_counters(self, user, uid, recurrenceid=None): 743 744 """ 745 For the given 'user', remove all counter-proposals associated with the 746 given 'uid' and 'recurrenceid'. 747 """ 748 749 filename = self.get_event_filename(user, uid, recurrenceid, "counters") 750 if not filename or not isdir(filename): 751 return False 752 753 removed = False 754 755 for other in listdir(filename): 756 counter_filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) 757 removed = removed or self._remove_object(counter_filename) 758 759 return removed 760 761 def remove_counter(self, user, other, uid, recurrenceid=None): 762 763 """ 764 For the given 'user', remove any counter-proposal from 'other' 765 associated with the given 'uid' and 'recurrenceid'. 766 """ 767 768 filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) 769 if not filename or not isfile(filename): 770 return False 771 772 return self._remove_object(filename) 773 774 # Event cancellation. 775 776 def cancel_event(self, user, uid, recurrenceid=None): 777 778 """ 779 Cancel an event for 'user' having the given 'uid'. If the optional 780 'recurrenceid' is specified, a specific instance or occurrence of an 781 event is cancelled. 782 """ 783 784 filename = self.get_event_filename(user, uid, recurrenceid) 785 cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") 786 787 if filename and cancelled_filename and isfile(filename): 788 return self.move_object(filename, cancelled_filename) 789 790 return False 791 792 def uncancel_event(self, user, uid, recurrenceid=None): 793 794 """ 795 Uncancel an event for 'user' having the given 'uid'. If the optional 796 'recurrenceid' is specified, a specific instance or occurrence of an 797 event is uncancelled. 798 """ 799 800 filename = self.get_event_filename(user, uid, recurrenceid) 801 cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") 802 803 if filename and cancelled_filename and isfile(cancelled_filename): 804 return self.move_object(cancelled_filename, filename) 805 806 return False 807 808 def remove_cancellation(self, user, uid, recurrenceid=None): 809 810 """ 811 Remove a cancellation for 'user' for the event having the given 'uid'. 812 If the optional 'recurrenceid' is specified, a specific instance or 813 occurrence of an event is affected. 814 """ 815 816 # Remove any parent event cancellation or a specific recurrence 817 # cancellation if indicated. 818 819 filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") 820 821 if filename and isfile(filename): 822 return self._remove_object(filename) 823 824 return False 825 826 class FilePublisher(FileBase, PublisherBase): 827 828 "A publisher of objects." 829 830 def __init__(self, store_dir=None): 831 FileBase.__init__(self, store_dir or PUBLISH_DIR) 832 833 def set_freebusy(self, user, freebusy): 834 835 "For the given 'user', set 'freebusy' details." 836 837 filename = self.get_object_in_store(user, "freebusy") 838 if not filename: 839 return False 840 841 record = [] 842 rwrite = record.append 843 844 rwrite(("ORGANIZER", {}, user)) 845 rwrite(("UID", {}, user)) 846 rwrite(("DTSTAMP", {}, datetime.utcnow().strftime("%Y%m%dT%H%M%SZ"))) 847 848 for fb in freebusy: 849 if not fb.transp or fb.transp == "OPAQUE": 850 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join( 851 map(format_datetime, [fb.get_start_point(), fb.get_end_point()])))) 852 853 f = open(filename, "wb") 854 try: 855 to_stream(f, make_calendar([("VFREEBUSY", {}, record)], "PUBLISH")) 856 finally: 857 f.close() 858 fix_permissions(filename) 859 860 return True 861 862 class FileJournal(FileStoreBase, JournalBase): 863 864 "A journal system to support quotas." 865 866 def __init__(self, store_dir=None): 867 FileBase.__init__(self, store_dir or JOURNAL_DIR) 868 869 # Quota and user identity/group discovery. 870 871 def get_quotas(self): 872 873 "Return a list of quotas." 874 875 return listdir(self.store_dir) 876 877 def get_quota_users(self, quota): 878 879 "Return a list of quota users." 880 881 filename = self.get_object_in_store(quota, "journal") 882 if not filename or not isdir(filename): 883 return [] 884 885 return listdir(filename) 886 887 # Groups of users sharing quotas. 888 889 def get_groups(self, quota): 890 891 "Return the identity mappings for the given 'quota' as a dictionary." 892 893 filename = self.get_object_in_store(quota, "groups") 894 if not filename or not isfile(filename): 895 return {} 896 897 return dict(self._get_table_atomic(quota, filename, tab_separated=False)) 898 899 def get_limits(self, quota): 900 901 """ 902 Return the limits for the 'quota' as a dictionary mapping identities or 903 groups to durations. 904 """ 905 906 filename = self.get_object_in_store(quota, "limits") 907 if not filename or not isfile(filename): 908 return None 909 910 return dict(self._get_table_atomic(quota, filename, tab_separated=False)) 911 912 # Free/busy period access for users within quota groups. 913 914 def get_freebusy(self, quota, user, mutable=False): 915 916 "Get free/busy details for the given 'quota' and 'user'." 917 918 filename = self.get_object_in_store(quota, "freebusy", user) 919 920 if not filename or not isfile(filename): 921 periods = [] 922 else: 923 periods = map(lambda t: FreeBusyPeriod(*t), 924 self._get_table_atomic(quota, filename)) 925 926 return FreeBusyCollection(periods, mutable) 927 928 def get_freebusy_for_update(self, quota, user): 929 930 "Get free/busy details for the given 'quota' and 'user'." 931 932 return self.get_freebusy(quota, user, True) 933 934 def set_freebusy(self, quota, user, freebusy): 935 936 "For the given 'quota' and 'user', set 'freebusy' details." 937 938 filename = self.get_object_in_store(quota, "freebusy", user) 939 if not filename: 940 return False 941 942 self._set_table_atomic(quota, filename, 943 map(lambda fb: fb.as_tuple(strings_only=True), freebusy.periods)) 944 return True 945 946 # Journal entry methods. 947 948 def get_entries(self, quota, group, mutable=False): 949 950 """ 951 Return a list of journal entries for the given 'quota' for the indicated 952 'group'. 953 """ 954 955 filename = self.get_object_in_store(quota, "journal", group) 956 957 if not filename or not isfile(filename): 958 periods = [] 959 else: 960 periods = map(lambda t: FreeBusyPeriod(*t), 961 self._get_table_atomic(quota, filename)) 962 963 return FreeBusyCollection(periods, mutable) 964 965 def get_entries_for_update(self, quota, group): 966 967 """ 968 Return a list of journal entries for the given 'quota' for the indicated 969 'group'. 970 """ 971 972 return self.get_entries(quota, group, True) 973 974 def set_entries(self, quota, group, entries): 975 976 """ 977 For the given 'quota' and indicated 'group', set the list of journal 978 'entries'. 979 """ 980 981 filename = self.get_object_in_store(quota, "journal", group) 982 if not filename: 983 return False 984 985 self._set_table_atomic(quota, filename, 986 map(lambda fb: fb.as_tuple(strings_only=True), entries.periods)) 987 return True 988 989 # vim: tabstop=4 expandtab shiftwidth=4