imip-agent

Annotated imipweb/calendar.py

606:d75510f99c06
2015-07-26 Paul Boddie Moved various handler methods to the client classes. Attempted to simplify various methods and to remove the need to obtain information from some methods only to pass it straight to other methods that could have obtained such information themselves.
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@446 6
Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>
paul@446 7
paul@446 8
This program is free software; you can redistribute it and/or modify it under
paul@446 9
the terms of the GNU General Public License as published by the Free Software
paul@446 10
Foundation; either version 3 of the License, or (at your option) any later
paul@446 11
version.
paul@446 12
paul@446 13
This program is distributed in the hope that it will be useful, but WITHOUT
paul@446 14
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
paul@446 15
FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
paul@446 16
details.
paul@446 17
paul@446 18
You should have received a copy of the GNU General Public License along with
paul@446 19
this program.  If not, see <http://www.gnu.org/licenses/>.
paul@446 20
"""
paul@446 21
paul@446 22
from datetime import datetime
paul@446 23
from imiptools.data import get_address, get_uri, uri_values
paul@446 24
from imiptools.dates import format_datetime, 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@446 27
                            to_timezone
paul@446 28
from imiptools.period import add_day_start_points, add_empty_days, add_slots, \
paul@529 29
                             get_scale, get_slots, get_spans, partition_by_day, Point
paul@446 30
from imipweb.resource import Resource
paul@446 31
paul@446 32
class CalendarPage(Resource):
paul@446 33
paul@446 34
    "A request handler for the calendar page."
paul@446 35
paul@446 36
    # Request logic methods.
paul@446 37
paul@446 38
    def handle_newevent(self):
paul@446 39
paul@446 40
        """
paul@446 41
        Handle any new event operation, creating a new event and redirecting to
paul@446 42
        the event page for further activity.
