imip-agent

Annotated imipweb/event.py

466:8d2dfe43b629
2015-03-31 Paul Boddie Fixed free/busy period conversion to retain the original period instance types.
paul@446 1
#!/usr/bin/env python
paul@446 2
paul@446 3
"""
paul@446 4
A Web interface to a calendar event.
paul@446 5
paul@446 6
Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>
paul@446 7
paul@446 8
This program is free software; you can redistribute it and/or modify it under
paul@446 9
the terms of the GNU General Public License as published by the Free Software
paul@446 10
Foundation; either version 3 of the License, or (at your option) any later
paul@446 11
version.
paul@446 12
paul@446 13
This program is distributed in the hope that it will be useful, but WITHOUT
paul@446 14
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
paul@446 15
FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
paul@446 16
details.
paul@446 17
paul@446 18
You should have received a copy of the GNU General Public License along with
paul@446 19
this program.  If not, see <http://www.gnu.org/licenses/>.
paul@446 20
"""
paul@446 21
paul@446 22
from datetime import datetime, timedelta
paul@446 23
from imiptools.client import update_attendees
paul@446 24
from imiptools.data import get_uri, uri_dict, uri_values
paul@446 25
from imiptools.dates import format_datetime, to_date, get_datetime, \
paul@446 26
                            get_datetime_item, get_period_item, \
paul@446 27
                            get_start_of_day, to_timezone
paul@446 28
from imiptools.mail import Messenger
paul@446 29
from imiptools.period import have_conflict
paul@446 30
from imipweb.handler import ManagerHandler
paul@446 31
from imipweb.resource import Resource
paul@446 32
import pytz
paul@446 33
paul@446 34
class EventPage(Resource):
paul@446 35
paul@446 36
    "A request handler for the event page."
paul@446 37
paul@446 38
    def __init__(self, resource=None, messenger=None):
paul@446 39
        Resource.__init__(self, resource)
paul@446 40
        self.messenger = messenger or Messenger()
paul@446 41
paul@446 42
    # Request logic methods.
paul@446 43
paul@446 44
    def handle_request(self, uid, recurrenceid, obj):
paul@446 45
paul@446 46
        """
paul@446 47
        Handle actions involving the given 'uid', 'recurrenceid', and 'obj' as
paul@446 48
        the object's representation, returning an error if one occurred, or None
paul@446 49
        if the request was successfully handled.
paul@446 50
        """
paul@446 51
paul@446 52
        # Handle a submitted form.
paul@446 53
paul@446 54
        args = self.env.get_args()
paul@446 55
paul@446 56
        # Get the possible actions.
paul@446 57
paul@446 58
        reply = args.has_key("reply")
paul@446 59
        discard = args.has_key("discard")
paul@446 60
        invite = args.has_key("invite")
paul@446 61
        cancel = args.has_key("cancel")
paul@446 62
        save = args.has_key("save")
paul@446 63
        ignore = args.has_key("ignore")
paul@446 64
paul@446 65
        have_action = reply or discard or invite or cancel or save or ignore
paul@446 66
paul@446 67
        if not have_action:
paul@446 68
            return ["action"]
paul@446 69
paul@446 70
        # If ignoring the object, return to the calendar.
paul@446 71
paul@446 72
        if ignore:
paul@446 73
            self.redirect(self.env.get_path())
paul@446 74
            return None
paul@446 75
paul@446 76
        # Update the object.
paul@446 77
paul@446 78
        if args.has_key("summary"):
paul@446 79
            obj["SUMMARY"] = [(args["summary"][0], {})]
paul@446 80
paul@446 81
        attendees = uri_dict(obj.get_value_map("ATTENDEE"))
paul@446 82
paul@446 83
        if args.has_key("partstat"):
paul@446 84
            if attendees.has_key(self.user):
paul@446 85
                attendees[self.user]["PARTSTAT"] = args["partstat"][0]
paul@446 86
                if attendees[self.user].has_key("RSVP"):
paul@446 87
                    del attendees[self.user]["RSVP"]
paul@446 88
paul@446 89
        is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user
paul@446 90
paul@446 91
        # Obtain the user's timezone and process datetime values.
paul@446 92
paul@446 93
        update = False
paul@446 94
paul@446 95
        if is_organiser:
paul@446 96
            periods, errors = self.handle_all_period_controls()
paul@446 97
            if errors:
paul@446 98
                return errors
paul@446 99
            elif periods:
paul@446 100
                self.set_period_in_object(obj, periods[0])
paul@446 101
                self.set_periods_in_object(obj, periods[1:])
paul@446 102
paul@446 103
        # Obtain any participants to be added or removed.
paul@446 104
paul@446 105
        removed = args.get("remove")
paul@446 106
        added = args.get("added")
paul@446 107
paul@446 108
        # Process any action.
paul@446 109
paul@446 110
        handled = True
paul@446 111
paul@446 112
        if reply or invite or cancel:
paul@446 113
paul@446 114
            handler = ManagerHandler(obj, self.user, self.messenger)
paul@446 115
paul@446 116
            # Process the object and remove it from the list of requests.
paul@446 117
paul@446 118
            if reply and handler.process_received_request(update) or \
paul@446 119
               is_organiser and (invite or cancel) and \
paul@446 120
               handler.process_created_request(invite and "REQUEST" or "CANCEL", update, removed, added):
paul@446 121
paul@446 122
                self.remove_request(uid, recurrenceid)
paul@446 123
paul@446 124
        # Save single user events.
paul@446 125
paul@446 126
        elif save:
paul@446 127
            to_cancel = update_attendees(obj, added, removed)
paul@446 128
            self.store.set_event(self.user, uid, recurrenceid, node=obj.to_node())
paul@446 129
            self.update_freebusy(uid, recurrenceid, obj)
paul@446 130
            self.remove_request(uid, recurrenceid)
paul@446 131
paul@446 132
        # Remove the request and the object.
paul@446 133
paul@446 134
        elif discard:
paul@446 135
            self.remove_from_freebusy(uid, recurrenceid)
paul@446 136
            self.remove_event(uid, recurrenceid)
paul@446 137
            self.remove_request(uid, recurrenceid)
paul@446 138
paul@446 139
        else:
