imip-agent

Annotated imip_manager.py

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