paul@446 43
        """
paul@446 44
paul@446 45
        # Handle a submitted form.
paul@446 46
paul@446 47
        args = self.env.get_args()
paul@446 48
paul@446 49
        if not args.has_key("newevent"):
paul@446 50
            return
paul@446 51
paul@446 52
        # Create a new event using the available information.
paul@446 53
paul@446 54
        slots = args.get("slot", [])
paul@446 55
        participants = args.get("participants", [])
paul@446 56
paul@446 57
        if not slots:
paul@446 58
            return
paul@446 59
paul@446 60
        # Obtain the user's timezone.
paul@446 61
paul@446 62
        tzid = self.get_tzid()
paul@446 63
paul@446 64
        # Coalesce the selected slots.
paul@446 65
paul@446 66
        slots.sort()
paul@446 67
        coalesced = []
paul@446 68
        last = None
paul@446 69
paul@446 70
        for slot in slots:
paul@537 71
            start, end = (slot.split("-", 1) + [None])[:2]
paul@446 72
            start = get_datetime(start, {"TZID" : tzid})
paul@446 73
            end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid)
paul@446 74
paul@446 75
            if last:
paul@446 76
                last_start, last_end = last
paul@446 77
paul@446 78
                # Merge adjacent dates and datetimes.
paul@446 79
paul@446 80
                if start == last_end or \
paul@446 81
                    not isinstance(start, datetime) and \
paul@446 82
                    get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid):
paul@446 83
paul@446 84
                    last = last_start, end
paul@446 85
                    continue
paul@446 86
paul@446 87
                # Handle datetimes within dates.
paul@446 88
                # Datetime periods are within single days and are therefore
paul@446 89
                # discarded.
paul@446 90
paul@446 91
                elif not isinstance(last_start, datetime) and \
paul@446 92
                    get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid):
paul@446 93
paul@446 94
                    continue
paul@446 95
paul@446 96
                # Add separate dates and datetimes.
paul@446 97
paul@446 98
                else:
paul@446 99
                    coalesced.append(last)
paul@446 100
paul@446 101
            last = start, end
paul@446 102
paul@446 103
        if last:
paul@446 104
            coalesced.append(last)
paul@446 105
paul@446 106
        # Invent a unique identifier.
paul@446 107
paul@446 108
        utcnow = get_timestamp()
paul@446 109
        uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user))
paul@446 110
paul@446 111
        # Create a calendar object and store it as a request.
paul@446 112
paul@446 113
        record = []
paul@446 114
        rwrite = record.append
paul@446 115
paul@446 116
        # Define a single occurrence if only one coalesced slot exists.
paul@446 117
paul@446 118
        start, end = coalesced[0]
paul@446 119
        start_value, start_attr = get_datetime_item(start, tzid)
paul@446 120
        end_value, end_attr = get_datetime_item(end, tzid)
paul@446 121
paul@446 122
        rwrite(("UID", {}, uid))
paul@446 123
        rwrite(("SUMMARY", {}, "New event at %s" % utcnow))
paul@446 124
        rwrite(("DTSTAMP", {}, utcnow))
paul@446 125
        rwrite(("DTSTART", start_attr, start_value))
paul@446 126
        rwrite(("DTEND", end_attr, end_value))
paul@446 127
        rwrite(("ORGANIZER", {}, self.user))
paul@446 128
paul@446 129
        participants = uri_values(filter(None, participants))
paul@446 130
paul@446 131
        for participant in participants:
paul@446 132
            rwrite(("ATTENDEE", {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}, participant))
paul@446 133
paul@446 134
        if self.user not in participants:
paul@446 135
            rwrite(("ATTENDEE", {"PARTSTAT" : "ACCEPTED"}, self.user))
paul@446 136
paul@446 137
        # Define additional occurrences if many slots are defined.
paul@446 138
paul@446 139
        rdates = []
paul@446 140
paul@446 141
        for start, end in coalesced[1:]:
paul@446 142
            start_value, start_attr = get_datetime_item(start, tzid)
paul@446 143
            end_value, end_attr = get_datetime_item(end, tzid)
paul@446 144
            rdates.append("%s/%s" % (start_value, end_value))
paul@446 145
paul@446 146
        if rdates:
paul@446 147
            rwrite(("RDATE", {"VALUE" : "PERIOD", "TZID" : tzid}, rdates))
paul@446 148
paul@446 149
        node = ("VEVENT", {}, record)
paul@446 150
paul@446 151
        self.store.set_event(self.user, uid, None, node=node)
paul@446 152
        self.store.queue_request(self.user, uid)
paul@446 153
paul@446 154
        # Redirect to the object (or the first of the objects), where instead of
paul@446 155
        # attendee controls, there will be organiser controls.
paul@446 156
paul@446 157
        self.redirect(self.link_to(uid))
paul@446 158
paul@446 159
    # Page fragment methods.
paul@446 160
paul@446 161
    def show_requests_on_page(self):
paul@446 162
paul@446 163
        "Show requests for the current user."
paul@446 164
paul@446 165
        page = self.page
paul@446 166
paul@446 167
        # NOTE: This list could be more informative, but it is envisaged that
paul@446 168
        # NOTE: the requests would be visited directly anyway.
paul@446 169
paul@446 170
        requests = self._get_requests()
paul@446 171
paul@446 172
        page.div(id="pending-requests")
paul@446 173
paul@446 174
        if requests:
paul@446 175
            page.p("Pending requests:")
paul@446 176
paul@446 177
            page.ul()
paul@446 178
paul@446 179
            for uid, recurrenceid in requests:
paul@606 180
                obj = self.get_stored_object(uid, recurrenceid)
paul@446 181
                if obj:
paul@446 182
                    page.li()
paul@446 183
                    page.a(obj.get_value("SUMMARY"), href="#request-%s-%s" % (uid, recurrenceid or ""))
paul@446 184
                    page.li.close()
paul@446 185
paul@446 186
            page.ul.close()
paul@446 187
paul@446 188
        else:
paul@446 189
            page.p("There are no pending requests.")
paul@446 190
paul@446 191
        page.div.close()
paul@446 192
paul@446 193
    def show_participants_on_page(self):
paul@446 194
paul@446 195
        "Show participants for scheduling purposes."
paul@446 196
paul@446 197
        page = self.page
paul@446 198
        args = self.env.get_args()
paul@446 199
        participants = args.get("participants", [])
paul@446 200
paul@446 201
        try:
paul@446 202
            for name, value in args.items():
paul@446 203
                if name.startswith("remove-participant-"):
paul@446 204
                    i = int(name[len("remove-participant-"):])
paul@446 205
                    del participants[i]
paul@446 206
                    break
paul@446 207
        except ValueError:
paul@446 208
            pass
paul@446 209
paul@446 210
        # Trim empty participants.
paul@446 211
paul@446 212
        while participants and not participants[-1].strip():
paul@446 213
            participants.pop()
paul@446 214
paul@446 215
        # Show any specified participants together with controls to remove and
paul@446 216
        # add participants.
paul@446 217
paul@446 218
        page.div(id="participants")
paul@446 219
paul@446 220
        page.p("Participants for scheduling:")
paul@446 221
paul@446 222
        for i, participant in enumerate(participants):
paul@446 223
            page.p()
paul@446 224
            page.input(name="participants", type="text", value=participant)
paul@446 225
            page.input(name="remove-participant-%d" % i, type="submit", value="Remove")
paul@446 226
            page.p.close()
paul@446 227
paul@446 228
        page.p()
paul@446 229
        page.input(name="participants", type="text")
paul@446 230
        page.input(name="add-participant", type="submit", value="Add")
paul@446 231
        page.p.close()
paul@446 232
paul@446 233
        page.div.close()
paul@446 234
paul@446 235
        return participants
paul@446 236
paul@446 237
    # Full page output methods.
paul@446 238
paul@446 239
    def show(self):
paul@446 240
paul@446 241
        "Show the calendar for the current user."
paul@446 242
paul@446 243
        self.new_page(title="Calendar")
paul@446 244
        page = self.page
paul@446 245
paul@513 246
        handled = self.handle_newevent()
paul@513 247
        freebusy = self.store.get_freebusy(self.user)
paul@513 248
paul@513 249
        if not freebusy:
paul@513 250
            page.p("No events scheduled.")
paul@513 251
            return
paul@513 252
paul@446 253
        # Form controls are used in various places on the calendar page.
paul@446 254
paul@446 255
        page.form(method="POST")
paul@446 256
paul@446 257
        self.show_requests_on_page()
paul@446 258
        participants = self.show_participants_on_page()
paul@446 259
paul@446 260
        # Obtain the user's timezone.
paul@446 261
paul@446 262
        tzid = self.get_tzid()
paul@446 263
paul@446 264
        # Day view: start at the earliest known day and produce days until the
paul@446 265
        # latest known day, perhaps with expandable sections of empty days.
paul@446 266
paul@446 267
        # Month view: start at the earliest known month and produce months until
paul@446 268
        # the latest known month, perhaps with expandable sections of empty
paul@446 269
        # months.
paul@446 270
paul@446 271
        # Details of users to invite to new events could be superimposed on the
paul@446 272
        # calendar.
paul@446 273
paul@446 274
        # Requests are listed and linked to their tentative positions in the
paul@446 275
        # calendar. Other participants are also shown.
paul@446 276
paul@446 277
        request_summary = self._get_request_summary()
paul@446 278
paul@446 279
        period_groups = [request_summary, freebusy]
paul@446 280
        period_group_types = ["request", "freebusy"]
paul@446 281
        period_group_sources = ["Pending requests", "Your schedule"]
paul@446 282
paul@446 283
        for i, participant in enumerate(participants):
paul@446 284
            period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant)))
paul@446 285
            period_group_types.append("freebusy-part%d" % i)
paul@446 286
            period_group_sources.append(participant)
paul@446 287
paul@446 288
        groups = []
paul@446 289
        group_columns = []
paul@446 290
        group_types = period_group_types
paul@446 291
        group_sources = period_group_sources
paul@446 292
        all_points = set()
paul@446 293
paul@446 294
        # Obtain time point information for each group of periods.
paul@446 295
paul@446 296
        for periods in period_groups:
paul@446 297
paul@446 298
            # Get the time scale with start and end points.
paul@446 299
paul@529 300
            scale = get_scale(periods, tzid)
paul@446 301
paul@446 302
            # Get the time slots for the periods.
paul@456 303
            # Time slots are collections of Point objects with lists of active
paul@456 304
            # periods.
paul@446 305
paul@446 306
            slots = get_slots(scale)
paul@446 307
paul@446 308
            # Add start of day time points for multi-day periods.
paul@446 309
paul@446 310
            add_day_start_points(slots, tzid)
paul@446 311
paul@446 312
            # Record the slots and all time points employed.
paul@446 313
paul@446 314
            groups.append(slots)
paul@456 315
            all_points.update([point for point, active in slots])
paul@446 316
paul@446 317
        # Partition the groups into days.
paul@446 318
paul@446 319
        days = {}
paul@446 320
        partitioned_groups = []
paul@446 321
        partitioned_group_types = []
paul@446 322
        partitioned_group_sources = []
paul@446 323
paul@446 324
        for slots, group_type, group_source in zip(groups, group_types, group_sources):
paul@446 325
paul@446 326
            # Propagate time points to all groups of time slots.
paul@446 327
paul@446 328
            add_slots(slots, all_points)
paul@446 329
paul@446 330
            # Count the number of columns employed by the group.
paul@446 331
paul@446 332
            columns = 0
paul@446 333
paul@446 334
            # Partition the time slots by day.
paul@446 335
paul@446 336
            partitioned = {}
paul@446 337
paul@446 338
            for day, day_slots in partition_by_day(slots).items():
paul@446 339
paul@446 340
                # Construct a list of time intervals within the day.
paul@446 341
paul@446 342
                intervals = []
paul@449 343
paul@449 344
                # Convert each partition to a mapping from points to active
paul@449 345
                # periods.
paul@449 346
paul@449 347
                partitioned[day] = day_points = {}
paul@449 348
paul@446 349
                last = None
paul@446 350
paul@455 351
                for point, active in day_slots:
paul@446 352
                    columns = max(columns, len(active))
paul@455 353
                    day_points[point] = active
paul@451 354
paul@446 355
                    if last:
paul@446 356
                        intervals.append((last, point))
paul@449 357
paul@455 358
                    last = point
paul@446 359
paul@446 360
                if last:
paul@446 361
                    intervals.append((last, None))
paul@446 362
paul@446 363
                if not days.has_key(day):
paul@446 364
                    days[day] = set()
paul@446 365
paul@446 366
                # Record the divisions or intervals within each day.
paul@446 367
paul@446 368
                days[day].update(intervals)
paul@446 369
paul@446 370
            # Only include the requests column if it provides objects.
paul@446 371
paul@446 372
            if group_type != "request" or columns:
paul@446 373
                group_columns.append(columns)
paul@446 374
                partitioned_groups.append(partitioned)
paul@446 375
                partitioned_group_types.append(group_type)
paul@446 376
                partitioned_group_sources.append(group_source)
paul@446 377
paul@446 378
        # Add empty days.
paul@446 379
paul@446 380
        add_empty_days(days, tzid)
paul@446 381
paul@513 382
        # Show the controls permitting day selection as well as the controls
paul@513 383
        # configuring the new event display.
paul@446 384
paul@446 385
        self.show_calendar_day_controls(days)
paul@513 386
        self.show_calendar_interval_controls(days)
paul@513 387
paul@513 388
        # Show a button for scheduling a new event.
paul@513 389
paul@513 390
        page.p(class_="controls")
paul@513 391
        page.input(name="newevent", type="submit", value="New event", id="newevent", class_="newevent-with-periods", accesskey="N")
paul@513 392
        page.span("Select days or periods for a new event.", class_="newevent-no-periods")
paul@513 393
        page.p.close()
paul@513 394
paul@513 395
        # Show controls for hiding empty days and busy slots.
paul@513 396
        # The positioning of the control, paragraph and table are important here.
paul@513 397
paul@513 398
        page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D")
paul@513 399
        page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B")
paul@513 400
paul@513 401
        page.p(class_="controls")
paul@513 402
        page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable")
paul@513 403
        page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable")
paul@513 404
        page.label("Show empty days", for_="showdays", class_="showdays disable")
paul@513 405
        page.label("Hide empty days", for_="showdays", class_="showdays enable")
paul@513 406
        page.input(name="reset", type="submit", value="Clear selections", id="reset")
paul@513 407
        page.label("Clear selections", for_="reset", class_="reset newevent-with-periods")
paul@513 408
        page.p.close()
paul@446 409
paul@446 410
        # Show the calendar itself.
paul@446 411
paul@446 412
        page.table(cellspacing=5, cellpadding=5, class_="calendar")
paul@446 413
        self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns)
paul@446 414
        self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns)
paul@446 415
        page.table.close()
paul@446 416
paul@446 417
        # End the form region.
paul@446 418
paul@446 419
        page.form.close()
paul@446 420
paul@446 421
    # More page fragment methods.
paul@446 422
paul@446 423
    def show_calendar_day_controls(self, days):
paul@446 424
paul@446 425
        "Show controls for the given 'days' in the calendar."
paul@446 426
paul@446 427
        page = self.page
paul@446 428
        slots = self.env.get_args().get("slot", [])
paul@446 429
paul@446 430
        for day in days:
paul@446 431
            value, identifier = self._day_value_and_identifier(day)
paul@446 432
            self._slot_selector(value, identifier, slots)
paul@446 433
paul@446 434
        # Generate a dynamic stylesheet to allow day selections to colour
paul@446 435
        # specific days.
paul@446 436
        # NOTE: The style details need to be coordinated with the static
paul@446 437
        # NOTE: stylesheet.
paul@446 438
paul@446 439
        page.style(type="text/css")
paul@446 440
paul@513 441
        l = []
paul@513 442
paul@446 443
        for day in days:
paul@513 444
            daystr, dayid = self._day_value_and_identifier(day)
paul@513 445
            l.append("""\