paul@446 140
            handled = False
paul@446 141
paul@446 142
        # Upon handling an action, redirect to the main page.
paul@446 143
paul@446 144
        if handled:
paul@446 145
            self.redirect(self.env.get_path())
paul@446 146
paul@446 147
        return None
paul@446 148
paul@446 149
    def handle_all_period_controls(self):
paul@446 150
paul@446 151
        """
paul@446 152
        Handle datetime controls for a particular period, where 'index' may be
paul@446 153
        used to indicate a recurring period, or the main start and end datetimes
paul@446 154
        are handled.
paul@446 155
        """
paul@446 156
paul@446 157
        args = self.env.get_args()
paul@446 158
paul@446 159
        periods = []
paul@446 160
paul@446 161
        # Get the main period details.
paul@446 162
paul@446 163
        dtend_enabled = args.get("dtend-control", [None])[0]
paul@446 164
        dttimes_enabled = args.get("dttimes-control", [None])[0]
paul@446 165
        start_values = self.get_date_control_values("dtstart")
paul@446 166
        end_values = self.get_date_control_values("dtend")
paul@446 167
paul@446 168
        period, errors = self.handle_period_controls(start_values, end_values, dtend_enabled, dttimes_enabled)
paul@446 169
paul@446 170
        if errors:
paul@446 171
            return None, errors
paul@446 172
paul@446 173
        periods.append(period)
paul@446 174
paul@446 175
        # Get the recurring period details.
paul@446 176
paul@446 177
        all_dtend_enabled = args.get("dtend-control-recur", [])
paul@446 178
        all_dttimes_enabled = args.get("dttimes-control-recur", [])
paul@446 179
        all_start_values = self.get_date_control_values("dtstart-recur", multiple=True)
paul@461 180
        all_end_values = self.get_date_control_values("dtend-recur", multiple=True, tzid_name="dtstart-recur")
paul@446 181
paul@446 182
        for index, (start_values, end_values, dtend_enabled, dttimes_enabled) in \
paul@446 183
            enumerate(map(None, all_start_values, all_end_values, all_dtend_enabled, all_dttimes_enabled)):
paul@446 184
paul@446 185
            dtend_enabled = str(index) in all_dtend_enabled
paul@446 186
            dttimes_enabled = str(index) in all_dttimes_enabled
paul@446 187
            period, errors = self.handle_period_controls(start_values, end_values, dtend_enabled, dttimes_enabled)
paul@446 188
paul@446 189
            if errors:
paul@446 190
                return None, errors
paul@446 191
paul@446 192
            periods.append(period)
paul@446 193
paul@446 194
        return periods, None
paul@446 195
paul@446 196
    def handle_period_controls(self, start_values, end_values, dtend_enabled, dttimes_enabled):
paul@446 197
paul@446 198
        """
paul@446 199
        Handle datetime controls for a particular period, described by the given
paul@446 200
        'start_values' and 'end_values', with 'dtend_enabled' and
paul@446 201
        'dttimes_enabled' affecting the usage of the provided values.
paul@446 202
        """
paul@446 203
paul@446 204
        t = self.handle_date_control_values(start_values, dttimes_enabled)
paul@446 205
        if t:
paul@446 206
            dtstart, dtstart_attr = t
paul@446 207
        else:
paul@446 208
            return None, ["dtstart"]
paul@446 209
paul@446 210
        # Handle specified end datetimes.
paul@446 211
paul@446 212
        if dtend_enabled:
paul@446 213
            t = self.handle_date_control_values(end_values, dttimes_enabled)
paul@446 214
            if t:
paul@446 215
                dtend, dtend_attr = t
paul@446 216
paul@446 217
                # Convert end dates to iCalendar "next day" dates.
paul@446 218
paul@446 219
                if not isinstance(dtend, datetime):
paul@446 220
                    dtend += timedelta(1)
paul@446 221
            else:
paul@446 222
                return None, ["dtend"]
paul@446 223
paul@446 224
        # Otherwise, treat the end date as the start date. Datetimes are
paul@446 225
        # handled by making the event occupy the rest of the day.
paul@446 226
paul@446 227
        else:
paul@446 228
            dtend = dtstart + timedelta(1)
paul@446 229
            dtend_attr = dtstart_attr
paul@446 230
paul@446 231
            if isinstance(dtstart, datetime):
paul@446 232
                dtend = get_start_of_day(dtend, attr["TZID"])
paul@446 233
paul@454 234
        if dtstart > dtend:
paul@446 235
            return None, ["dtstart", "dtend"]
paul@446 236
paul@446 237
        return ((dtstart, dtstart_attr), (dtend, dtend_attr)), None
paul@446 238
paul@446 239
    def handle_date_control_values(self, values, with_time=True):
paul@446 240
paul@446 241
        """
paul@446 242
        Handle date control information for the given 'values', returning a
paul@446 243
        (datetime, attr) tuple, or None if the fields cannot be used to
paul@446 244
        construct a datetime object.
paul@446 245
        """
paul@446 246
paul@446 247
        if not values or not values["date"]:
paul@446 248
            return None
paul@446 249
        elif with_time:
paul@446 250
            value = "%s%s" % (values["date"], values["time"])
paul@446 251
            attr = {"TZID" : values["tzid"], "VALUE" : "DATE-TIME"}
paul@446 252
            dt = get_datetime(value, attr)
paul@446 253
        else:
paul@446 254
            attr = {"VALUE" : "DATE"}
paul@446 255
            dt = get_datetime(values["date"])
paul@446 256
paul@446 257
        if dt:
paul@446 258
            return dt, attr
paul@446 259
paul@446 260
        return None
paul@446 261
paul@461 262
    def get_date_control_values(self, name, multiple=False, tzid_name=None):
paul@446 263
paul@446 264
        """
paul@446 265
        Return a dictionary containing date, time and tzid entries for fields
paul@461 266
        starting with 'name'. If 'multiple' is set to a true value, many
paul@461 267
        dictionaries will be returned corresponding to a collection of
paul@461 268
        datetimes. If 'tzid_name' is specified, the time zone information will
paul@461 269
        be acquired from a field starting with 'tzid_name' instead of 'name'.
paul@446 270
        """
