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 datetime 28 import babel.dates 29 import cgi, os, sys 30 31 sys.path.append(LIBRARY_PATH) 32 33 from imiptools.content import Handler, get_address, \ 34 get_item, get_uri, get_utc_datetime, get_value, \ 35 get_value_map, get_values, parse_object, to_part 36 from imiptools.dates import format_datetime, get_datetime, get_start_of_day, \ 37 get_end_of_day, ends_on_same_day, to_timezone 38 from imiptools.mail import Messenger 39 from imiptools.period import add_day_start_points, add_slots, convert_periods, \ 40 get_freebusy_details, \ 41 get_scale, have_conflict, get_slots, get_spans, \ 42 partition_by_day 43 from imiptools.profile import Preferences 44 from vCalendar import to_node 45 import markup 46 import imip_store 47 48 getenv = os.environ.get 49 setenv = os.environ.__setitem__ 50 51 class CGIEnvironment: 52 53 "A CGI-compatible environment." 54 55 def __init__(self): 56 self.args = None 57 self.method = None 58 self.path = None 59 self.path_info = None 60 self.user = None 61 62 def get_args(self): 63 if self.args is None: 64 if self.get_method() != "POST": 65 setenv("QUERY_STRING", "") 66 self.args = cgi.parse(keep_blank_values=True) 67 return self.args 68 69 def get_method(self): 70 if self.method is None: 71 self.method = getenv("REQUEST_METHOD") or "GET" 72 return self.method 73 74 def get_path(self): 75 if self.path is None: 76 self.path = getenv("SCRIPT_NAME") or "" 77 return self.path 78 79 def get_path_info(self): 80 if self.path_info is None: 81 self.path_info = getenv("PATH_INFO") or "" 82 return self.path_info 83 84 def get_user(self): 85 if self.user is None: 86 self.user = getenv("REMOTE_USER") or "" 87 return self.user 88 89 def get_output(self): 90 return sys.stdout 91 92 def get_url(self): 93 path = self.get_path() 94 path_info = self.get_path_info() 95 return "%s%s" % (path.rstrip("/"), path_info) 96 97 def new_url(self, path_info): 98 path = self.get_path() 99 return "%s/%s" % (path.rstrip("/"), path_info.lstrip("/")) 100 101 class ManagerHandler(Handler): 102 103 """ 104 A content handler for use by the manager, as opposed to operating within the 105 mail processing pipeline. 106 """ 107 108 def __init__(self, obj, user, messenger): 109 details, details_attr = obj.values()[0] 110 Handler.__init__(self, details) 111 self.obj = obj 112 self.user = user 113 self.messenger = messenger 114 115 self.organiser = self.get_value("ORGANIZER") 116 self.attendees = self.get_values("ATTENDEE") 117 118 # Communication methods. 119 120 def send_message(self, method, sender): 121 122 """ 123 Create a full calendar object employing the given 'method', and send it 124 to the appropriate recipients, also sending a copy to the 'sender'. 125 """ 126 127 node = to_node(self.obj) 128 part = to_part(method, [node]) 129 130 if self.user == self.organiser: 131 recipients = map(get_address, self.attendees) 132 else: 133 recipients = [get_address(self.organiser)] 134 135 message = self.messenger.make_outgoing_message([part], recipients, outgoing_bcc=sender) 136 self.messenger.sendmail(recipients, message.as_string(), outgoing_bcc=sender) 137 138 # Action methods. 139 140 def process_received_request(self, accept, update=False): 141 142 """ 143 Process the current request for the given 'user', accepting any request 144 when 'accept' is true, declining requests otherwise. Return whether any 145 action was taken. 146 147 If 'update' is given, the sequence number will be incremented in order 148 to override any previous response. 149 """ 150 151 # When accepting or declining, do so only on behalf of this user, 152 # preserving any other attributes set as an attendee. 153 154 for attendee, attendee_attr in self.get_items("ATTENDEE"): 155 156 if attendee == self.user: 157 attendee_attr["PARTSTAT"] = accept and "ACCEPTED" or "DECLINED" 158 if self.messenger and self.messenger.sender != get_address(attendee): 159 attendee_attr["SENT-BY"] = get_uri(self.messenger.sender) 160 self.details["ATTENDEE"] = [(attendee, attendee_attr)] 161 if update: 162 sequence = self.get_value("SEQUENCE") or "0" 163 self.details["SEQUENCE"] = [(str(int(sequence) + 1), {})] 164 self.update_dtstamp() 165 166 self.send_message("REPLY", get_address(attendee)) 167 168 return True 169 170 return False 171 172 def process_created_request(self, update=False): 173 174 """ 175 Process the current request for the given 'user', sending a created 176 request to attendees. Return whether any action was taken. 177 178 If 'update' is given, the sequence number will be incremented in order 179 to override any previous message. 180 """ 181 182 if update: 183 sequence = self.get_value("SEQUENCE") or "0" 184 self.details["SEQUENCE"] = [(str(int(sequence) + 1), {})] 185 self.update_dtstamp() 186 187 self.send_message("REQUEST", get_address(self.organiser)) 188 189 return True 190 191 class Manager: 192 193 "A simple manager application." 194 195 def __init__(self, messenger=None): 196 self.messenger = messenger or Messenger() 197 198 self.env = CGIEnvironment() 199 user = self.env.get_user() 200 self.user = user and get_uri(user) or None 201 self.preferences = None 202 self.locale = None 203 self.requests = None 204 205 self.out = self.env.get_output() 206 self.page = markup.page() 207 self.encoding = "utf-8" 208 209 self.store = imip_store.FileStore() 210 self.objects = {} 211 212 try: 213 self.publisher = imip_store.FilePublisher() 214 except OSError: 215 self.publisher = None 216 217 def _get_uid(self, path_info): 218 return path_info.lstrip("/").split("/", 1)[0] 219 220 def _get_object(self, uid): 221 if self.objects.has_key(uid): 222 return self.objects[uid] 223 224 f = uid and self.store.get_event(self.user, uid) or None 225 226 if not f: 227 return None 228 229 self.objects[uid] = obj = parse_object(f, "utf-8") 230 231 if not obj: 232 return None 233 234 return obj 235 236 def _get_details(self, obj): 237 details, details_attr = obj.values()[0] 238 return details 239 240 def _get_requests(self): 241 if self.requests is None: 242 self.requests = self.store.get_requests(self.user) 243 return self.requests 244 245 def _get_request_summary(self): 246 summary = [] 247 for uid in self._get_requests(): 248 obj = self._get_object(uid) 249 if obj: 250 details = self._get_details(obj) 251 summary.append(( 252 get_value(details, "DTSTART"), 253 get_value(details, "DTEND"), 254 uid 255 )) 256 return summary 257 258 # Preference methods. 259 260 def get_user_locale(self): 261 if not self.locale: 262 self.locale = self.get_preferences().get("LANG", "C") 263 return self.locale 264 265 def get_preferences(self): 266 if not self.preferences: 267 self.preferences = Preferences(self.user) 268 return self.preferences 269 270 # Prettyprinting of dates and times. 271 272 def format_date(self, dt, format): 273 return self._format_datetime(babel.dates.format_date, dt, format) 274 275 def format_time(self, dt, format): 276 return self._format_datetime(babel.dates.format_time, dt, format) 277 278 def format_datetime(self, dt, format): 279 return self._format_datetime(babel.dates.format_datetime, dt, format) 280 281 def _format_datetime(self, fn, dt, format): 282 return fn(dt, format=format, locale=self.get_user_locale()) 283 284 # Data management methods. 285 286 def remove_request(self, uid): 287 return self.store.dequeue_request(self.user, uid) 288 289 # Presentation methods. 290 291 def new_page(self, title): 292 self.page.init(title=title, charset=self.encoding, css=self.env.new_url("styles.css")) 293 294 def status(self, code, message): 295 self.header("Status", "%s %s" % (code, message)) 296 297 def header(self, header, value): 298 print >>self.out, "%s: %s" % (header, value) 299 300 def no_user(self): 301 self.status(403, "Forbidden") 302 self.new_page(title="Forbidden") 303 self.page.p("You are not logged in and thus cannot access scheduling requests.") 304 305 def no_page(self): 306 self.status(404, "Not Found") 307 self.new_page(title="Not Found") 308 self.page.p("No page is provided at the given address.") 309 310 def redirect(self, url): 311 self.status(302, "Redirect") 312 self.header("Location", url) 313 self.new_page(title="Redirect") 314 self.page.p("Redirecting to: %s" % url) 315 316 # Request logic and page fragment methods. 317 318 def handle_newevent(self): 319 320 """ 321 Handle any new event operation, creating a new event and redirecting to 322 the event page for further activity. 323 """ 324 325 # Handle a submitted form. 326 327 args = self.env.get_args() 328 329 if not args.has_key("newevent"): 330 return 331 332 # Create a new event using the available information. 333 334 slot = args.get("slot", [None])[0] 335 participants = args.get("participants", []) 336 337 if not slot: 338 return 339 340 start, end = slot.split("-") 341 342 # Obtain the user's timezone. 343 344 prefs = self.get_preferences() 345 tzid = prefs.get("TZID", "UTC") 346 347 # Invent a unique identifier. 348 349 utcnow = format_datetime(to_timezone(datetime.utcnow(), "UTC")) 350 uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) 351 352 # Create a calendar object and store it as a request. 353 354 record = [] 355 rwrite = record.append 356 357 rwrite(("UID", {}, uid)) 358 rwrite(("SUMMARY", {}, "New event at %s" % utcnow)) 359 rwrite(("DTSTAMP", {}, utcnow)) 360 rwrite(("DTSTART", {"VALUE" : "DATE-TIME", "TZID" : tzid}, start)) 361 rwrite(("DTEND", {"VALUE" : "DATE-TIME", "TZID" : tzid}, end or 362 format_datetime(get_end_of_day(get_datetime(start, {"TZID" : tzid}))) 363 )) 364 rwrite(("ORGANIZER", {}, self.user)) 365 366 for participant in participants: 367 if not participant: 368 continue 369 participant = get_uri(participant) 370 if participant != self.user: 371 rwrite(("ATTENDEE", {}, participant)) 372 373 obj = ("VEVENT", {}, record) 374 375 self.store.set_event(self.user, uid, obj) 376 self.store.queue_request(self.user, uid) 377 378 # Redirect to the object, where instead of attendee controls, 379 # there will be organiser controls. 380 381 self.redirect(self.env.new_url(uid)) 382 383 def handle_request(self, uid, request, queued): 384 385 """ 386 Handle actions involving the given 'uid' and 'request' object, where 387 'queued' indicates that the object has not yet been handled. 388 """ 389 390 # Handle a submitted form. 391 392 args = self.env.get_args() 393 handled = True 394 395 accept = args.has_key("accept") 396 decline = args.has_key("decline") 397 invite = args.has_key("invite") 398 update = not queued and args.has_key("update") 399 400 if accept or decline or invite: 401 402 handler = ManagerHandler(request, self.user, self.messenger) 403 404 # Process the request and remove it from the list. 405 406 if (accept or decline) and handler.process_received_request(accept, update) or \ 407 invite and handler.process_created_request(update): 408 409 self.remove_request(uid) 410 411 elif args.has_key("discard"): 412 413 # Remove the request from the list. 414 415 self.remove_request(uid) 416 417 else: 418 handled = False 419 420 if handled: 421 self.redirect(self.env.get_path()) 422 423 return handled 424 425 def show_request_form(self, obj, needs_action): 426 427 """ 428 Show a form for a request concerning 'obj', indicating whether action is 429 needed if 'needs_action' is specified as a true value. 430 """ 431 432 details = self._get_details(obj) 433 434 is_organiser = get_value(details, "ORGANIZER") == self.user 435 436 if not is_organiser: 437 attendees = get_value_map(details, "ATTENDEE") 438 attendee_attr = attendees.get(self.user) 439 440 if attendee_attr: 441 partstat = attendee_attr.get("PARTSTAT") 442 if partstat == "ACCEPTED": 443 self.page.p("This request has been accepted.") 444 elif partstat == "DECLINED": 445 self.page.p("This request has been declined.") 446 else: 447 self.page.p("This request has not yet been dealt with.") 448 449 if needs_action: 450 self.page.p("An action is required for this request:") 451 else: 452 self.page.p("This request can be updated as follows:") 453 454 self.page.form(method="POST") 455 self.page.p() 456 457 # Show appropriate options depending on the role of the user. 458 459 if is_organiser: 460 self.page.input(name="invite", type="submit", value="Invite") 461 else: 462 self.page.input(name="accept", type="submit", value="Accept") 463 self.page.add(" ") 464 self.page.input(name="decline", type="submit", value="Decline") 465 466 self.page.add(" ") 467 self.page.input(name="discard", type="submit", value="Discard") 468 469 # Updated objects need to have details updated upon sending. 470 471 if not needs_action: 472 self.page.input(name="update", type="hidden", value="true") 473 474 self.page.p.close() 475 self.page.form.close() 476 477 def show_object_on_page(self, uid, obj): 478 479 """ 480 Show the calendar object with the given 'uid' and representation 'obj' 481 on the current page. 482 """ 483 484 # Obtain the user's timezone. 485 486 prefs = self.get_preferences() 487 tzid = prefs.get("TZID", "UTC") 488 489 # Provide a summary of the object. 490 491 details = self._get_details(obj) 492 493 self.page.dl() 494 495 for name in ["SUMMARY", "DTSTART", "DTEND", "ORGANIZER", "ATTENDEE"]: 496 if name in ["DTSTART", "DTEND"]: 497 value, attr = get_item(details, name) 498 tzid = attr.get("TZID", tzid) 499 value = self.format_datetime(to_timezone(get_datetime(value), tzid), "full") 500 self.page.dt(name) 501 self.page.dd(value) 502 else: 503 for value in get_values(details, name): 504 self.page.dt(name) 505 self.page.dd(value) 506 507 self.page.dl.close() 508 509 dtstart = format_datetime(get_utc_datetime(details, "DTSTART")) 510 dtend = format_datetime(get_utc_datetime(details, "DTEND")) 511 512 # Indicate whether there are conflicting events. 513 514 freebusy = self.store.get_freebusy(self.user) 515 516 if freebusy: 517 518 # Obtain any time zone details from the suggested event. 519 520 _dtstart, attr = get_item(details, "DTSTART") 521 tzid = attr.get("TZID", tzid) 522 523 # Show any conflicts. 524 525 for t in have_conflict(freebusy, [(dtstart, dtend)], True): 526 start, end, found_uid = t[:3] 527 528 # Provide details of any conflicting event. 529 530 if uid != found_uid: 531 start = self.format_datetime(to_timezone(get_datetime(start), tzid), "full") 532 end = self.format_datetime(to_timezone(get_datetime(end), tzid), "full") 533 self.page.p("Event conflicts with another from %s to %s: " % (start, end)) 534 535 # Show the event summary for the conflicting event. 536 537 found_obj = self._get_object(found_uid) 538 if found_obj: 539 found_details = self._get_details(found_obj) 540 self.page.a(get_value(found_details, "SUMMARY"), href=self.env.new_url(found_uid)) 541 542 def show_requests_on_page(self): 543 544 "Show requests for the current user." 545 546 # NOTE: This list could be more informative, but it is envisaged that 547 # NOTE: the requests would be visited directly anyway. 548 549 requests = self._get_requests() 550 551 self.page.div(id="pending-requests") 552 553 if requests: 554 self.page.p("Pending requests:") 555 556 self.page.ul() 557 558 for request in requests: 559 obj = self._get_object(request) 560 if obj: 561 details = self._get_details(obj) 562 self.page.li() 563 self.page.a(get_value(details, "SUMMARY"), href="#request-%s" % request) 564 self.page.li.close() 565 566 self.page.ul.close() 567 568 else: 569 self.page.p("There are no pending requests.") 570 571 self.page.div.close() 572 573 def show_participants_on_page(self): 574 575 "Show participants for scheduling purposes." 576 577 args = self.env.get_args() 578 participants = args.get("participants", []) 579 580 try: 581 for name, value in args.items(): 582 if name.startswith("remove-participant-"): 583 i = int(name[len("remove-participant-"):]) 584 del participants[i] 585 break 586 except ValueError: 587 pass 588 589 # Trim empty participants. 590 591 while participants and not participants[-1].strip(): 592 participants.pop() 593 594 # Show any specified participants together with controls to remove and 595 # add participants. 596 597 self.page.div(id="participants") 598 599 self.page.p("Participants for scheduling:") 600 601 for i, participant in enumerate(participants): 602 self.page.p() 603 self.page.input(name="participants", type="text", value=participant) 604 self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove") 605 self.page.p.close() 606 607 self.page.p() 608 self.page.input(name="participants", type="text") 609 self.page.input(name="add-participant", type="submit", value="Add") 610 self.page.p.close() 611 612 self.page.div.close() 613 614 return participants 615 616 # Full page output methods. 617 618 def show_object(self, path_info): 619 620 "Show an object request using the given 'path_info' for the current user." 621 622 uid = self._get_uid(path_info) 623 obj = self._get_object(uid) 624 625 if not obj: 626 return False 627 628 is_request = uid in self._get_requests() 629 handled = self.handle_request(uid, obj, is_request) 630 631 if handled: 632 return True 633 634 self.new_page(title="Event") 635 636 self.show_object_on_page(uid, obj) 637 638 self.show_request_form(obj, is_request and not handled) 639 640 return True 641 642 def show_calendar(self): 643 644 "Show the calendar for the current user." 645 646 handled = self.handle_newevent() 647 648 self.new_page(title="Calendar") 649 page = self.page 650 651 # Form controls are used in various places on the calendar page. 652 653 page.form(method="POST") 654 655 self.show_requests_on_page() 656 participants = self.show_participants_on_page() 657 658 # Show a button for scheduling a new event. 659 660 page.p() 661 page.input(name="newevent", type="submit", value="New event", id="newevent") 662 page.p.close() 663 664 # Show a control for hiding empty slots. 665 # The positioning of the control, paragraph and table are important here. 666 667 page.input(name="hideslots", type="checkbox", value="hide", id="hideslots") 668 669 page.p() 670 page.label("Hide unused time periods", for_="hideslots", class_="enable") 671 page.label("Show unused time periods", for_="hideslots", class_="disable") 672 page.p.close() 673 674 freebusy = self.store.get_freebusy(self.user) 675 676 if not freebusy: 677 page.p("No events scheduled.") 678 return 679 680 # Obtain the user's timezone. 681 682 prefs = self.get_preferences() 683 tzid = prefs.get("TZID", "UTC") 684 685 # Day view: start at the earliest known day and produce days until the 686 # latest known day, perhaps with expandable sections of empty days. 687 688 # Month view: start at the earliest known month and produce months until 689 # the latest known month, perhaps with expandable sections of empty 690 # months. 691 692 # Details of users to invite to new events could be superimposed on the 693 # calendar. 694 695 # Requests are listed and linked to their tentative positions in the 696 # calendar. Other participants are also shown. 697 698 request_summary = self._get_request_summary() 699 700 period_groups = [request_summary, freebusy] 701 period_group_types = ["request", "freebusy"] 702 period_group_sources = ["Pending requests", "Your schedule"] 703 704 for i, participant in enumerate(participants): 705 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 706 period_group_types.append("freebusy-part%d" % i) 707 period_group_sources.append(participant) 708 709 groups = [] 710 group_columns = [] 711 group_types = period_group_types 712 group_sources = period_group_sources 713 all_points = set() 714 715 # Obtain time point information for each group of periods. 716 717 for periods in period_groups: 718 periods = convert_periods(periods, tzid) 719 720 # Get the time scale with start and end points. 721 722 scale = get_scale(periods) 723 724 # Get the time slots for the periods. 725 726 slots = get_slots(scale) 727 728 # Add start of day time points for multi-day periods. 729 730 add_day_start_points(slots) 731 732 # Record the slots and all time points employed. 733 734 groups.append(slots) 735 all_points.update([point for point, active in slots]) 736 737 # Partition the groups into days. 738 739 days = {} 740 partitioned_groups = [] 741 partitioned_group_types = [] 742 partitioned_group_sources = [] 743 744 for slots, group_type, group_source in zip(groups, group_types, group_sources): 745 746 # Propagate time points to all groups of time slots. 747 748 add_slots(slots, all_points) 749 750 # Count the number of columns employed by the group. 751 752 columns = 0 753 754 # Partition the time slots by day. 755 756 partitioned = {} 757 758 for day, day_slots in partition_by_day(slots).items(): 759 intervals = [] 760 last = None 761 762 for point, active in day_slots: 763 columns = max(columns, len(active)) 764 if last: 765 intervals.append((last, point)) 766 last = point 767 768 if last: 769 intervals.append((last, None)) 770 771 if not days.has_key(day): 772 days[day] = set() 773 774 # Convert each partition to a mapping from points to active 775 # periods. 776 777 partitioned[day] = dict(day_slots) 778 779 # Record the divisions or intervals within each day. 780 781 days[day].update(intervals) 782 783 if group_type != "request" or columns: 784 group_columns.append(columns) 785 partitioned_groups.append(partitioned) 786 partitioned_group_types.append(group_type) 787 partitioned_group_sources.append(group_source) 788 789 page.table(cellspacing=5, cellpadding=5, id="calendar") 790 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 791 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 792 page.table.close() 793 794 # End the form region. 795 796 page.form.close() 797 798 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 799 800 """ 801 Show headings for the participants and other scheduling contributors, 802 defined by 'group_types', 'group_sources' and 'group_columns'. 803 """ 804 805 page = self.page 806 807 page.colgroup(span=1, id="columns-timeslot") 808 809 for group_type, columns in zip(group_types, group_columns): 810 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 811 812 page.thead() 813 page.tr() 814 page.th("", class_="emptyheading") 815 816 for group_type, source, columns in zip(group_types, group_sources, group_columns): 817 page.th(source, 818 class_=(group_type == "request" and "requestheading" or "participantheading"), 819 colspan=max(columns, 1)) 820 821 page.tr.close() 822 page.thead.close() 823 824 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 825 826 """ 827 Show calendar days, defined by a collection of 'days', the contributing 828 period information as 'partitioned_groups' (partitioned by day), the 829 'partitioned_group_types' indicating the kind of contribution involved, 830 and the 'group_columns' defining the number of columns in each group. 831 """ 832 833 page = self.page 834 835 # Determine the number of columns required. Where participants provide 836 # no columns for events, one still needs to be provided for the 837 # participant itself. 838 839 all_columns = sum([max(columns, 1) for columns in group_columns]) 840 841 # Determine the days providing time slots. 842 843 all_days = days.items() 844 all_days.sort() 845 846 # Produce a heading and time points for each day. 847 848 for day, intervals in all_days: 849 page.thead() 850 page.tr() 851 page.th(class_="dayheading", colspan=all_columns+1) 852 page.add(self.format_date(day, "full")) 853 page.th.close() 854 page.tr.close() 855 page.thead.close() 856 857 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 858 859 page.tbody() 860 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 861 page.tbody.close() 862 863 def show_calendar_points(self, intervals, groups, group_types, group_columns): 864 865 """ 866 Show the time 'intervals' along with period information from the given 867 'groups', having the indicated 'group_types', each with the number of 868 columns given by 'group_columns'. 869 """ 870 871 page = self.page 872 873 # Produce a row for each interval. 874 875 intervals = list(intervals) 876 intervals.sort() 877 878 for point, endpoint in intervals: 879 continuation = point == get_start_of_day(point) 880 881 # Some rows contain no period details and are marked as such. 882 883 have_active = reduce(lambda x, y: x or y, [slots.get(point) for slots in groups], None) 884 885 css = " ".join( 886 ["slot"] + 887 (not have_active and ["empty"] or []) + 888 (continuation and ["daystart"] or []) 889 ) 890 891 page.tr(class_=css) 892 page.th(class_="timeslot") 893 self._time_point(point, endpoint) 894 page.th.close() 895 896 # Obtain slots for the time point from each group. 897 898 for columns, slots, group_type in zip(group_columns, groups, group_types): 899 active = slots and slots.get(point) 900 901 # Where no periods exist for the given time interval, generate 902 # an empty cell. Where a participant provides no periods at all, 903 # the colspan is adjusted to be 1, not 0. 904 905 if not active: 906 page.td(class_="empty container", colspan=max(columns, 1)) 907 self._empty_slot(point, endpoint) 908 page.td.close() 909 continue 910 911 slots = slots.items() 912 slots.sort() 913 spans = get_spans(slots) 914 915 # Show a column for each active period. 916 917 for t in active: 918 if t and len(t) >= 2: 919 start, end, uid, key = get_freebusy_details(t) 920 span = spans[key] 921 922 # Produce a table cell only at the start of the period 923 # or when continued at the start of a day. 924 925 if point == start or continuation: 926 927 has_continued = continuation and point != start 928 will_continue = not ends_on_same_day(point, end) 929 css = " ".join( 930 ["event"] + 931 (has_continued and ["continued"] or []) + 932 (will_continue and ["continues"] or []) 933 ) 934 935 # Only anchor the first cell of events. 936 937 if point == start: 938 page.td(class_=css, rowspan=span, id="%s-%s" % (group_type, uid)) 939 else: 940 page.td(class_=css, rowspan=span) 941 942 obj = self._get_object(uid) 943 944 if not obj: 945 page.span("") 946 else: 947 details = self._get_details(obj) 948 summary = get_value(details, "SUMMARY") 949 950 # Only link to events if they are not being 951 # updated by requests. 952 953 if uid in self._get_requests() and group_type != "request": 954 page.span(summary) 955 else: 956 href = "%s/%s" % (self.env.get_url().rstrip("/"), uid) 957 page.a(summary, href=href) 958 959 page.td.close() 960 else: 961 page.td(class_="empty container") 962 self._empty_slot(point, endpoint) 963 page.td.close() 964 965 # Pad with empty columns. 966 967 i = columns - len(active) 968 while i > 0: 969 i -= 1 970 page.td(class_="empty container") 971 self._empty_slot(point, endpoint) 972 page.td.close() 973 974 page.tr.close() 975 976 def _time_point(self, point, endpoint): 977 page = self.page 978 value, identifier = self._slot_value_and_identifier(point, endpoint) 979 slot = self.env.get_args().get("slot", [None])[0] 980 if slot == value: 981 page.input(name="slot", type="radio", value=value, id=identifier, class_="newevent", checked="checked") 982 else: 983 page.input(name="slot", type="radio", value=value, id=identifier, class_="newevent") 984 page.label(self.format_time(point, "long"), class_="timepoint", for_=identifier) 985 986 def _empty_slot(self, point, endpoint): 987 page = self.page 988 value, identifier = self._slot_value_and_identifier(point, endpoint) 989 page.label("Make a new event in this period", class_="newevent popup", for_=identifier) 990 991 def _slot_value_and_identifier(self, point, endpoint): 992 value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "") 993 identifier = "slot-%s" % value 994 return value, identifier 995 996 def select_action(self): 997 998 "Select the desired action and show the result." 999 1000 path_info = self.env.get_path_info().strip("/") 1001 1002 if not path_info: 1003 self.show_calendar() 1004 elif self.show_object(path_info): 1005 pass 1006 else: 1007 self.no_page() 1008 1009 def __call__(self): 1010 1011 "Interpret a request and show an appropriate response." 1012 1013 if not self.user: 1014 self.no_user() 1015 else: 1016 self.select_action() 1017 1018 # Write the headers and actual content. 1019 1020 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 1021 print >>self.out 1022 self.out.write(unicode(self.page).encode(self.encoding)) 1023 1024 if __name__ == "__main__": 1025 Manager()() 1026 1027 # vim: tabstop=4 expandtab shiftwidth=4