paul@513 446
input.newevent.selector#%s:checked ~ table label.day.day-%s,
paul@513 447
input.newevent.selector#%s:checked ~ table label.timepoint.day-%s""" % (dayid, daystr, dayid, daystr))
paul@513 448
paul@513 449
        page.add(",\n".join(l))
paul@513 450
        page.add(""" {
paul@446 451
    background-color: #5f4;
paul@446 452
    text-decoration: underline;
paul@446 453
}
paul@513 454
""")
paul@513 455
paul@513 456
        page.style.close()
paul@513 457
paul@513 458
    def show_calendar_interval_controls(self, days):
paul@513 459
paul@513 460
        "Show controls for the intervals provided by 'days'."
paul@513 461
paul@513 462
        page = self.page
paul@513 463
        slots = self.env.get_args().get("slot", [])
paul@513 464
paul@513 465
        for day, intervals in days.items():
paul@513 466
            for point, endpoint in intervals:
paul@513 467
                value, identifier = self._slot_value_and_identifier(point, endpoint)
paul@513 468
                self._slot_selector(value, identifier, slots)
paul@513 469
paul@513 470
        # Generate a dynamic stylesheet to allow day selections to colour
paul@513 471
        # specific days.
paul@513 472
        # NOTE: The style details need to be coordinated with the static