paul@446 271
paul@446 272
        args = self.env.get_args()
paul@446 273
paul@446 274
        dates = args.get("%s-date" % name, [])
paul@446 275
        hours = args.get("%s-hour" % name, [])
paul@446 276
        minutes = args.get("%s-minute" % name, [])
paul@446 277
        seconds = args.get("%s-second" % name, [])
paul@461 278
        tzids = args.get("%s-tzid" % (tzid_name or name), [])
paul@446 279
paul@446 280
        # Handle absent values by employing None values.
paul@446 281
paul@446 282
        field_values = map(None, dates, hours, minutes, seconds, tzids)
paul@446 283
        if not field_values and not multiple:
paul@446 284
            field_values = [(None, None, None, None, None)]
paul@446 285
paul@446 286
        all_values = []
paul@446 287
paul@446 288
        for date, hour, minute, second, tzid in field_values:
paul@446 289
paul@446 290
            # Construct a usable dictionary of values.
paul@446 291
paul@446 292
            time = (hour or minute or second) and \
paul@446 293
                "T%s%s%s" % (
paul@446 294
                    (hour or "").rjust(2, "0")[:2],
paul@446 295
                    (minute or "").rjust(2, "0")[:2],
paul@446 296
                    (second or "").rjust(2, "0")[:2]
paul@446 297
                    ) or ""
paul@446 298
paul@446 299
            value = {
paul@446 300
                "date" : date,
paul@446 301
                "time" : time,
paul@446 302
                "tzid" : tzid or self.get_tzid()
paul@446 303
                }
paul@446 304
paul@446 305
            # Return a single value or append to a collection of all values.
paul@446 306
paul@446 307
            if not multiple:
paul@446 308
                return value
paul@446 309
            else:
paul@446 310
                all_values.append(value)
paul@446 311
paul@446 312
        return all_values
paul@446 313
paul@446 314
    def set_period_in_object(self, obj, period):
paul@446 315
paul@446 316
        "Set in the given 'obj' the given 'period' as the main start and end."
paul@446 317
paul@446 318
        (dtstart, dtstart_attr), (dtend, dtend_attr) = period
paul@446 319
paul@457 320
        result = self.set_datetime_in_object(dtstart, dtstart_attr.get("TZID"), "DTSTART", obj)
paul@457 321
        result = self.set_datetime_in_object(dtend, dtend_attr.get("TZID"), "DTEND", obj) or result
paul@457 322
        return result
paul@446 323
paul@446 324
    def set_periods_in_object(self, obj, periods):
paul@446 325
paul@446 326
        "Set in the given 'obj' the given 'periods'."
paul@446 327
paul@446 328
        update = False
paul@446 329
paul@446 330
        old_values = obj.get_values("RDATE")
paul@446 331
        new_rdates = []
paul@446 332
paul@446 333
        if obj.has_key("RDATE"):
paul@446 334
            del obj["RDATE"]
paul@446 335
paul@446 336
        for period in periods:
paul@446 337
            (dtstart, dtstart_attr), (dtend, dtend_attr) = period
paul@446 338
            tzid = dtstart_attr.get("TZID") or dtend_attr.get("TZID")
paul@446 339
            new_rdates.append(get_period_item(dtstart, dtend, tzid))
paul@446 340
paul@446 341
        obj["RDATE"] = new_rdates
paul@446 342
paul@446 343
        # NOTE: To do: calculate the update status.
paul@446 344
        return update
paul@446 345
paul@446 346
    def set_datetime_in_object(self, dt, tzid, property, obj):
paul@446 347
paul@446 348
        """
paul@446 349
        Set 'dt' and 'tzid' for the given 'property' in 'obj', returning whether
paul@446 350
        an update has occurred.
paul@446 351
        """
paul@446 352
paul@446 353
        if dt:
paul@446 354
            old_value = obj.get_value(property)
paul@446 355
            obj[property] = [get_datetime_item(dt, tzid)]
paul@446 356
            return format_datetime(dt) != old_value
paul@446 357
paul@446 358
        return False
paul@446 359
paul@446 360
    def handle_new_attendees(self, obj):
paul@446 361
paul@446 362
        "Add or remove new attendees. This does not affect the stored object."
paul@446 363
paul@446 364
        args = self.env.get_args()
paul@446 365
paul@446 366
        existing_attendees = uri_values(obj.get_values("ATTENDEE") or [])
paul@446 367
        new_attendees = args.get("added", [])
paul@446 368
        new_attendee = args.get("attendee", [""])[0]
paul@446 369
paul@446 370
        if args.has_key("add"):
paul@446 371
            if new_attendee.strip():
paul@446 372
                new_attendee = get_uri(new_attendee.strip())
paul@446 373
                if new_attendee not in new_attendees and new_attendee not in existing_attendees:
paul@446 374
                    new_attendees.append(new_attendee)
paul@446 375
                new_attendee = ""
paul@446 376
paul@446 377
        if args.has_key("removenew"):
paul@446 378
            removed_attendee = args["removenew"][0]
paul@446 379
            if removed_attendee in new_attendees:
paul@446 380
                new_attendees.remove(removed_attendee)
paul@446 381
paul@446 382
        return new_attendees, new_attendee
paul@446 383
paul@446 384
    def get_event_period(self, obj):
paul@446 385
paul@446 386
        """
paul@446 387
        Return (dtstart, dtstart attributes), (dtend, dtend attributes) for
paul@446 388
        'obj'.
paul@446 389
        """
paul@446 390
paul@446 391
        dtstart, dtstart_attr = obj.get_datetime_item("DTSTART")
paul@446 392
        if obj.has_key("DTEND"):
paul@446 393
            dtend, dtend_attr = obj.get_datetime_item("DTEND")
paul@446 394
        elif obj.has_key("DURATION"):
paul@446 395
            duration = obj.get_duration("DURATION")
paul@446 396
            dtend = dtstart + duration
paul@446 397
            dtend_attr = dtstart_attr
paul@446 398
        else:
paul@446 399
            dtend, dtend_attr = dtstart, dtstart_attr
paul@446 400
        return (dtstart, dtstart_attr), (dtend, dtend_attr)
paul@446 401
paul@446 402
    # Page fragment methods.
