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 freebusy = self.store.get_freebusy(self.user) 622 623 if not freebusy: 624 page.p("No events scheduled.") 625 return 626 627 # Obtain the user's timezone. 628 629 prefs = self.get_preferences() 630 tzid = prefs.get("TZID", "UTC") 631 632 # Day view: start at the earliest known day and produce days until the 633 # latest known day, perhaps with expandable sections of empty days. 634 635 # Month view: start at the earliest known month and produce months until 636 # the latest known month, perhaps with expandable sections of empty 637 # months. 638 639 # Details of users to invite to new events could be superimposed on the 640 # calendar. 641 642 # Requests are listed and linked to their tentative positions in the 643 # calendar. Other participants are also shown. 644 645 request_summary = self._get_request_summary() 646 647 period_groups = [request_summary, freebusy] 648 period_group_types = ["request", "freebusy"] 649 period_group_sources = ["Pending requests", "Your schedule"] 650 651 for i, participant in enumerate(participants): 652 period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) 653 period_group_types.append("freebusy-part%d" % i) 654 period_group_sources.append(participant) 655 656 groups = [] 657 group_columns = [] 658 group_types = period_group_types 659 group_sources = period_group_sources 660 all_points = set() 661 662 # Obtain time point information for each group of periods. 663 664 for periods in period_groups: 665 periods = convert_periods(periods, tzid) 666 667 # Get the time scale with start and end points. 668 669 scale = get_scale(periods) 670 671 # Get the time slots for the periods. 672 673 slots = get_slots(scale) 674 675 # Add start of day time points for multi-day periods. 676 677 add_day_start_points(slots) 678 679 # Record the slots and all time points employed. 680 681 groups.append(slots) 682 all_points.update([point for point, active in slots]) 683 684 # Partition the groups into days. 685 686 days = {} 687 partitioned_groups = [] 688 partitioned_group_types = [] 689 partitioned_group_sources = [] 690 691 for slots, group_type, group_source in zip(groups, group_types, group_sources): 692 693 # Propagate time points to all groups of time slots. 694 695 add_slots(slots, all_points) 696 697 # Count the number of columns employed by the group. 698 699 columns = 0 700 701 # Partition the time slots by day. 702 703 partitioned = {} 704 705 for day, day_slots in partition_by_day(slots).items(): 706 intervals = [] 707 last = None 708 709 for point, active in day_slots: 710 columns = max(columns, len(active)) 711 if last: 712 intervals.append((last, point)) 713 last = point 714 715 if last: 716 intervals.append((last, None)) 717 718 if not days.has_key(day): 719 days[day] = set() 720 721 # Convert each partition to a mapping from points to active 722 # periods. 723 724 partitioned[day] = dict(day_slots) 725 726 # Record the divisions or intervals within each day. 727 728 days[day].update(intervals) 729 730 if group_type != "request" or columns: 731 group_columns.append(columns) 732 partitioned_groups.append(partitioned) 733 partitioned_group_types.append(group_type) 734 partitioned_group_sources.append(group_source) 735 736 page.table(cellspacing=5, cellpadding=5, id="calendar") 737 self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) 738 self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) 739 page.table.close() 740 741 # End the form region. 742 743 page.form.close() 744 745 def show_calendar_participant_headings(self, group_types, group_sources, group_columns): 746 747 """ 748 Show headings for the participants and other scheduling contributors, 749 defined by 'group_types', 'group_sources' and 'group_columns'. 750 """ 751 752 page = self.page 753 754 page.colgroup(span=1, id="columns-timeslot") 755 756 for group_type, columns in zip(group_types, group_columns): 757 page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) 758 759 page.thead() 760 page.tr() 761 page.th("", class_="emptyheading") 762 763 for group_type, source, columns in zip(group_types, group_sources, group_columns): 764 page.th(source, 765 class_=(group_type == "request" and "requestheading" or "participantheading"), 766 colspan=max(columns, 1)) 767 768 page.tr.close() 769 page.thead.close() 770 771 def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): 772 773 """ 774 Show calendar days, defined by a collection of 'days', the contributing 775 period information as 'partitioned_groups' (partitioned by day), the 776 'partitioned_group_types' indicating the kind of contribution involved, 777 and the 'group_columns' defining the number of columns in each group. 778 """ 779 780 page = self.page 781 782 # Determine the number of columns required. Where participants provide 783 # no columns for events, one still needs to be provided for the 784 # participant itself. 785 786 all_columns = sum([max(columns, 1) for columns in group_columns]) 787 788 # Determine the days providing time slots. 789 790 all_days = days.items() 791 all_days.sort() 792 793 # Produce a heading and time points for each day. 794 795 for day, intervals in all_days: 796 page.thead() 797 page.tr() 798 page.th(class_="dayheading", colspan=all_columns+1) 799 page.add(self.format_date(day, "full")) 800 page.th.close() 801 page.tr.close() 802 page.thead.close() 803 804 groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] 805 806 page.tbody() 807 self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) 808 page.tbody.close() 809 810 def show_calendar_points(self, intervals, groups, group_types, group_columns): 811 812 """ 813 Show the time 'intervals' along with period information from the given 814 'groups', having the indicated 'group_types', each with the number of 815 columns given by 'group_columns'. 816 """ 817 818 page = self.page 819 820 # Produce a row for each time point. 821 822 intervals = list(intervals) 823 intervals.sort() 824 825 for point, endpoint in intervals: 826 continuation = point == get_start_of_day(point) 827 828 page.tr() 829 page.th(class_="timeslot") 830 self._time_point(point, endpoint) 831 page.th.close() 832 833 # Obtain slots for the time point from each group. 834 835 for columns, slots, group_type in zip(group_columns, groups, group_types): 836 active = slots and slots.get(point) 837 838 # Where no periods exist for the given time interval, generate 839 # an empty cell. Where a participant provides no periods at all, 840 # the colspan is adjusted to be 1, not 0. 841 842 if not active: 843 page.td(class_="empty container", colspan=max(columns, 1)) 844 self._empty_slot(point, endpoint) 845 page.td.close() 846 continue 847 848 slots = slots.items() 849 slots.sort() 850 spans = get_spans(slots) 851 852 # Show a column for each active period. 853 854 for t in active: 855 if t and len(t) >= 2: 856 start, end, uid, key = get_freebusy_details(t) 857 span = spans[key] 858 859 # Produce a table cell only at the start of the period 860 # or when continued at the start of a day. 861 862 if point == start or continuation: 863 864 has_continued = continuation and point != start 865 will_continue = not ends_on_same_day(point, end) 866 css = " ".join( 867 ["event"] + 868 (has_continued and ["continued"] or []) + 869 (will_continue and ["continues"] or []) 870 ) 871 872 # Only anchor the first cell of events. 873 874 if point == start: 875 page.td(class_=css, rowspan=span, id="%s-%s" % (group_type, uid)) 876 else: 877 page.td(class_=css, rowspan=span) 878 879 obj = self._get_object(uid) 880 881 if not obj: 882 page.span("") 883 else: 884 details = self._get_details(obj) 885 summary = get_value(details, "SUMMARY") 886 887 # Only link to events if they are not being 888 # updated by requests. 889 890 if uid in self._get_requests() and group_type != "request": 891 page.span(summary) 892 else: 893 href = "%s/%s" % (self.env.get_url().rstrip("/"), uid) 894 page.a(summary, href=href) 895 896 page.td.close() 897 else: 898 page.td(class_="empty container") 899 self._empty_slot(point, endpoint) 900 page.td.close() 901 902 # Pad with empty columns. 903 904 i = columns - len(active) 905 while i > 0: 906 i -= 1 907 page.td(class_="empty container") 908 self._empty_slot(point, endpoint) 909 page.td.close() 910 911 page.tr.close() 912 913 def _time_point(self, point, endpoint): 914 page = self.page 915 value, identifier = self._slot_value_and_identifier(point, endpoint) 916 slot = self.env.get_args().get("slot", [None])[0] 917 if slot == value: 918 page.input(name="slot", type="radio", value=value, id=identifier, class_="newevent", checked="checked") 919 else: 920 page.input(name="slot", type="radio", value=value, id=identifier, class_="newevent") 921 page.label(self.format_time(point, "long"), class_="timepoint", for_=identifier) 922 923 def _empty_slot(self, point, endpoint): 924 page = self.page 925 value, identifier = self._slot_value_and_identifier(point, endpoint) 926 page.label("Make a new event in this period", class_="newevent popup", for_=identifier) 927 928 def _slot_value_and_identifier(self, point, endpoint): 929 value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "") 930 identifier = "slot-%s" % value 931 return value, identifier 932 933 def select_action(self): 934 935 "Select the desired action and show the result." 936 937 path_info = self.env.get_path_info().strip("/") 938 939 if not path_info: 940 self.show_calendar() 941 elif self.show_object(path_info): 942 pass 943 else: 944 self.no_page() 945 946 def __call__(self): 947 948 "Interpret a request and show an appropriate response." 949 950 if not self.user: 951 self.no_user() 952 else: 953 self.select_action() 954 955 # Write the headers and actual content. 956 957 print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding 958 print >>self.out 959 self.out.write(unicode(self.page).encode(self.encoding)) 960 961 if __name__ == "__main__": 962 Manager()() 963 964 # vim: tabstop=4 expandtab shiftwidth=4