paul@513 473
        # NOTE: stylesheet.
paul@513 474
paul@513 475
        page.style(type="text/css")
paul@513 476
paul@513 477
        l = []; l2 = []; l3 = []
paul@513 478
paul@513 479
        for day, intervals in days.items():
paul@513 480
            for point, endpoint in intervals:
paul@513 481
                daystr, dayid = self._day_value_and_identifier(day)
paul@513 482
                timestr, timeid = self._slot_value_and_identifier(point, endpoint)
paul@513 483
                l.append("""\
paul@513 484
input.newevent.selector#%s:checked ~ p .newevent-no-periods,
paul@513 485
input.newevent.selector#%s:checked ~ p .newevent-no-periods""" % (dayid, timeid))
paul@513 486
                l2.append("""\
paul@513 487
input.newevent.selector#%s:checked ~ p .newevent-with-periods,
paul@513 488
input.newevent.selector#%s:checked ~ p .newevent-with-periods""" % (dayid, timeid))
paul@513 489
                l3.append("""\
paul@513 490
input.newevent.selector#%s:checked ~ table label.timepoint[for=%s]""" % (timeid, timeid))
paul@513 491
paul@513 492
        page.add(",\n".join(l))
paul@513 493
        page.add(""" {
paul@513 494
    display: none;
paul@513 495
}""")
paul@513 496
paul@513 497
        page.add(",\n".join(l2))
