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 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 if empty_defaults: 111 item = self._set_defaults(list(item), empty_defaults) 112 f.write("\t".join(item) + "\n") 113 finally: 114 f.close() 115 fix_permissions(filename) 116 117 def _set_table_atomic(self, user, filename, items, empty_defaults=None): 118 119 """ 120 For the given 'user', write to the file having the given 'filename' the 121 'items'. 122 123 The 'empty_defaults' is a list of (index, value) tuples indicating the 124 default value where a column either does not exist or provides an empty 125 value. 126 """ 127 128 self.acquire_lock(user) 129 try: 130 self._set_table(user, filename, items, empty_defaults) 131 finally: 132 self.release_lock(user) 133 134 # Store object access. 135 136 def _get_object(self, user, filename): 137 138 """ 139 Return the parsed object for the given 'user' having the given 140 'filename'. 141 """ 142 143 self.acquire_lock(user) 144 try: 145 f = open(filename, "rb") 146 try: 147 return parse_object(f, "utf-8") 148 finally: 149 f.close() 150 finally: 151 self.release_lock(user) 152 153 def _set_object(self, user, filename, node): 154 155 """ 156 Set an object for the given 'user' having the given 'filename', using 157 'node' to define the object. 158 """ 159 160 self.acquire_lock(user) 161 try: 162 f = open(filename, "wb") 163 try: 164 to_stream(f, node) 165 finally: 166 f.close() 167 fix_permissions(filename) 168 finally: 169 self.release_lock(user) 170 171 return True 172 173 def _remove_object(self, filename): 174 175 "Remove the object with the given 'filename'." 176 177 try: 178 remove(filename) 179 except OSError: 180 return False 181 182 return True 183 184 def _remove_collection(self, filename): 185 186 "Remove the collection with the given 'filename'." 187 188 try: 189 rmdir(filename) 190 except OSError: 191 return False 192 193 return True 194 195 # User discovery. 196 197 def get_users(self): 198 199 "Return a list of users." 200 201 return listdir(self.store_dir) 202 203 # Event and event metadata access. 204 205 def get_events(self, user): 206 207 "Return a list of event identifiers." 208 209 filename = self.get_object_in_store(user, "objects") 210 if not filename or not exists(filename): 211 return None 212 213 return [name for name in listdir(filename) if isfile(join(filename, name))] 214 215 def get_all_events(self, user): 216 217 "Return a set of (uid, recurrenceid) tuples for all events." 218 219 uids = self.get_events(user) 220 if not uids: 221 return set() 222 223 all_events = set() 224 for uid in uids: 225 all_events.add((uid, None)) 226 all_events.update([(uid, recurrenceid) for recurrenceid in self.get_recurrences(user, uid)]) 227 228 return all_events 229 230 def get_event_filename(self, user, uid, recurrenceid=None, dirname=None): 231 232 """ 233 Get the filename providing the event for the given 'user' with the given 234 'uid'. If the optional 'recurrenceid' is specified, a specific instance 235 or occurrence of an event is returned. 236 237 Where 'dirname' is specified, the given directory name is used as the 238 base of the location within which any filename will reside. 239 """ 240 241 if recurrenceid: 242 return self.get_recurrence_filename(user, uid, recurrenceid, dirname) 243 else: 244 return self.get_complete_event_filename(user, uid, dirname) 245 246 def get_event(self, user, uid, recurrenceid=None): 247 248 """ 249 Get the event for the given 'user' with the given 'uid'. If 250 the optional 'recurrenceid' is specified, a specific instance or 251 occurrence of an event is returned. 252 """ 253 254 filename = self.get_event_filename(user, uid, recurrenceid) 255 if not filename or not exists(filename): 256 return None 257 258 return filename and self._get_object(user, filename) 259 260 def get_complete_event_filename(self, user, uid, dirname=None): 261 262 """ 263 Get the filename providing the event for the given 'user' with the given 264 'uid'. 265 266 Where 'dirname' is specified, the given directory name is used as the 267 base of the location within which any filename will reside. 268 """ 269 270 return self.get_object_in_store(user, dirname, "objects", uid) 271 272 def get_complete_event(self, user, uid): 273 274 "Get the event for the given 'user' with the given 'uid'." 275 276 filename = self.get_complete_event_filename(user, uid) 277 if not filename or not exists(filename): 278 return None 279 280 return filename and self._get_object(user, filename) 281 282 def set_event(self, user, uid, recurrenceid, node): 283 284 """ 285 Set an event for 'user' having the given 'uid' and 'recurrenceid' (which 286 if the latter is specified, a specific instance or occurrence of an 287 event is referenced), using the given 'node' description. 288 """ 289 290 if recurrenceid: 291 return self.set_recurrence(user, uid, recurrenceid, node) 292 else: 293 return self.set_complete_event(user, uid, node) 294 295 def set_complete_event(self, user, uid, node): 296 297 "Set an event for 'user' having the given 'uid' and 'node'." 298 299 filename = self.get_object_in_store(user, "objects", uid) 300 if not filename: 301 return False 302 303 return self._set_object(user, filename, node) 304 305 def remove_event(self, user, uid, recurrenceid=None): 306 307 """ 308 Remove an event for 'user' having the given 'uid'. If the optional 309 'recurrenceid' is specified, a specific instance or occurrence of an 310 event is removed. 311 """ 312 313 if recurrenceid: 314 return self.remove_recurrence(user, uid, recurrenceid) 315 else: 316 for recurrenceid in self.get_recurrences(user, uid) or []: 317 self.remove_recurrence(user, uid, recurrenceid) 318 return self.remove_complete_event(user, uid) 319 320 def remove_complete_event(self, user, uid): 321 322 "Remove an event for 'user' having the given 'uid'." 323 324 self.remove_recurrences(user, uid) 325 326 filename = self.get_object_in_store(user, "objects", uid) 327 if not filename: 328 return False 329 330 return self._remove_object(filename) 331 332 def get_recurrences(self, user, uid): 333 334 """ 335 Get additional event instances for an event of the given 'user' with the 336 indicated 'uid'. Both active and cancelled recurrences are returned. 337 """ 338 339 return self.get_active_recurrences(user, uid) + self.get_cancelled_recurrences(user, uid) 340 341 def get_active_recurrences(self, user, uid): 342 343 """ 344 Get additional event instances for an event of the given 'user' with the 345 indicated 'uid'. Cancelled recurrences are not returned. 346 """ 347 348 filename = self.get_object_in_store(user, "recurrences", uid) 349 if not filename or not exists(filename): 350 return [] 351 352 return [name for name in listdir(filename) if isfile(join(filename, name))] 353 354 def get_cancelled_recurrences(self, user, uid): 355 356 """ 357 Get additional event instances for an event of the given 'user' with the 358 indicated 'uid'. Only cancelled recurrences are returned. 359 """ 360 361 filename = self.get_object_in_store(user, "cancelled", "recurrences", uid) 362 if not filename or not exists(filename): 363 return [] 364 365 return [name for name in listdir(filename) if isfile(join(filename, name))] 366 367 def get_recurrence_filename(self, user, uid, recurrenceid, dirname=None): 368 369 """ 370 For the event of the given 'user' with the given 'uid', return the 371 filename providing the recurrence with the given 'recurrenceid'. 372 373 Where 'dirname' is specified, the given directory name is used as the 374 base of the location within which any filename will reside. 375 """ 376 377 return self.get_object_in_store(user, dirname, "recurrences", uid, recurrenceid) 378 379 def get_recurrence(self, user, uid, recurrenceid): 380 381 """ 382 For the event of the given 'user' with the given 'uid', return the 383 specific recurrence indicated by the 'recurrenceid'. 384 """ 385 386 filename = self.get_recurrence_filename(user, uid, recurrenceid) 387 if not filename or not exists(filename): 388 return None 389 390 return filename and self._get_object(user, filename) 391 392 def set_recurrence(self, user, uid, recurrenceid, node): 393 394 "Set an event for 'user' having the given 'uid' and 'node'." 395 396 filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) 397 if not filename: 398 return False 399 400 return self._set_object(user, filename, node) 401 402 def remove_recurrence(self, user, uid, recurrenceid): 403 404 """ 405 Remove a special recurrence from an event stored by 'user' having the 406 given 'uid' and 'recurrenceid'. 407 """ 408 409 filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) 410 if not filename: 411 return False 412 413 return self._remove_object(filename) 414 415 def remove_recurrences(self, user, uid): 416 417 """ 418 Remove all recurrences for an event stored by 'user' having the given 419 'uid'. 420 """ 421 422 for recurrenceid in self.get_recurrences(user, uid): 423 self.remove_recurrence(user, uid, recurrenceid) 424 425 recurrences = self.get_object_in_store(user, "recurrences", uid) 426 if recurrences: 427 return self._remove_collection(recurrences) 428 429 return True 430 431 # Free/busy period providers, upon extension of the free/busy records. 432 433 def _get_freebusy_providers(self, user): 434 435 """ 436 Return the free/busy providers for the given 'user'. 437 438 This function returns any stored datetime and a list of providers as a 439 2-tuple. Each provider is itself a (uid, recurrenceid) tuple. 440 """ 441 442 filename = self.get_object_in_store(user, "freebusy-providers") 443 if not filename or not exists(filename): 444 return None 445 446 # Attempt to read providers, with a declaration of the datetime 447 # from which such providers are considered as still being active. 448 449 t = self._get_table_atomic(user, filename, [(1, None)]) 450 try: 451 dt_string = t[0][0] 452 except IndexError: 453 return None 454 455 return dt_string, t[1:] 456 457 def get_freebusy_providers(self, user, dt=None): 458 459 """ 460 Return a set of uncancelled events of the form (uid, recurrenceid) 461 providing free/busy details beyond the given datetime 'dt'. 462 463 If 'dt' is not specified, all events previously found to provide 464 details will be returned. Otherwise, if 'dt' is earlier than the 465 datetime recorded for the known providers, None is returned, indicating 466 that the list of providers must be recomputed. 467 468 This function returns a list of (uid, recurrenceid) tuples upon success. 469 """ 470 471 t = self._get_freebusy_providers(user) 472 if not t: 473 return None 474 475 dt_string, t = t 476 477 # If the requested datetime is earlier than the stated datetime, the 478 # providers will need to be recomputed. 479 480 if dt: 481 providers_dt = get_datetime(dt_string) 482 if not providers_dt or providers_dt > dt: 483 return None 484 485 # Otherwise, return the providers. 486 487 return t[1:] 488 489 def _set_freebusy_providers(self, user, dt_string, t): 490 491 "Set the given provider timestamp 'dt_string' and table 't'." 492 493 filename = self.get_object_in_store(user, "freebusy-providers") 494 if not filename: 495 return False 496 497 t.insert(0, (dt_string,)) 498 self._set_table_atomic(user, filename, t, [(1, "")]) 499 return True 500 501 def set_freebusy_providers(self, user, dt, providers): 502 503 """ 504 Define the uncancelled events providing free/busy details beyond the 505 given datetime 'dt'. 506 """ 507 508 t = [] 509 510 for obj in providers: 511 t.append((obj.get_uid(), obj.get_recurrenceid())) 512 513 return self._set_freebusy_providers(user, format_datetime(dt), t) 514 515 def append_freebusy_provider(self, user, provider): 516 517 "For the given 'user', append the free/busy 'provider'." 518 519 t = self._get_freebusy_providers(user) 520 if not t: 521 return False 522 523 dt_string, t = t 524 t.append((provider.get_uid(), provider.get_recurrenceid())) 525 526 return self._set_freebusy_providers(user, dt_string, t) 527 528 def remove_freebusy_provider(self, user, provider): 529 530 "For the given 'user', remove the free/busy 'provider'." 531 532 t = self._get_freebusy_providers(user) 533 if not t: 534 return False 535 536 dt_string, t = t 537 try: 538 t.remove((provider.get_uid(), provider.get_recurrenceid())) 539 except ValueError: 540 return False 541 542 return self._set_freebusy_providers(user, dt_string, t) 543 544 # Free/busy period access. 545 546 def get_freebusy(self, user, name=None, get_table=None): 547 548 "Get free/busy details for the given 'user'." 549 550 filename = self.get_object_in_store(user, name or "freebusy") 551 if not filename or not exists(filename): 552 return [] 553 else: 554 return map(lambda t: FreeBusyPeriod(*t), 555 (get_table or self._get_table_atomic)(user, filename, [(4, None)])) 556 557 def get_freebusy_for_update(self, user, name=None): 558 559 """ 560 Get free/busy details for the given 'user', locking the table. Dependent 561 code must release this lock regardless of it completing successfully. 562 """ 563 564 self.acquire_lock(user) 565 return self.get_freebusy(user, name, self._get_table) 566 567 def get_freebusy_for_other(self, user, other, get_table=None): 568 569 "For the given 'user', get free/busy details for the 'other' user." 570 571 filename = self.get_object_in_store(user, "freebusy-other", other) 572 if not filename or not exists(filename): 573 return [] 574 else: 575 return map(lambda t: FreeBusyPeriod(*t), 576 (get_table or self._get_table_atomic)(user, filename, [(4, None)])) 577 578 def get_freebusy_for_other_for_update(self, user, other): 579 580 """ 581 For the given 'user', get free/busy details for the 'other' user, 582 locking the table. Dependent code must release this lock regardless of 583 it completing successfully. 584 """ 585 586 self.acquire_lock(user) 587 return self.get_freebusy_for_other(user, other, self._get_table) 588 589 def set_freebusy(self, user, freebusy, name=None, set_table=None): 590 591 "For the given 'user', set 'freebusy' details." 592 593 filename = self.get_object_in_store(user, name or "freebusy") 594 if not filename: 595 return False 596 597 (set_table or self._set_table_atomic)(user, filename, 598 map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) 599 return True 600 601 def set_freebusy_in_update(self, user, freebusy, name=None): 602 603 "For the given 'user', set 'freebusy' details during a compound update." 604 605 return self.set_freebusy(user, freebusy, name, self._set_table) 606 607 def set_freebusy_for_other(self, user, freebusy, other, set_table=None): 608 609 "For the given 'user', set 'freebusy' details for the 'other' user." 610 611 filename = self.get_object_in_store(user, "freebusy-other", other) 612 if not filename: 613 return False 614 615 (set_table or self._set_table_atomic)(user, filename, 616 map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) 617 return True 618 619 def set_freebusy_for_other_in_update(self, user, freebusy, other): 620 621 """ 622 For the given 'user', set 'freebusy' details for the 'other' user during 623 a compound update. 624 """ 625 626 return self.set_freebusy_for_other(user, freebusy, other, self._set_table) 627 628 # Release methods. 629 630 release_freebusy = release_lock 631 632 # Object status details access. 633 634 def _get_requests(self, user, queue): 635 636 "Get requests for the given 'user' from the given 'queue'." 637 638 filename = self.get_object_in_store(user, queue) 639 if not filename or not exists(filename): 640 return None 641 642 return self._get_table_atomic(user, filename, [(1, None)]) 643 644 def get_requests(self, user): 645 646 "Get requests for the given 'user'." 647 648 return self._get_requests(user, "requests") 649 650 def _set_requests(self, user, requests, queue): 651 652 """ 653 For the given 'user', set the list of queued 'requests' in the given 654 'queue'. 655 """ 656 657 filename = self.get_object_in_store(user, queue) 658 if not filename: 659 return False 660 661 self.acquire_lock(user) 662 try: 663 f = open(filename, "w") 664 try: 665 for request in requests: 666 print >>f, "\t".join([value or "" for value in request]) 667 finally: 668 f.close() 669 fix_permissions(filename) 670 finally: 671 self.release_lock(user) 672 673 return True 674 675 def set_requests(self, user, requests): 676 677 "For the given 'user', set the list of queued 'requests'." 678 679 return self._set_requests(user, requests, "requests") 680 681 def _set_request(self, user, uid, recurrenceid, queue): 682 683 """ 684 For the given 'user', set the queued 'uid' and 'recurrenceid' in the 685 given 'queue'. 686 """ 687 688 filename = self.get_object_in_store(user, queue) 689 if not filename: 690 return False 691 692 self.acquire_lock(user) 693 try: 694 f = open(filename, "a") 695 try: 696 print >>f, "\t".join([uid, recurrenceid or ""]) 697 finally: 698 f.close() 699 fix_permissions(filename) 700 finally: 701 self.release_lock(user) 702 703 return True 704 705 def set_request(self, user, uid, recurrenceid=None): 706 707 "For the given 'user', set the queued 'uid' and 'recurrenceid'." 708 709 return self._set_request(user, uid, recurrenceid, "requests") 710 711 def queue_request(self, user, uid, recurrenceid=None): 712 713 """ 714 Queue a request for 'user' having the given 'uid'. If the optional 715 'recurrenceid' is specified, the request refers to a specific instance 716 or occurrence of an event. 717 """ 718 719 requests = self.get_requests(user) or [] 720 721 if (uid, recurrenceid) not in requests: 722 return self.set_request(user, uid, recurrenceid) 723 724 return False 725 726 def dequeue_request(self, user, uid, recurrenceid=None): 727 728 """ 729 Dequeue a request for 'user' having the given 'uid'. If the optional 730 'recurrenceid' is specified, the request refers to a specific instance 731 or occurrence of an event. 732 """ 733 734 requests = self.get_requests(user) or [] 735 736 try: 737 requests.remove((uid, recurrenceid)) 738 self.set_requests(user, requests) 739 except ValueError: 740 return False 741 else: 742 return True 743 744 def cancel_event(self, user, uid, recurrenceid=None): 745 746 """ 747 Cancel an event for 'user' having the given 'uid'. If the optional 748 'recurrenceid' is specified, a specific instance or occurrence of an 749 event is cancelled. 750 """ 751 752 filename = self.get_event_filename(user, uid, recurrenceid) 753 cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") 754 755 if filename and cancelled_filename and exists(filename): 756 return self.move_object(filename, cancelled_filename) 757 758 return False 759 760 class FilePublisher(FileBase): 761 762 "A publisher of objects." 763 764 def __init__(self, store_dir=None): 765 FileBase.__init__(self, store_dir or PUBLISH_DIR) 766 767 def set_freebusy(self, user, freebusy): 768 769 "For the given 'user', set 'freebusy' details." 770 771 filename = self.get_object_in_store(user, "freebusy") 772 if not filename: 773 return False 774 775 record = [] 776 rwrite = record.append 777 778 rwrite(("ORGANIZER", {}, user)) 779 rwrite(("UID", {}, user)) 780 rwrite(("DTSTAMP", {}, datetime.utcnow().strftime("%Y%m%dT%H%M%SZ"))) 781 782 for fb in freebusy: 783 if not fb.transp or fb.transp == "OPAQUE": 784 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join( 785 map(format_datetime, [fb.get_start_point(), fb.get_end_point()])))) 786 787 f = open(filename, "wb") 788 try: 789 to_stream(f, make_calendar([("VFREEBUSY", {}, record)], "PUBLISH")) 790 finally: 791 f.close() 792 fix_permissions(filename) 793 794 return True 795 796 # vim: tabstop=4 expandtab shiftwidth=4