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