paul@513 498
        page.add(""" {
paul@513 499
    display: inline;
paul@513 500
}
paul@513 501
""")
paul@513 502
paul@513 503
        page.add(",\n".join(l3))
paul@513 504
        page.add(""" {
paul@513 505
    background-color: #5f4;
paul@513 506
    text-decoration: underline;
paul@513 507
}
paul@513 508
""")
paul@446 509
paul@446 510
        page.style.close()
paul@446 511
paul@446 512
    def show_calendar_participant_headings(self, group_types, group_sources, group_columns):
paul@446 513
paul@446 514
        """
paul@446 515
        Show headings for the participants and other scheduling contributors,
paul@446 516
        defined by 'group_types', 'group_sources' and 'group_columns'.
paul@446 517
        """
paul@446 518
paul@446 519
        page = self.page
paul@446 520
paul@446 521
        page.colgroup(span=1, id="columns-timeslot")
paul@446 522
paul@446 523
        for group_type, columns in zip(group_types, group_columns):
paul@446 524
            page.colgroup(span=max(columns, 1), id="columns-%s" % group_type)
paul@446 525
paul@446 526
        page.thead()
paul@446 527
        page.tr()
paul@446 528
        page.th("", class_="emptyheading")
paul@446 529
paul@446 530
        for group_type, source, columns in zip(group_types, group_sources, group_columns):
