imip-agent

Annotated imipweb/calendar.py

1459:977b3f6d785a
2018-03-29 Paul Boddie Introduced resource_script and freebusy_request functions. Added missing error stream redirection for the showmail function, also adding an optional messages-to-skip parameter. Reformatted some commands. client-editing-simplification
paul@446 1
#!/usr/bin/env python
paul@446 2
paul@446 3
"""
paul@446 4
A Web interface to an event calendar.
paul@446 5
paul@1213 6
Copyright (C) 2014, 2015, 2016, 2017 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@876 22
from datetime import datetime, timedelta
paul@1204 23
from imiptools.data import get_address, get_uri, get_verbose_address, make_uid, \
paul@1204 24
                           uri_parts
paul@883 25
from imiptools.dates import format_datetime, get_date, get_datetime, \
paul@446 26
                            get_datetime_item, get_end_of_day, get_start_of_day, \
paul@446 27
                            get_start_of_next_day, get_timestamp, ends_on_same_day, \
paul@889 28
                            to_date, to_timezone
paul@446 29
from imiptools.period import add_day_start_points, add_empty_days, add_slots, \
paul@874 30
                             get_scale, get_slots, get_spans, partition_by_day, \
paul@931 31
                             remove_end_slot, Period, Point
paul@928 32
from imipweb.resource import FormUtilities, ResourceClient
paul@446 33
paul@928 34
class CalendarPage(ResourceClient, FormUtilities):
paul@446 35
paul@446 36
    "A request handler for the calendar page."
paul@446 37
paul@446 38
    # Request logic methods.
paul@446 39
paul@446 40
    def handle_newevent(self):
paul@446 41
paul@446 42
        """
paul@446 43
        Handle any new event operation, creating a new event and redirecting to
paul@446 44
        the event page for further activity.