paul@446 403
paul@446 404
    def show_request_controls(self, obj):
paul@446 405
paul@446 406
        "Show form controls for a request concerning 'obj'."
paul@446 407
paul@446 408
        page = self.page
paul@446 409
        args = self.env.get_args()
paul@446 410
paul@446 411
        is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user
paul@446 412
paul@446 413
        attendees = uri_values((obj.get_values("ATTENDEE") or []) + filter(None, args.get("attendee", [])))
paul@446 414
        is_attendee = self.user in attendees
paul@446 415
paul@446 416
        is_request = (obj.get_value("UID"), obj.get_value("RECURRENCE-ID")) in self._get_requests()
paul@446 417
paul@446 418
        have_other_attendees = len(attendees) > (is_attendee and 1 or 0)
paul@446 419
paul@446 420
        # Show appropriate options depending on the role of the user.
paul@446 421
paul@446 422
        if is_attendee and not is_organiser:
paul@446 423
            page.p("An action is required for this request:")
paul@446 424
paul@446 425
            page.p()
paul@446 426
            page.input(name="reply", type="submit", value="Send reply")
paul@446 427
            page.add(" ")
paul@446 428
            page.input(name="discard", type="submit", value="Discard event")
paul@446 429
            page.add(" ")
paul@446 430
            page.input(name="ignore", type="submit", value="Do nothing for now")
paul@446 431
            page.p.close()
paul@446 432
paul@446 433
        if is_organiser:
paul@446 434
            page.p("As organiser, you can perform the following:")
paul@446 435
paul@446 436
            if have_other_attendees:
paul@446 437
                page.p()
paul@446 438
                page.input(name="invite", type="submit", value="Invite/notify attendees")
paul@446 439
                page.add(" ")
paul@446 440
                if is_request:
paul@446 441
                    page.input(name="discard", type="submit", value="Discard event")
paul@446 442
                else:
paul@446 443
                    page.input(name="cancel", type="submit", value="Cancel event")
paul@446 444
                page.add(" ")
paul@446 445
                page.input(name="ignore", type="submit", value="Do nothing for now")
paul@446 446
                page.p.close()
paul@446 447
            else:
paul@446 448
                page.p()
paul@446 449
                page.input(name="save", type="submit", value="Save event")
paul@446 450
                page.add(" ")
paul@446 451
                page.input(name="discard", type="submit", value="Discard event")
paul@446 452
                page.add(" ")
paul@446 453
                page.input(name="ignore", type="submit", value="Do nothing for now")
paul@446 454
                page.p.close()
paul@446 455
paul@446 456
    property_items = [
paul@446 457
        ("SUMMARY", "Summary"),
paul@446 458
        ("DTSTART", "Start"),
paul@446 459
        ("DTEND", "End"),
paul@446 460
        ("ORGANIZER", "Organiser"),
paul@446 461
        ("ATTENDEE", "Attendee"),
paul@446 462
        ]
paul@446 463
paul@446 464
    partstat_items = [
paul@446 465
        ("NEEDS-ACTION", "Not confirmed"),
paul@446 466
        ("ACCEPTED", "Attending"),
paul@446 467
        ("TENTATIVE", "Tentatively attending"),
paul@446 468
        ("DECLINED", "Not attending"),
paul@446 469
        ("DELEGATED", "Delegated"),
paul@446 470
        (None, "Not indicated"),
paul@446 471
        ]
paul@446 472
paul@446 473
    def show_object_on_page(self, uid, obj, error=None):
paul@446 474
paul@446 475
        """
paul@446 476
        Show the calendar object with the given 'uid' and representation 'obj'
paul@446 477
        on the current page. If 'error' is given, show a suitable message.
paul@446 478
        """
paul@446 479
paul@446 480
        page = self.page
paul@446 481
        page.form(method="POST")
paul@446 482
paul@446 483
        page.input(name="editing", type="hidden", value="true")
paul@446 484
paul@446 485
        args = self.env.get_args()
paul@446 486
paul@446 487
        # Obtain the user's timezone.
paul@446 488
paul@446 489
        tzid = self.get_tzid()
paul@446 490
paul@446 491
        # Obtain basic event information, showing any necessary editing controls.
paul@446 492
paul@446 493
        is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user
paul@446 494
paul@446 495
        if is_organiser:
paul@446 496
            new_attendees, new_attendee = self.handle_new_attendees(obj)
paul@446 497
        else:
paul@446 498
            new_attendees = []
paul@446 499
            new_attendee = ""
paul@446 500
paul@446 501
        (dtstart, dtstart_attr), (dtend, dtend_attr) = self.get_event_period(obj)
paul@446 502
        self.show_object_datetime_controls(dtstart, dtend)
paul@446 503
paul@446 504
        # Provide a summary of the object.
paul@446 505
paul@446 506
        page.table(class_="object", cellspacing=5, cellpadding=5)
paul@446 507
        page.thead()
paul@446 508
        page.tr()
paul@446 509
        page.th("Event", class_="mainheading", colspan=2)
paul@446 510
        page.tr.close()
paul@446 511
        page.thead.close()
paul@446 512
        page.tbody()
paul@446 513
paul@446 514
        for name, label in self.property_items:
paul@446 515
            field = name.lower()
paul@446 516
paul@446 517
            items = obj.get_items(name) or []
paul@446 518
            rowspan = len(items)
paul@446 519
paul@446 520
            if name == "ATTENDEE":
paul@446 521
                rowspan += len(new_attendees) + 1
paul@446 522
            elif not items:
paul@446 523
                continue
paul@446 524
paul@446 525
            page.tr()
paul@446 526
            page.th(label, class_="objectheading %s%s" % (field, error and field in error and " error" or ""), rowspan=rowspan)
paul@446 527
paul@446 528
            # Handle datetimes specially.
paul@446 529
paul@446 530
            if name in ["DTSTART", "DTEND"]:
paul@446 531
paul@446 532
                # Obtain the datetime.
paul@446 533
paul@446 534
                if name == "DTSTART":
paul@446 535
                    dt, attr = dtstart, dtstart_attr
paul@446 536
paul@446 537
                # Where no end datetime exists, use the start datetime as the
