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.common 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 Store(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 _set_freebusy_providers(self, user, dt_string, t): 440 441 "Set the given provider timestamp 'dt_string' and table 't'." 442 443 filename = self.get_object_in_store(user, "freebusy-providers") 444 if not filename: 445 return False 446 447 t.insert(0, (dt_string,)) 448 self._set_table_atomic(user, filename, t, [(1, "")]) 449 return True 450 451 # Free/busy period access. 452 453 def get_freebusy(self, user, name=None, mutable=False): 454 455 "Get free/busy details for the given 'user'." 456 457 filename = self.get_object_in_store(user, name or "freebusy") 458 459 if not filename or not isfile(filename): 460 periods = [] 461 else: 462 periods = map(lambda t: FreeBusyPeriod(*t), 463 self._get_table_atomic(user, filename)) 464 465 return FreeBusyCollection(periods, mutable) 466 467 def get_freebusy_for_other(self, user, other, mutable=False): 468 469 "For the given 'user', get free/busy details for the 'other' user." 470 471 filename = self.get_object_in_store(user, "freebusy-other", other) 472 473 if not filename or not isfile(filename): 474 periods = [] 475 else: 476 periods = map(lambda t: FreeBusyPeriod(*t), 477 self._get_table_atomic(user, filename)) 478 479 return FreeBusyCollection(periods, mutable) 480 481 def set_freebusy(self, user, freebusy, name=None): 482 483 "For the given 'user', set 'freebusy' details." 484 485 filename = self.get_object_in_store(user, name or "freebusy") 486 if not filename: 487 return False 488 489 self._set_table_atomic(user, filename, 490 map(lambda fb: fb.as_tuple(strings_only=True), freebusy.periods)) 491 return True 492 493 def set_freebusy_for_other(self, user, freebusy, other): 494 495 "For the given 'user', set 'freebusy' details for the 'other' user." 496 497 filename = self.get_object_in_store(user, "freebusy-other", other) 498 if not filename: 499 return False 500 501 self._set_table_atomic(user, filename, 502 map(lambda fb: fb.as_tuple(strings_only=True), freebusy.periods)) 503 return True 504 505 # Tentative free/busy periods related to countering. 506 507 def get_freebusy_offers(self, user, mutable=False): 508 509 "Get free/busy offers for the given 'user'." 510 511 offers = [] 512 expired = [] 513 now = to_timezone(datetime.utcnow(), "UTC") 514 515 # Expire old offers and save the collection if modified. 516 517 self.acquire_lock(user) 518 try: 519 l = self.get_freebusy(user, "freebusy-offers") 520 for fb in l: 521 if fb.expires and get_datetime(fb.expires) <= now: 522 expired.append(fb) 523 else: 524 offers.append(fb) 525 526 if expired: 527 self.set_freebusy_offers(user, offers) 528 finally: 529 self.release_lock(user) 530 531 return FreeBusyCollection(offers, mutable) 532 533 # Requests and counter-proposals. 534 535 def _get_requests(self, user, queue): 536 537 "Get requests for the given 'user' from the given 'queue'." 538 539 filename = self.get_object_in_store(user, queue) 540 if not filename or not isfile(filename): 541 return None 542 543 return self._get_table_atomic(user, filename, [(1, None), (2, None)]) 544 545 def get_requests(self, user): 546 547 "Get requests for the given 'user'." 548 549 return self._get_requests(user, "requests") 550 551 def _set_requests(self, user, requests, queue): 552 553 """ 554 For the given 'user', set the list of queued 'requests' in the given 555 'queue'. 556 """ 557 558 filename = self.get_object_in_store(user, queue) 559 if not filename: 560 return False 561 562 self._set_table_atomic(user, filename, requests, [(1, ""), (2, "")]) 563 return True 564 565 def set_requests(self, user, requests): 566 567 "For the given 'user', set the list of queued 'requests'." 568 569 return self._set_requests(user, requests, "requests") 570 571 def _set_request(self, user, request, queue): 572 573 """ 574 For the given 'user', set the given 'request' in the given 'queue'. 575 """ 576 577 filename = self.get_object_in_store(user, queue) 578 if not filename: 579 return False 580 581 self.acquire_lock(user) 582 try: 583 f = codecs.open(filename, "ab", encoding="utf-8") 584 try: 585 self._set_table_item(f, request, [(1, ""), (2, "")]) 586 finally: 587 f.close() 588 fix_permissions(filename) 589 finally: 590 self.release_lock(user) 591 592 return True 593 594 def set_request(self, user, uid, recurrenceid=None, type=None): 595 596 """ 597 For the given 'user', set the queued 'uid' and 'recurrenceid', 598 indicating a request, along with any given 'type'. 599 """ 600 601 return self._set_request(user, (uid, recurrenceid, type), "requests") 602 603 def get_counters(self, user, uid, recurrenceid=None): 604 605 """ 606 For the given 'user', return a list of users from whom counter-proposals 607 have been received for the given 'uid' and optional 'recurrenceid'. 608 """ 609 610 filename = self.get_event_filename(user, uid, recurrenceid, "counters") 611 if not filename or not isdir(filename): 612 return False 613 614 return [name for name in listdir(filename) if isfile(join(filename, name))] 615 616 def get_counter(self, user, other, uid, recurrenceid=None): 617 618 """ 619 For the given 'user', return the counter-proposal from 'other' for the 620 given 'uid' and optional 'recurrenceid'. 621 """ 622 623 filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) 624 if not filename: 625 return False 626 627 return self._get_object(user, filename) 628 629 def set_counter(self, user, other, node, uid, recurrenceid=None): 630 631 """ 632 For the given 'user', store a counter-proposal received from 'other' the 633 given 'node' representing that proposal for the given 'uid' and 634 'recurrenceid'. 635 """ 636 637 filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) 638 if not filename: 639 return False 640 641 return self._set_object(user, filename, node) 642 643 def remove_counters(self, user, uid, recurrenceid=None): 644 645 """ 646 For the given 'user', remove all counter-proposals associated with the 647 given 'uid' and 'recurrenceid'. 648 """ 649 650 filename = self.get_event_filename(user, uid, recurrenceid, "counters") 651 if not filename or not isdir(filename): 652 return False 653 654 removed = False 655 656 for other in listdir(filename): 657 counter_filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) 658 removed = removed or self._remove_object(counter_filename) 659 660 return removed 661 662 def remove_counter(self, user, other, uid, recurrenceid=None): 663 664 """ 665 For the given 'user', remove any counter-proposal from 'other' 666 associated with the given 'uid' and 'recurrenceid'. 667 """ 668 669 filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) 670 if not filename or not isfile(filename): 671 return False 672 673 return self._remove_object(filename) 674 675 # Event cancellation. 676 677 def cancel_event(self, user, uid, recurrenceid=None): 678 679 """ 680 Cancel an event for 'user' having the given 'uid'. If the optional 681 'recurrenceid' is specified, a specific instance or occurrence of an 682 event is cancelled. 683 """ 684 685 filename = self.get_event_filename(user, uid, recurrenceid) 686 cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") 687 688 if filename and cancelled_filename and isfile(filename): 689 return self.move_object(filename, cancelled_filename) 690 691 return False 692 693 def uncancel_event(self, user, uid, recurrenceid=None): 694 695 """ 696 Uncancel an event for 'user' having the given 'uid'. If the optional 697 'recurrenceid' is specified, a specific instance or occurrence of an 698 event is uncancelled. 699 """ 700 701 filename = self.get_event_filename(user, uid, recurrenceid) 702 cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") 703 704 if filename and cancelled_filename and isfile(cancelled_filename): 705 return self.move_object(cancelled_filename, filename) 706 707 return False 708 709 def remove_cancellation(self, user, uid, recurrenceid=None): 710 711 """ 712 Remove a cancellation for 'user' for the event having the given 'uid'. 713 If the optional 'recurrenceid' is specified, a specific instance or 714 occurrence of an event is affected. 715 """ 716 717 # Remove any parent event cancellation or a specific recurrence 718 # cancellation if indicated. 719 720 filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") 721 722 if filename and isfile(filename): 723 return self._remove_object(filename) 724 725 return False 726 727 class Publisher(FileBase, PublisherBase): 728 729 "A publisher of objects." 730 731 def __init__(self, store_dir=None): 732 FileBase.__init__(self, store_dir or PUBLISH_DIR) 733 734 def set_freebusy(self, user, freebusy): 735 736 "For the given 'user', set 'freebusy' details." 737 738 filename = self.get_object_in_store(user, "freebusy") 739 if not filename: 740 return False 741 742 record = [] 743 rwrite = record.append 744 745 rwrite(("ORGANIZER", {}, user)) 746 rwrite(("UID", {}, user)) 747 rwrite(("DTSTAMP", {}, datetime.utcnow().strftime("%Y%m%dT%H%M%SZ"))) 748 749 for fb in freebusy: 750 if not fb.transp or fb.transp == "OPAQUE": 751 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join( 752 map(format_datetime, [fb.get_start_point(), fb.get_end_point()])))) 753 754 f = open(filename, "wb") 755 try: 756 to_stream(f, make_calendar([("VFREEBUSY", {}, record)], "PUBLISH")) 757 finally: 758 f.close() 759 fix_permissions(filename) 760 761 return True 762 763 class Journal(FileStoreBase, JournalBase): 764 765 "A journal system to support quotas." 766 767 def __init__(self, store_dir=None): 768 FileBase.__init__(self, store_dir or JOURNAL_DIR) 769 770 # Quota and user identity/group discovery. 771 772 def get_quotas(self): 773 774 "Return a list of quotas." 775 776 return listdir(self.store_dir) 777 778 def get_quota_users(self, quota): 779 780 "Return a list of quota users." 781 782 filename = self.get_object_in_store(quota, "journal") 783 if not filename or not isdir(filename): 784 return [] 785 786 return listdir(filename) 787 788 # Groups of users sharing quotas. 789 790 def get_groups(self, quota): 791 792 "Return the identity mappings for the given 'quota' as a dictionary." 793 794 filename = self.get_object_in_store(quota, "groups") 795 if not filename or not isfile(filename): 796 return {} 797 798 return dict(self._get_table_atomic(quota, filename, tab_separated=False)) 799 800 def get_limits(self, quota): 801 802 """ 803 Return the limits for the 'quota' as a dictionary mapping identities or 804 groups to durations. 805 """ 806 807 filename = self.get_object_in_store(quota, "limits") 808 if not filename or not isfile(filename): 809 return None 810 811 return dict(self._get_table_atomic(quota, filename, tab_separated=False)) 812 813 def set_limit(self, quota, group, limit): 814 815 """ 816 For the given 'quota', set for a user 'group' the given 'limit' on 817 resource usage. 818 """ 819 820 filename = self.get_object_in_store(quota, "limits") 821 if not filename: 822 return None 823 824 limits = self.get_limits(quota) or {} 825 limits[group] = limit 826 827 self._set_table_atomic(quota, filename, limits.items()) 828 return True 829 830 # Free/busy period access for users within quota groups. 831 832 def get_freebusy(self, quota, user, mutable=False): 833 834 "Get free/busy details for the given 'quota' and 'user'." 835 836 filename = self.get_object_in_store(quota, "freebusy", user) 837 838 if not filename or not isfile(filename): 839 periods = [] 840 else: 841 periods = map(lambda t: FreeBusyPeriod(*t), 842 self._get_table_atomic(quota, filename)) 843 844 return FreeBusyCollection(periods, mutable) 845 846 def set_freebusy(self, quota, user, freebusy): 847 848 "For the given 'quota' and 'user', set 'freebusy' details." 849 850 filename = self.get_object_in_store(quota, "freebusy", user) 851 if not filename: 852 return False 853 854 self._set_table_atomic(quota, filename, 855 map(lambda fb: fb.as_tuple(strings_only=True), freebusy.periods)) 856 return True 857 858 # Journal entry methods. 859 860 def get_entries(self, quota, group, mutable=False): 861 862 """ 863 Return a list of journal entries for the given 'quota' for the indicated 864 'group'. 865 """ 866 867 filename = self.get_object_in_store(quota, "journal", group) 868 869 if not filename or not isfile(filename): 870 periods = [] 871 else: 872 periods = map(lambda t: FreeBusyPeriod(*t), 873 self._get_table_atomic(quota, filename)) 874 875 return FreeBusyCollection(periods, mutable) 876 877 def set_entries(self, quota, group, entries): 878 879 """ 880 For the given 'quota' and indicated 'group', set the list of journal 881 'entries'. 882 """ 883 884 filename = self.get_object_in_store(quota, "journal", group) 885 if not filename: 886 return False 887 888 self._set_table_atomic(quota, filename, 889 map(lambda fb: fb.as_tuple(strings_only=True), entries.periods)) 890 return True 891 892 # vim: tabstop=4 expandtab shiftwidth=4