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