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