paul@446 45
        """
paul@446 46
paul@1005 47
        _ = self.get_translator()
paul@1005 48
paul@1162 49
        # Check the validation token.
paul@1162 50
paul@1162 51
        if not self.check_validation_token():
paul@1162 52
            return False
paul@1162 53
paul@446 54
        # Handle a submitted form.
paul@446 55
paul@446 56
        args = self.env.get_args()
paul@446 57
paul@778 58
        for key in args.keys():
paul@778 59
            if key.startswith("newevent-"):
paul@778 60
                i = key[len("newevent-"):]
paul@778 61
                break
paul@778 62
        else:
paul@873 63
            return False
paul@446 64
paul@446 65
        # Create a new event using the available information.
paul@446 66
paul@446 67
        slots = args.get("slot", [])
paul@446 68
        participants = args.get("participants", [])
paul@778 69
        summary = args.get("summary-%s" % i, [None])[0]
paul@446 70
paul@446 71
        if not slots:
paul@873 72
            return False
paul@446 73
paul@446 74
        # Obtain the user's timezone.
paul@446 75
paul@446 76
        tzid = self.get_tzid()
paul@446 77
paul@446 78
        # Coalesce the selected slots.
paul@446 79
paul@446 80
        slots.sort()
paul@446 81
        coalesced = []
paul@446 82
        last = None
paul@446 83
paul@446 84
        for slot in slots:
paul@537 85
            start, end = (slot.split("-", 1) + [None])[:2]
paul@446 86
            start = get_datetime(start, {"TZID" : tzid})
paul@446 87
            end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid)
paul@446 88
paul@446 89
            if last:
paul@446 90
                last_start, last_end = last
paul@446 91
paul@446 92
                # Merge adjacent dates and datetimes.
paul@446 93
paul@446 94
                if start == last_end or \
paul@446 95
                    not isinstance(start, datetime) and \
paul@446 96
                    get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid):
paul@446 97
paul@446 98
                    last = last_start, end
paul@446 99
                    continue
paul@446 100
paul@446 101
                # Handle datetimes within dates.
paul@446 102
                # Datetime periods are within single days and are therefore
paul@446 103
                # discarded.
paul@446 104
paul@446 105
                elif not isinstance(last_start, datetime) and \
paul@446 106
                    get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid):
paul@446 107
paul@446 108
                    continue
paul@446 109
paul@446 110
                # Add separate dates and datetimes.
paul@446 111
paul@446 112
                else:
paul@446 113
                    coalesced.append(last)
paul@446 114
paul@446 115
            last = start, end
paul@446 116
paul@446 117
        if last:
paul@446 118
            coalesced.append(last)
paul@446 119
paul@446 120
        # Invent a unique identifier.
paul@446 121
paul@1204 122
        uid = make_uid(self.user)
paul@446 123
paul@446 124
        # Create a calendar object and store it as a request.
paul@446 125
paul@446 126
        record = []
paul@446 127
        rwrite = record.append
paul@446 128
paul@446 129
        # Define a single occurrence if only one coalesced slot exists.
paul@446 130
paul@446 131
        start, end = coalesced[0]
paul@446 132
        start_value, start_attr = get_datetime_item(start, tzid)
paul@446 133
        end_value, end_attr = get_datetime_item(end, tzid)
paul@794 134
        user_attr = self.get_user_attributes()
paul@446 135
paul@1213 136
        utcnow = get_timestamp()
paul@1213 137
paul@446 138
        rwrite(("UID", {}, uid))
paul@1005 139
        rwrite(("SUMMARY", {}, summary or (_("New event at %s") % utcnow)))
paul@446 140
        rwrite(("DTSTAMP", {}, utcnow))
paul@446 141
        rwrite(("DTSTART", start_attr, start_value))
paul@446 142
        rwrite(("DTEND", end_attr, end_value))
paul@794 143
        rwrite(("ORGANIZER", user_attr, self.user))
paul@794 144
paul@794 145
        cn_participants = uri_parts(filter(None, participants))
paul@794 146
        participants = []
paul@446 147
paul@794 148
        for cn, participant in cn_participants:
paul@794 149
            d = {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}
paul@794 150
            if cn:
paul@794 151
                d["CN"] = cn
paul@794 152
            rwrite(("ATTENDEE", d, participant))
paul@794 153
            participants.append(participant)
paul@446 154
paul@446 155
        if self.user not in participants:
paul@794 156
            d = {"PARTSTAT" : "ACCEPTED"}
paul@794 157
            d.update(user_attr)
paul@794 158
            rwrite(("ATTENDEE", d, self.user))
paul@446 159
paul@446 160
        # Define additional occurrences if many slots are defined.
paul@446 161
paul@446 162
        rdates = []
paul@446 163
paul@446 164
        for start, end in coalesced[1:]:
paul@446 165
            start_value, start_attr = get_datetime_item(start, tzid)
paul@446 166
            end_value, end_attr = get_datetime_item(end, tzid)
paul@446 167
            rdates.append("%s/%s" % (start_value, end_value))
paul@446 168
paul@446 169
        if rdates:
paul@446 170
            rwrite(("RDATE", {"VALUE" : "PERIOD", "TZID" : tzid}, rdates))
paul@446 171
paul@446 172
        node = ("VEVENT", {}, record)
paul@446 173
paul@446 174
        self.store.set_event(self.user, uid, None, node=node)
paul@446 175
        self.store.queue_request(self.user, uid)
paul@446 176
paul@446 177
        # Redirect to the object (or the first of the objects), where instead of
paul@446 178
        # attendee controls, there will be organiser controls.
paul@446 179
paul@877 180
        self.redirect(self.link_to(uid, args=self.get_time_navigation_args()))
paul@873 181
        return True
paul@446 182
paul@881 183
    def update_participants(self):
paul@881 184
paul@881 185
        "Update the participants used for scheduling purposes."
paul@881 186
paul@881 187
        args = self.env.get_args()
paul@881 188
        participants = args.get("participants", [])
paul@881 189
paul@881 190
        try:
paul@881 191
            for name, value in args.items():
paul@881 192
                if name.startswith("remove-participant-"):
paul@881 193
                    i = int(name[len("remove-participant-"):])
paul@881 194
                    del participants[i]
paul@881 195
                    break
paul@881 196
        except ValueError:
paul@881 197
            pass
paul@881 198
paul@881 199
        # Trim empty participants.
paul@881 200
paul@881 201
        while participants and not participants[-1].strip():
paul@881 202
            participants.pop()
paul@881 203
paul@881 204
        return participants
paul@881 205
paul@446 206
    # Page fragment methods.
paul@446 207
paul@923 208
    def show_user_navigation(self):
paul@923 209
paul@923 210
        "Show user-specific navigation."
paul@923 211
paul@923 212
        page = self.page
paul@923 213
        user_attr = self.get_user_attributes()
paul@923 214
paul@923 215
        page.p(id_="user-navigation")
paul@923 216
        page.a(get_verbose_address(self.user, user_attr), href=self.link_to("profile"), class_="username")
paul@923 217
        page.p.close()
paul@923 218
paul@446 219
    def show_requests_on_page(self):
paul@446 220
paul@446 221
        "Show requests for the current user."
paul@446 222
paul@1005 223
        _ = self.get_translator()
paul@1005 224
paul@446 225
        page = self.page
paul@889 226
        view_period = self.get_view_period()
paul@889 227
        duration = view_period and view_period.get_duration() or timedelta(1)
paul@446 228
paul@446 229
        # NOTE: This list could be more informative, but it is envisaged that
paul@446 230
        # NOTE: the requests would be visited directly anyway.
paul@446 231
paul@446 232
        requests = self._get_requests()
paul@446 233
paul@446 234
        page.div(id="pending-requests")
paul@446 235
paul@446 236
        if requests:
paul@1008 237
            page.p(_("Pending requests:"))
paul@446 238
paul@446 239
            page.ul()
paul@446 240
paul@751 241
            for uid, recurrenceid, request_type in requests:
paul@751 242
                obj = self._get_object(uid, recurrenceid)
paul@446 243
                if obj:
paul@889 244
paul@889 245
                    # Provide a link showing the request in context.
paul@889 246
paul@889 247
                    periods = self.get_periods(obj)
paul@889 248
                    if periods:
paul@889 249
                        start = to_date(periods[0].get_start())
paul@889 250
                        end = max(to_date(periods[0].get_end()), start + duration)
paul@889 251
                        d = {"start" : format_datetime(start), "end" : format_datetime(end)}
paul@889 252
                        page.li()
paul@889 253
                        page.a(obj.get_value("SUMMARY"), href="%s#request-%s-%s" % (self.link_to(args=d), uid, recurrenceid or ""))
paul@889 254
                        page.li.close()
paul@446 255
paul@446 256
            page.ul.close()
paul@446 257
paul@446 258
        else:
paul@1005 259
            page.p(_("There are no pending requests."))
paul@446 260
paul@446 261
        page.div.close()
paul@446 262
paul@881 263
    def show_participants_on_page(self, participants):
paul@446 264
paul@446 265
        "Show participants for scheduling purposes."
paul@446 266
paul@1005 267
        _ = self.get_translator()
paul@1005 268
paul@446 269
        page = self.page
paul@446 270
paul@446 271
        # Show any specified participants together with controls to remove and
paul@446 272
        # add participants.
paul@446 273
paul@446 274
        page.div(id="participants")
paul@446 275
paul@1005 276
        page.p(_("Participants for scheduling:"))
paul@446 277
paul@446 278
        for i, participant in enumerate(participants):
paul@446 279
            page.p()
paul@446 280
            page.input(name="participants", type="text", value=participant)
paul@1005 281
            page.input(name="remove-participant-%d" % i, type="submit", value=_("Remove"))
paul@446 282
            page.p.close()
paul@446 283
paul@446 284
        page.p()
paul@446 285
        page.input(name="participants", type="text")
paul@1005 286
        page.input(name="add-participant", type="submit", value=_("Add"))
paul@446 287
        page.p.close()
paul@446 288
paul@446 289
        page.div.close()
paul@446 290
paul@881 291
    def show_calendar_controls(self):
paul@881 292
paul@881 293
        """
paul@881 294
        Show controls for hiding empty days and busy slots in the calendar.
paul@881 295
paul@881 296
        The positioning of the controls, paragraph and table are important here:
paul@881 297
        the CSS file describes the relationship between them and the calendar
paul@881 298
        tables.