paul@446 538
                # basis of any potential datetime specified if dt-control is
paul@446 539
                # set.
paul@446 540
paul@446 541
                else:
paul@446 542
                    dt, attr = dtend or dtstart, dtend_attr or dtstart_attr
paul@446 543
paul@446 544
                self.show_datetime_controls(obj, dt, attr, name == "DTSTART")
paul@446 545
paul@446 546
                page.tr.close()
paul@446 547
paul@446 548
            # Handle the summary specially.
paul@446 549
paul@446 550
            elif name == "SUMMARY":
paul@446 551
                value = args.get("summary", [obj.get_value(name)])[0]
paul@446 552
paul@446 553
                page.td()
paul@446 554
                if is_organiser:
paul@446 555
                    page.input(name="summary", type="text", value=value, size=80)
paul@446 556
                else:
paul@446 557
                    page.add(value)
paul@446 558
                page.td.close()
paul@446 559
                page.tr.close()
paul@446 560
paul@446 561
            # Handle potentially many values.
paul@446 562
paul@446 563
            else:
paul@446 564
                first = True
paul@446 565
paul@446 566
                for i, (value, attr) in enumerate(items):
paul@446 567
                    if not first:
paul@446 568
                        page.tr()
paul@446 569
                    else:
paul@446 570
                        first = False
paul@446 571
paul@446 572
                    if name == "ATTENDEE":
paul@446 573
                        value = get_uri(value)
paul@446 574
paul@446 575
                        page.td(class_="objectvalue")
paul@446 576
                        page.add(value)
paul@446 577
                        page.add(" ")
paul@446 578
paul@446 579
                        partstat = attr.get("PARTSTAT")
paul@446 580
                        if value == self.user:
paul@446 581
                            self._show_menu("partstat", partstat, self.partstat_items, "partstat")
paul@446 582
                        else:
paul@446 583
                            page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat")
paul@446 584
paul@446 585
                        if is_organiser:
paul@446 586
                            if value in args.get("remove", []):
paul@446 587
                                page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove", checked="checked")
paul@446 588
                            else:
paul@446 589
                                page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove")
paul@446 590
                            page.label("Remove", for_="remove-%d" % i, class_="remove")
paul@446 591
                            page.label("Uninvited", for_="remove-%d" % i, class_="removed")
paul@446 592
paul@446 593
                    else:
paul@446 594
                        page.td(class_="objectvalue")
paul@446 595
                        page.add(value)
paul@446 596
paul@446 597
                    page.td.close()
paul@446 598
                    page.tr.close()
paul@446 599
paul@446 600
                # Allow more attendees to be specified.
paul@446 601
paul@446 602
                if is_organiser and name == "ATTENDEE":
paul@446 603
                    for i, attendee in enumerate(new_attendees):
paul@446 604
                        if not first:
paul@446 605
                            page.tr()
paul@446 606
                        else:
paul@446 607
                            first = False
paul@446 608
paul@446 609
                        page.td()
paul@446 610
                        page.input(name="added", type="value", value=attendee)
paul@446 611
                        page.input(name="removenew", type="submit", value=attendee, id="removenew-%d" % i, class_="remove")
paul@446 612
                        page.label("Remove", for_="removenew-%d" % i, class_="remove")
paul@446 613
                        page.td.close()
paul@446 614
                        page.tr.close()
paul@446 615
paul@446 616
                    if not first:
paul@446 617
                        page.tr()
paul@446 618
paul@446 619
                    page.td()
paul@446 620
                    page.input(name="attendee", type="value", value=new_attendee)
paul@446 621
                    page.input(name="add", type="submit", value="add", id="add-%d" % i, class_="add")
paul@446 622
                    page.label("Add", for_="add-%d" % i, class_="add")
paul@446 623
                    page.td.close()
paul@446 624
                    page.tr.close()
paul@446 625
paul@446 626
        page.tbody.close()
paul@446 627
        page.table.close()
paul@446 628
paul@446 629
        self.show_recurrences(obj)
paul@446 630
        self.show_conflicting_events(uid, obj)
paul@446 631
        self.show_request_controls(obj)
paul@446 632
paul@446 633
        page.form.close()
paul@446 634
paul@446 635
    def show_object_datetime_controls(self, start, end, index=None):
paul@446 636
paul@446 637
        """
paul@446 638
        Show datetime-related controls if already active or if an object needs
paul@446 639
        them for the given 'start' to 'end' period. The given 'index' is used to
paul@446 640
        parameterise individual controls for dynamic manipulation.
paul@446 641
        """
paul@446 642
paul@446 643
        page = self.page
paul@446 644
        args = self.env.get_args()
paul@446 645
        sn = self._suffixed_name
paul@446 646
        ssn = self._simple_suffixed_name
paul@446 647
paul@446 648
        # Add a dynamic stylesheet to permit the controls to modify the display.
paul@446 649
        # NOTE: The style details need to be coordinated with the static
paul@446 650
        # NOTE: stylesheet.
paul@446 651
paul@446 652
        if index is not None:
paul@446 653
            page.style(type="text/css")
paul@446 654
paul@446 655
            # Unlike the rules for object properties, these affect recurrence
paul@446 656
            # properties.
paul@446 657
paul@446 658
            page.add("""\
paul@446 659
input#dttimes-enable-%(index)d,
paul@446 660
input#dtend-enable-%(index)d,
paul@446 661
input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled,
paul@446 662
input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled,
paul@446 663
input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled,
paul@446 664
input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled {
paul@446 665
    display: none;
paul@446 666
}""" % {"index" : index})
paul@446 667
paul@446 668
            page.style.close()
paul@446 669
paul@446 670
        dtend_control = args.get(ssn("dtend-control", "recur", index), [])
paul@446 671
        dttimes_control = args.get(ssn("dttimes-control", "recur", index), [])
paul@446 672
paul@446 673
        dtend_enabled = index is not None and str(index) in dtend_control or index is None and dtend_control
paul@446 674
        dttimes_enabled = index is not None and str(index) in dttimes_control or index is None and dttimes_control
paul@446 675
paul@446 676
        initial_load = not args.has_key("editing")
paul@446 677
paul@446 678
        dtend_enabled = dtend_enabled or initial_load and (isinstance(end, datetime) or start != end - timedelta(1))
