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