paul@881 299
        """
paul@881 300
paul@1005 301
        _ = self.get_translator()
paul@1005 302
paul@881 303
        page = self.page
paul@928 304
        args = self.env.get_args()
paul@881 305
paul@928 306
        self.control("showdays", "checkbox", "show", ("show" in args.get("showdays", [])), id="showdays", accesskey="D")
paul@928 307
        self.control("hidebusy", "checkbox", "hide", ("hide" in args.get("hidebusy", [])), id="hidebusy", accesskey="B")
paul@881 308
paul@883 309
        page.p(id_="calendar-controls", class_="controls")
paul@1005 310
        page.span(_("Select days or periods for a new event."))
paul@1005 311
        page.label(_("Hide busy time periods"), for_="hidebusy", class_="hidebusy enable")
paul@1005 312
        page.label(_("Show busy time periods"), for_="hidebusy", class_="hidebusy disable")
paul@1005 313
        page.label(_("Show empty days"), for_="showdays", class_="showdays disable")
paul@1005 314
        page.label(_("Hide empty days"), for_="showdays", class_="showdays enable")
paul@1005 315
        page.input(name="reset", type="submit", value=_("Clear selections"), id="reset")
paul@881 316
        page.p.close()
paul@446 317
paul@926 318
    def show_time_navigation(self, freebusy, view_period):
paul@876 319
paul@876 320
        """
paul@926 321
        Show the calendar navigation links for the schedule defined by
paul@926 322
        'freebusy' and for the period defined by 'view_period'.
paul@876 323
        """
paul@876 324
paul@1005 325
        _ = self.get_translator()
paul@1005 326
paul@876 327
        page = self.page
paul@889 328
        view_start = view_period.get_start()
paul@889 329
        view_end = view_period.get_end()
paul@889 330
        duration = view_period.get_duration()
paul@876 331
paul@1189 332
        preceding_events = view_start and freebusy.get_overlapping([Period(None, view_start, self.get_tzid())]) or []
paul@1189 333
        following_events = view_end and freebusy.get_overlapping([Period(view_end, None, self.get_tzid())]) or []
paul@926 334
paul@926 335
        last_preceding = preceding_events and to_date(preceding_events[-1].get_end()) + timedelta(1) or None
paul@926 336
        first_following = following_events and to_date(following_events[0].get_start()) or None
paul@926 337
paul@881 338
        page.p(id_="time-navigation")
paul@876 339
paul@876 340
        if view_start:
paul@930 341
            page.input(name="start", type="hidden", value=format_datetime(view_start))
paul@930 342
paul@926 343
            if last_preceding:
paul@926 344
                preceding_start = last_preceding - duration
paul@1005 345
                page.label(_("Show earlier events"), for_="earlier-events", class_="earlier-events")
paul@926 346
                page.input(name="earlier-events", id_="earlier-events", type="submit")
paul@926 347
                page.input(name="earlier-events-start", type="hidden", value=format_datetime(preceding_start))
paul@926 348
                page.input(name="earlier-events-end", type="hidden", value=format_datetime(last_preceding))
paul@926 349
paul@889 350
            earlier_start = view_start - duration
paul@1005 351
            page.label(_("Show earlier"), for_="earlier", class_="earlier")
paul@876 352
            page.input(name="earlier", id_="earlier", type="submit")
paul@876 353
            page.input(name="earlier-start", type="hidden", value=format_datetime(earlier_start))
paul@876 354
            page.input(name="earlier-end", type="hidden", value=format_datetime(view_start))
paul@926 355
paul@876 356
        if view_end:
paul@930 357
            page.input(name="end", type="hidden", value=format_datetime(view_end))
paul@926 358
paul@889 359
            later_end = view_end + duration
paul@1005 360
            page.label(_("Show later"), for_="later", class_="later")
paul@876 361
            page.input(name="later", id_="later", type="submit")
paul@876 362
            page.input(name="later-start", type="hidden", value=format_datetime(view_end))
paul@876 363
            page.input(name="later-end", type="hidden", value=format_datetime(later_end))
paul@930 364
paul@930 365
            if first_following:
paul@930 366
                following_end = first_following + duration
paul@1005 367
                page.label(_("Show later events"), for_="later-events", class_="later-events")
paul@930 368
                page.input(name="later-events", id_="later-events", type="submit")
paul@930 369
                page.input(name="later-events-start", type="hidden", value=format_datetime(first_following))
paul@930 370
                page.input(name="later-events-end", type="hidden", value=format_datetime(following_end))
paul@876 371
paul@876 372
        page.p.close()
paul@876 373
paul@876 374
    def get_time_navigation(self):
paul@876 375
paul@876 376
        "Return the start and end dates for the calendar view."
paul@876 377
paul@876 378
        for args in [self.env.get_args(), self.env.get_query()]:
paul@876 379
            if args.has_key("earlier"):
paul@876 380
                start_name, end_name = "earlier-start", "earlier-end"
paul@876 381
                break
paul@926 382
            elif args.has_key("earlier-events"):
paul@926 383
                start_name, end_name = "earlier-events-start", "earlier-events-end"
paul@926 384
                break
paul@876 385
            elif args.has_key("later"):
paul@876 386
                start_name, end_name = "later-start", "later-end"
paul@876 387
                break
paul@926 388
            elif args.has_key("later-events"):
paul@926 389
                start_name, end_name = "later-events-start", "later-events-end"
paul@926 390
                break
paul@876 391
            elif args.has_key("start") or args.has_key("end"):
paul@876 392
                start_name, end_name = "start", "end"
paul@876 393
                break
paul@876 394
        else:
paul@876 395
            return None, None
paul@876 396
paul@876 397
        view_start = self.get_date_arg(args, start_name)
paul@876 398
        view_end = self.get_date_arg(args, end_name)
paul@876 399
        return view_start, view_end
paul@876 400
paul@877 401
    def get_time_navigation_args(self):
paul@877 402
paul@877 403
        "Return a dictionary containing start and/or end navigation details."
paul@877 404
paul@889 405
        view_period = self.get_view_period()
paul@889 406
        view_start = view_period.get_start()
paul@889 407
        view_end = view_period.get_end()
paul@877 408
        link_args = {}
paul@877 409
        if view_start:
paul@877 410
            link_args["start"] = format_datetime(view_start)
paul@877 411
        if view_end:
paul@877 412
            link_args["end"] = format_datetime(view_end)
paul@877 413
        return link_args
paul@877 414
paul@889 415
    def get_view_period(self):
paul@889 416
paul@889 417
        "Return the current view period."
paul@889 418
paul@889 419
        view_start, view_end = self.get_time_navigation()
paul@889 420
paul@889 421
        # Without any explicit limits, impose a reasonable view period.
paul@889 422
paul@889 423
        if not (view_start or view_end):
paul@889 424
            view_start = get_date()
paul@889 425
            view_end = get_date(timedelta(7))
paul@889 426
paul@889 427
        return Period(view_start, view_end, self.get_tzid())
paul@889 428
paul@913 429
    def show_view_period(self, view_period):
paul@913 430
paul@913 431
        "Show a description of the 'view_period'."
paul@913 432
paul@1005 433
        _ = self.get_translator()
paul@1005 434
paul@913 435
        page = self.page
paul@913 436
paul@913 437
        view_start = view_period.get_start()
paul@913 438
        view_end = view_period.get_end()
paul@913 439
paul@913 440
        if not (view_start or view_end):
paul@913 441
            return
paul@913 442
paul@913 443
        page.p(class_="view-period")
paul@913 444
paul@913 445
        if view_start and view_end:
paul@1005 446
            page.add(_("Showing events from %(start)s until %(end)s") % {
paul@1005 447
                "start" : self.format_date(view_start, "full"),
paul@1005 448
                "end" : self.format_date(view_end, "full")})
paul@913 449
        elif view_start:
paul@1005 450
            page.add(_("Showing events from %s") % self.format_date(view_start, "full"))
paul@913 451
        elif view_end:
paul@1005 452
            page.add(_("Showing events until %s") % self.format_date(view_end, "full"))
paul@913 453
paul@913 454
        page.p.close()
paul@913 455
paul@883 456
    def get_period_group_details(self, freebusy, participants, view_period):
paul@873 457
paul@883 458
        """
