1 #!/usr/bin/env python 2 3 """ 4 A Web interface to a user's calendar. 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 # Edit this path to refer to the location of the imiptools libraries, if 23 # necessary. 24 25 LIBRARY_PATH = "/var/lib/imip-agent" 26 27 from datetime import date, datetime, timedelta 28 import babel.dates 29 import cgi, os, sys 30 31 sys.path.append(LIBRARY_PATH) 32 33 from imiptools.content import Handler 34 from imiptools.data import get_address, get_uri, make_freebusy, Object, to_part, \ 35 uri_dict, uri_item, uri_items, uri_values 36 from imiptools.dates import format_datetime, format_time, get_date, get_datetime, \ 37 get_datetime_item, get_default_timezone, \ 38 get_end_of_day, get_start_of_day, get_start_of_next_day, \ 39 get_timestamp, ends_on_same_day, to_timezone 40 from imiptools.mail import Messenger 41 from imiptools.period import add_day_start_points, add_empty_days, add_slots, \ 42 convert_periods, get_freebusy_details, \ 43 get_scale, have_conflict, get_slots, get_spans, \ 44 partition_by_day, remove_from_freebusy, update_freebusy, \ 45 _update_freebusy 46 from imiptools.profile import Preferences 47 import imip_store 48 import markup 49 50 getenv = os.environ.get 51 setenv = os.environ.__setitem__ 52 53 class CGIEnvironment: 54 55 "A CGI-compatible environment." 56 57 def __init__(self, charset=None): 58 self.charset = charset 59 self.args = None 60 self.method = None 61 self.path = None 62 self.path_info = None 63 self.user = None 64 65 def get_args(self): 66 if self.args is None: 67 if self.get_method() != "POST": 68 setenv("QUERY_STRING", "") 69 args = cgi.parse(keep_blank_values=True) 70 71 if not self.charset: 72 self.args = args 73 else: 74 self.args = {} 75 for key, values in args.items(): 76 self.args[key] = [unicode(value, self.charset) for value in values] 77 78 return self.args 79 80 def get_method(self): 81 if self.method is None: 82 self.method = getenv("REQUEST_METHOD") or "GET" 83 return self.method 84 85 def get_path(self): 86 if self.path is None: 87 self.path = getenv("SCRIPT_NAME") or "" 88 return self.path 89 90 def get_path_info(self): 91 if self.path_info is None: 92 self.path_info = getenv("PATH_INFO") or "" 93 return self.path_info 94 95 def get_user(self): 96 if self.user is None: 97 self.user = getenv("REMOTE_USER") or "" 98 return self.user 99 100 def get_output(self): 101 return sys.stdout 102 103 def get_url(self): 104 path = self.get_path() 105 path_info = self.get_path_info() 106 return "%s%s" % (path.rstrip("/"), path_info) 107 108 def new_url(self, path_info): 109 path = self.get_path() 110 return "%s/%s" % (path.rstrip("/"), path_info.lstrip("/")) 111 112 class Common: 113 114 "Common handler and manager methods." 115 116 def __init__(self, user): 117 self.user = user 118 self.preferences = None 119 120 def get_preferences(self): 121 if not self.preferences: 122 self.preferences = Preferences(self.user) 123 return self.preferences 124 125 def get_tzid(self): 126 prefs = self.get_preferences() 127 return prefs.get("TZID") or get_default_timezone() 128 129 class ManagerHandler(Handler, Common): 130 131 """ 132 A content handler for use by the manager, as opposed to operating within the 133 mail processing pipeline. 134 """ 135 136 def __init__(self, obj, user, messenger): 137 Handler.__init__(self, messenger=messenger) 138 Common.__init__(self, user) 139 140 self.set_object(obj) 141 142 # Communication methods. 143 144 def send_message(self, method, sender, for_organiser): 145 146 """ 147 Create a full calendar object employing the given 'method', and send it 148 to the appropriate recipients, also sending a copy to the 'sender'. The 149 'for_organiser' value indicates whether the organiser is sending this 150 message. 151 """ 152 153 parts = [self.obj.to_part(method)] 154 155 # As organiser, send an invitation to attendees, excluding oneself if 156 # also attending. The updated event will be saved by the outgoing 157 # handler. 158 159 organiser = get_uri(self.obj.get_value("ORGANIZER")) 160 attendees = uri_values(self.obj.get_values("ATTENDEE")) 161 162 if for_organiser: 163 recipients = [get_address(attendee) for attendee in attendees if attendee != self.user] 164 else: 165 recipients = [get_address(organiser)] 166 167 # Bundle free/busy information if appropriate. 168 169 preferences = Preferences(self.user) 170 171 if preferences.get("freebusy_sharing") == "share" and \ 172 preferences.get("freebusy_bundling") == "always": 173 174 # Invent a unique identifier. 175 176 utcnow = get_timestamp() 177 uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) 178 179 freebusy = self.store.get_freebusy(self.user) 180 181 # Replace the non-updated free/busy details for this event with 182 # newer details (since the outgoing handler updates this user's 183 # free/busy details). 184 185 tzid = self.get_tzid() 186 187 _update_freebusy(freebusy, self.obj.get_periods_for_freebusy(tzid), 188 self.obj.get_value("TRANSP") or "OPAQUE", 189 self.uid, self.recurrenceid) 190 191 user_attr = self.messenger and self.messenger.sender != get_address(self.user) and \ 192 {"SENT-BY" : get_uri(self.messenger.sender)} or {} 193 194 parts.append(to_part("PUBLISH", [ 195 make_freebusy(freebusy, uid, self.user, user_attr) 196 ])) 197 198 message = self.messenger.make_outgoing_message(parts, recipients, outgoing_bcc=sender) 199 self.messenger.sendmail(recipients, message.as_string(), outgoing_bcc=sender) 200 201 # Action methods. 202 203 def process_received_request(self, update=False): 204 205 """ 206 Process the current request for the given 'user'. Return whether any 207 action was taken. 208 209 If 'update' is given, the sequence number will be incremented in order 210 to override any previous response. 211 """ 212 213 # Reply only on behalf of this user. 214 215 for attendee, attendee_attr in uri_items(self.obj.get_items("ATTENDEE")): 216 217 if attendee == self.user: 218 if attendee_attr.has_key("RSVP"): 219 del attendee_attr["RSVP"] 220 if self.messenger and self.messenger.sender != get_address(attendee): 221 attendee_attr["SENT-BY"] = get_uri(self.messenger.sender) 222 self.obj["ATTENDEE"] = [(attendee, attendee_attr)] 223 224 self.update_dtstamp() 225 self.set_sequence(update) 226 227 self.send_message("REPLY", get_address(attendee), for_organiser=False) 228 229 return True 230 231 return False 232 233 def process_created_request(self, method, update=False, removed=None, added=None): 234 235 """ 236 Process the current request for the given 'user', sending a created 237 request of the given 'method' to attendees. Return whether any action 238 was taken. 239 240 If 'update' is given, the sequence number will be incremented in order 241 to override any previous message. 242 243 If 'removed' is specified, a list of participants to be removed is 244 provided. 245 246 If 'added' is specified, a list of participants to be added is provided. 247 """ 248 249 organiser, organiser_attr = uri_item(self.obj.get_item("ORGANIZER")) 250 251 if self.messenger and self.messenger.sender != get_address(organiser): 252 organiser_attr["SENT-BY"] = get_uri(self.messenger.sender) 253 254 to_cancel = [] 255 256 if added or removed: 257 attendees = uri_items(self.obj.get_items("ATTENDEE") or []) 258 259 if removed: 260 remaining = [] 261 262 for attendee, attendee_attr in attendees: 263 if attendee in removed: 264 to_cancel.append((attendee, attendee_attr)) 265 else: 266 remaining.append((attendee, attendee_attr)) 267 268 attendees = remaining 269 270 if added: 271 for attendee in added: 272 attendees.append((attendee, {"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"})) 273 274 self.obj["ATTENDEE"] = attendees 275 276 self.update_dtstamp() 277 self.set_sequence(update) 278 279 self.send_message(method, get_address(organiser), for_organiser=True) 280 281 # When cancelling, replace the attendees with those for whom the event 282 # is now cancelled. 283 284 if to_cancel: 285 self.obj["ATTENDEE"] = to_cancel 286 self.send_message("CANCEL", get_address(organiser), for_organiser=True) 287 288 # Just in case more work is done with this event, the attendees are 289 # now restored. 290 291 self.obj["ATTENDEE"] = remaining 292 293 return True 294 295 class Manager(Common): 296 297 "A simple manager application." 298 299 def __init__(self, messenger=None): 300 self.messenger = messenger or Messenger() 301 self.encoding = "utf-8" 302 self.env = CGIEnvironment(self.encoding) 303 304 user = self.env.get_user() 305 Common.__init__(self, user and get_uri(user) or None) 306 307 self.locale = None 308 self.requests = None 309 310 self.out = self.env.get_output() 311 self.page = markup.page() 312 313 self.store = imip_store.FileStore() 314 self.objects = {} 315 316 try: 317 self.publisher = imip_store.FilePublisher() 318 except OSError: 319 self.publisher = None 320 321 def _get_identifiers(self, path_info): 322 parts = path_info.lstrip("/").split("/") 323 if len(parts) == 1: 324 return parts[0], None 325 else: 326 return parts[:2] 327 328 def _get_object(self, uid, recurrenceid=None): 329 if self.objects.has_key((uid, recurrenceid)): 330 return self.objects[(uid, recurrenceid)] 331 332 fragment = uid and self.store.get_event(self.user, uid, recurrenceid) or None 333 obj = self.objects[(uid, recurrenceid)] = fragment and Object(fragment) 334 return obj 335 336 def _get_requests(self): 337 if self.requests is None: 338 cancellations = self.store.get_cancellations(self.user) 339 requests = set(self.store.get_requests(self.user)) 340 self.requests = requests.difference(cancellations) 341 return self.requests 342 343 def _get_request_summary(self): 344 summary = [] 345 for uid, recurrenceid in self._get_requests(): 346 obj = self._get_object(uid, recurrenceid) 347 if obj: 348 for start, end in obj.get_periods_for_freebusy(self.get_tzid()): 349 summary.append((start, end, uid, obj.get_value("TRANSP"), recurrenceid)) 350 return summary 351 352 # Preference methods. 353 354 def get_user_locale(self): 355 if not self.locale: 356 self.locale = self.get_preferences().get("LANG", "en") 357 return self.locale 358 359 # Prettyprinting of dates and times. 360 361 def format_date(self, dt, format): 362 return self._format_datetime(babel.dates.format_date, dt, format) 363 364 def format_time(self, dt, format): 365 return self._format_datetime(babel.dates.format_time, dt, format) 366 367 def format_datetime(self, dt, format): 368 return self._format_datetime( 369 isinstance(dt, datetime) and babel.dates.format_datetime or babel.dates.format_date, 370 dt, format) 371 372 def _format_datetime(self, fn, dt, format): 373 return fn(dt, format=format, locale=self.get_user_locale()) 374 375 # Data management methods. 376 377 def remove_request(self, uid, recurrenceid=None): 378 return self.store.dequeue_request(self.user, uid, recurrenceid) 379 380 def remove_event(self, uid, recurrenceid=None): 381 return self.store.remove_event(self.user, uid, recurrenceid) 382 383 def update_freebusy(self, uid, recurrenceid, obj): 384 tzid = self.get_tzid() 385 freebusy = self.store.get_freebusy(self.user) 386 update_freebusy(freebusy, self.user, obj.get_periods_for_freebusy(tzid), 387 obj.get_value("TRANSP"), uid, recurrenceid, self.store) 388 389 def remove_from_freebusy(self, uid, recurrenceid=None): 390 freebusy = self.store.get_freebusy(self.user) 391 remove_from_freebusy(freebusy, self.user, uid, recurrenceid, self.store) 392 393 # Presentation methods. 394 395 def new_page(self, title): 396 self.page.init(title=title, charset=self.encoding, css=self.env.new_url("styles.css")) 397 398 def status(self, code, message): 399 self.header("Status", "%s %s" % (code, message)) 400 401 def header(self, header, value): 402 print >>self.out, "%s: %s" % (header, value) 403 404 def no_user(self): 405 self.status(403, "Forbidden") 406 self.new_page(title="Forbidden") 407 self.page.p("You are not logged in and thus cannot access scheduling requests.") 408 409 def no_page(self): 410 self.status(404, "Not Found") 411 self.new_page(title="Not Found") 412 self.page.p("No page is provided at the given address.") 413 414 def redirect(self, url): 415 self.status(302, "Redirect") 416 self.header("Location", url) 417 self.new_page(title="Redirect") 418 self.page.p("Redirecting to: %s" % url) 419 420 def link_to(self, uid, recurrenceid=None): 421 if recurrenceid: 422 return self.env.new_url("/".join([uid, recurrenceid])) 423 else: 424 return self.env.new_url(uid) 425 426 # Request logic methods. 427 428 def handle_newevent(self): 429 430 """ 431 Handle any new event operation, creating a new event and redirecting to 432 the event page for further activity. 433 """ 434 435 # Handle a submitted form. 436 437 args = self.env.get_args() 438 439 if not args.has_key("newevent"): 440 return 441 442 # Create a new event using the available information. 443 444 slots = args.get("slot", []) 445 participants = args.get("participants", []) 446 447 if not slots: 448 return 449 450 # Obtain the user's timezone. 451 452 tzid = self.get_tzid() 453 454 # Coalesce the selected slots. 455 456 slots.sort() 457 coalesced = [] 458 last = None 459 460 for slot in slots: 461 start, end = slot.split("-") 462 start = get_datetime(start, {"TZID" : tzid}) 463 end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid) 464 465 if last: 466 last_start, last_end = last 467 468 # Merge adjacent dates and datetimes. 469 470 if start == last_end or get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid): 471 last = last_start, end 472 continue 473 474 # Handle datetimes within dates. 475 # Datetime periods are within single days and are therefore 476 # discarded. 477 478 elif get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid): 479 continue 480 481 # Add separate dates and datetimes. 482 483 else: 484 coalesced.append(last) 485 486 last = start, end 487 488 if last: 489 coalesced.append(last) 490 491 # Invent a unique identifier. 492 493 utcnow = get_timestamp() 494 uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) 495 496 # Define a single occurrence if only one coalesced slot exists. 497 # Otherwise, many occurrences are defined. 498 499 for i, (start, end) in enumerate(coalesced): 500 this_uid = "%s-%s" % (uid, i) 501 502 start_value, start_attr = get_datetime_item(start, tzid) 503 end_value, end_attr = get_datetime_item(end, tzid) 504 505 # Create a calendar object and store it as a request. 506 507 record = [] 508 rwrite = record.append 509 510 rwrite(("UID", {}, this_uid)) 511 rwrite(("SUMMARY", {}, "New event at %s" % utcnow)) 512 rwrite(("DTSTAMP", {}, utcnow)) 513 rwrite(("DTSTART", start_attr, start_value)) 514 rwrite(("DTEND", end_attr, end_value)) 515 rwrite(("ORGANIZER", {}, self.user)) 516 517 for participant in participants: 518 if not participant: 519 continue 520 participant = get_uri(participant) 521 rwrite(("ATTENDEE", {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}, participant)) 522 523 node = ("VEVENT", {}, record) 524 525 self.store.set_event(self.user, this_uid, node=node) 526 self.store.queue_request(self.user, this_uid) 527 528 # Redirect to the object (or the first of the objects), where instead of 529 # attendee controls, there will be organiser controls. 530 531 self.redirect(self.link_to("%s-0" % uid)) 532 533 def handle_request(self, uid, obj): 534 535 """ 536 Handle actions involving the given 'uid' and 'obj' object, returning an 537 error if one occurred, or None if the request was successfully handled. 538 """ 539 540 # Handle a submitted form. 541 542 args = self.env.get_args() 543 544 # Get the possible actions. 545 546 reply = args.has_key("reply") 547 discard = args.has_key("discard") 548 invite = args.has_key("invite") 549 cancel = args.has_key("cancel") 550 save = args.has_key("save") 551 552 have_action = reply or discard or invite or cancel or save 553 554 if not have_action: 555 return ["action"] 556 557 # Update the object. 558 559 if args.has_key("summary"): 560 obj["SUMMARY"] = [(args["summary"][0], {})] 561 562 organisers = uri_dict(obj.get_value_map("ORGANIZER")) 563 attendees = uri_dict(obj.get_value_map("ATTENDEE")) 564 565 if args.has_key("partstat"): 566 for d in attendees, organisers: 567 if d.has_key(self.user): 568 d[self.user]["PARTSTAT"] = args["partstat"][0] 569 if d[self.user].has_key("RSVP"): 570 del d[self.user]["RSVP"] 571 572 is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user 573 574 # Obtain the user's timezone and process datetime values. 575 576 update = False 577 578 if is_organiser: 579 dtend_enabled = args.get("dtend-control", [None])[0] == "enable" 580 dttimes_enabled = args.get("dttimes-control", [None])[0] == "enable" 581 582 t = self.handle_date_controls("dtstart", dttimes_enabled) 583 if t: 584 dtstart, attr = t 585 update = self.set_datetime_in_object(dtstart, attr.get("TZID"), "DTSTART", obj) or update 586 else: 587 return ["dtstart"] 588 589 # Handle specified end datetimes. 590 591 if dtend_enabled: 592 t = self.handle_date_controls("dtend", dttimes_enabled) 593 if t: 594 dtend, attr = t 595 596 # Convert end dates to iCalendar "next day" dates. 597 598 if not isinstance(dtend, datetime): 599 dtend += timedelta(1) 600 update = self.set_datetime_in_object(dtend, attr.get("TZID"), "DTEND", obj) or update 601 else: 602 return ["dtend"] 603 604 # Otherwise, treat the end date as the start date. Datetimes are 605 # handled by making the event occupy the rest of the day. 606 607 else: 608 dtend = dtstart + timedelta(1) 609 if isinstance(dtstart, datetime): 610 dtend = get_start_of_day(dtend, attr["TZID"]) 611 update = self.set_datetime_in_object(dtend, attr.get("TZID"), "DTEND", obj) or update 612 613 if dtstart >= dtend: 614 return ["dtstart", "dtend"] 615 616 # Obtain any participants to be added or removed. 617 618 removed = args.get("remove") 619 added = args.get("added") 620 621 # Process any action. 622 623 handled = True 624 625 if reply or invite or cancel: 626 627 handler = ManagerHandler(obj, self.user, self.messenger) 628 629 # Process the object and remove it from the list of requests. 630 631 if reply and handler.process_received_request(update) or \ 632 is_organiser and (invite or cancel) and \ 633 handler.process_created_request(invite and "REQUEST" or "CANCEL", update, removed, added): 634 635 self.remove_request(uid) 636 637 # Save single user events. 638 639 elif save: 640 self.store.set_event(self.user, uid, node=obj.to_node()) 641 self.update_freebusy(uid, None, obj=obj) 642 self.remove_request(uid) 643 644 # Remove the request and the object. 645 646 elif discard: 647 self.remove_from_freebusy(uid) 648 self.remove_event(uid) 649 self.remove_request(uid) 650 651 else: 652 handled = False 653 654 # Upon handling an action, redirect to the main page. 655 656 if handled: 657 self.redirect(self.env.get_path()) 658 659 return None 660 661 def handle_date_controls(self, name, with_time=True): 662 663 """ 664 Handle date control information for fields starting with 'name', 665 returning a (datetime, attr) tuple or None if the fields cannot be used 666 to construct a datetime object. 667 """ 668 669 args = self.env.get_args() 670 671 if args.has_key("%s-date" % name): 672 date = args["%s-date" % name][0] 673 674 if with_time: 675 hour = args.get("%s-hour" % name, [None])[0] 676 minute = args.get("%s-minute" % name, [None])[0] 677 second = args.get("%s-second" % name, [None])[0] 678 tzid = args.get("%s-tzid" % name, [self.get_tzid()])[0] 679 680 time = (hour or minute or second) and "T%s%s%s" % (hour, minute, second) or "" 681 value = "%s%s" % (date, time) 682 attr = {"TZID" : tzid, "VALUE" : "DATE-TIME"} 683 dt = get_datetime(value, attr) 684 else: 685 attr = {"VALUE" : "DATE"} 686 dt = get_datetime(date) 687 688 if dt: 689 return dt, attr 690 691 return None 692 693 def set_datetime_in_object(self, dt, tzid, property, obj): 694 695 """ 696 Set 'dt' and 'tzid' for the given 'property' in 'obj', returning whether 697 an update has occurred. 698 """ 699 700 if dt: 701 old_value = obj.get_value(property) 702 obj[property] = [get_datetime_item(dt, tzid)] 703 return format_datetime(dt) != old_value 704 705 return False 706 707 # Page fragment methods. 708 709 def show_request_controls(self, obj): 710 711 "Show form controls for a request concerning 'obj'." 712 713 page = self.page 714 args = self.env.get_args() 715 716 is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user 717 718 attendees = uri_values((obj.get_values("ATTENDEE") or []) + args.get("attendee", [])) 719 is_attendee = self.user in attendees 720 721 is_request = (obj.get_value("UID"), obj.get_value("RECURRENCE-ID")) in self._get_requests() 722 723 have_other_attendees = len(attendees) > (is_attendee and 1 or 0) 724 725 # Show appropriate options depending on the role of the user. 726 727 if is_attendee and not is_organiser: 728 page.p("An action is required for this request:") 729 730 page.p() 731 page.input(name="reply", type="submit", value="Reply") 732 page.add(" ") 733 page.input(name="discard", type="submit", value="Discard") 734 page.p.close() 735 736 if is_organiser: 737 if have_other_attendees: 738 page.p("As organiser, you can perform the following:") 739 740 page.p() 741 page.input(name="invite", type="submit", value="Invite") 742 page.add(" ") 743 if is_request: 744 page.input(name="discard", type="submit", value="Discard") 745 else: 746 page.input(name="cancel", type="submit", value="Cancel") 747 page.p.close() 748 else: 749 page.p("As attendee, you can perform the following:") 750 751 page.p() 752 page.input(name="save", type="submit", value="Save") 753 page.add(" ") 754 page.input(name="discard", type="submit", value="Discard") 755 page.p.close() 756 757 property_items = [ 758 ("SUMMARY", "Summary"), 759 ("DTSTART", "Start"), 760 ("DTEND", "End"), 761 ("ORGANIZER", "Organiser"), 762 ("ATTENDEE", "Attendee"), 763 ] 764 765 partstat_items = [ 766 ("NEEDS-ACTION", "Not confirmed"), 767 ("ACCEPTED", "Attending"), 768 ("TENTATIVE", "Tentatively attending"), 769 ("DECLINED", "Not attending"), 770 ("DELEGATED", "Delegated"), 771 (None, "Not indicated"), 772 ] 773 774 def show_object_on_page(self, uid, obj, error=None): 775 776 """ 777 Show the calendar object with the given 'uid' and representation 'obj' 778 on the current page. If 'error' is given, show a suitable message. 779 """ 780 781 page = self.page 782 page.form(method="POST") 783 784 # Obtain the user's timezone. 785 786 tzid = self.get_tzid() 787 788 # Provide controls to change the displayed object. 789 790 args = self.env.get_args() 791 792 # Add or remove new attendees. 793 # This does not affect the stored object. 794 795 existing_attendees = uri_values(obj.get_values("ATTENDEE") or []) 796 new_attendees = args.get("added", []) 797 new_attendee = args.get("attendee", [""])[0] 798 799 if args.has_key("add"): 800 if new_attendee.strip(): 801 new_attendee = get_uri(new_attendee.strip()) 802 if new_attendee not in new_attendees and new_attendee not in existing_attendees: 803 new_attendees.append(new_attendee) 804 new_attendee = "" 805 806 if args.has_key("removenew"): 807 removed_attendee = args["removenew"][0] 808 if removed_attendee in new_attendees: 809 new_attendees.remove(removed_attendee) 810 811 # Configure the start and end datetimes. 812 813 dtend_control = args.get("dtend-control", [None])[0] 814 dttimes_control = args.get("dttimes-control", [None])[0] 815 with_time = dttimes_control == "enable" 816 817 t = self.handle_date_controls("dtstart", with_time) 818 if t: 819 dtstart, dtstart_attr = t 820 else: 821 dtstart, dtstart_attr = obj.get_datetime_item("DTSTART") 822 823 if dtend_control == "enable": 824 t = self.handle_date_controls("dtend", with_time) 825 if t: 826 dtend, dtend_attr = t 827 else: 828 dtend, dtend_attr = None, {} 829 elif dtend_control == "disable": 830 dtend, dtend_attr = None, {} 831 else: 832 dtend, dtend_attr = obj.get_datetime_item("DTEND") 833 834 # Change end dates to refer to the actual dates, not the iCalendar 835 # "next day" dates. 836 837 if dtend and not isinstance(dtend, datetime): 838 dtend -= timedelta(1) 839 840 # Show the end datetime controls if already active or if an object needs 841 # them. 842 843 dtend_enabled = dtend_control == "enable" or isinstance(dtend, datetime) or dtstart != dtend 844 dttimes_enabled = dttimes_control == "enable" or isinstance(dtstart, datetime) or isinstance(dtend, datetime) 845 846 if dtend_enabled: 847 page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable", checked="checked") 848 page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable") 849 else: 850 page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable") 851 page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable", checked="checked") 852 853 if dttimes_enabled: 854 page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable", checked="checked") 855 page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable") 856 else: 857 page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable") 858 page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable", checked="checked") 859 860 # Provide a summary of the object. 861 862 page.table(class_="object", cellspacing=5, cellpadding=5) 863 page.thead() 864 page.tr() 865 page.th("Event", class_="mainheading", colspan=2) 866 page.tr.close() 867 page.thead.close() 868 page.tbody() 869 870 is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user 871 872 for name, label in self.property_items: 873 page.tr() 874 875 # Handle datetimes specially. 876 877 if name in ["DTSTART", "DTEND"]: 878 field = name.lower() 879 880 page.th(label, class_="objectheading %s%s" % (field, error and field in error and " error" or "")) 881 882 # Obtain the datetime. 883 884 if name == "DTSTART": 885 dt, attr, event_tzid = dtstart, dtstart_attr, dtstart_attr.get("TZID", tzid) 886 887 # Where no end datetime exists, use the start datetime as the 888 # basis of any potential datetime specified if dt-control is 889 # set. 890 891 else: 892 dt, attr, event_tzid = dtend or dtstart, dtend_attr or dtstart_attr, (dtend_attr or dtstart_attr).get("TZID", tzid) 893 894 # Show controls for editing as organiser. 895 896 if is_organiser: 897 value = format_datetime(dt) 898 899 page.td(class_="objectvalue %s" % field) 900 if name == "DTEND": 901 page.div(class_="dt disabled") 902 page.label("Specify end date", for_="dtend-enable", class_="enable") 903 page.div.close() 904 905 page.div(class_="dt enabled") 906 self._show_date_controls(field, value, attr, tzid) 907 if name == "DTSTART": 908 page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") 909 page.label("Specify dates only", for_="dttimes-disable", class_="time enabled disable") 910 elif name == "DTEND": 911 page.label("End on same day", for_="dtend-disable", class_="disable") 912 page.div.close() 913 914 page.td.close() 915 916 # Show a label as attendee. 917 918 else: 919 page.td(self.format_datetime(dt, "full")) 920 921 page.tr.close() 922 923 # Handle the summary specially. 924 925 elif name == "SUMMARY": 926 value = args.get("summary", [obj.get_value(name)])[0] 927 928 page.th(label, class_="objectheading") 929 page.td() 930 if is_organiser: 931 page.input(name="summary", type="text", value=value, size=80) 932 else: 933 page.add(value) 934 page.td.close() 935 page.tr.close() 936 937 # Handle potentially many values. 938 939 else: 940 items = obj.get_items(name) or [] 941 rowspan = len(items) 942 943 if name == "ATTENDEE": 944 rowspan += len(new_attendees) + 1 945 elif not items: 946 continue 947 948 page.th(label, class_="objectheading", rowspan=rowspan) 949 950 first = True 951 952 for i, (value, attr) in enumerate(items): 953 if not first: 954 page.tr() 955 else: 956 first = False 957 958 if name in ("ATTENDEE", "ORGANIZER"): 959 value = get_uri(value) 960 961 page.td(class_="objectvalue") 962 page.add(value) 963 page.add(" ") 964 965 partstat = attr.get("PARTSTAT") 966 if value == self.user and (not is_organiser or name == "ORGANIZER"): 967 self._show_menu("partstat", partstat, self.partstat_items, "partstat") 968 else: 969 page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat") 970 971 if is_organiser and name == "ATTENDEE": 972 if value in args.get("remove", []): 973 page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove", checked="checked") 974 else: 975 page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove") 976 page.label("Remove", for_="remove-%d" % i, class_="remove") 977 page.label("Uninvited", for_="remove-%d" % i, class_="removed") 978 979 else: 980 page.td(class_="objectvalue") 981 page.add(value) 982 983 page.td.close() 984 page.tr.close() 985 986 # Allow more attendees to be specified. 987 988 if is_organiser and name == "ATTENDEE": 989 for i, attendee in enumerate(new_attendees): 990 if not first: 991 page.tr() 992 else: 993 first = False 994 995 page.td() 996 page.input(name="added", type="value", value=attendee) 997 page.input(name="removenew", type="submit", value=attendee, id="removenew-%d" % i, class_="remove") 998 page.label("Remove", for_="removenew-%d" % i, class_="remove") 999 page.td.close() 1000 page.tr.close() 1001 1002 if not first: 1003 page.tr() 1004 1005 page.td() 1006 page.input(name="attendee", type="value", value=new_attendee) 1007 page.input(name="add", type="submit", value="add", id="add-%d" % i, class_="add") 1008 page.label("Add", for_="add-%d" % i, class_="add") 1009 page.td.close() 1010 page.tr.close() 1011 1012 page.tbody.close() 1013 page.table.close() 1014 1015 self.show_recurrences(obj) 1016 self.show_conflicting_events(uid, obj) 1017 self.show_request_controls(obj) 1018 1019 page.form.close() 1020 1021 def show_recurrences(self, obj): 1022 1023 "Show recurrences for the object having the given representation 'obj'." 1024 1025 page = self.page 1026 1027 # Obtain any parent object if this object is a specific recurrence. 1028 1029 recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) 1030 1031 if recurrenceid: 1032 obj = self._get_object(obj.get_value("UID")) 1033 if not obj: 1034 return 1035 1036 page.p("This event modifies a recurring event.") 1037 1038 # Obtain the user's timezone. 1039 1040 tzid = self.get_tzid() 1041 1042 window_size = 100 1043 1044 periods = obj.get_periods(self.get_tzid(), window_size) 1045 1046 if len(periods) == 1: 1047 return 1048 1049 page.p("This event occurs on the following occasions within the next %d days:" % window_size) 1050 1051 page.table(cellspacing=5, cellpadding=5, class_="conflicts") 1052 page.thead() 1053 page.tr() 1054 page.th("Start") 1055 page.th("End") 1056 page.tr.close() 1057 page.thead.close() 1058 page.tbody() 1059 1060 for start, end in periods: 1061 start_utc = format_datetime(to_timezone(start, "UTC")) 1062 css = recurrenceid and start_utc == recurrenceid and "replaced" or "" 1063 1064 page.tr() 1065 page.td(self.format_datetime(start, "long"), class_=css) 1066 page.td(self.format_datetime(end, "long"), class_=css) 1067 page.tr.close() 1068 1069 page.tbody.close() 1070 page.table.close() 1071 1072 def show_conflicting_events(self, uid, obj): 1073 1074 """ 1075 Show conflicting events for the object having the given 'uid' and 1076 representation 'obj'. 1077 """ 1078 1079 page = self.page 1080 1081 # Obtain the user's timezone. 1082 1083 tzid = self.get_tzid() 1084 1085 dtstart = format_datetime(obj.get_utc_datetime("DTSTART")) 1086 dtend = format_datetime(obj.get_utc_datetime("DTEND")) 1087 1088 # Indicate whether there are conflicting events. 1089 1090 freebusy = self.store.get_freebusy(self.user) 1091 1092 if freebusy: 1093 1094 # Obtain any time zone details from the suggested event. 1095 1096 _dtstart, attr = obj.get_item("DTSTART") 1097 tzid = attr.get("TZID", tzid) 1098 1099 # Show any conflicts. 1100 1101 conflicts = [t for t in have_conflict(freebusy, [(dtstart, dtend)], True) if t[2] != uid] 1102 1103 if conflicts: 1104 page.p("This event conflicts with others:") 1105 1106 page.table(cellspacing=5, cellpadding=5, class_="conflicts") 1107 page.thead() 1108 page.tr() 1109 page.th("Event") 1110 page.th("Start") 1111 page.th("End") 1112 page.tr.close() 1113 page.thead.close() 1114 page.tbody() 1115 1116 for t in conflicts: 1117 start, end, found_uid, transp, found_recurrenceid = t[:5] 1118 1119 # Provide details of any conflicting event. 1120 1121 start = self.format_datetime(to_timezone(get_datetime(start), tzid), "long") 1122 end = self.format_datetime(to_timezone(get_datetime(end), tzid), "long") 1123 1124 page.tr() 1125 1126 # Show the event summary for the conflicting event. 1127 1128 page.td() 1129 1130 found_obj = self._get_object(found_uid, found_recurrenceid) 1131 if found_obj: 1132 page.a(found_obj.get_value("SUMMARY"), href=self.link_to(found_uid)) 1133 else: 1134 page.add("No details available") 1135 1136 page.td.close() 1137 1138 page.td(start) 1139 page.td(end) 1140 1141 page.tr.close() 1142 1143 page.tbody.close() 1144 page.table.close() 1145 1146 def show_requests_on_page(self): 1147 1148 "Show requests for the current user." 1149 1150 # NOTE: This list could be more informative, but it is envisaged that 1151 # NOTE: the requests would be visited directly anyway. 1152 1153 requests = self._get_requests() 1154 1155 self.page.div(id="pending-requests") 1156 1157 if requests: 1158 self.page.p("Pending requests:") 1159 1160 self.page.ul() 1161 1162 for uid, recurrenceid in requests: 1163 obj = self._get_object(uid, recurrenceid) 1164 if obj: 1165 self.page.li() 1166 self.page.a(obj.get_value("SUMMARY"), href="#request-%s-%s" % (uid, recurrenceid or "")) 1167 self.page.li.close() 1168 1169 self.page.ul.close() 1170 1171 else: 1172 self.page.p("There are no pending requests.") 1173 1174 self.page.div.close() 1175 1176 def show_participants_on_page(self): 1177 1178 "Show participants for scheduling purposes." 1179 1180 args = self.env.get_args() 1181 participants = args.get("participants", []) 1182 1183 try: 1184 for name, value in args.items(): 1185 if name.startswith("remove-participant-"): 1186 i = int(name[len("remove-participant-"):]) 1187 del participants[i] 1188 break 1189 except ValueError: 1190 pass 1191 1192 # Trim empty participants. 1193 1194 while participants and not participants[-1].strip(): 1195 participants.pop() 1196 1197 # Show any specified participants together with controls to remove and 1198 # add participants. 1199 1200 self.page.div(id="participants") 1201 1202 self.page.p("Participants for scheduling:") 1203 1204 for i, participant in enumerate(participants): 1205 self.page.p() 1206 self.page.input(name="participants", type="text", value=participant) 1207 self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove") 1208 self.page.p.close() 1209 1210 self.page.p() 1211 self.page.input(name="participants", type="text") 1212 self.page.input(name="add-participant", type="submit", value="Add") 1213 self.page.p.close() 1214 1215 self.page.div.close() 1216 1217 return participants 1218 1219 # Full page output methods. 1220 1221 def show_object(self, path_info): 1222 1223 "Show an object request using the given 'path_info' for the current user." 1224 1225 uid, recurrenceid = self._get_identifiers(path_info) 1226 obj = self._get_object(uid, recurrenceid) 1227 1228 if not obj: 1229 return False 1230 1231 error = self.handle_request(uid, obj) 1232 1233 if not error: 1234 return True 1235 1236 self.new_page(title="Event") 1237 self.show_object_on_page(uid, obj, error) 1238 1239 return True 1240 1241 def show_calendar(self): 1242 1243 "Show the calendar for the current user." 1244 1245 handled = self.handle_newevent() 1246 1247 self.new_page(title="Calendar") 1248 page = self.page 1249 1250 # Form controls are used in various places on the calendar page. 1251 1252 page.form(method="POST") 1253 1254 self.show_requests_on_page() 1255 participants = self.show_participants_on_page() 1256 1257 # Show a button for scheduling a new event. 1258 1259 page.p(class_="controls") 1260 page.input(name="newevent", type="submit", value="New event", id="newevent", accesskey="N") 1261 page.input(name="reset", type="submit", value="Clear selections", id="reset") 1262 page.p.close() 1263 1264 # Show controls for hiding empty days and busy slots. 1265 # The positioning of the control, paragraph and table are important here. 1266 1267 page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D") 1268 page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B") 1269 1270 page.p(class_="controls") 1271 page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable") 1272 page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable") 1273 page.label("Show empty days", for_="showdays", class_="showdays disable") 1274 page.label("Hide empty days", for_="showdays", class_="showdays enable") 1275 page.p.close() 1276 1277 freebusy = self.store.get_freebusy(self.user) 1278 1279 if not freebusy: 1280 page.p("No events scheduled.") 1281 return 1282 1283 # Obtain the user's timezone. 1284 1285 tzid = self.get_tzid() 1286 1287 # Day view: start at the earliest known day and produce days until the 1288 # latest known day, perhaps with expandable sections of empty days. 1289 1290 # Month view: start at the earliest known month and produce months until 1291 # the latest known month, perhaps with expandable sections of empty 1292 # months. 1293 1294 # Details of users to invite to new events could be superimposed on the 1295 # calendar. 1296 1297 # Requests are listed and linked to their tentative positions in the 1298 # calendar. Other participants are also shown. 1299 1300 request_summary = self._get_request_summary() 1301 1302 period_groups = [request_summary, freebusy] 1303 period_group_types = ["request", "freebusy"] 1304 period_group_sources = ["Pending requests", "Your schedule"] 1305 1306 for i, participant in enumerate(participants): 1307 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 1308 period_group_types.append("freebusy-part%d" % i) 1309 period_group_sources.append(participant) 1310 1311 groups = [] 1312 group_columns = [] 1313 group_types = period_group_types 1314 group_sources = period_group_sources 1315 all_points = set() 1316 1317 # Obtain time point information for each group of periods. 1318 1319 for periods in period_groups: 1320 periods = convert_periods(periods, tzid) 1321 1322 # Get the time scale with start and end points. 1323 1324 scale = get_scale(periods) 1325 1326 # Get the time slots for the periods. 1327 1328 slots = get_slots(scale) 1329 1330 # Add start of day time points for multi-day periods. 1331 1332 add_day_start_points(slots, tzid) 1333 1334 # Record the slots and all time points employed. 1335 1336 groups.append(slots) 1337 all_points.update([point for point, active in slots]) 1338 1339 # Partition the groups into days. 1340 1341 days = {} 1342 partitioned_groups = [] 1343 partitioned_group_types = [] 1344 partitioned_group_sources = [] 1345 1346 for slots, group_type, group_source in zip(groups, group_types, group_sources): 1347 1348 # Propagate time points to all groups of time slots. 1349 1350 add_slots(slots, all_points) 1351 1352 # Count the number of columns employed by the group. 1353 1354 columns = 0 1355 1356 # Partition the time slots by day. 1357 1358 partitioned = {} 1359 1360 for day, day_slots in partition_by_day(slots).items(): 1361 intervals = [] 1362 last = None 1363 1364 for point, active in day_slots: 1365 columns = max(columns, len(active)) 1366 if last: 1367 intervals.append((last, point)) 1368 last = point 1369 1370 if last: 1371 intervals.append((last, None)) 1372 1373 if not days.has_key(day): 1374 days[day] = set() 1375 1376 # Convert each partition to a mapping from points to active 1377 # periods. 1378 1379 partitioned[day] = dict(day_slots) 1380 1381 # Record the divisions or intervals within each day. 1382 1383 days[day].update(intervals) 1384 1385 if group_type != "request" or columns: 1386 group_columns.append(columns) 1387 partitioned_groups.append(partitioned) 1388 partitioned_group_types.append(group_type) 1389 partitioned_group_sources.append(group_source) 1390 1391 # Add empty days. 1392 1393 add_empty_days(days, tzid) 1394 1395 # Show the controls permitting day selection. 1396 1397 self.show_calendar_day_controls(days) 1398 1399 # Show the calendar itself. 1400 1401 page.table(cellspacing=5, cellpadding=5, class_="calendar") 1402 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 1403 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 1404 page.table.close() 1405 1406 # End the form region. 1407 1408 page.form.close() 1409 1410 # More page fragment methods. 1411 1412 def show_calendar_day_controls(self, days): 1413 1414 "Show controls for the given 'days' in the calendar." 1415 1416 page = self.page 1417 slots = self.env.get_args().get("slot", []) 1418 1419 for day in days: 1420 value, identifier = self._day_value_and_identifier(day) 1421 self._slot_selector(value, identifier, slots) 1422 1423 # Generate a dynamic stylesheet to allow day selections to colour 1424 # specific days. 1425 # NOTE: The style details need to be coordinated with the static 1426 # NOTE: stylesheet. 1427 1428 page.style(type="text/css") 1429 1430 for day in days: 1431 daystr = format_datetime(day) 1432 page.add("""\ 1433 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s, 1434 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s { 1435 background-color: #5f4; 1436 text-decoration: underline; 1437 } 1438 """ % (daystr, daystr, daystr, daystr)) 1439 1440 page.style.close() 1441 1442 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 1443 1444 """ 1445 Show headings for the participants and other scheduling contributors, 1446 defined by 'group_types', 'group_sources' and 'group_columns'. 1447 """ 1448 1449 page = self.page 1450 1451 page.colgroup(span=1, id="columns-timeslot") 1452 1453 for group_type, columns in zip(group_types, group_columns): 1454 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 1455 1456 page.thead() 1457 page.tr() 1458 page.th("", class_="emptyheading") 1459 1460 for group_type, source, columns in zip(group_types, group_sources, group_columns): 1461 page.th(source, 1462 class_=(group_type == "request" and "requestheading" or "participantheading"), 1463 colspan=max(columns, 1)) 1464 1465 page.tr.close() 1466 page.thead.close() 1467 1468 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 1469 1470 """ 1471 Show calendar days, defined by a collection of 'days', the contributing 1472 period information as 'partitioned_groups' (partitioned by day), the 1473 'partitioned_group_types' indicating the kind of contribution involved, 1474 and the 'group_columns' defining the number of columns in each group. 1475 """ 1476 1477 page = self.page 1478 1479 # Determine the number of columns required. Where participants provide 1480 # no columns for events, one still needs to be provided for the 1481 # participant itself. 1482 1483 all_columns = sum([max(columns, 1) for columns in group_columns]) 1484 1485 # Determine the days providing time slots. 1486 1487 all_days = days.items() 1488 all_days.sort() 1489 1490 # Produce a heading and time points for each day. 1491 1492 for day, intervals in all_days: 1493 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 1494 is_empty = True 1495 1496 for slots in groups_for_day: 1497 if not slots: 1498 continue 1499 1500 for active in slots.values(): 1501 if active: 1502 is_empty = False 1503 break 1504 1505 page.thead(class_="separator%s" % (is_empty and " empty" or "")) 1506 page.tr() 1507 page.th(class_="dayheading container", colspan=all_columns+1) 1508 self._day_heading(day) 1509 page.th.close() 1510 page.tr.close() 1511 page.thead.close() 1512 1513 page.tbody(class_="points%s" % (is_empty and " empty" or "")) 1514 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 1515 page.tbody.close() 1516 1517 def show_calendar_points(self, intervals, groups, group_types, group_columns): 1518 1519 """ 1520 Show the time 'intervals' along with period information from the given 1521 'groups', having the indicated 'group_types', each with the number of 1522 columns given by 'group_columns'. 1523 """ 1524 1525 page = self.page 1526 1527 # Obtain the user's timezone. 1528 1529 tzid = self.get_tzid() 1530 1531 # Produce a row for each interval. 1532 1533 intervals = list(intervals) 1534 intervals.sort() 1535 1536 for point, endpoint in intervals: 1537 continuation = point == get_start_of_day(point, tzid) 1538 1539 # Some rows contain no period details and are marked as such. 1540 1541 have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None) 1542 1543 css = " ".join( 1544 ["slot"] + 1545 (have_active and ["busy"] or ["empty"]) + 1546 (continuation and ["daystart"] or []) 1547 ) 1548 1549 page.tr(class_=css) 1550 page.th(class_="timeslot") 1551 self._time_point(point, endpoint) 1552 page.th.close() 1553 1554 # Obtain slots for the time point from each group. 1555 1556 for columns, slots, group_type in zip(group_columns, groups, group_types): 1557 active = slots and slots.get(point) 1558 1559 # Where no periods exist for the given time interval, generate 1560 # an empty cell. Where a participant provides no periods at all, 1561 # the colspan is adjusted to be 1, not 0. 1562 1563 if not active: 1564 page.td(class_="empty container", colspan=max(columns, 1)) 1565 self._empty_slot(point, endpoint) 1566 page.td.close() 1567 continue 1568 1569 slots = slots.items() 1570 slots.sort() 1571 spans = get_spans(slots) 1572 1573 empty = 0 1574 1575 # Show a column for each active period. 1576 1577 for t in active: 1578 if t and len(t) >= 2: 1579 1580 # Flush empty slots preceding this one. 1581 1582 if empty: 1583 page.td(class_="empty container", colspan=empty) 1584 self._empty_slot(point, endpoint) 1585 page.td.close() 1586 empty = 0 1587 1588 start, end, uid, recurrenceid, key = get_freebusy_details(t) 1589 span = spans[key] 1590 1591 # Produce a table cell only at the start of the period 1592 # or when continued at the start of a day. 1593 1594 if point == start or continuation: 1595 1596 obj = self._get_object(uid, recurrenceid) 1597 1598 has_continued = continuation and point != start 1599 will_continue = not ends_on_same_day(point, end, tzid) 1600 is_organiser = obj and get_uri(obj.get_value("ORGANIZER")) == self.user 1601 1602 css = " ".join( 1603 ["event"] + 1604 (has_continued and ["continued"] or []) + 1605 (will_continue and ["continues"] or []) + 1606 (is_organiser and ["organising"] or ["attending"]) 1607 ) 1608 1609 # Only anchor the first cell of events. 1610 # NOTE: Need to only anchor the first period for a 1611 # NOTE: recurring event. 1612 1613 if point == start: 1614 page.td(class_=css, rowspan=span, id="%s-%s-%s" % (group_type, uid, recurrenceid or "")) 1615 else: 1616 page.td(class_=css, rowspan=span) 1617 1618 if not obj: 1619 page.span("(Participant is busy)") 1620 else: 1621 summary = obj.get_value("SUMMARY") 1622 1623 # Only link to events if they are not being 1624 # updated by requests. 1625 1626 if (uid, recurrenceid) in self._get_requests() and group_type != "request": 1627 page.span(summary) 1628 else: 1629 page.a(summary, href=self.link_to(uid, recurrenceid)) 1630 1631 page.td.close() 1632 else: 1633 empty += 1 1634 1635 # Pad with empty columns. 1636 1637 empty = columns - len(active) 1638 1639 if empty: 1640 page.td(class_="empty container", colspan=empty) 1641 self._empty_slot(point, endpoint) 1642 page.td.close() 1643 1644 page.tr.close() 1645 1646 def _day_heading(self, day): 1647 1648 """ 1649 Generate a heading for 'day' of the following form: 1650 1651 <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label> 1652 """ 1653 1654 page = self.page 1655 daystr = format_datetime(day) 1656 value, identifier = self._day_value_and_identifier(day) 1657 page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier) 1658 1659 def _time_point(self, point, endpoint): 1660 1661 """ 1662 Generate headings for the 'point' to 'endpoint' period of the following 1663 form: 1664 1665 <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label> 1666 <span class="endpoint">10:00:00 CET</span> 1667 """ 1668 1669 page = self.page 1670 tzid = self.get_tzid() 1671 daystr = format_datetime(point.date()) 1672 value, identifier = self._slot_value_and_identifier(point, endpoint) 1673 slots = self.env.get_args().get("slot", []) 1674 self._slot_selector(value, identifier, slots) 1675 page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) 1676 page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint") 1677 1678 def _slot_selector(self, value, identifier, slots): 1679 reset = self.env.get_args().has_key("reset") 1680 page = self.page 1681 if not reset and value in slots: 1682 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") 1683 else: 1684 page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") 1685 1686 def _empty_slot(self, point, endpoint): 1687 page = self.page 1688 value, identifier = self._slot_value_and_identifier(point, endpoint) 1689 page.label("Select/deselect period", class_="newevent popup", for_=identifier) 1690 1691 def _day_value_and_identifier(self, day): 1692 value = "%s-" % format_datetime(day) 1693 identifier = "day-%s" % value 1694 return value, identifier 1695 1696 def _slot_value_and_identifier(self, point, endpoint): 1697 value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "") 1698 identifier = "slot-%s" % value 1699 return value, identifier 1700 1701 def _show_menu(self, name, default, items, class_=""): 1702 page = self.page 1703 values = self.env.get_args().get(name, [default]) 1704 page.select(name=name, class_=class_) 1705 for v, label in items: 1706 if v is None: 1707 continue 1708 if v in values: 1709 page.option(label, value=v, selected="selected") 1710 else: 1711 page.option(label, value=v) 1712 page.select.close() 1713 1714 def _show_date_controls(self, name, default, attr, tzid): 1715 1716 """ 1717 Show date controls for a field with the given 'name' and 'default' value 1718 and 'attr', with the given 'tzid' being used if no other time regime 1719 information is provided. 1720 """ 1721 1722 page = self.page 1723 args = self.env.get_args() 1724 1725 event_tzid = attr.get("TZID", tzid) 1726 dt = get_datetime(default, attr) 1727 1728 # Show dates for up to one week around the current date. 1729 1730 base = get_date(dt) 1731 items = [] 1732 for i in range(-7, 8): 1733 d = base + timedelta(i) 1734 items.append((format_datetime(d), self.format_date(d, "full"))) 1735 1736 self._show_menu("%s-date" % name, format_datetime(base), items) 1737 1738 # Show time details. 1739 1740 dt_time = isinstance(dt, datetime) and dt or None 1741 hour = args.get("%s-hour" % name, "%02d" % (dt_time and dt_time.hour or 0)) 1742 minute = args.get("%s-minute" % name, "%02d" % (dt_time and dt_time.minute or 0)) 1743 second = args.get("%s-second" % name, "%02d" % (dt_time and dt_time.second or 0)) 1744 1745 page.span(class_="time enabled") 1746 page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2) 1747 page.add(":") 1748 page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2) 1749 page.add(":") 1750 page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2) 1751 page.add(" ") 1752 self._show_menu("%s-tzid" % name, event_tzid, 1753 [(event_tzid, event_tzid)] + ( 1754 event_tzid != tzid and [(tzid, tzid)] or [] 1755 )) 1756 page.span.close() 1757 1758 # Incoming HTTP request direction. 1759 1760 def select_action(self): 1761 1762 "Select the desired action and show the result." 1763 1764 path_info = self.env.get_path_info().strip("/") 1765 1766 if not path_info: 1767 self.show_calendar() 1768 elif self.show_object(path_info): 1769 pass 1770 else: 1771 self.no_page() 1772 1773 def __call__(self): 1774 1775 "Interpret a request and show an appropriate response." 1776 1777 if not self.user: 1778 self.no_user() 1779 else: 1780 self.select_action() 1781 1782 # Write the headers and actual content. 1783 1784 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 1785 print >>self.out 1786 self.out.write(unicode(self.page).encode(self.encoding)) 1787 1788 if __name__ == "__main__": 1789 Manager()() 1790 1791 # vim: tabstop=4 expandtab shiftwidth=4