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