paul@883 459
        Return details of periods in the given 'freebusy' collection and for the
paul@883 460
        collections of the given 'participants'.
paul@883 461
        """
paul@446 462
paul@1005 463
        _ = self.get_translator()
paul@1005 464
paul@446 465
        # Obtain the user's timezone.
paul@446 466
paul@446 467
        tzid = self.get_tzid()
paul@446 468
paul@446 469
        # Requests are listed and linked to their tentative positions in the
paul@446 470
        # calendar. Other participants are also shown.
paul@446 471
paul@1238 472
        request_summary = self._get_request_summary(view_period)
paul@446 473
paul@446 474
        period_groups = [request_summary, freebusy]
paul@446 475
        period_group_types = ["request", "freebusy"]
paul@1005 476
        period_group_sources = [_("Pending requests"), _("Your schedule")]
paul@446 477
paul@446 478
        for i, participant in enumerate(participants):
paul@446 479
            period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant)))
paul@446 480
            period_group_types.append("freebusy-part%d" % i)
paul@446 481
            period_group_sources.append(participant)
paul@446 482
paul@446 483
        groups = []
paul@446 484
        group_columns = []
paul@446 485
        group_types = period_group_types
paul@446 486
        group_sources = period_group_sources
paul@446 487
        all_points = set()
paul@446 488
paul@446 489
        # Obtain time point information for each group of periods.
paul@446 490
paul@446 491
        for periods in period_groups:
paul@446 492
paul@874 493
            # Filter periods outside the given view.
paul@874 494
paul@874 495
            if view_period:
paul@1189 496
                periods = periods.get_overlapping([view_period])
paul@874 497
paul@446 498
            # Get the time scale with start and end points.
paul@446 499
paul@884 500
            scale = get_scale(periods, tzid, view_period)
paul@446 501
paul@446 502
            # Get the time slots for the periods.
paul@456 503
            # Time slots are collections of Point objects with lists of active
paul@456 504
            # periods.
paul@446 505
paul@446 506
            slots = get_slots(scale)
paul@446 507
paul@446 508
            # Add start of day time points for multi-day periods.
paul@446 509
paul@446 510
            add_day_start_points(slots, tzid)
paul@446 511
paul@931 512
            # Remove the slot at the end of a view.
paul@931 513
paul@931 514
            if view_period:
paul@931 515
                remove_end_slot(slots, view_period)
paul@931 516
paul@446 517
            # Record the slots and all time points employed.
paul@446 518
paul@446 519
            groups.append(slots)
paul@456 520
            all_points.update([point for point, active in slots])
paul@446 521
paul@446 522
        # Partition the groups into days.
paul@446 523
paul@446 524
        days = {}
paul@446 525
        partitioned_groups = []
paul@446 526
        partitioned_group_types = []
paul@446 527
        partitioned_group_sources = []
paul@446 528
paul@446 529
        for slots, group_type, group_source in zip(groups, group_types, group_sources):
paul@446 530
paul@446 531
            # Propagate time points to all groups of time slots.
paul@446 532
paul@446 533
            add_slots(slots, all_points)
paul@446 534
paul@446 535
            # Count the number of columns employed by the group.
paul@446 536
paul@446 537
            columns = 0
paul@446 538
paul@446 539
            # Partition the time slots by day.
paul@446 540
paul@446 541
            partitioned = {}
paul@446 542
paul@446 543
            for day, day_slots in partition_by_day(slots).items():
paul@446 544
paul@446 545
                # Construct a list of time intervals within the day.
paul@446 546
paul@446 547
                intervals = []
paul@449 548
paul@449 549
                # Convert each partition to a mapping from points to active
paul@449 550
                # periods.
paul@449 551
paul@449 552
                partitioned[day] = day_points = {}
paul@449 553
paul@446 554
                last = None
paul@446 555
paul@455 556
                for point, active in day_slots:
paul@446 557
                    columns = max(columns, len(active))
paul@455 558
                    day_points[point] = active
paul@451 559
paul@446 560
                    if last:
paul@446 561
                        intervals.append((last, point))
paul@449 562
paul@455 563
                    last = point
paul@446 564
paul@446 565
                if last:
paul@446 566
                    intervals.append((last, None))
paul@446 567
paul@446 568
                if not days.has_key(day):
paul@446 569
                    days[day] = set()
paul@446 570
paul@446 571
                # Record the divisions or intervals within each day.
paul@446 572
paul@446 573
                days[day].update(intervals)
paul@446 574
paul@446 575
            # Only include the requests column if it provides objects.
paul@446 576
paul@446 577
            if group_type != "request" or columns:
paul@869 578
                if group_type != "request":
paul@869 579
                    columns += 1
paul@446 580
                group_columns.append(columns)
paul@446 581
                partitioned_groups.append(partitioned)
paul@446 582
                partitioned_group_types.append(group_type)
paul@446 583
                partitioned_group_sources.append(group_source)
paul@446 584
paul@883 585
        return days, partitioned_groups, partitioned_group_types, partitioned_group_sources, group_columns
paul@883 586
paul@883 587
    # Full page output methods.
paul@883 588
paul@883 589
    def show(self):
paul@883 590
paul@883 591
        "Show the calendar for the current user."
paul@883 592
paul@1005 593
        _ = self.get_translator()
paul@1005 594
paul@1005 595
        self.new_page(title=_("Calendar"))
paul@883 596
        page = self.page
paul@883 597
paul@883 598
        if self.handle_newevent():
paul@883 599
            return
paul@883 600
paul@883 601
        freebusy = self.store.get_freebusy(self.user)
paul@883 602
        participants = self.update_participants()
paul@883 603
paul@883 604
        # Form controls are used in various places on the calendar page.
paul@883 605
paul@883 606
        page.form(method="POST")
paul@1162 607
        self.validator()
paul@923 608
        self.show_user_navigation()
paul@883 609
        self.show_requests_on_page()
paul@883 610
        self.show_participants_on_page(participants)
paul@883 611
paul@926 612
        # Get the view period and details of events within it and outside it.
paul@926 613
paul@926 614
        view_period = self.get_view_period()
paul@926 615
paul@883 616
        # Day view: start at the earliest known day and produce days until the
paul@883 617
        # latest known day, with expandable sections of empty days.
paul@883 618
paul@883 619
        (days, partitioned_groups, partitioned_group_types, partitioned_group_sources, group_columns) = \
paul@883 620
            self.get_period_group_details(freebusy, participants, view_period)
paul@883 621
paul@446 622
        # Add empty days.
paul@446 623
paul@889 624
        add_empty_days(days, self.get_tzid(), view_period.get_start(), view_period.get_end())
paul@513 625
paul@881 626
        # Show controls to change the calendar appearance.
paul@513 627
paul@913 628
        self.show_view_period(view_period)
paul@881 629
        self.show_calendar_controls()
paul@926 630
        self.show_time_navigation(freebusy, view_period)
paul@446 631
paul@446 632
        # Show the calendar itself.
paul@446 633
paul@772 634
        self.show_calendar_days(days, partitioned_groups, partitioned_group_types, partitioned_group_sources, group_columns)
paul@446 635
paul@446 636
        # End the form region.
paul@446 637
paul@446 638
        page.form.close()
paul@446 639
paul@446 640
    # More page fragment methods.
paul@446 641
paul@773 642
    def show_calendar_day_controls(self, day):
paul@446 643
paul@773 644
        "Show controls for the given 'day' in the calendar."
paul@446 645
paul@446 646
        page = self.page
paul@773 647
        daystr, dayid = self._day_value_and_identifier(day)
paul@446 648
paul@446 649
        # Generate a dynamic stylesheet to allow day selections to colour
paul@446 650
        # specific days.
paul@446 651
        # NOTE: The style details need to be coordinated with the static
paul@446 652
        # NOTE: stylesheet.
paul@446 653
paul@446 654
        page.style(type="text/css")
paul@446 655
paul@773 656
        page.add("""\
