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