paul@446 679
        dttimes_enabled = dttimes_enabled or initial_load and (isinstance(start, datetime) or isinstance(end, datetime))
paul@446 680
paul@446 681
        if dtend_enabled:
paul@446 682
            page.input(name=ssn("dtend-control", "recur", index), type="checkbox",
paul@446 683
                       value=(index is not None and str(index) or "enable"), id=sn("dtend-enable", index), checked="checked")
paul@446 684
        else:
paul@446 685
            page.input(name=ssn("dtend-control", "recur", index), type="checkbox",
paul@446 686
                       value=(index is not None and str(index) or "enable"), id=sn("dtend-enable", index))
paul@446 687
paul@446 688
        if dttimes_enabled:
paul@446 689
            page.input(name=ssn("dttimes-control", "recur", index), type="checkbox",
paul@446 690
                       value=(index is not None and str(index) or "enable"), id=sn("dttimes-enable", index), checked="checked")
paul@446 691
        else:
paul@446 692
            page.input(name=ssn("dttimes-control", "recur", index), type="checkbox",
paul@446 693
                       value=(index is not None and str(index) or "enable"), id=sn("dttimes-enable", index))
paul@446 694
paul@446 695
    def show_datetime_controls(self, obj, dt, attr, show_start):
paul@446 696
paul@446 697
        """
paul@446 698
        Show datetime details from the given 'obj' for the datetime 'dt' and
paul@446 699
        attributes 'attr', showing start details if 'show_start' is set
paul@446 700
        to a true value. Details will appear as controls for organisers and
paul@446 701
        labels for attendees.
paul@446 702
        """
paul@446 703
paul@446 704
        page = self.page
paul@446 705
        is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user
paul@446 706
paul@446 707
        # Change end dates to refer to the actual dates, not the iCalendar
paul@446 708
        # "next day" dates.
paul@446 709
paul@446 710
        if not show_start and not isinstance(dt, datetime):
paul@446 711
            dt -= timedelta(1)
paul@446 712
paul@446 713
        # Show controls for editing as organiser.
paul@446 714
paul@446 715
        if is_organiser:
paul@446 716
            page.td(class_="objectvalue dt%s" % (show_start and "start" or "end"))
paul@446 717
paul@446 718
            if show_start:
paul@446 719
                page.div(class_="dt enabled")
paul@446 720
                self._show_date_controls("dtstart", dt, attr.get("TZID"))
paul@446 721
                page.br()
paul@446 722
                page.label("Specify times", for_="dttimes-enable", class_="time disabled enable")
paul@446 723
                page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable")
paul@446 724
                page.div.close()
paul@446 725
paul@446 726
            else:
paul@446 727
                page.div(class_="dt disabled")
paul@446 728
                page.label("Specify end date", for_="dtend-enable", class_="enable")
paul@446 729
                page.div.close()
paul@446 730
                page.div(class_="dt enabled")
paul@446 731
                self._show_date_controls("dtend", dt, attr.get("TZID"))
paul@446 732
                page.br()
paul@446 733
                page.label("End on same day", for_="dtend-enable", class_="disable")
paul@446 734
                page.div.close()
paul@446 735
paul@446 736
            page.td.close()
paul@446 737
paul@446 738
        # Show a label as attendee.
paul@446 739
paul@446 740
        else:
paul@446 741
            page.td(self.format_datetime(dt, "full"))
paul@446 742
paul@446 743
    def show_recurrence_controls(self, obj, index, start, end, origin, recurrenceid, recurrenceids, show_start):
paul@446 744
paul@446 745
        """
paul@446 746
        Show datetime details from the given 'obj' for the recurrence having the
paul@446 747
        given 'index', with the recurrence period described by the datetimes
paul@446 748
        'start' and 'end', indicating the 'origin' of the period from the event
paul@446 749
        details, employing any 'recurrenceid' and 'recurrenceids' for the object
paul@446 750
        to configure the displayed information.
paul@446 751
paul@446 752
        If 'show_start' is set to a true value, the start details will be shown;
paul@446 753
        otherwise, the end details will be shown.
paul@446 754
        """
paul@446 755
paul@446 756
        page = self.page
paul@446 757
        sn = self._suffixed_name
paul@446 758
        ssn = self._simple_suffixed_name
paul@446 759
paul@446 760
        is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user
paul@446 761
paul@446 762
        # Change end dates to refer to the actual dates, not the iCalendar
paul@446 763
        # "next day" dates.
paul@446 764
paul@446 765
        if not isinstance(end, datetime):
paul@446 766
            end -= timedelta(1)
paul@446 767
paul@446 768
        start_utc = format_datetime(to_timezone(start, "UTC"))
paul@446 769
        replaced = recurrenceids and start_utc in recurrenceids and "replaced" or ""
paul@446 770
        css = " ".join([
paul@446 771
            replaced,
paul@446 772
            recurrenceid and start_utc == recurrenceid and "affected" or ""
paul@446 773
            ])
paul@446 774
paul@446 775
        # Show controls for editing as organiser.
paul@446 776
paul@446 777
        if is_organiser and not replaced and origin != "RRULE":
paul@446 778
            page.td(class_="objectvalue dt%s" % (show_start and "start" or "end"))
paul@446 779
paul@446 780
            if show_start:
paul@446 781
                page.div(class_="dt enabled")
paul@461 782
                self._show_date_controls(ssn("dtstart", "recur", index), start, index=index)
paul@446 783
                page.br()
paul@446 784
                page.label("Specify times", for_=sn("dttimes-enable", index), class_="time disabled enable")
paul@446 785
                page.label("Specify dates only", for_=sn("dttimes-enable", index), class_="time enabled disable")
paul@446 786
                page.div.close()
paul@446 787
paul@446 788
            else:
paul@446 789
                page.div(class_="dt disabled")
paul@446 790
                page.label("Specify end date", for_=sn("dtend-enable", index), class_="enable")
paul@446 791
                page.div.close()
paul@446 792
                page.div(class_="dt enabled")
paul@461 793
                self._show_date_controls(ssn("dtend", "recur", index), end, index=index, show_tzid=False)
