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