imip-agent

Annotated imipweb/calendar.py

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