imip-agent

Annotated imipweb/calendar.py

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