paul@446 794
                page.br()
paul@446 795
                page.label("End on same day", for_=sn("dtend-enable", index), class_="disable")
paul@446 796
                page.div.close()
paul@446 797
paul@446 798
            page.td.close()
paul@446 799
paul@446 800
        # Show label as attendee.
paul@446 801
paul@446 802
        else:
paul@446 803
            page.td(self.format_datetime(show_start and start or end, "long"), class_=css)
paul@446 804
paul@446 805
    def show_recurrences(self, obj):
paul@446 806
paul@446 807
        "Show recurrences for the object having the given representation 'obj'."
paul@446 808
paul@446 809
        page = self.page
paul@446 810
        is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user
paul@446 811
paul@446 812
        # Obtain any parent object if this object is a specific recurrence.
paul@446 813
paul@446 814
        uid = obj.get_value("UID")
paul@446 815
        recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID"))
paul@446 816
paul@446 817
        if recurrenceid:
paul@446 818
            obj = self._get_object(uid)
paul@446 819
            if not obj:
paul@446 820
                return
paul@446 821
paul@446 822
            page.p("This event modifies a recurring event.")
paul@446 823
paul@446 824
        # Obtain the periods associated with the event in the user's time zone.
paul@446 825
paul@458 826
        periods = obj.get_periods(self.get_tzid(), self.get_window_end())
paul@446 827
        recurrenceids = self._get_recurrences(uid)
paul@446 828
paul@446 829
        if len(periods) == 1:
paul@446 830
            return
paul@446 831
paul@446 832
        if is_organiser:
paul@446 833
            page.p("This event recurs on the following occasions within the next %d days:" % self.get_window_size())
paul@446 834
        else:
paul@446 835
            page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size())
paul@446 836
paul@446 837
        # Determine whether any periods are explicitly created or are part of a
paul@446 838
        # rule.
paul@446 839
paul@458 840
        explicit_periods = filter(lambda p: p.origin != "RRULE", periods)
paul@446 841
paul@446 842
        # Show each recurrence in a separate table if editable.
paul@446 843
paul@446 844
        if is_organiser and explicit_periods:
paul@446 845
paul@458 846
            for index, p in enumerate(periods[1:]):
paul@446 847
paul@446 848
                # Isolate the controls from neighbouring tables.
paul@446 849
paul@446 850
                page.div()
paul@446 851
paul@458 852
                self.show_object_datetime_controls(p.start, p.end, index)
paul@446 853
paul@446 854
                # NOTE: Need to customise the TH classes according to errors and
paul@446 855
                # NOTE: index information.
paul@446 856
paul@446 857
                page.table(cellspacing=5, cellpadding=5, class_="recurrence")
paul@446 858
                page.caption("Occurrence")
paul@446 859
                page.tbody()
paul@446 860
                page.tr()
paul@446 861
                page.th("Start", class_="objectheading start")
paul@458 862
                self.show_recurrence_controls(obj, index, p.start, p.end, p.origin, recurrenceid, recurrenceids, True)
paul@446 863
                page.tr.close()
paul@446 864
                page.tr()
paul@446 865
                page.th("End", class_="objectheading end")
paul@458 866
                self.show_recurrence_controls(obj, index, p.start, p.end, p.origin, recurrenceid, recurrenceids, False)
paul@446 867
                page.tr.close()
paul@446 868
                page.tbody.close()
paul@446 869
                page.table.close()
paul@446 870
paul@446 871
                page.div.close()
paul@446 872
paul@446 873
        # Otherwise, use a compact single table.
paul@446 874
paul@446 875
        else:
paul@446 876
            page.table(cellspacing=5, cellpadding=5, class_="recurrence")
paul@446 877
            page.caption("Occurrences")
paul@446 878
            page.thead()
paul@446 879
            page.tr()
paul@446 880
            page.th("Start", class_="objectheading start")
paul@446 881
            page.th("End", class_="objectheading end")
paul@446 882
            page.tr.close()
paul@446 883
            page.thead.close()
paul@446 884
            page.tbody()
paul@446 885
paul@446 886
            # Show only subsequent periods if organiser, since the principal
paul@446 887
            # period will be the start and end datetimes.
paul@446 888
paul@458 889
            for index, p in enumerate(is_organiser and periods[1:] or periods):
paul@446 890
                page.tr()
paul@458 891
                self.show_recurrence_controls(obj, index, p.start, p.end, p.origin, recurrenceid, recurrenceids, True)
paul@458 892
                self.show_recurrence_controls(obj, index, p.start, p.end, p.origin, recurrenceid, recurrenceids, False)
paul@446 893
                page.tr.close()
paul@446 894
            page.tbody.close()
paul@446 895
            page.table.close()
paul@446 896
paul@446 897
    def show_conflicting_events(self, uid, obj):
paul@446 898
paul@446 899
        """
paul@446 900
        Show conflicting events for the object having the given 'uid' and
paul@446 901
        representation 'obj'.
paul@446 902
        """
paul@446 903
paul@446 904
        page = self.page
paul@446 905
paul@446 906
        # Obtain the user's timezone.
paul@446 907
paul@446 908
        tzid = self.get_tzid()
paul@446 909
        periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end())
paul@446 910
paul@446 911
        # Indicate whether there are conflicting events.
paul@446 912
paul@446 913
        freebusy = self.store.get_freebusy(self.user)
paul@446 914
paul@446 915
        if freebusy:
paul@446 916
paul@446 917
            # Obtain any time zone details from the suggested event.
paul@446 918
paul@446 919
            _dtstart, attr = obj.get_item("DTSTART")
paul@446 920
            tzid = attr.get("TZID", tzid)
paul@446 921
paul@446 922
            # Show any conflicts.
paul@446 923
paul@458 924
            conflicts = list([p for p in have_conflict(freebusy, periods, True) if p.uid != uid])
paul@458 925
            conflicts.sort()
paul@446 926
paul@446 927
            if conflicts:
paul@446 928
                page.p("This event conflicts with others:")
paul@446 929
paul@446 930
                page.table(cellspacing=5, cellpadding=5, class_="conflicts")
paul@446 931
                page.thead()
paul@446 932
                page.tr()
paul@446 933
                page.th("Event")
