1 #!/usr/bin/env python 2 3 """ 4 A database 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 imiptools.stores import StoreBase, JournalBase 23 24 from datetime import datetime 25 from imiptools.data import parse_string, to_string 26 from imiptools.dates import format_datetime, get_datetime, to_timezone 27 from imiptools.period import FreeBusyPeriod, FreeBusyDatabaseCollection 28 from imiptools.sql import DatabaseOperations 29 30 class DatabaseStoreBase: 31 32 "A database store supporting user-specific locking." 33 34 def acquire_lock(self, user, timeout=None): 35 FileBase.acquire_lock(self, timeout, user) 36 37 def release_lock(self, user): 38 FileBase.release_lock(self, user) 39 40 class DatabaseStore(DatabaseStoreBase, StoreBase, DatabaseOperations): 41 42 "A database store of tabular free/busy data and objects." 43 44 def __init__(self, connection, paramstyle=None): 45 DatabaseOperations.__init__(self, paramstyle=paramstyle) 46 self.connection = connection 47 self.cursor = connection.cursor() 48 49 # User discovery. 50 51 def get_users(self): 52 53 "Return a list of users." 54 55 query = "select distinct store_user from freebusy" 56 self.cursor.execute(query) 57 return [r[0] for r in self.cursor.fetchall()] 58 59 # Event and event metadata access. 60 61 def get_events(self, user): 62 63 "Return a list of event identifiers." 64 65 columns = ["store_user", "status"] 66 values = [user, "active"] 67 68 query, values = self.get_query( 69 "select object_uid from objects :condition", 70 columns, values) 71 72 self.cursor.execute(query, values) 73 return [r[0] for r in self.cursor.fetchall()] 74 75 def get_all_events(self, user): 76 77 "Return a set of (uid, recurrenceid) tuples for all events." 78 79 query, values = self.get_query( 80 "select object_uid, null as object_recurrenceid from objects :condition " 81 "union all " 82 "select object_uid, object_recurrenceid from recurrences :condition", 83 ["store_user"], [user]) 84 85 self.cursor.execute(query, values) 86 return self.cursor.fetchall() 87 88 def get_event_table(self, recurrenceid=None, dirname=None): 89 90 "Get the table providing events for any specified 'dirname'." 91 92 if recurrenceid: 93 return self.get_recurrence_table(dirname) 94 else: 95 return self.get_complete_event_table(dirname) 96 97 def get_event_table_filters(self, dirname=None): 98 99 "Get filter details for any specified 'dirname'." 100 101 if dirname == "cancellations": 102 return ["status"], ["cancelled"] 103 else: 104 return [], [] 105 106 def get_event(self, user, uid, recurrenceid=None, dirname=None): 107 108 """ 109 Get the event for the given 'user' with the given 'uid'. If 110 the optional 'recurrenceid' is specified, a specific instance or 111 occurrence of an event is returned. 112 """ 113 114 table = self.get_event_table(recurrenceid, dirname) 115 columns, values = self.get_event_table_filters(dirname) 116 117 if recurrenceid: 118 columns += ["store_user", "object_uid", "object_recurrenceid"] 119 values += [user, uid, recurrenceid] 120 else: 121 columns += ["store_user", "object_uid"] 122 values += [user, uid] 123 124 query, values = self.get_query( 125 "select object_text from %(table)s :condition" % { 126 "table" : table 127 }, 128 columns, values) 129 130 self.cursor.execute(query, values) 131 result = self.cursor.fetchone() 132 return result and parse_string(result[0], "utf-8") 133 134 def get_complete_event_table(self, dirname=None): 135 136 "Get the table providing events for any specified 'dirname'." 137 138 if dirname == "counters": 139 return "countered_objects" 140 else: 141 return "objects" 142 143 def get_complete_event(self, user, uid): 144 145 "Get the event for the given 'user' with the given 'uid'." 146 147 columns = ["store_user", "object_uid"] 148 values = [user, uid] 149 150 query, values = self.get_query( 151 "select object_text from objects :condition", 152 columns, values) 153 154 self.cursor.execute(query, values) 155 result = self.cursor.fetchone() 156 return result and parse_string(result[0], "utf-8") 157 158 def set_complete_event(self, user, uid, node): 159 160 "Set an event for 'user' having the given 'uid' and 'node'." 161 162 columns = ["store_user", "object_uid"] 163 values = [user, uid] 164 setcolumns = ["object_text", "status"] 165 setvalues = [to_string(node, "utf-8"), "active"] 166 167 query, values = self.get_query( 168 "update objects :set :condition", 169 columns, values, setcolumns, setvalues) 170 171 self.cursor.execute(query, values) 172 173 if self.cursor.rowcount > 0 or self.get_complete_event(user, uid): 174 return True 175 176 columns = ["store_user", "object_uid", "object_text", "status"] 177 values = [user, uid, to_string(node, "utf-8"), "active"] 178 179 query, values = self.get_query( 180 "insert into objects (:columns) values (:values)", 181 columns, values) 182 183 self.cursor.execute(query, values) 184 return True 185 186 def remove_parent_event(self, user, uid): 187 188 "Remove the parent event for 'user' having the given 'uid'." 189 190 columns = ["store_user", "object_uid"] 191 values = [user, uid] 192 193 query, values = self.get_query( 194 "delete from objects :condition", 195 columns, values) 196 197 self.cursor.execute(query, values) 198 return self.cursor.rowcount > 0 199 200 def get_active_recurrences(self, user, uid): 201 202 """ 203 Get additional event instances for an event of the given 'user' with the 204 indicated 'uid'. Cancelled recurrences are not returned. 205 """ 206 207 columns = ["store_user", "object_uid", "status"] 208 values = [user, uid, "active"] 209 210 query, values = self.get_query( 211 "select object_recurrenceid from recurrences :condition", 212 columns, values) 213 214 self.cursor.execute(query, values) 215 return [t[0] for t in self.cursor.fetchall() or []] 216 217 def get_cancelled_recurrences(self, user, uid): 218 219 """ 220 Get additional event instances for an event of the given 'user' with the 221 indicated 'uid'. Only cancelled recurrences are returned. 222 """ 223 224 columns = ["store_user", "object_uid", "status"] 225 values = [user, uid, "cancelled"] 226 227 query, values = self.get_query( 228 "select object_recurrenceid from recurrences :condition", 229 columns, values) 230 231 self.cursor.execute(query, values) 232 return [t[0] for t in self.cursor.fetchall() or []] 233 234 def get_recurrence_table(self, dirname=None): 235 236 "Get the table providing recurrences for any specified 'dirname'." 237 238 if dirname == "counters": 239 return "countered_recurrences" 240 else: 241 return "recurrences" 242 243 def get_recurrence(self, user, uid, recurrenceid): 244 245 """ 246 For the event of the given 'user' with the given 'uid', return the 247 specific recurrence indicated by the 'recurrenceid'. 248 """ 249 250 columns = ["store_user", "object_uid", "object_recurrenceid"] 251 values = [user, uid, recurrenceid] 252 253 query, values = self.get_query( 254 "select object_text from recurrences :condition", 255 columns, values) 256 257 self.cursor.execute(query, values) 258 result = self.cursor.fetchone() 259 return result and parse_string(result[0], "utf-8") 260 261 def set_recurrence(self, user, uid, recurrenceid, node): 262 263 "Set an event for 'user' having the given 'uid' and 'node'." 264 265 columns = ["store_user", "object_uid", "object_recurrenceid"] 266 values = [user, uid, recurrenceid] 267 setcolumns = ["object_text", "status"] 268 setvalues = [to_string(node, "utf-8"), "active"] 269 270 query, values = self.get_query( 271 "update recurrences :set :condition", 272 columns, values, setcolumns, setvalues) 273 274 self.cursor.execute(query, values) 275 276 if self.cursor.rowcount > 0 or self.get_recurrence(user, uid, recurrenceid): 277 return True 278 279 columns = ["store_user", "object_uid", "object_recurrenceid", "object_text", "status"] 280 values = [user, uid, recurrenceid, to_string(node, "utf-8"), "active"] 281 282 query, values = self.get_query( 283 "insert into recurrences (:columns) values (:values)", 284 columns, values) 285 286 self.cursor.execute(query, values) 287 return True 288 289 def remove_recurrence(self, user, uid, recurrenceid): 290 291 """ 292 Remove a special recurrence from an event stored by 'user' having the 293 given 'uid' and 'recurrenceid'. 294 """ 295 296 columns = ["store_user", "object_uid", "object_recurrenceid"] 297 values = [user, uid, recurrenceid] 298 299 query, values = self.get_query( 300 "delete from recurrences :condition", 301 columns, values) 302 303 self.cursor.execute(query, values) 304 return True 305 306 def remove_recurrences(self, user, uid): 307 308 """ 309 Remove all recurrences for an event stored by 'user' having the given 310 'uid'. 311 """ 312 313 columns = ["store_user", "object_uid"] 314 values = [user, uid] 315 316 query, values = self.get_query( 317 "delete from recurrences :condition", 318 columns, values) 319 320 self.cursor.execute(query, values) 321 return True 322 323 # Free/busy period providers, upon extension of the free/busy records. 324 325 def _get_freebusy_providers(self, user): 326 327 """ 328 Return the free/busy providers for the given 'user'. 329 330 This function returns any stored datetime and a list of providers as a 331 2-tuple. Each provider is itself a (uid, recurrenceid) tuple. 332 """ 333 334 columns = ["store_user"] 335 values = [user] 336 337 query, values = self.get_query( 338 "select object_uid, object_recurrenceid from freebusy_providers :condition", 339 columns, values) 340 341 self.cursor.execute(query, values) 342 providers = self.cursor.fetchall() 343 344 columns = ["store_user"] 345 values = [user] 346 347 query, values = self.get_query( 348 "select start from freebusy_provider_datetimes :condition", 349 columns, values) 350 351 self.cursor.execute(query, values) 352 result = self.cursor.fetchone() 353 dt_string = result and result[0] 354 355 return dt_string, providers 356 357 def _set_freebusy_providers(self, user, dt_string, t): 358 359 "Set the given provider timestamp 'dt_string' and table 't'." 360 361 # NOTE: Locking? 362 363 columns = ["store_user"] 364 values = [user] 365 366 query, values = self.get_query( 367 "delete from freebusy_providers :condition", 368 columns, values) 369 370 self.cursor.execute(query, values) 371 372 columns = ["store_user", "object_uid", "object_recurrenceid"] 373 374 for uid, recurrenceid in t: 375 values = [user, uid, recurrenceid] 376 377 query, values = self.get_query( 378 "insert into freebusy_providers (:columns) values (:values)", 379 columns, values) 380 381 self.cursor.execute(query, values) 382 383 columns = ["store_user"] 384 values = [user] 385 setcolumns = ["start"] 386 setvalues = [dt_string] 387 388 query, values = self.get_query( 389 "update freebusy_provider_datetimes :set :condition", 390 columns, values, setcolumns, setvalues) 391 392 self.cursor.execute(query, values) 393 394 if self.cursor.rowcount > 0: 395 return True 396 397 columns = ["store_user", "start"] 398 values = [user, dt_string] 399 400 query, values = self.get_query( 401 "insert into freebusy_provider_datetimes (:columns) values (:values)", 402 columns, values) 403 404 self.cursor.execute(query, values) 405 return True 406 407 # Free/busy period access. 408 409 def get_freebusy(self, user, name=None, mutable=False): 410 411 "Get free/busy details for the given 'user'." 412 413 table = name or "freebusy" 414 return FreeBusyDatabaseCollection(self.cursor, table, ["store_user"], [user], mutable, self.paramstyle) 415 416 def get_freebusy_for_other(self, user, other, mutable=False): 417 418 "For the given 'user', get free/busy details for the 'other' user." 419 420 table = "freebusy" 421 return FreeBusyDatabaseCollection(self.cursor, table, ["store_user", "other"], [user, other], mutable, self.paramstyle) 422 423 def set_freebusy(self, user, freebusy, name=None): 424 425 "For the given 'user', set 'freebusy' details." 426 427 table = name or "freebusy" 428 429 if not isinstance(freebusy, FreeBusyDatabaseCollection) or freebusy.table_name != table: 430 fbc = FreeBusyDatabaseCollection(self.cursor, table, ["store_user"], [user], True, self.paramstyle) 431 fbc += freebusy 432 433 return True 434 435 def set_freebusy_for_other(self, user, freebusy, other): 436 437 "For the given 'user', set 'freebusy' details for the 'other' user." 438 439 table = "freebusy" 440 441 if not isinstance(freebusy, FreeBusyDatabaseCollection) or freebusy.table_name != table: 442 fbc = FreeBusyDatabaseCollection(self.cursor, table, ["store_user", "other"], [user, other], True, self.paramstyle) 443 fbc += freebusy 444 445 return True 446 447 # Tentative free/busy periods related to countering. 448 449 def get_freebusy_offers(self, user, mutable=False): 450 451 "Get free/busy offers for the given 'user'." 452 453 # Expire old offers and save the collection if modified. 454 455 now = format_datetime(to_timezone(datetime.utcnow(), "UTC")) 456 columns = ["store_user", "expires"] 457 values = [user, now] 458 459 query, values = self.get_query( 460 "delete from freebusy_offers :condition", 461 columns, values) 462 463 self.cursor.execute(query, values) 464 465 return self.get_freebusy(user, "freebusy_offers", mutable) 466 467 # Requests and counter-proposals. 468 469 def get_requests(self, user): 470 471 "Get requests for the given 'user'." 472 473 columns = ["store_user"] 474 values = [user] 475 476 query, values = self.get_query( 477 "select object_uid, object_recurrenceid from requests :condition", 478 columns, values) 479 480 self.cursor.execute(query, values) 481 return self.cursor.fetchall() 482 483 def set_requests(self, user, requests): 484 485 "For the given 'user', set the list of queued 'requests'." 486 487 # NOTE: Locking? 488 489 columns = ["store_user"] 490 values = [user] 491 492 query, values = self.get_query( 493 "delete from requests :condition", 494 columns, values) 495 496 self.cursor.execute(query, values) 497 498 for uid, recurrenceid, type in requests: 499 columns = ["store_user", "object_uid", "object_recurrenceid", "request_type"] 500 values = [user, uid, recurrenceid, type] 501 502 query, values = self.get_query( 503 "insert into requests (:columns) values (:values)", 504 columns, values) 505 506 self.cursor.execute(query, values) 507 508 return True 509 510 def set_request(self, user, uid, recurrenceid=None, type=None): 511 512 """ 513 For the given 'user', set the queued 'uid' and 'recurrenceid', 514 indicating a request, along with any given 'type'. 515 """ 516 517 columns = ["store_user", "object_uid", "object_recurrenceid", "request_type"] 518 values = [user, uid, recurrenceid, type] 519 520 query, values = self.get_query( 521 "insert into requests (:columns) values (:values)", 522 columns, values) 523 524 self.cursor.execute(query, values) 525 return True 526 527 def get_counters(self, user, uid, recurrenceid=None): 528 529 """ 530 For the given 'user', return a list of users from whom counter-proposals 531 have been received for the given 'uid' and optional 'recurrenceid'. 532 """ 533 534 table = self.get_event_table(recurrenceid, "counters") 535 536 if recurrenceid: 537 columns = ["store_user", "object_uid", "object_recurrenceid"] 538 values = [user, uid, recurrenceid] 539 else: 540 columns = ["store_user", "object_uid"] 541 values = [user, uid] 542 543 query, values = self.get_query( 544 "select other from %(table)s :condition" % { 545 "table" : table 546 }, 547 columns, values) 548 549 self.cursor.execute(query, values) 550 return self.cursor.fetchall() 551 552 def get_counter(self, user, other, uid, recurrenceid=None): 553 554 """ 555 For the given 'user', return the counter-proposal from 'other' for the 556 given 'uid' and optional 'recurrenceid'. 557 """ 558 559 table = self.get_event_table(recurrenceid, "counters") 560 561 if recurrenceid: 562 columns = ["store_user", "other", "object_uid", "object_recurrenceid"] 563 values = [user, other, uid, recurrenceid] 564 else: 565 columns = ["store_user", "other", "object_uid"] 566 values = [user, other, uid] 567 568 query, values = self.get_query( 569 "select object_text from %(table)s :condition" % { 570 "table" : table 571 }, 572 columns, values) 573 574 self.cursor.execute(query, values) 575 result = self.cursor.fetchall() 576 return result and parse_string(result[0], "utf-8") 577 578 def set_counter(self, user, other, node, uid, recurrenceid=None): 579 580 """ 581 For the given 'user', store a counter-proposal received from 'other' the 582 given 'node' representing that proposal for the given 'uid' and 583 'recurrenceid'. 584 """ 585 586 table = self.get_event_table(recurrenceid, "counters") 587 588 columns = ["store_user", "other", "object_uid", "object_recurrenceid", "object_text"] 589 values = [user, other, uid, recurrenceid, to_string(node, "utf-8")] 590 591 query, values = self.get_query( 592 "insert into %(table)s (:columns) values (:values)" % { 593 "table" : table 594 }, 595 columns, values) 596 597 self.cursor.execute(query, values) 598 return True 599 600 def remove_counters(self, user, uid, recurrenceid=None): 601 602 """ 603 For the given 'user', remove all counter-proposals associated with the 604 given 'uid' and 'recurrenceid'. 605 """ 606 607 table = self.get_event_table(recurrenceid, "counters") 608 609 if recurrenceid: 610 columns = ["store_user", "object_uid", "object_recurrenceid"] 611 values = [user, uid, recurrenceid] 612 else: 613 columns = ["store_user", "object_uid"] 614 values = [user, uid] 615 616 query, values = self.get_query( 617 "delete from %(table)s :condition" % { 618 "table" : table 619 }, 620 columns, values) 621 622 self.cursor.execute(query, values) 623 return True 624 625 def remove_counter(self, user, other, uid, recurrenceid=None): 626 627 """ 628 For the given 'user', remove any counter-proposal from 'other' 629 associated with the given 'uid' and 'recurrenceid'. 630 """ 631 632 table = self.get_event_table(recurrenceid, "counters") 633 634 if recurrenceid: 635 columns = ["store_user", "other", "object_uid", "object_recurrenceid"] 636 values = [user, other, uid, recurrenceid] 637 else: 638 columns = ["store_user", "other", "object_uid"] 639 values = [user, other, uid] 640 641 query, values = self.get_query( 642 "delete from %(table)s :condition" % { 643 "table" : table 644 }, 645 columns, values) 646 647 self.cursor.execute(query, values) 648 return True 649 650 # Event cancellation. 651 652 def cancel_event(self, user, uid, recurrenceid=None): 653 654 """ 655 Cancel an event for 'user' having the given 'uid'. If the optional 656 'recurrenceid' is specified, a specific instance or occurrence of an 657 event is cancelled. 658 """ 659 660 table = self.get_event_table(recurrenceid) 661 662 if recurrenceid: 663 columns = ["store_user", "object_uid", "object_recurrenceid"] 664 values = [user, uid, recurrenceid] 665 else: 666 columns = ["store_user", "object_uid"] 667 values = [user, uid] 668 669 setcolumns = ["status"] 670 setvalues = ["cancelled"] 671 672 query, values = self.get_query( 673 "update %(table)s :set :condition" % { 674 "table" : table 675 }, 676 columns, values, setcolumns, setvalues) 677 678 self.cursor.execute(query, values) 679 return True 680 681 def uncancel_event(self, user, uid, recurrenceid=None): 682 683 """ 684 Uncancel an event for 'user' having the given 'uid'. If the optional 685 'recurrenceid' is specified, a specific instance or occurrence of an 686 event is uncancelled. 687 """ 688 689 table = self.get_event_table(recurrenceid) 690 691 if recurrenceid: 692 columns = ["store_user", "object_uid", "object_recurrenceid"] 693 values = [user, uid, recurrenceid] 694 else: 695 columns = ["store_user", "object_uid"] 696 values = [user, uid] 697 698 setcolumns = ["status"] 699 setvalues = ["active"] 700 701 query, values = self.get_query( 702 "update %(table)s :set :condition" % { 703 "table" : table 704 }, 705 columns, values, setcolumns, setvalues) 706 707 self.cursor.execute(query, values) 708 return True 709 710 def remove_cancellation(self, user, uid, recurrenceid=None): 711 712 """ 713 Remove a cancellation for 'user' for the event having the given 'uid'. 714 If the optional 'recurrenceid' is specified, a specific instance or 715 occurrence of an event is affected. 716 """ 717 718 table = self.get_event_table(recurrenceid) 719 720 if recurrenceid: 721 columns = ["store_user", "object_uid", "object_recurrenceid", "status"] 722 values = [user, uid, recurrenceid, "cancelled"] 723 else: 724 columns = ["store_user", "object_uid", "status"] 725 values = [user, uid, "cancelled"] 726 727 query, values = self.get_query( 728 "delete from %(table)s :condition" % { 729 "table" : table 730 }, 731 columns, values) 732 733 self.cursor.execute(query, values) 734 return True 735 736 class DatabaseJournal(DatabaseStoreBase, JournalBase): 737 738 "A journal system to support quotas." 739 740 # Quota and user identity/group discovery. 741 742 def get_quotas(self): 743 744 "Return a list of quotas." 745 746 query = "select distinct journal_quota from quota_freebusy" 747 self.cursor.execute(query) 748 return [r[0] for r in self.cursor.fetchall()] 749 750 def get_quota_users(self, quota): 751 752 "Return a list of quota users." 753 754 columns = ["quota"] 755 values = [quota] 756 757 query, values = self.get_query( 758 "select distinct user_group from quota_freebusy :condition", 759 columns, values) 760 761 self.cursor.execute(query) 762 return [r[0] for r in self.cursor.fetchall()] 763 764 # Groups of users sharing quotas. 765 766 def get_groups(self, quota): 767 768 "Return the identity mappings for the given 'quota' as a dictionary." 769 770 columns = ["quota"] 771 values = [quota] 772 773 query, values = self.get_query( 774 "select store_user, user_group from user_groups :condition", 775 columns, values) 776 777 self.cursor.execute(query) 778 return dict(self.cursor.fetchall()) 779 780 def get_limits(self, quota): 781 782 """ 783 Return the limits for the 'quota' as a dictionary mapping identities or 784 groups to durations. 785 """ 786 787 columns = ["quota"] 788 values = [quota] 789 790 query, values = self.get_query( 791 "select user_group, quota_limit from quota_limits :condition", 792 columns, values) 793 794 self.cursor.execute(query) 795 return dict(self.cursor.fetchall()) 796 797 # Free/busy period access for users within quota groups. 798 799 def get_freebusy(self, quota, user, mutable=False): 800 801 "Get free/busy details for the given 'quota' and 'user'." 802 803 table = "user_freebusy" 804 return FreeBusyDatabaseCollection(self.cursor, table, ["quota", "store_user"], [quota, user], mutable, self.paramstyle) 805 806 def set_freebusy(self, quota, user, freebusy): 807 808 "For the given 'quota' and 'user', set 'freebusy' details." 809 810 table = "user_freebusy" 811 812 if not isinstance(freebusy, FreeBusyDatabaseCollection) or freebusy.table_name != table: 813 fbc = FreeBusyDatabaseCollection(self.cursor, table, ["quota", "store_user"], [quota, user], True, self.paramstyle) 814 fbc += freebusy 815 816 return True 817 818 # Journal entry methods. 819 820 def get_entries(self, quota, group, mutable=False): 821 822 """ 823 Return a list of journal entries for the given 'quota' for the indicated 824 'group'. 825 """ 826 827 table = "quota_freebusy" 828 return FreeBusyDatabaseCollection(self.cursor, table, ["quota", "user_group"], [quota, group], mutable, self.paramstyle) 829 830 def set_entries(self, quota, group, entries): 831 832 """ 833 For the given 'quota' and indicated 'group', set the list of journal 834 'entries'. 835 """ 836 837 table = "quota_freebusy" 838 839 if not isinstance(entries, FreeBusyDatabaseCollection) or entries.table_name != table: 840 fbc = FreeBusyDatabaseCollection(self.cursor, table, ["quota", "user_group"], [quota, group], True, self.paramstyle) 841 fbc += entries 842 843 return True 844 845 # vim: tabstop=4 expandtab shiftwidth=4