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 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 if not filename or not isfile(filename): 581 return [] 582 else: 583 return map(lambda t: FreeBusyPeriod(*t), 584 self._get_table_atomic(user, filename, [(4, None)])) 585 586 def get_freebusy_for_other(self, user, other): 587 588 "For the given 'user', get free/busy details for the 'other' user." 589 590 filename = self.get_object_in_store(user, "freebusy-other", other) 591 if not filename or not isfile(filename): 592 return [] 593 else: 594 return map(lambda t: FreeBusyPeriod(*t), 595 self._get_table_atomic(user, filename, [(4, None)])) 596 597 def set_freebusy(self, user, freebusy, name=None): 598 599 "For the given 'user', set 'freebusy' details." 600 601 filename = self.get_object_in_store(user, name or "freebusy") 602 if not filename: 603 return False 604 605 self._set_table_atomic(user, filename, 606 map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) 607 return True 608 609 def set_freebusy_for_other(self, user, freebusy, other): 610 611 "For the given 'user', set 'freebusy' details for the 'other' user." 612 613 filename = self.get_object_in_store(user, "freebusy-other", other) 614 if not filename: 615 return False 616 617 self._set_table_atomic(user, filename, 618 map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) 619 return True 620 621 # Tentative free/busy periods related to countering. 622 623 def get_freebusy_offers(self, user): 624 625 "Get free/busy offers for the given 'user'." 626 627 offers = [] 628 expired = [] 629 now = to_timezone(datetime.utcnow(), "UTC") 630 631 # Expire old offers and save the collection if modified. 632 633 self.acquire_lock(user) 634 try: 635 l = self.get_freebusy(user, "freebusy-offers") 636 for fb in l: 637 if fb.expires and get_datetime(fb.expires) <= now: 638 expired.append(fb) 639 else: 640 offers.append(fb) 641 642 if expired: 643 self.set_freebusy_offers(user, offers) 644 finally: 645 self.release_lock(user) 646 647 return offers 648 649 def set_freebusy_offers(self, user, freebusy): 650 651 "For the given 'user', set 'freebusy' offers." 652 653 return self.set_freebusy(user, freebusy, "freebusy-offers") 654 655 # Requests and counter-proposals. 656 657 def _get_requests(self, user, queue): 658 659 "Get requests for the given 'user' from the given 'queue'." 660 661 filename = self.get_object_in_store(user, queue) 662 if not filename or not isfile(filename): 663 return None 664 665 return self._get_table_atomic(user, filename, [(1, None), (2, None)]) 666 667 def get_requests(self, user): 668 669 "Get requests for the given 'user'." 670 671 return self._get_requests(user, "requests") 672 673 def _set_requests(self, user, requests, queue): 674 675 """ 676 For the given 'user', set the list of queued 'requests' in the given 677 'queue'. 678 """ 679 680 filename = self.get_object_in_store(user, queue) 681 if not filename: 682 return False 683 684 self._set_table_atomic(user, filename, requests, [(1, ""), (2, "")]) 685 return True 686 687 def set_requests(self, user, requests): 688 689 "For the given 'user', set the list of queued 'requests'." 690 691 return self._set_requests(user, requests, "requests") 692 693 def _set_request(self, user, request, queue): 694 695 """ 696 For the given 'user', set the given 'request' in the given 'queue'. 697 """ 698 699 filename = self.get_object_in_store(user, queue) 700 if not filename: 701 return False 702 703 self.acquire_lock(user) 704 try: 705 f = codecs.open(filename, "ab", encoding="utf-8") 706 try: 707 self._set_table_item(f, request, [(1, ""), (2, "")]) 708 finally: 709 f.close() 710 fix_permissions(filename) 711 finally: 712 self.release_lock(user) 713 714 return True 715 716 def set_request(self, user, uid, recurrenceid=None, type=None): 717 718 """ 719 For the given 'user', set the queued 'uid' and 'recurrenceid', 720 indicating a request, along with any given 'type'. 721 """ 722 723 return self._set_request(user, (uid, recurrenceid, type), "requests") 724 725 def queue_request(self, user, uid, recurrenceid=None, type=None): 726 727 """ 728 Queue a request for 'user' having the given 'uid'. If the optional 729 'recurrenceid' is specified, the entry refers to a specific instance 730 or occurrence of an event. The 'type' parameter can be used to indicate 731 a specific type of request. 732 """ 733 734 requests = self.get_requests(user) or [] 735 736 if not self.have_request(requests, uid, recurrenceid): 737 return self.set_request(user, uid, recurrenceid, type) 738 739 return False 740 741 def dequeue_request(self, user, uid, recurrenceid=None): 742 743 """ 744 Dequeue all requests for 'user' having the given 'uid'. If the optional 745 'recurrenceid' is specified, all requests for that specific instance or 746 occurrence of an event are dequeued. 747 """ 748 749 requests = self.get_requests(user) or [] 750 result = [] 751 752 for request in requests: 753 if request[:2] != (uid, recurrenceid): 754 result.append(request) 755 756 self.set_requests(user, result) 757 return True 758 759 def has_request(self, user, uid, recurrenceid=None, type=None, strict=False): 760 return self.have_request(self.get_requests(user) or [], uid, recurrenceid, type, strict) 761 762 def have_request(self, requests, uid, recurrenceid=None, type=None, strict=False): 763 764 """ 765 Return whether 'requests' contains a request with the given 'uid' and 766 any specified 'recurrenceid' and 'type'. If 'strict' is set to a true 767 value, the precise type of the request must match; otherwise, any type 768 of request for the identified object may be matched. 769 """ 770 771 for request in requests: 772 if request[:2] == (uid, recurrenceid) and ( 773 not strict or 774 not request[2:] and not type or 775 request[2:] and request[2] == type): 776 777 return True 778 779 return False 780 781 def get_counters(self, user, uid, recurrenceid=None): 782 783 """ 784 For the given 'user', return a list of users from whom counter-proposals 785 have been received for the given 'uid' and optional 'recurrenceid'. 786 """ 787 788 filename = self.get_event_filename(user, uid, recurrenceid, "counters") 789 if not filename or not isdir(filename): 790 return False 791 792 return [name for name in listdir(filename) if isfile(join(filename, name))] 793 794 def get_counter(self, user, other, uid, recurrenceid=None): 795 796 """ 797 For the given 'user', return the counter-proposal from 'other' for the 798 given 'uid' and optional 'recurrenceid'. 799 """ 800 801 filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) 802 if not filename: 803 return False 804 805 return self._get_object(user, filename) 806 807 def set_counter(self, user, other, node, uid, recurrenceid=None): 808 809 """ 810 For the given 'user', store a counter-proposal received from 'other' the 811 given 'node' representing that proposal for the given 'uid' and 812 'recurrenceid'. 813 """ 814 815 filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) 816 if not filename: 817 return False 818 819 return self._set_object(user, filename, node) 820 821 def remove_counters(self, user, uid, recurrenceid=None): 822 823 """ 824 For the given 'user', remove all counter-proposals associated with the 825 given 'uid' and 'recurrenceid'. 826 """ 827 828 filename = self.get_event_filename(user, uid, recurrenceid, "counters") 829 if not filename or not isdir(filename): 830 return False 831 832 removed = False 833 834 for other in listdir(filename): 835 counter_filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) 836 removed = removed or self._remove_object(counter_filename) 837 838 return removed 839 840 def remove_counter(self, user, other, uid, recurrenceid=None): 841 842 """ 843 For the given 'user', remove any counter-proposal from 'other' 844 associated with the given 'uid' and 'recurrenceid'. 845 """ 846 847 filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) 848 if not filename or not isfile(filename): 849 return False 850 851 return self._remove_object(filename) 852 853 # Event cancellation. 854 855 def cancel_event(self, user, uid, recurrenceid=None): 856 857 """ 858 Cancel an event for 'user' having the given 'uid'. If the optional 859 'recurrenceid' is specified, a specific instance or occurrence of an 860 event is cancelled. 861 """ 862 863 filename = self.get_event_filename(user, uid, recurrenceid) 864 cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") 865 866 if filename and cancelled_filename and isfile(filename): 867 return self.move_object(filename, cancelled_filename) 868 869 return False 870 871 def uncancel_event(self, user, uid, recurrenceid=None): 872 873 """ 874 Uncancel an event for 'user' having the given 'uid'. If the optional 875 'recurrenceid' is specified, a specific instance or occurrence of an 876 event is uncancelled. 877 """ 878 879 filename = self.get_event_filename(user, uid, recurrenceid) 880 cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") 881 882 if filename and cancelled_filename and isfile(cancelled_filename): 883 return self.move_object(cancelled_filename, filename) 884 885 return False 886 887 def remove_cancellations(self, user, uid, recurrenceid=None): 888 889 """ 890 Remove cancellations for 'user' for any event having the given 'uid'. If 891 the optional 'recurrenceid' is specified, a specific instance or 892 occurrence of an event is affected. 893 """ 894 895 # Remove all recurrence cancellations if a general event is indicated. 896 897 if not recurrenceid: 898 for _recurrenceid in self.get_cancelled_recurrences(user, uid): 899 self.remove_cancellation(user, uid, _recurrenceid) 900 901 return self.remove_cancellation(user, uid, recurrenceid) 902 903 def remove_cancellation(self, user, uid, recurrenceid=None): 904 905 """ 906 Remove a cancellation for 'user' for the event having the given 'uid'. 907 If the optional 'recurrenceid' is specified, a specific instance or 908 occurrence of an event is affected. 909 """ 910 911 # Remove any parent event cancellation or a specific recurrence 912 # cancellation if indicated. 913 914 filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") 915 916 if filename and isfile(filename): 917 return self._remove_object(filename) 918 919 return False 920 921 class FilePublisher(FileBase): 922 923 "A publisher of objects." 924 925 def __init__(self, store_dir=None): 926 FileBase.__init__(self, store_dir or PUBLISH_DIR) 927 928 def set_freebusy(self, user, freebusy): 929 930 "For the given 'user', set 'freebusy' details." 931 932 filename = self.get_object_in_store(user, "freebusy") 933 if not filename: 934 return False 935 936 record = [] 937 rwrite = record.append 938 939 rwrite(("ORGANIZER", {}, user)) 940 rwrite(("UID", {}, user)) 941 rwrite(("DTSTAMP", {}, datetime.utcnow().strftime("%Y%m%dT%H%M%SZ"))) 942 943 for fb in freebusy: 944 if not fb.transp or fb.transp == "OPAQUE": 945 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join( 946 map(format_datetime, [fb.get_start_point(), fb.get_end_point()])))) 947 948 f = open(filename, "wb") 949 try: 950 to_stream(f, make_calendar([("VFREEBUSY", {}, record)], "PUBLISH")) 951 finally: 952 f.close() 953 fix_permissions(filename) 954 955 return True 956 957 class FileJournal(FileStoreBase): 958 959 "A journal system to support quotas." 960 961 def __init__(self, store_dir=None): 962 FileBase.__init__(self, store_dir or JOURNAL_DIR) 963 964 # Quota and user identity/group discovery. 965 966 def get_quotas(self): 967 968 "Return a list of quotas." 969 970 return listdir(self.store_dir) 971 972 def get_quota_users(self, quota): 973 974 "Return a list of quota users." 975 976 filename = self.get_object_in_store(quota, "journal") 977 if not filename or not isdir(filename): 978 return [] 979 980 return listdir(filename) 981 982 # Groups of users sharing quotas. 983 984 def get_groups(self, quota): 985 986 "Return the identity mappings for the given 'quota' as a dictionary." 987 988 filename = self.get_object_in_store(quota, "groups") 989 if not filename or not isfile(filename): 990 return {} 991 992 return dict(self._get_table_atomic(quota, filename, tab_separated=False)) 993 994 def get_limits(self, quota): 995 996 """ 997 Return the limits for the 'quota' as a dictionary mapping identities or 998 groups to durations. 999 """ 1000 1001 filename = self.get_object_in_store(quota, "limits") 1002 if not filename or not isfile(filename): 1003 return None 1004 1005 return dict(self._get_table_atomic(quota, filename, tab_separated=False)) 1006 1007 # Free/busy period access for users within quota groups. 1008 1009 def get_freebusy(self, quota, user): 1010 1011 "Get free/busy details for the given 'quota' and 'user'." 1012 1013 filename = self.get_object_in_store(quota, "freebusy", user) 1014 if not filename or not isfile(filename): 1015 return [] 1016 1017 return map(lambda t: FreeBusyPeriod(*t), 1018 self._get_table_atomic(quota, filename, [(4, None)])) 1019 1020 def set_freebusy(self, quota, user, freebusy): 1021 1022 "For the given 'quota' and 'user', set 'freebusy' details." 1023 1024 filename = self.get_object_in_store(quota, "freebusy", user) 1025 if not filename: 1026 return False 1027 1028 self._set_table_atomic(quota, filename, 1029 map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) 1030 return True 1031 1032 # Journal entry methods. 1033 1034 def get_entries(self, quota, group): 1035 1036 """ 1037 Return a list of journal entries for the given 'quota' for the indicated 1038 'group'. 1039 """ 1040 1041 filename = self.get_object_in_store(quota, "journal", group) 1042 if not filename or not isfile(filename): 1043 return [] 1044 1045 return map(lambda t: FreeBusyPeriod(*t), 1046 self._get_table_atomic(quota, filename, [(4, None)])) 1047 1048 def set_entries(self, quota, group, entries): 1049 1050 """ 1051 For the given 'quota' and indicated 'group', set the list of journal 1052 'entries'. 1053 """ 1054 1055 filename = self.get_object_in_store(quota, "journal", group) 1056 if not filename: 1057 return False 1058 1059 self._set_table_atomic(quota, filename, 1060 map(lambda fb: fb.as_tuple(strings_only=True), entries)) 1061 return True 1062 1063 # vim: tabstop=4 expandtab shiftwidth=4