paul@446 934
                page.th("Start")
paul@446 935
                page.th("End")
paul@446 936
                page.tr.close()
paul@446 937
                page.thead.close()
paul@446 938
                page.tbody()
paul@446 939
paul@458 940
                for p in conflicts:
paul@446 941
paul@446 942
                    # Provide details of any conflicting event.
paul@446 943
paul@458 944
                    start = self.format_datetime(to_timezone(get_datetime(p.start), tzid), "long")
paul@458 945
                    end = self.format_datetime(to_timezone(get_datetime(p.end), tzid), "long")
paul@446 946
paul@446 947
                    page.tr()
paul@446 948
paul@446 949
                    # Show the event summary for the conflicting event.
paul@446 950
paul@446 951
                    page.td()
paul@458 952
                    page.a(p.summary, href=self.link_to(p.uid))
paul@446 953
                    page.td.close()
paul@446 954
paul@446 955
                    page.td(start)
paul@446 956
                    page.td(end)
paul@446 957
paul@446 958
                    page.tr.close()
paul@446 959
paul@446 960
                page.tbody.close()
paul@446 961
                page.table.close()
paul@446 962
paul@446 963
    # Full page output methods.
paul@446 964
paul@446 965
    def show(self, path_info):
paul@446 966
paul@446 967
        "Show an object request using the given 'path_info' for the current user."
paul@446 968
paul@446 969
        uid, recurrenceid = self._get_identifiers(path_info)
paul@446 970
        obj = self._get_object(uid, recurrenceid)
paul@446 971
paul@446 972
        if not obj:
paul@446 973
            return False
paul@446 974
paul@446 975
        error = self.handle_request(uid, recurrenceid, obj)
paul@446 976
paul@446 977
        if not error:
paul@446 978
            return True
paul@446 979
paul@446 980
        self.new_page(title="Event")
paul@446 981
        self.show_object_on_page(uid, obj, error)
paul@446 982
paul@446 983
        return True
paul@446 984
paul@446 985
    # Utility methods.
paul@446 986
paul@446 987
    def _show_menu(self, name, default, items, class_="", index=None):
paul@446 988
paul@446 989
        """
paul@446 990
        Show a select menu having the given 'name', set to the given 'default',
paul@446 991
        providing the given (value, label) 'items', and employing the given CSS
paul@446 992
        'class_' if specified.
paul@446 993
        """
paul@446 994
paul@446 995
        page = self.page
paul@446 996
        values = self.env.get_args().get(name, [default])
paul@446 997
        if index is not None:
paul@446 998
            values = values[index:]
paul@446 999
            values = values and values[0:1] or [default]
paul@446 1000
paul@446 1001
        page.select(name=name, class_=class_)
paul@446 1002
        for v, label in items:
paul@446 1003
            if v is None:
paul@446 1004
                continue
paul@446 1005
            if v in values:
paul@446 1006
                page.option(label, value=v, selected="selected")
paul@446 1007
            else:
paul@446 1008
                page.option(label, value=v)
paul@446 1009
        page.select.close()
paul@446 1010
paul@461 1011
    def _show_date_controls(self, name, default, tzid=None, index=None, show_tzid=True):
paul@446 1012
paul@446 1013
        """
paul@446 1014
        Show date controls for a field with the given 'name' and 'default' value
paul@461 1015
        and 'tzid'. If 'index' is specified, default field values will be
paul@461 1016
        overridden by the element from a collection of existing form values with
paul@461 1017
        the specified index; otherwise, field values will be overridden by a
paul@461 1018
        single form value.
paul@461 1019
paul@461 1020
        If 'show_tzid' is set to a false value, the time zone menu will not be
paul@461 1021
        provided.
paul@446 1022
        """
paul@446 1023
paul@446 1024
        page = self.page
paul@446 1025
        args = self.env.get_args()
paul@446 1026
paul@446 1027
        # Show dates for up to one week around the current date.
paul@446 1028
paul@446 1029
        base = to_date(default)
paul@446 1030
        items = []
paul@446 1031
        for i in range(-7, 8):
paul@446 1032
            d = base + timedelta(i)
paul@446 1033
            items.append((format_datetime(d), self.format_date(d, "full")))
paul@446 1034
paul@446 1035
        self._show_menu("%s-date" % name, format_datetime(base), items, index=index)
paul@446 1036
paul@446 1037
        # Show time details.
paul@446 1038
paul@446 1039
        default_time = isinstance(default, datetime) and default or None
paul@446 1040
paul@446 1041
        hour = args.get("%s-hour" % name, [])[index or 0:]
paul@446 1042
        hour = hour and hour[0] or "%02d" % (default_time and default_time.hour or 0)
paul@446 1043
        minute = args.get("%s-minute" % name, [])[index or 0:]
paul@446 1044
        minute = minute and minute[0] or "%02d" % (default_time and default_time.minute or 0)
paul@446 1045
        second = args.get("%s-second" % name, [])[index or 0:]
paul@446 1046
        second = second and second[0] or "%02d" % (default_time and default_time.second or 0)
paul@446 1047
paul@446 1048
        page.span(class_="time enabled")
paul@446 1049
        page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2)
paul@446 1050
        page.add(":")
paul@446 1051
        page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2)
paul@446 1052
        page.add(":")
paul@446 1053
        page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2)
paul@461 1054
        if show_tzid:
paul@461 1055
            tzid = tzid or self.get_tzid()
paul@461 1056
            page.add(" ")
paul@461 1057
            self._show_timezone_menu("%s-tzid" % name, tzid, index)
paul@446 1058
        page.span.close()
paul@446 1059
paul@446 1060
    def _show_timezone_menu(self, name, default, index=None):
paul@446 1061
paul@446 1062
        """
paul@446 1063
        Show timezone controls using a menu with the given 'name', set to the
paul@446 1064
        given 'default' unless a field of the given 'name' provides a value.
paul@446 1065
        """
paul@446 1066
paul@446 1067
        entries = [(tzid, tzid) for tzid in pytz.all_timezones]
paul@446 1068
        self._show_menu(name, default, entries, index=index)
paul@446 1069
paul@446 1070
# vim: tabstop=4 expandtab shiftwidth=4