paul@446 531
            page.th(source,
paul@446 532
                class_=(group_type == "request" and "requestheading" or "participantheading"),
paul@446 533
                colspan=max(columns, 1))
paul@446 534
paul@446 535
        page.tr.close()
paul@446 536
        page.thead.close()
paul@446 537
paul@446 538
    def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns):
paul@446 539
paul@446 540
        """
paul@446 541
        Show calendar days, defined by a collection of 'days', the contributing
paul@446 542
        period information as 'partitioned_groups' (partitioned by day), the
paul@446 543
        'partitioned_group_types' indicating the kind of contribution involved,
paul@446 544
        and the 'group_columns' defining the number of columns in each group.
paul@446 545
        """
paul@446 546
paul@446 547
        page = self.page
paul@446 548
paul@446 549
        # Determine the number of columns required. Where participants provide
paul@446 550
        # no columns for events, one still needs to be provided for the
paul@446 551
        # participant itself.
paul@446 552
paul@446 553
        all_columns = sum([max(columns, 1) for columns in group_columns])
paul@446 554
paul@446 555
        # Determine the days providing time slots.
paul@446 556
paul@446 557
        all_days = days.items()
paul@446 558
        all_days.sort()
paul@446 559
paul@446 560
        # Produce a heading and time points for each day.
paul@446 561
paul@446 562
        for day, intervals in all_days:
paul@446 563
            groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups]
paul@446 564
            is_empty = True
paul@446 565
paul@446 566
            for slots in groups_for_day:
paul@446 567
                if not slots:
paul@446 568
                    continue
paul@446 569
paul@446 570
                for active in slots.values():
paul@446 571
                    if active:
paul@446 572
                        is_empty = False
paul@446 573
                        break
paul@446 574
paul@446 575
            page.thead(class_="separator%s" % (is_empty and " empty" or ""))
paul@446 576
            page.tr()
paul@446 577
            page.th(class_="dayheading container", colspan=all_columns+1)
paul@446 578
            self._day_heading(day)
paul@446 579
            page.th.close()
paul@446 580
            page.tr.close()
paul@446 581
            page.thead.close()
paul@446 582
paul@446 583
            page.tbody(class_="points%s" % (is_empty and " empty" or ""))
paul@446 584
            self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns)
paul@446 585
            page.tbody.close()
paul@446 586
paul@446 587
    def show_calendar_points(self, intervals, groups, group_types, group_columns):
paul@446 588
paul@446 589
        """
paul@446 590
        Show the time 'intervals' along with period information from the given
paul@446 591
        'groups', having the indicated 'group_types', each with the number of
paul@446 592
        columns given by 'group_columns'.
paul@446 593
        """
paul@446 594
paul@446 595
        page = self.page
paul@446 596
paul@446 597
        # Obtain the user's timezone.
paul@446 598
paul@446 599
        tzid = self.get_tzid()
paul@446 600
paul@446 601
        # Produce a row for each interval.
paul@446 602
paul@446 603
        intervals = list(intervals)
paul@446 604
        intervals.sort()
paul@446 605
paul@455 606
        for point, endpoint in intervals:
paul@455 607
            continuation = point.point == get_start_of_day(point.point, tzid)
paul@446 608
paul@446 609
            # Some rows contain no period details and are marked as such.
paul@446 610
paul@448 611
            have_active = False
paul@448 612
            have_active_request = False
paul@448 613
paul@448 614
            for slots, group_type in zip(groups, group_types):
paul@455 615
                if slots and slots.get(point):
paul@448 616
                    if group_type == "request":
