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_active_events(self, user): 205 206 "Return a set of uncancelled events of the form (uid, recurrenceid)." 207 208 all_events = self.get_all_events(user) 209 210 # Filter out cancelled events. 211 212 cancelled = self.get_cancellations(user) or [] 213 all_events.difference_update(cancelled) 214 return all_events 215 216 def get_event(self, user, uid, recurrenceid=None): 217 218 """ 219 Get the event for the given 'user' with the given 'uid'. If 220 the optional 'recurrenceid' is specified, a specific instance or 221 occurrence of an event is returned. 222 """ 223 224 if recurrenceid: 225 return self.get_recurrence(user, uid, recurrenceid) 226 else: 227 return self.get_complete_event(user, uid) 228 229 def get_complete_event(self, user, uid): 230 231 "Get the event for the given 'user' with the given 'uid'." 232 233 filename = self.get_object_in_store(user, "objects", uid) 234 if not filename or not exists(filename): 235 return None 236 237 return self._get_object(user, filename) 238 239 def set_event(self, user, uid, recurrenceid, node): 240 241 """ 242 Set an event for 'user' having the given 'uid' and 'recurrenceid' (which 243 if the latter is specified, a specific instance or occurrence of an 244 event is referenced), using the given 'node' description. 245 """ 246 247 if recurrenceid: 248 return self.set_recurrence(user, uid, recurrenceid, node) 249 else: 250 return self.set_complete_event(user, uid, node) 251 252 def set_complete_event(self, user, uid, node): 253 254 "Set an event for 'user' having the given 'uid' and 'node'." 255 256 filename = self.get_object_in_store(user, "objects", uid) 257 if not filename: 258 return False 259 260 return self._set_object(user, filename, node) 261 262 def remove_event(self, user, uid, recurrenceid=None): 263 264 """ 265 Remove an event for 'user' having the given 'uid'. If the optional 266 'recurrenceid' is specified, a specific instance or occurrence of an 267 event is removed. 268 """ 269 270 if recurrenceid: 271 return self.remove_recurrence(user, uid, recurrenceid) 272 else: 273 for recurrenceid in self.get_recurrences(user, uid) or []: 274 self.remove_recurrence(user, uid, recurrenceid) 275 return self.remove_complete_event(user, uid) 276 277 def remove_complete_event(self, user, uid): 278 279 "Remove an event for 'user' having the given 'uid'." 280 281 self.remove_recurrences(user, uid) 282 283 filename = self.get_object_in_store(user, "objects", uid) 284 if not filename: 285 return False 286 287 return self._remove_object(filename) 288 289 def get_recurrences(self, user, uid): 290 291 """ 292 Get additional event instances for an event of the given 'user' with the 293 indicated 'uid'. 294 """ 295 296 filename = self.get_object_in_store(user, "recurrences", uid) 297 if not filename or not exists(filename): 298 return [] 299 300 return [name for name in listdir(filename) if isfile(join(filename, name))] 301 302 def get_recurrence(self, user, uid, recurrenceid): 303 304 """ 305 For the event of the given 'user' with the given 'uid', return the 306 specific recurrence indicated by the 'recurrenceid'. 307 """ 308 309 filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) 310 if not filename or not exists(filename): 311 return None 312 313 return self._get_object(user, filename) 314 315 def set_recurrence(self, user, uid, recurrenceid, node): 316 317 "Set an event for 'user' having the given 'uid' and 'node'." 318 319 filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) 320 if not filename: 321 return False 322 323 return self._set_object(user, filename, node) 324 325 def remove_recurrence(self, user, uid, recurrenceid): 326 327 """ 328 Remove a special recurrence from an event stored by 'user' having the 329 given 'uid' and 'recurrenceid'. 330 """ 331 332 filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) 333 if not filename: 334 return False 335 336 return self._remove_object(filename) 337 338 def remove_recurrences(self, user, uid): 339 340 """ 341 Remove all recurrences for an event stored by 'user' having the given 342 'uid'. 343 """ 344 345 for recurrenceid in self.get_recurrences(user, uid): 346 self.remove_recurrence(user, uid, recurrenceid) 347 348 recurrences = self.get_object_in_store(user, "recurrences", uid) 349 if recurrences: 350 return self._remove_collection(recurrences) 351 352 return True 353 354 # Free/busy period providers, upon extension of the free/busy records. 355 356 def _get_freebusy_providers(self, user): 357 358 """ 359 Return the free/busy providers for the given 'user'. 360 361 This function returns any stored datetime and a list of providers as a 362 2-tuple. Each provider is itself a (uid, recurrenceid) tuple. 363 """ 364 365 filename = self.get_object_in_store(user, "freebusy-providers") 366 if not filename or not exists(filename): 367 return None 368 369 # Attempt to read providers, with a declaration of the datetime 370 # from which such providers are considered as still being active. 371 372 t = self._get_table(user, filename, [(1, None)]) 373 try: 374 dt_string = t[0][0] 375 except IndexError: 376 return None 377 378 return dt_string, t[1:] 379 380 def get_freebusy_providers(self, user, dt=None): 381 382 """ 383 Return a set of uncancelled events of the form (uid, recurrenceid) 384 providing free/busy details beyond the given datetime 'dt'. 385 386 If 'dt' is not specified, all events previously found to provide 387 details will be returned. Otherwise, if 'dt' is earlier than the 388 datetime recorded for the known providers, None is returned, indicating 389 that the list of providers must be recomputed. 390 391 This function returns a list of (uid, recurrenceid) tuples upon success. 392 """ 393 394 t = self._get_freebusy_providers(user) 395 if not t: 396 return None 397 398 dt_string, t = t 399 400 # If the requested datetime is earlier than the stated datetime, the 401 # providers will need to be recomputed. 402 403 if dt: 404 providers_dt = get_datetime(dt_string) 405 if not providers_dt or providers_dt > dt: 406 return None 407 408 # Otherwise, return the providers. 409 410 return t[1:] 411 412 def _set_freebusy_providers(self, user, dt_string, t): 413 414 "Set the given provider timestamp 'dt_string' and table 't'." 415 416 filename = self.get_object_in_store(user, "freebusy-providers") 417 if not filename: 418 return False 419 420 t.insert(0, (dt_string,)) 421 self._set_table(user, filename, t, [(1, "")]) 422 return True 423 424 def set_freebusy_providers(self, user, dt, providers): 425 426 """ 427 Define the uncancelled events providing free/busy details beyond the 428 given datetime 'dt'. 429 """ 430 431 t = [] 432 433 for obj in providers: 434 t.append((obj.get_uid(), obj.get_recurrenceid())) 435 436 return self._set_freebusy_providers(user, format_datetime(dt), t) 437 438 def append_freebusy_provider(self, user, provider): 439 440 "For the given 'user', append the free/busy 'provider'." 441 442 t = self._get_freebusy_providers(user) 443 if not t: 444 return False 445 446 dt_string, t = t 447 t.append((provider.get_uid(), provider.get_recurrenceid())) 448 449 return self._set_freebusy_providers(user, dt_string, t) 450 451 def remove_freebusy_provider(self, user, provider): 452 453 "For the given 'user', remove the free/busy 'provider'." 454 455 t = self._get_freebusy_providers(user) 456 if not t: 457 return False 458 459 dt_string, t = t 460 try: 461 t.remove((provider.get_uid(), provider.get_recurrenceid())) 462 except ValueError: 463 return False 464 465 return self._set_freebusy_providers(user, dt_string, t) 466 467 # Free/busy period access. 468 469 def get_freebusy(self, user): 470 471 "Get free/busy details for the given 'user'." 472 473 filename = self.get_object_in_store(user, "freebusy") 474 if not filename or not exists(filename): 475 return [] 476 else: 477 return map(lambda t: FreeBusyPeriod(*t), self._get_table(user, filename, [(4, None)])) 478 479 def get_freebusy_for_other(self, user, other): 480 481 "For the given 'user', get free/busy details for the 'other' user." 482 483 filename = self.get_object_in_store(user, "freebusy-other", other) 484 if not filename or not exists(filename): 485 return [] 486 else: 487 return map(lambda t: FreeBusyPeriod(*t), self._get_table(user, filename, [(4, None)])) 488 489 def set_freebusy(self, user, freebusy): 490 491 "For the given 'user', set 'freebusy' details." 492 493 filename = self.get_object_in_store(user, "freebusy") 494 if not filename: 495 return False 496 497 self._set_table(user, filename, map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) 498 return True 499 500 def set_freebusy_for_other(self, user, freebusy, other): 501 502 "For the given 'user', set 'freebusy' details for the 'other' user." 503 504 filename = self.get_object_in_store(user, "freebusy-other", other) 505 if not filename: 506 return False 507 508 self._set_table(user, filename, map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) 509 return True 510 511 # Object status details access. 512 513 def _get_requests(self, user, queue): 514 515 "Get requests for the given 'user' from the given 'queue'." 516 517 filename = self.get_object_in_store(user, queue) 518 if not filename or not exists(filename): 519 return None 520 521 return self._get_table(user, filename, [(1, None)]) 522 523 def get_requests(self, user): 524 525 "Get requests for the given 'user'." 526 527 return self._get_requests(user, "requests") 528 529 def get_cancellations(self, user): 530 531 "Get cancellations for the given 'user'." 532 533 return self._get_requests(user, "cancellations") 534 535 def _set_requests(self, user, requests, queue): 536 537 """ 538 For the given 'user', set the list of queued 'requests' in the given 539 'queue'. 540 """ 541 542 filename = self.get_object_in_store(user, queue) 543 if not filename: 544 return False 545 546 self.acquire_lock(user) 547 try: 548 f = open(filename, "w") 549 try: 550 for request in requests: 551 print >>f, "\t".join([value or "" for value in request]) 552 finally: 553 f.close() 554 fix_permissions(filename) 555 finally: 556 self.release_lock(user) 557 558 return True 559 560 def set_requests(self, user, requests): 561 562 "For the given 'user', set the list of queued 'requests'." 563 564 return self._set_requests(user, requests, "requests") 565 566 def set_cancellations(self, user, cancellations): 567 568 "For the given 'user', set the list of queued 'cancellations'." 569 570 return self._set_requests(user, cancellations, "cancellations") 571 572 def _set_request(self, user, uid, recurrenceid, queue): 573 574 """ 575 For the given 'user', set the queued 'uid' and 'recurrenceid' in the 576 given 'queue'. 577 """ 578 579 filename = self.get_object_in_store(user, queue) 580 if not filename: 581 return False 582 583 self.acquire_lock(user) 584 try: 585 f = open(filename, "a") 586 try: 587 print >>f, "\t".join([uid, recurrenceid or ""]) 588 finally: 589 f.close() 590 fix_permissions(filename) 591 finally: 592 self.release_lock(user) 593 594 return True 595 596 def set_request(self, user, uid, recurrenceid=None): 597 598 "For the given 'user', set the queued 'uid' and 'recurrenceid'." 599 600 return self._set_request(user, uid, recurrenceid, "requests") 601 602 def set_cancellation(self, user, uid, recurrenceid=None): 603 604 "For the given 'user', set the queued 'uid' and 'recurrenceid'." 605 606 return self._set_request(user, uid, recurrenceid, "cancellations") 607 608 def queue_request(self, user, uid, recurrenceid=None): 609 610 """ 611 Queue a request for 'user' having the given 'uid'. If the optional 612 'recurrenceid' is specified, the request refers to a specific instance 613 or occurrence of an event. 614 """ 615 616 requests = self.get_requests(user) or [] 617 618 if (uid, recurrenceid) not in requests: 619 return self.set_request(user, uid, recurrenceid) 620 621 return False 622 623 def dequeue_request(self, user, uid, recurrenceid=None): 624 625 """ 626 Dequeue a request for 'user' having the given 'uid'. If the optional 627 'recurrenceid' is specified, the request refers to a specific instance 628 or occurrence of an event. 629 """ 630 631 requests = self.get_requests(user) or [] 632 633 try: 634 requests.remove((uid, recurrenceid)) 635 self.set_requests(user, requests) 636 except ValueError: 637 return False 638 else: 639 return True 640 641 def cancel_event(self, user, uid, recurrenceid=None): 642 643 """ 644 Queue an event for cancellation for 'user' having the given 'uid'. If 645 the optional 'recurrenceid' is specified, a specific instance or 646 occurrence of an event is cancelled. 647 """ 648 649 cancellations = self.get_cancellations(user) or [] 650 651 if (uid, recurrenceid) not in cancellations: 652 return self.set_cancellation(user, uid, recurrenceid) 653 654 return False 655 656 class FilePublisher(FileBase): 657 658 "A publisher of objects." 659 660 def __init__(self, store_dir=None): 661 FileBase.__init__(self, store_dir or PUBLISH_DIR) 662 663 def set_freebusy(self, user, freebusy): 664 665 "For the given 'user', set 'freebusy' details." 666 667 filename = self.get_object_in_store(user, "freebusy") 668 if not filename: 669 return False 670 671 record = [] 672 rwrite = record.append 673 674 rwrite(("ORGANIZER", {}, user)) 675 rwrite(("UID", {}, user)) 676 rwrite(("DTSTAMP", {}, datetime.utcnow().strftime("%Y%m%dT%H%M%SZ"))) 677 678 for fb in freebusy: 679 if not fb.transp or fb.transp == "OPAQUE": 680 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join( 681 map(format_datetime, [fb.get_start_point(), fb.get_end_point()])))) 682 683 f = open(filename, "wb") 684 try: 685 to_stream(f, make_calendar([("VFREEBUSY", {}, record)], "PUBLISH")) 686 finally: 687 f.close() 688 fix_permissions(filename) 689 690 return True 691 692 # vim: tabstop=4 expandtab shiftwidth=4