paul@773 657
input.newevent.selector#%s:checked ~ table#region-%s label.day,
paul@773 658
input.newevent.selector#%s:checked ~ table#region-%s label.timepoint {
paul@773 659
    background-color: #5f4;
paul@773 660
    text-decoration: underline;
paul@773 661
}
paul@773 662
""" % (dayid, dayid, dayid, dayid))
paul@773 663
paul@773 664
        page.style.close()
paul@773 665
paul@773 666
        # Generate controls to select days.
paul@773 667
paul@773 668
        slots = self.env.get_args().get("slot", [])
paul@773 669
        value, identifier = self._day_value_and_identifier(day)
paul@773 670
        self._slot_selector(value, identifier, slots)
paul@773 671
paul@773 672
    def show_calendar_interval_controls(self, day, intervals):
paul@773 673
paul@773 674
        "Show controls for the intervals provided by 'day' and 'intervals'."
paul@773 675
paul@773 676
        page = self.page
paul@773 677
        daystr, dayid = self._day_value_and_identifier(day)
paul@773 678
paul@773 679
        # Generate a dynamic stylesheet to allow day selections to colour
paul@773 680
        # specific days.
paul@773 681
        # NOTE: The style details need to be coordinated with the static
paul@773 682
        # NOTE: stylesheet.
paul@773 683
paul@513 684
        l = []
paul@513 685
paul@773 686
        for point, endpoint in intervals:
paul@773 687
            timestr, timeid = self._slot_value_and_identifier(point, endpoint)
paul@513 688
            l.append("""\
paul@773 689
input.newevent.selector#%s:checked ~ table#region-%s th#region-%s""" % (timeid, dayid, timeid))
paul@773 690
paul@773 691
        page.style(type="text/css")
paul@513 692
paul@513 693
        page.add(",\n".join(l))
paul@513 694
        page.add(""" {
paul@446 695
    background-color: #5f4;
paul@446 696
    text-decoration: underline;
paul@446 697
}
paul@513 698
""")
paul@513 699
paul@513 700
        page.style.close()
paul@513 701
paul@773 702
        # Generate controls to select time periods.
paul@513 703
paul@773 704
        slots = self.env.get_args().get("slot", [])
paul@774 705
        last = None
paul@774 706
paul@774 707
        # Produce controls for the intervals/slots. Where instants in time are
paul@774 708
        # encountered, they are merged with the following slots, permitting the