paul@448 617
                        have_active_request = True
paul@448 618
                    else:
paul@448 619
                        have_active = True
paul@446 620
paul@450 621
            # Emit properties of the time interval, where post-instant intervals
paul@450 622
            # are also treated as busy.
paul@450 623
paul@446 624
            css = " ".join([
paul@446 625
                "slot",
paul@455 626
                (have_active or point.indicator == Point.REPEATED) and "busy" or \
paul@455 627
                    have_active_request and "suggested" or "empty",
paul@446 628
                continuation and "daystart" or ""
paul@446 629
                ])
paul@446 630
paul@446 631
            page.tr(class_=css)
paul@455 632
            if point.indicator == Point.PRINCIPAL:
paul@453 633
                page.th(class_="timeslot")
paul@449 634
                self._time_point(point, endpoint)
paul@453 635
            else:
paul@453 636
                page.th()
paul@446 637
            page.th.close()
paul@446 638
paul@446 639
            # Obtain slots for the time point from each group.
paul@446 640
paul@446 641
            for columns, slots, group_type in zip(group_columns, groups, group_types):
paul@455 642
                active = slots and slots.get(point)
paul@446 643
paul@446 644
                # Where no periods exist for the given time interval, generate
paul@446 645
                # an empty cell. Where a participant provides no periods at all,
paul@446 646
                # the colspan is adjusted to be 1, not 0.
paul@446 647
paul@446 648
                if not active:
paul@455 649
                    self._empty_slot(point, endpoint, max(columns, 1))
paul@446 650
                    continue
paul@446 651
paul@446 652
                slots = slots.items()
paul@446 653
                slots.sort()
paul@446 654
                spans = get_spans(slots)
paul@446 655
paul@446 656
                empty = 0
paul@446 657
paul@446 658
                # Show a column for each active period.
paul@446 659
paul@458 660
                for p in active:
paul@458 661
paul@458 662
                    # The period can be None, meaning an empty column.
paul@458 663
paul@458 664
                    if p:
paul@446 665
paul@446 666
                        # Flush empty slots preceding this one.
paul@446 667
paul@446 668
                        if empty:
paul@455 669
                            self._empty_slot(point, endpoint, empty)
paul@446 670
                            empty = 0
paul@446 671
paul@458 672
                        key = p.get_key()
paul@446 673
                        span = spans[key]
paul@446 674
paul@446 675
                        # Produce a table cell only at the start of the period
paul@446 676
                        # or when continued at the start of a day.
paul@453 677
                        # Points defining the ends of instant events should
paul@453 678
                        # never define the start of new events.
paul@446 679
paul@546 680
                        if point.indicator == Point.PRINCIPAL and (point.point == p.get_start() or continuation):
paul@446 681
paul@546 682
                            has_continued = continuation and point.point != p.get_start()
paul@546 683
                            will_continue = not ends_on_same_day(point.point, p.get_end(), tzid)
paul@458 684
                            is_organiser = p.organiser == self.user
paul@446 685
paul@446 686
                            css = " ".join([
paul@446 687
                                "event",
paul@446 688
                                has_continued and "continued" or "",
paul@446 689
                                will_continue and "continues" or "",
paul@486 690
                                p.transp == "ORG" and "only-organising" or is_organiser and "organising" or "attending"
paul@446 691
                                ])
paul@446 692
paul@446 693
                            # Only anchor the first cell of events.
paul@446 694
                            # Need to only anchor the first period for a recurring
paul@446 695
                            # event.
paul@446 696
paul@458 697
                            html_id = "%s-%s-%s" % (group_type, p.uid, p.recurrenceid or "")
paul@446 698
paul@546 699
                            if point.point == p.get_start() and html_id not in self.html_ids:
paul@446 700
                                page.td(class_=css, rowspan=span, id=html_id)
paul@446 701
                                self.html_ids.add(html_id)
paul@446 702
                            else:
paul@446 703
                                page.td(class_=css, rowspan=span)
paul@446 704
paul@446 705
                            # Only link to events if they are not being
paul@446 706
                            # updated by requests.
paul@446 707
paul@458 708
                            if not p.summary or (p.uid, p.recurrenceid) in self._get_requests() and group_type != "request":
paul@458 709
                                page.span(p.summary or "(Participant is busy)")