paul@774 709
        # selection of contiguous time periods. However, the identifiers
paul@774 710
        # employed by controls corresponding to merged periods will encode the
paul@774 711
        # instant so that labels may reference them conveniently.
paul@774 712
paul@774 713
        intervals = list(intervals)
paul@774 714
        intervals.sort()
paul@774 715
paul@773 716
        for point, endpoint in intervals:
paul@774 717
paul@774 718
            # Merge any previous slot with this one, producing a control.
paul@774 719
paul@774 720
            if last:
paul@774 721
                _value, identifier = self._slot_value_and_identifier(last, last)
paul@774 722
                value, _identifier = self._slot_value_and_identifier(last, endpoint)
paul@774 723
                self._slot_selector(value, identifier, slots)
paul@774 724
paul@774 725
            # If representing an instant, hold the slot for merging.
paul@774 726
paul@774 727
            if endpoint and point.point == endpoint.point:
paul@774 728
                last = point
paul@774 729
paul@774 730
            # If not representing an instant, produce a control.
paul@774 731
paul@774 732
            else:
paul@774 733
                value, identifier = self._slot_value_and_identifier(point, endpoint)
paul@774 734
                self._slot_selector(value, identifier, slots)
paul@774 735
                last = None
paul@774 736
paul@774 737
        # Produce a control for any unmerged slot.
paul@774 738
paul@774 739
        if last:
paul@774 740
            _value, identifier = self._slot_value_and_identifier(last, last)
paul@774 741
            value, _identifier = self._slot_value_and_identifier(last, endpoint)
paul@773 742
            self._slot_selector(value, identifier, slots)
paul@446 743
paul@446 744
    def show_calendar_participant_headings(self, group_types, group_sources, group_columns):
paul@446 745
paul@446 746
        """
paul@446 747
        Show headings for the participants and other scheduling contributors,
paul@446 748
        defined by 'group_types', 'group_sources' and 'group_columns'.
paul@446 749
        """
paul@446 750
paul@446 751
        page = self.page
paul@446 752
paul@446 753
        page.colgroup(span=1, id="columns-timeslot")
paul@446 754
paul@995 755
        # Make column groups at least two cells wide.
paul@995 756
paul@446 757
        for group_type, columns in zip(group_types, group_columns):
paul@929 758
            page.colgroup(span=max(columns, 2), id="columns-%s" % group_type)
paul@446 759
paul@446 760
        page.thead()
paul@446 761
        page.tr()
paul@446 762
        page.th("", class_="emptyheading")
paul@446 763
paul@446 764
        for group_type, source, columns in zip(group_types, group_sources, group_columns):
paul@446 765
            page.th(source,
paul@446 766
                class_=(group_type == "request" and "requestheading" or "participantheading"),
paul@929 767
                colspan=max(columns, 2))
paul@446 768
paul@446 769
        page.tr.close()
paul@446 770
        page.thead.close()
paul@446 771
paul@772 772
    def show_calendar_days(self, days, partitioned_groups, partitioned_group_types,
paul@772 773
        partitioned_group_sources, group_columns):
paul@446 774
paul@446 775
        """
paul@446 776
        Show calendar days, defined by a collection of 'days', the contributing
paul@446 777
        period information as 'partitioned_groups' (partitioned by day), the
paul@446 778
        'partitioned_group_types' indicating the kind of contribution involved,
paul@772 779
        the 'partitioned_group_sources' indicating the origin of each group, and
paul@772 780
        the 'group_columns' defining the number of columns in each group.
paul@446 781
        """
paul@446 782
paul@1005 783
        _ = self.get_translator()
paul@1005 784
paul@446 785
        page = self.page
paul@446 786
paul@446 787
        # Determine the number of columns required. Where participants provide
paul@446 788
        # no columns for events, one still needs to be provided for the
paul@446 789
        # participant itself.
paul@446 790
paul@446 791
        all_columns = sum([max(columns, 1) for columns in group_columns])
paul@446 792
paul@446 793
        # Determine the days providing time slots.
paul@446 794
paul@446 795
        all_days = days.items()
paul@446 796
        all_days.sort()
paul@446 797
paul@446 798
        # Produce a heading and time points for each day.
paul@446 799
paul@778 800
        i = 0
paul@778 801
paul@446 802
        for day, intervals in all_days:
paul@446 803
            groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups]
paul@446 804
            is_empty = True
paul@446 805
paul@446 806
            for slots in groups_for_day:
paul@446 807
                if not slots:
paul@446 808
                    continue
paul@446 809
paul@446 810
                for active in slots.values():
paul@446 811
                    if active:
paul@446 812
                        is_empty = False
paul@446 813
                        break
paul@446 814
paul@768 815
            daystr, dayid = self._day_value_and_identifier(day)
paul@768 816
paul@773 817
            # Put calendar tables within elements for quicker CSS selection.
paul@773 818
paul@773 819
            page.div(class_="calendar")
paul@773 820
paul@773 821
            # Show the controls permitting day selection as well as the controls
paul@773 822
            # configuring the new event display.
paul@773 823
paul@773 824
            self.show_calendar_day_controls(day)
paul@773 825
            self.show_calendar_interval_controls(day, intervals)
paul@773 826
paul@773 827
            # Show an actual table containing the day information.
paul@773 828
paul@772 829
            page.table(cellspacing=5, cellpadding=5, class_="calendar %s" % (is_empty and " empty" or ""), id="region-%s" % dayid)
paul@772 830
paul@772 831
            page.caption(class_="dayheading container separator")
paul@446 832
            self._day_heading(day)
paul@772 833
            page.caption.close()
paul@446 834
paul@772 835
            self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns)
paul@772 836
paul@772 837
            page.tbody(class_="points")
paul@446 838
            self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns)
paul@446 839
            page.tbody.close()
paul@446 840
paul@772 841
            page.table.close()
paul@772 842
paul@773 843
            # Show a button for scheduling a new event.
paul@773 844
paul@773 845
            page.p(class_="newevent-with-periods")
paul@1005 846
            page.label(_("Summary:"))
paul@778 847
            page.input(name="summary-%d" % i, type="text")