paul@446 710
                            else:
paul@458 711
                                page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid))
paul@446 712
paul@446 713
                            page.td.close()
paul@446 714
                    else:
paul@446 715
                        empty += 1
paul@446 716
paul@446 717
                # Pad with empty columns.
paul@446 718
paul@446 719
                empty = columns - len(active)
paul@446 720
paul@446 721
                if empty:
paul@455 722
                    self._empty_slot(point, endpoint, empty)
paul@446 723
paul@446 724
            page.tr.close()
paul@446 725
paul@446 726
    def _day_heading(self, day):
paul@446 727
paul@446 728
        """
paul@446 729
        Generate a heading for 'day' of the following form:
paul@446 730
paul@446 731
        <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label>
paul@446 732
        """
paul@446 733
paul@446 734
        page = self.page
paul@446 735
        daystr = format_datetime(day)
paul@446 736
        value, identifier = self._day_value_and_identifier(day)
paul@446 737
        page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier)
paul@446 738
paul@446 739
    def _time_point(self, point, endpoint):
paul@446 740
paul@446 741
        """
paul@446 742
        Generate headings for the 'point' to 'endpoint' period of the following
paul@446 743
        form:
paul@446 744
paul@446 745
        <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label>
paul@446 746
        <span class="endpoint">10:00:00 CET</span>
paul@446 747
        """
paul@446 748
paul@446 749
        page = self.page
paul@446 750
        tzid = self.get_tzid()
paul@455 751
        daystr = format_datetime(point.point.date())
paul@446 752
        value, identifier = self._slot_value_and_identifier(point, endpoint)
paul@455 753
        page.label(self.format_time(point.point, "long"), class_="timepoint day-%s" % daystr, for_=identifier)
paul@455 754
        page.span(self.format_time(endpoint and endpoint.point or get_end_of_day(point.point, tzid), "long"), class_="endpoint")
paul@446 755
paul@446 756
    def _slot_selector(self, value, identifier, slots):
paul@446 757
paul@446 758
        """
paul@446 759
        Provide a timeslot control having the given 'value', employing the
paul@446 760
        indicated HTML 'identifier', and using the given 'slots' collection
paul@446 761
        to select any control whose 'value' is in this collection, unless the
paul@446 762
        "reset" request parameter has been asserted.
paul@446 763
        """
paul@446 764
paul@446 765
        reset = self.env.get_args().has_key("reset")
paul@446 766
        page = self.page
paul@446 767
        if not reset and value in slots:
paul@446 768
            page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked")
paul@446 769
        else:
paul@446 770
            page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector")
paul@446 771
paul@455 772
    def _empty_slot(self, point, endpoint, colspan):
paul@446 773
paul@453 774
        """
paul@453 775
        Show an empty slot cell for the given 'point' and 'endpoint', with the
paul@455 776
        given 'colspan' configuring the cell's appearance.
paul@453 777
        """
paul@446 778
paul@446 779
        page = self.page
paul@455 780
        page.td(class_="empty%s" % (point.indicator == Point.PRINCIPAL and " container" or ""), colspan=colspan)
paul@455 781
        if point.indicator == Point.PRINCIPAL:
paul@453 782
            value, identifier = self._slot_value_and_identifier(point, endpoint)
paul@453 783
            page.label("Select/deselect period", class_="newevent popup", for_=identifier)
paul@453 784
        page.td.close()
paul@446 785
paul@446 786
    def _day_value_and_identifier(self, day):
paul@446 787
paul@446 788
        "Return a day value and HTML identifier for the given 'day'."
paul@446 789
paul@513 790
        value = format_datetime(day)
paul@446 791
        identifier = "day-%s" % value
paul@446 792
        return value, identifier
paul@446 793
paul@446 794
    def _slot_value_and_identifier(self, point, endpoint):
paul@446 795
paul@446 796
        """
paul@446 797
        Return a slot value and HTML identifier for the given 'point' and
paul@446 798
        'endpoint'.
paul@446 799
        """
paul@446 800
paul@455 801
        value = "%s-%s" % (format_datetime(point.point), endpoint and format_datetime(endpoint.point) or "")
paul@446 802
        identifier = "slot-%s" % value
paul@446 803
        return value, identifier
paul@446 804
paul@446 805
# vim: tabstop=4 expandtab shiftwidth=4