paul@1005 848
            page.input(name="newevent-%d" % i, type="submit", value=_("New event"), accesskey="N")
paul@773 849
            page.p.close()
paul@773 850
paul@876 851
            page.p(class_="newevent-with-periods")
paul@1005 852
            page.label(_("Clear selections"), for_="reset", class_="reset")
paul@876 853
            page.p.close()
paul@876 854
paul@773 855
            page.div.close()
paul@773 856
paul@778 857
            i += 1
paul@778 858
paul@446 859
    def show_calendar_points(self, intervals, groups, group_types, group_columns):
paul@446 860
paul@446 861
        """
paul@446 862
        Show the time 'intervals' along with period information from the given
paul@446 863
        'groups', having the indicated 'group_types', each with the number of
paul@446 864
        columns given by 'group_columns'.
paul@446 865
        """
paul@446 866
paul@1005 867
        _ = self.get_translator()
paul@1005 868
paul@446 869
        page = self.page
paul@446 870
paul@446 871
        # Obtain the user's timezone.
paul@446 872
paul@446 873
        tzid = self.get_tzid()
paul@446 874
paul@877 875
        # Get view information for links.
paul@877 876
paul@877 877
        link_args = self.get_time_navigation_args()
paul@877 878
paul@446 879
        # Produce a row for each interval.
paul@446 880
paul@446 881
        intervals = list(intervals)
paul@446 882
        intervals.sort()
paul@446 883
paul@455 884
        for point, endpoint in intervals:
paul@455 885
            continuation = point.point == get_start_of_day(point.point, tzid)
paul@446 886
paul@446 887
            # Some rows contain no period details and are marked as such.
paul@446 888
paul@448 889
            have_active = False
paul@448 890
            have_active_request = False
paul@448 891
paul@448 892
            for slots, group_type in zip(groups, group_types):
paul@455 893
                if slots and slots.get(point):
paul@448 894
                    if group_type == "request":
paul@448 895
                        have_active_request = True
paul@448 896
                    else:
paul@448 897
                        have_active = True
paul@446 898
paul@450 899
            # Emit properties of the time interval, where post-instant intervals
paul@450 900
            # are also treated as busy.
paul@450 901
paul@446 902
            css = " ".join([
paul@446 903
                "slot",
paul@455 904
                (have_active or point.indicator == Point.REPEATED) and "busy" or \
paul@455 905
                    have_active_request and "suggested" or "empty",
paul@446 906
                continuation and "daystart" or ""
paul@446 907
                ])
paul@446 908
paul@446 909
            page.tr(class_=css)
paul@774 910
paul@774 911
            # Produce a time interval heading, spanning two rows if this point
paul@774 912
            # represents an instant.
paul@774 913
paul@455 914
            if point.indicator == Point.PRINCIPAL:
paul@768 915
                timestr, timeid = self._slot_value_and_identifier(point, endpoint)
paul@774 916
                page.th(class_="timeslot", id="region-%s" % timeid,
paul@774 917
                    rowspan=(endpoint and point.point == endpoint.point and 2 or 1))
paul@449 918
                self._time_point(point, endpoint)
paul@774 919
                page.th.close()
paul@446 920
paul@446 921
            # Obtain slots for the time point from each group.
paul@446 922
paul@446 923
            for columns, slots, group_type in zip(group_columns, groups, group_types):
paul@995 924
paul@995 925
                # Make column groups at least two cells wide.
paul@995 926
paul@995 927
                columns = max(columns, 2)
paul@455 928
                active = slots and slots.get(point)
paul@446 929
paul@446 930
                # Where no periods exist for the given time interval, generate
paul@446 931
                # an empty cell. Where a participant provides no periods at all,
paul@869 932
                # one column is provided; otherwise, one more column than the
paul@869 933
                # number required is provided.
paul@446 934
paul@446 935
                if not active:
paul@929 936
                    self._empty_slot(point, endpoint, max(columns, 2))
paul@446 937
                    continue
paul@446 938
paul@446 939
                slots = slots.items()
paul@446 940
                slots.sort()
paul@446 941
                spans = get_spans(slots)
paul@446 942
paul@446 943
                empty = 0
paul@446 944
paul@446 945
                # Show a column for each active period.
paul@446 946
paul@458 947
                for p in active:
paul@458 948
paul@458 949
                    # The period can be None, meaning an empty column.
paul@458 950
paul@458 951
                    if p:
paul@446 952
paul@446 953
                        # Flush empty slots preceding this one.
paul@446 954
paul@446 955
                        if empty:
paul@455 956
                            self._empty_slot(point, endpoint, empty)
paul@446 957
                            empty = 0
paul@446 958
paul@458 959
                        key = p.get_key()
paul@446 960
                        span = spans[key]
paul@446 961
paul@446 962
                        # Produce a table cell only at the start of the period
paul@446 963
                        # or when continued at the start of a day.
paul@453 964
                        # Points defining the ends of instant events should
paul@453 965
                        # never define the start of new events.
paul@446 966
paul@546 967
                        if point.indicator == Point.PRINCIPAL and (point.point == p.get_start() or continuation):
paul@446 968
paul@546 969
                            has_continued = continuation and point.point != p.get_start()
paul@546 970
                            will_continue = not ends_on_same_day(point.point, p.get_end(), tzid)
paul@458 971
                            is_organiser = p.organiser == self.user
paul@446 972
paul@446 973
                            css = " ".join([
paul@446 974
                                "event",
paul@446 975
                                has_continued and "continued" or "",
paul@446 976
                                will_continue and "continues" or "",
paul@763 977
                                p.transp == "ORG" and "only-organising" or is_organiser and "organising" or "attending",
paul@763 978
                                self._have_request(p.uid, p.recurrenceid, "COUNTER", True) and "counter" or "",
paul@446 979
                                ])
paul@446 980
paul@446 981
                            # Only anchor the first cell of events.
paul@446 982
                            # Need to only anchor the first period for a recurring
paul@446 983
                            # event.
paul@446 984
paul@458 985
                            html_id = "%s-%s-%s" % (group_type, p.uid, p.recurrenceid or "")
paul@446 986
paul@546 987
                            if point.point == p.get_start() and html_id not in self.html_ids:
paul@446 988
                                page.td(class_=css, rowspan=span, id=html_id)
paul@446 989
                                self.html_ids.add(html_id)
paul@446 990
                            else:
paul@446 991
                                page.td(class_=css, rowspan=span)
paul@446 992
paul@755 993
                            # Only link to events if they are not being updated
paul@755 994
                            # by requests.
paul@755 995
paul@755 996
                            if not p.summary or \
paul@755 997
                                group_type != "request" and self._have_request(p.uid, p.recurrenceid, None, True):
paul@755 998
paul@1005 999
                                page.span(p.summary or _("(Participant is busy)"))
paul@446 1000
paul@755 1001
                            # Link to requests and events (including ones for
paul@755 1002
                            # which counter-proposals exist).
paul@755 1003
paul@777 1004
                            elif group_type == "request" and self._have_request(p.uid, p.recurrenceid, "COUNTER", True):
paul@877 1005
                                d = {"counter" : self._period_identifier(p)}
paul@877 1006
                                d.update(link_args)
paul@877 1007
                                page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid, d))
paul@777 1008
paul@446 1009
                            else:
paul@877 1010
                                page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid, link_args))
paul@446 1011
paul@446 1012
                            page.td.close()
paul@446 1013
                    else:
paul@446 1014
                        empty += 1
paul@446 1015
paul@446 1016
                # Pad with empty columns.
paul@446 1017
paul@446 1018
                empty = columns - len(active)
paul@446 1019
paul@446 1020
                if empty:
paul@904 1021
                    self._empty_slot(point, endpoint, empty, True)
paul@446 1022
paul@446 1023
            page.tr.close()
paul@446 1024
paul@446 1025
    def _day_heading(self, day):
paul@446 1026
paul@446 1027
        """
paul@446 1028
        Generate a heading for 'day' of the following form:
paul@446 1029
paul@768 1030
        <label class="day" for="day-20150203">Tuesday, 3 February 2015</label>
paul@446 1031
        """
paul@446 1032
paul@446 1033
        page = self.page
paul@446 1034
        value, identifier = self._day_value_and_identifier(day)
paul@768 1035
        page.label(self.format_date(day, "full"), class_="day", for_=identifier)
paul@446 1036
paul@446 1037
    def _time_point(self, point, endpoint):
paul@446 1038
paul@446 1039
        """
paul@446 1040
        Generate headings for the 'point' to 'endpoint' period of the following
paul@446 1041
        form:
paul@446 1042
paul@768 1043
        <label class="timepoint" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label>
paul@446 1044
        <span class="endpoint">10:00:00 CET</span>
paul@446 1045
        """
paul@446 1046
paul@446 1047
        page = self.page
paul@446 1048
        tzid = self.get_tzid()
paul@446 1049
        value, identifier = self._slot_value_and_identifier(point, endpoint)
paul@768 1050
        page.label(self.format_time(point.point, "long"), class_="timepoint", for_=identifier)
paul@455 1051
        page.span(self.format_time(endpoint and endpoint.point or get_end_of_day(point.point, tzid), "long"), class_="endpoint")
paul@446 1052
paul@446 1053
    def _slot_selector(self, value, identifier, slots):
paul@446 1054
paul@446 1055
        """
paul@446 1056
        Provide a timeslot control having the given 'value', employing the
paul@446 1057
        indicated HTML 'identifier', and using the given 'slots' collection
paul@446 1058
        to select any control whose 'value' is in this collection, unless the
paul@446 1059
        "reset" request parameter has been asserted.
paul@446 1060
        """
paul@446 1061
paul@446 1062
        reset = self.env.get_args().has_key("reset")
paul@446 1063
        page = self.page
paul@446 1064
        if not reset and value in slots:
paul@446 1065
            page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked")
paul@446 1066
        else:
paul@446 1067
            page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector")
paul@446 1068
paul@904 1069
    def _empty_slot(self, point, endpoint, colspan, at_end=False):
paul@446 1070
paul@453 1071
        """
paul@453 1072
        Show an empty slot cell for the given 'point' and 'endpoint', with the
paul@455 1073
        given 'colspan' configuring the cell's appearance.
paul@453 1074
        """
paul@446 1075
paul@1005 1076
        _ = self.get_translator()
paul@1005 1077
paul@446 1078
        page = self.page
paul@904 1079
        page.td(class_="empty%s%s" % (point.indicator == Point.PRINCIPAL and " container" or "", at_end and " padding" or ""), colspan=colspan)
paul@455 1080
        if point.indicator == Point.PRINCIPAL:
paul@453 1081
            value, identifier = self._slot_value_and_identifier(point, endpoint)
paul@1005 1082
            page.label(_("Select/deselect period"), class_="newevent popup", for_=identifier)
paul@453 1083
        page.td.close()
paul@446 1084
paul@446 1085
    def _day_value_and_identifier(self, day):
paul@446 1086
paul@446 1087
        "Return a day value and HTML identifier for the given 'day'."
paul@446 1088
paul@513 1089
        value = format_datetime(day)
paul@446 1090
        identifier = "day-%s" % value
paul@446 1091
        return value, identifier
paul@446 1092
paul@446 1093
    def _slot_value_and_identifier(self, point, endpoint):
paul@446 1094
paul@446 1095
        """
paul@446 1096
        Return a slot value and HTML identifier for the given 'point' and
paul@446 1097
        'endpoint'.
paul@446 1098
        """
paul@446 1099
paul@455 1100
        value = "%s-%s" % (format_datetime(point.point), endpoint and format_datetime(endpoint.point) or "")
paul@446 1101
        identifier = "slot-%s" % value
paul@446 1102
        return value, identifier
paul@446 1103
paul@777 1104
    def _period_identifier(self, period):
paul@777 1105
        return "%s-%s" % (format_datetime(period.get_start()), format_datetime(period.get_end()))
paul@777 1106
paul@874 1107
    def get_date_arg(self, args, name):
paul@874 1108
        values = args.get(name)
paul@874 1109
        if not values:
paul@874 1110
            return None
paul@874 1111
        return get_datetime(values[0], {"VALUE-TYPE" : "DATE"})
paul@874 1112
paul@446 1113
# vim: tabstop=4 expandtab shiftwidth=4