imip-agent

Annotated imip_manager.py

196:d04da7398c2a
2015-01-29 Paul Boddie Added hidden radio buttons and pop-up labels selecting them, so that the start time of a new event can be defined.
paul@69 1
#!/usr/bin/env python
paul@69 2
paul@146 3
"""
paul@146 4
A Web interface to a user's calendar.
paul@146 5
paul@146 6
Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>
paul@146 7
paul@146 8
This program is free software; you can redistribute it and/or modify it under
paul@146 9
the terms of the GNU General Public License as published by the Free Software
paul@146 10
Foundation; either version 3 of the License, or (at your option) any later
paul@146 11
version.
paul@146 12
paul@146 13
This program is distributed in the hope that it will be useful, but WITHOUT
paul@146 14
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
paul@146 15
FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
paul@146 16
details.
paul@146 17
paul@146 18
You should have received a copy of the GNU General Public License along with
paul@146 19
this program.  If not, see <http://www.gnu.org/licenses/>.
paul@146 20
"""
paul@146 21
paul@146 22
# Edit this path to refer to the location of the imiptools libraries, if
paul@146 23
# necessary.
paul@146 24
paul@146 25
LIBRARY_PATH = "/var/lib/imip-agent"
paul@146 26
paul@149 27
import babel.dates
paul@149 28
import cgi, os, sys
paul@69 29
paul@146 30
sys.path.append(LIBRARY_PATH)
paul@69 31
paul@153 32
from imiptools.content import Handler, get_address, \
paul@117 33
                              get_item, get_uri, get_utc_datetime, get_value, \
paul@155 34
                              get_value_map, get_values, parse_object, to_part
paul@153 35
from imiptools.dates import format_datetime, get_datetime, get_start_of_day, \
paul@195 36
                            ends_on_same_day, to_timezone
paul@83 37
from imiptools.mail import Messenger
paul@162 38
from imiptools.period import add_day_start_points, add_slots, convert_periods, \
paul@185 39
                             get_freebusy_details, \
paul@162 40
                             get_scale, have_conflict, get_slots, get_spans, \
paul@162 41
                             partition_by_day
paul@147 42
from imiptools.profile import Preferences
paul@77 43
from vCalendar import to_node
paul@69 44
import markup
paul@69 45
import imip_store
paul@69 46
paul@69 47
getenv = os.environ.get
paul@69 48
setenv = os.environ.__setitem__
paul@69 49
paul@69 50
class CGIEnvironment:
paul@69 51
paul@69 52
    "A CGI-compatible environment."
paul@69 53
paul@69 54
    def __init__(self):
paul@69 55
        self.args = None
paul@69 56
        self.method = None
paul@69 57
        self.path = None
paul@69 58
        self.path_info = None
paul@69 59
        self.user = None
paul@69 60
paul@69 61
    def get_args(self):
paul@69 62
        if self.args is None:
paul@69 63
            if self.get_method() != "POST":
paul@69 64
                setenv("QUERY_STRING", "")
paul@69 65
            self.args = cgi.parse(keep_blank_values=True)
paul@69 66
        return self.args
paul@69 67
paul@69 68
    def get_method(self):
paul@69 69
        if self.method is None:
paul@69 70
            self.method = getenv("REQUEST_METHOD") or "GET"
paul@69 71
        return self.method
paul@69 72
paul@69 73
    def get_path(self):
paul@69 74
        if self.path is None:
paul@69 75
            self.path = getenv("SCRIPT_NAME") or ""
paul@69 76
        return self.path
paul@69 77
paul@69 78
    def get_path_info(self):
paul@69 79
        if self.path_info is None:
paul@69 80
            self.path_info = getenv("PATH_INFO") or ""
paul@69 81
        return self.path_info
paul@69 82
paul@69 83
    def get_user(self):
paul@69 84
        if self.user is None:
paul@69 85
            self.user = getenv("REMOTE_USER") or ""
paul@69 86
        return self.user
paul@69 87
paul@69 88
    def get_output(self):
paul@69 89
        return sys.stdout
paul@69 90
paul@69 91
    def get_url(self):
paul@69 92
        path = self.get_path()
paul@69 93
        path_info = self.get_path_info()
paul@69 94
        return "%s%s" % (path.rstrip("/"), path_info)
paul@69 95
paul@154 96
    def new_url(self, path_info):
paul@154 97
        path = self.get_path()
paul@154 98
        return "%s/%s" % (path.rstrip("/"), path_info.lstrip("/"))
paul@154 99
paul@79 100
class ManagerHandler(Handler):
paul@79 101
paul@121 102
    """
paul@121 103
    A content handler for use by the manager, as opposed to operating within the
paul@121 104
    mail processing pipeline.
paul@121 105
    """
paul@79 106
paul@121 107
    def __init__(self, obj, user, messenger):
paul@121 108
        details, details_attr = obj.values()[0]
paul@79 109
        Handler.__init__(self, details)
paul@121 110
        self.obj = obj
paul@79 111
        self.user = user
paul@82 112
        self.messenger = messenger
paul@82 113
paul@79 114
        self.organisers = map(get_address, self.get_values("ORGANIZER"))
paul@79 115
paul@79 116
    # Communication methods.
paul@79 117
paul@79 118
    def send_message(self, sender):
paul@79 119
paul@79 120
        """
paul@128 121
        Create a full calendar object and send it to the organisers, sending a
paul@128 122
        copy to the 'sender'.
paul@79 123
        """
paul@79 124
paul@121 125
        node = to_node(self.obj)
paul@79 126
        part = to_part("REPLY", [node])
paul@178 127
        message = self.messenger.make_outgoing_message([part], self.organisers, outgoing_bcc=sender)
paul@128 128
        self.messenger.sendmail(self.organisers, message.as_string(), outgoing_bcc=sender)
paul@79 129
paul@79 130
    # Action methods.
paul@79 131
paul@155 132
    def process_request(self, accept, update=False):
paul@79 133
paul@79 134
        """
paul@79 135
        Process the current request for the given 'user', accepting any request
paul@79 136
        when 'accept' is true, declining requests otherwise. Return whether any
paul@79 137
        action was taken.
paul@155 138
paul@155 139
        If 'update' is given, the sequence number will be incremented in order
paul@155 140
        to override any previous response.
paul@79 141
        """
paul@79 142
paul@79 143
        # When accepting or declining, do so only on behalf of this user,
paul@79 144
        # preserving any other attributes set as an attendee.
paul@79 145
paul@79 146
        for attendee, attendee_attr in self.get_items("ATTENDEE"):
paul@79 147
paul@79 148
            if attendee == self.user:
paul@79 149
                freebusy = self.store.get_freebusy(attendee)
paul@79 150
paul@79 151
                attendee_attr["PARTSTAT"] = accept and "ACCEPTED" or "DECLINED"
paul@128 152
                if self.messenger and self.messenger.sender != get_address(attendee):
paul@128 153
                    attendee_attr["SENT-BY"] = get_uri(self.messenger.sender)
paul@79 154
                self.details["ATTENDEE"] = [(attendee, attendee_attr)]
paul@155 155
                if update:
paul@155 156
                    sequence = self.get_value("SEQUENCE") or "0"
paul@155 157
                    self.details["SEQUENCE"] = [(str(int(sequence) + 1), {})]
paul@158 158
                self.update_dtstamp()
paul@155 159
paul@79 160
                self.send_message(get_address(attendee))
paul@79 161
paul@79 162
                return True
paul@79 163
paul@79 164
        return False
paul@79 165
paul@69 166
class Manager:
paul@69 167
paul@69 168
    "A simple manager application."
paul@69 169
paul@82 170
    def __init__(self, messenger=None):
paul@82 171
        self.messenger = messenger or Messenger()
paul@82 172
paul@69 173
        self.env = CGIEnvironment()
paul@69 174
        user = self.env.get_user()
paul@77 175
        self.user = user and get_uri(user) or None
paul@147 176
        self.preferences = None
paul@149 177
        self.locale = None
paul@121 178
        self.requests = None
paul@121 179
paul@69 180
        self.out = self.env.get_output()
paul@69 181
        self.page = markup.page()
paul@69 182
        self.encoding = "utf-8"
paul@69 183
paul@77 184
        self.store = imip_store.FileStore()
paul@162 185
        self.objects = {}
paul@77 186
paul@77 187
        try:
paul@77 188
            self.publisher = imip_store.FilePublisher()
paul@77 189
        except OSError:
paul@77 190
            self.publisher = None
paul@77 191
paul@121 192
    def _get_uid(self, path_info):
paul@121 193
        return path_info.lstrip("/").split("/", 1)[0]
paul@121 194
paul@117 195
    def _get_object(self, uid):
paul@162 196
        if self.objects.has_key(uid):
paul@162 197
            return self.objects[uid]
paul@162 198
paul@117 199
        f = uid and self.store.get_event(self.user, uid) or None
paul@117 200
paul@117 201
        if not f:
paul@117 202
            return None
paul@117 203
paul@162 204
        self.objects[uid] = obj = parse_object(f, "utf-8")
paul@117 205
paul@117 206
        if not obj:
paul@117 207
            return None
paul@117 208
paul@121 209
        return obj
paul@121 210
paul@121 211
    def _get_details(self, obj):
paul@121 212
        details, details_attr = obj.values()[0]
paul@121 213
        return details
paul@121 214
paul@121 215
    def _get_requests(self):
paul@121 216
        if self.requests is None:
paul@121 217
            self.requests = self.store.get_requests(self.user)
paul@121 218
        return self.requests
paul@117 219
paul@162 220
    def _get_request_summary(self):
paul@162 221
        summary = []
paul@162 222
        for uid in self._get_requests():
paul@162 223
            obj = self._get_object(uid)
paul@162 224
            if obj:
paul@162 225
                details = self._get_details(obj)
paul@162 226
                summary.append((
paul@162 227
                    get_value(details, "DTSTART"),
paul@162 228
                    get_value(details, "DTEND"),
paul@162 229
                    uid
paul@162 230
                    ))
paul@162 231
        return summary
paul@162 232
paul@147 233
    # Preference methods.
paul@147 234
paul@149 235
    def get_user_locale(self):
paul@149 236
        if not self.locale:
paul@149 237
            self.locale = self.get_preferences().get("LANG", "C")
paul@149 238
        return self.locale
paul@147 239
paul@147 240
    def get_preferences(self):
paul@147 241
        if not self.preferences:
paul@147 242
            self.preferences = Preferences(self.user)
paul@147 243
        return self.preferences
paul@147 244
paul@162 245
    # Prettyprinting of dates and times.
paul@162 246
paul@149 247
    def format_date(self, dt, format):
paul@149 248
        return self._format_datetime(babel.dates.format_date, dt, format)
paul@149 249
paul@149 250
    def format_time(self, dt, format):
paul@149 251
        return self._format_datetime(babel.dates.format_time, dt, format)
paul@149 252
paul@149 253
    def format_datetime(self, dt, format):
paul@149 254
        return self._format_datetime(babel.dates.format_datetime, dt, format)
paul@149 255
paul@149 256
    def _format_datetime(self, fn, dt, format):
paul@149 257
        return fn(dt, format=format, locale=self.get_user_locale())
paul@149 258
paul@78 259
    # Data management methods.
paul@78 260
paul@78 261
    def remove_request(self, uid):
paul@105 262
        return self.store.dequeue_request(self.user, uid)
paul@78 263
paul@78 264
    # Presentation methods.
paul@78 265
paul@69 266
    def new_page(self, title):
paul@192 267
        self.page.init(title=title, charset=self.encoding, css=self.env.new_url("styles.css"))
paul@69 268
paul@69 269
    def status(self, code, message):
paul@123 270
        self.header("Status", "%s %s" % (code, message))
paul@123 271
paul@123 272
    def header(self, header, value):
paul@123 273
        print >>self.out, "%s: %s" % (header, value)
paul@69 274
paul@69 275
    def no_user(self):
paul@69 276
        self.status(403, "Forbidden")
paul@69 277
        self.new_page(title="Forbidden")
paul@69 278
        self.page.p("You are not logged in and thus cannot access scheduling requests.")
paul@69 279
paul@70 280
    def no_page(self):
paul@70 281
        self.status(404, "Not Found")
paul@70 282
        self.new_page(title="Not Found")
paul@70 283
        self.page.p("No page is provided at the given address.")
paul@70 284
paul@123 285
    def redirect(self, url):
paul@123 286
        self.status(302, "Redirect")
paul@123 287
        self.header("Location", url)
paul@123 288
        self.new_page(title="Redirect")
paul@123 289
        self.page.p("Redirecting to: %s" % url)
paul@123 290
paul@121 291
    # Request logic and page fragment methods.
paul@121 292
paul@155 293
    def handle_request(self, uid, request, queued):
paul@121 294
paul@155 295
        """
paul@155 296
        Handle actions involving the given 'uid' and 'request' object, where
paul@155 297
        'queued' indicates that the object has not yet been handled.
paul@155 298
        """
paul@121 299
paul@121 300
        # Handle a submitted form.
paul@121 301
paul@121 302
        args = self.env.get_args()
paul@123 303
        handled = True
paul@121 304
paul@121 305
        accept = args.has_key("accept")
paul@121 306
        decline = args.has_key("decline")
paul@155 307
        update = not queued and args.has_key("update")
paul@121 308
paul@121 309
        if accept or decline:
paul@121 310
paul@121 311
            handler = ManagerHandler(request, self.user, self.messenger)
paul@121 312
paul@155 313
            if handler.process_request(accept, update):
paul@121 314
paul@121 315
                # Remove the request from the list.
paul@121 316
paul@121 317
                self.remove_request(uid)
paul@121 318
paul@121 319
        elif args.has_key("ignore"):
paul@121 320
paul@121 321
            # Remove the request from the list.
paul@121 322
paul@121 323
            self.remove_request(uid)
paul@121 324
paul@121 325
        else:
paul@123 326
            handled = False
paul@121 327
paul@123 328
        if handled:
paul@123 329
            self.redirect(self.env.get_path())
paul@123 330
paul@123 331
        return handled
paul@121 332
paul@155 333
    def show_request_form(self, obj, needs_action):
paul@155 334
paul@155 335
        """
paul@155 336
        Show a form for a request concerning 'obj', indicating whether action is
paul@155 337
        needed if 'needs_action' is specified as a true value.
paul@155 338
        """
paul@155 339
paul@155 340
        details = self._get_details(obj)
paul@155 341
paul@155 342
        attendees = get_value_map(details, "ATTENDEE")
paul@155 343
        attendee_attr = attendees.get(self.user)
paul@121 344
paul@155 345
        if attendee_attr:
paul@155 346
            partstat = attendee_attr.get("PARTSTAT")
paul@155 347
            if partstat == "ACCEPTED":
paul@155 348
                self.page.p("This request has been accepted.")
paul@155 349
            elif partstat == "DECLINED":
paul@155 350
                self.page.p("This request has been declined.")
paul@155 351
            else:
paul@155 352
                self.page.p("This request has been ignored.")
paul@121 353
paul@155 354
        if needs_action:
paul@155 355
            self.page.p("An action is required for this request:")
paul@155 356
        else:
paul@155 357
            self.page.p("This request can be updated as follows:")
paul@155 358
paul@121 359
        self.page.form(method="POST")
paul@121 360
        self.page.p()
paul@121 361
        self.page.input(name="accept", type="submit", value="Accept")
paul@121 362
        self.page.add(" ")
paul@121 363
        self.page.input(name="decline", type="submit", value="Decline")
paul@121 364
        self.page.add(" ")
paul@121 365
        self.page.input(name="ignore", type="submit", value="Ignore")
paul@155 366
        if not needs_action:
paul@155 367
            self.page.input(name="update", type="hidden", value="true")
paul@121 368
        self.page.p.close()
paul@121 369
        self.page.form.close()
paul@121 370
paul@121 371
    def show_object_on_page(self, uid, obj):
paul@121 372
paul@121 373
        """
paul@121 374
        Show the calendar object with the given 'uid' and representation 'obj'
paul@121 375
        on the current page.
paul@121 376
        """
paul@121 377
paul@154 378
        # Obtain the user's timezone.
paul@154 379
paul@154 380
        prefs = self.get_preferences()
paul@154 381
        tzid = prefs.get("TZID", "UTC")
paul@121 382
paul@121 383
        # Provide a summary of the object.
paul@121 384
paul@154 385
        details = self._get_details(obj)
paul@154 386
paul@121 387
        self.page.dl()
paul@121 388
paul@121 389
        for name in ["SUMMARY", "DTSTART", "DTEND", "ORGANIZER", "ATTENDEE"]:
paul@147 390
            if name in ["DTSTART", "DTEND"]:
paul@147 391
                value, attr = get_item(details, name)
paul@154 392
                tzid = attr.get("TZID", tzid)
paul@149 393
                value = self.format_datetime(to_timezone(get_datetime(value), tzid), "full")
paul@121 394
                self.page.dt(name)
paul@121 395
                self.page.dd(value)
paul@147 396
            else:
paul@147 397
                for value in get_values(details, name):
paul@147 398
                    self.page.dt(name)
paul@147 399
                    self.page.dd(value)
paul@121 400
paul@121 401
        self.page.dl.close()
paul@121 402
paul@121 403
        dtstart = format_datetime(get_utc_datetime(details, "DTSTART"))
paul@121 404
        dtend = format_datetime(get_utc_datetime(details, "DTEND"))
paul@121 405
paul@121 406
        # Indicate whether there are conflicting events.
paul@121 407
paul@121 408
        freebusy = self.store.get_freebusy(self.user)
paul@121 409
paul@121 410
        if freebusy:
paul@121 411
paul@121 412
            # Obtain any time zone details from the suggested event.
paul@121 413
paul@121 414
            _dtstart, attr = get_item(details, "DTSTART")
paul@154 415
            tzid = attr.get("TZID", tzid)
paul@121 416
paul@121 417
            # Show any conflicts.
paul@121 418
paul@121 419
            for t in have_conflict(freebusy, [(dtstart, dtend)], True):
paul@121 420
                start, end, found_uid = t[:3]
paul@154 421
paul@154 422
                # Provide details of any conflicting event.
paul@154 423
paul@121 424
                if uid != found_uid:
paul@149 425
                    start = self.format_datetime(to_timezone(get_datetime(start), tzid), "full")
paul@149 426
                    end = self.format_datetime(to_timezone(get_datetime(end), tzid), "full")
paul@154 427
                    self.page.p("Event conflicts with another from %s to %s: " % (start, end))
paul@154 428
paul@154 429
                    # Show the event summary for the conflicting event.
paul@154 430
paul@154 431
                    found_obj = self._get_object(found_uid)
paul@154 432
                    if found_obj:
paul@154 433
                        found_details = self._get_details(found_obj)
paul@154 434
                        self.page.a(get_value(found_details, "SUMMARY"), href=self.env.new_url(found_uid))
paul@121 435
paul@121 436
    def show_requests_on_page(self):
paul@69 437
paul@69 438
        "Show requests for the current user."
paul@69 439
paul@69 440
        # NOTE: This list could be more informative, but it is envisaged that
paul@69 441
        # NOTE: the requests would be visited directly anyway.
paul@69 442
paul@121 443
        requests = self._get_requests()
paul@70 444
paul@185 445
        self.page.div(id="pending-requests")
paul@185 446
paul@80 447
        if requests:
paul@114 448
            self.page.p("Pending requests:")
paul@114 449
paul@80 450
            self.page.ul()
paul@69 451
paul@80 452
            for request in requests:
paul@165 453
                obj = self._get_object(request)
paul@165 454
                if obj:
paul@165 455
                    details = self._get_details(obj)
paul@165 456
                    self.page.li()
paul@171 457
                    self.page.a(get_value(details, "SUMMARY"), href="#request-%s" % request)
paul@165 458
                    self.page.li.close()
paul@80 459
paul@80 460
            self.page.ul.close()
paul@80 461
paul@80 462
        else:
paul@80 463
            self.page.p("There are no pending requests.")
paul@69 464
paul@185 465
        self.page.div.close()
paul@185 466
paul@185 467
    def show_participants_on_page(self):
paul@185 468
paul@185 469
        "Show participants for scheduling purposes."
paul@185 470
paul@185 471
        args = self.env.get_args()
paul@185 472
        participants = args.get("participants", [])
paul@185 473
paul@185 474
        try:
paul@185 475
            for name, value in args.items():
paul@185 476
                if name.startswith("remove-participant-"):
paul@185 477
                    i = int(name[len("remove-participant-"):])
paul@185 478
                    del participants[i]
paul@185 479
                    break
paul@185 480
        except ValueError:
paul@185 481
            pass
paul@185 482
paul@185 483
        # Trim empty participants.
paul@185 484
paul@185 485
        while participants and not participants[-1].strip():
paul@185 486
            participants.pop()
paul@185 487
paul@185 488
        # Show any specified participants together with controls to remove and
paul@185 489
        # add participants.
paul@185 490
paul@185 491
        self.page.div(id="participants")
paul@185 492
paul@185 493
        self.page.p("Participants for scheduling:")
paul@185 494
paul@185 495
        for i, participant in enumerate(participants):
paul@185 496
            self.page.p()
paul@185 497
            self.page.input(name="participants", type="text", value=participant)
paul@185 498
            self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove")
paul@185 499
            self.page.p.close()
paul@185 500
paul@185 501
        self.page.p()
paul@185 502
        self.page.input(name="participants", type="text")
paul@185 503
        self.page.input(name="add-participant", type="submit", value="Add")
paul@185 504
        self.page.p.close()
paul@185 505
paul@185 506
        self.page.div.close()
paul@185 507
paul@185 508
        return participants
paul@185 509
paul@121 510
    # Full page output methods.
paul@70 511
paul@121 512
    def show_object(self, path_info):
paul@70 513
paul@121 514
        "Show an object request using the given 'path_info' for the current user."
paul@70 515
paul@121 516
        uid = self._get_uid(path_info)
paul@121 517
        obj = self._get_object(uid)
paul@121 518
paul@121 519
        if not obj:
paul@70 520
            return False
paul@70 521
paul@123 522
        is_request = uid in self._get_requests()
paul@155 523
        handled = self.handle_request(uid, obj, is_request)
paul@77 524
paul@123 525
        if handled:
paul@123 526
            return True
paul@73 527
paul@123 528
        self.new_page(title="Event")
paul@79 529
paul@121 530
        self.show_object_on_page(uid, obj)
paul@73 531
paul@155 532
        self.show_request_form(obj, is_request and not handled)
paul@73 533
paul@70 534
        return True
paul@70 535
paul@114 536
    def show_calendar(self):
paul@114 537
paul@114 538
        "Show the calendar for the current user."
paul@114 539
paul@114 540
        self.new_page(title="Calendar")
paul@162 541
        page = self.page
paul@162 542
paul@196 543
        # Form controls are used in various places on the calendar page.
paul@196 544
paul@196 545
        page.form(method="POST")
paul@196 546
paul@121 547
        self.show_requests_on_page()
paul@185 548
        participants = self.show_participants_on_page()
paul@114 549
paul@196 550
        # Show a button for scheduling a new event.
paul@196 551
paul@196 552
        page.p()
paul@196 553
        page.input(name="newevent", type="submit", value="New event", id="newevent")
paul@196 554
        page.p.close()
paul@196 555
paul@114 556
        freebusy = self.store.get_freebusy(self.user)
paul@114 557
paul@114 558
        if not freebusy:
paul@114 559
            page.p("No events scheduled.")
paul@114 560
            return
paul@114 561
paul@154 562
        # Obtain the user's timezone.
paul@147 563
paul@147 564
        prefs = self.get_preferences()
paul@153 565
        tzid = prefs.get("TZID", "UTC")
paul@147 566
paul@114 567
        # Day view: start at the earliest known day and produce days until the
paul@114 568
        # latest known day, perhaps with expandable sections of empty days.
paul@114 569
paul@114 570
        # Month view: start at the earliest known month and produce months until
paul@114 571
        # the latest known month, perhaps with expandable sections of empty
paul@114 572
        # months.
paul@114 573
paul@114 574
        # Details of users to invite to new events could be superimposed on the
paul@114 575
        # calendar.
paul@114 576
paul@185 577
        # Requests are listed and linked to their tentative positions in the
paul@185 578
        # calendar. Other participants are also shown.
paul@185 579
paul@185 580
        request_summary = self._get_request_summary()
paul@185 581
paul@185 582
        period_groups = [request_summary, freebusy]
paul@185 583
        period_group_types = ["request", "freebusy"]
paul@185 584
        period_group_sources = ["Pending requests", "Your schedule"]
paul@185 585
paul@187 586
        for i, participant in enumerate(participants):
paul@185 587
            period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant)))
paul@187 588
            period_group_types.append("freebusy-part%d" % i)
paul@185 589
            period_group_sources.append(participant)
paul@114 590
paul@162 591
        groups = []
paul@162 592
        group_columns = []
paul@185 593
        group_types = period_group_types
paul@185 594
        group_sources = period_group_sources
paul@162 595
        all_points = set()
paul@162 596
paul@162 597
        # Obtain time point information for each group of periods.
paul@162 598
paul@185 599
        for periods in period_groups:
paul@162 600
            periods = convert_periods(periods, tzid)
paul@162 601
paul@162 602
            # Get the time scale with start and end points.
paul@162 603
paul@162 604
            scale = get_scale(periods)
paul@162 605
paul@162 606
            # Get the time slots for the periods.
paul@162 607
paul@162 608
            slots = get_slots(scale)
paul@162 609
paul@162 610
            # Add start of day time points for multi-day periods.
paul@162 611
paul@162 612
            add_day_start_points(slots)
paul@162 613
paul@162 614
            # Record the slots and all time points employed.
paul@162 615
paul@162 616
            groups.append(slots)
paul@162 617
            all_points.update([point for point, slot in slots])
paul@162 618
paul@162 619
        # Partition the groups into days.
paul@162 620
paul@162 621
        days = {}
paul@162 622
        partitioned_groups = []
paul@171 623
        partitioned_group_types = []
paul@185 624
        partitioned_group_sources = []
paul@162 625
paul@185 626
        for slots, group_type, group_source in zip(groups, group_types, group_sources):
paul@162 627
paul@162 628
            # Propagate time points to all groups of time slots.
paul@162 629
paul@162 630
            add_slots(slots, all_points)
paul@162 631
paul@162 632
            # Count the number of columns employed by the group.
paul@162 633
paul@162 634
            columns = 0
paul@162 635
paul@162 636
            # Partition the time slots by day.
paul@162 637
paul@162 638
            partitioned = {}
paul@162 639
paul@162 640
            for day, day_slots in partition_by_day(slots).items():
paul@162 641
                columns = max(columns, max(map(lambda i: len(i[1]), day_slots)))
paul@162 642
paul@162 643
                if not days.has_key(day):
paul@162 644
                    days[day] = set()
paul@162 645
paul@162 646
                # Convert each partition to a mapping from points to active
paul@162 647
                # periods.
paul@162 648
paul@162 649
                day_slots = dict(day_slots)
paul@162 650
                partitioned[day] = day_slots
paul@162 651
                days[day].update(day_slots.keys())
paul@162 652
paul@194 653
            if group_type != "request" or columns:
paul@194 654
                group_columns.append(columns)
paul@194 655
                partitioned_groups.append(partitioned)
paul@194 656
                partitioned_group_types.append(group_type)
paul@194 657
                partitioned_group_sources.append(group_source)
paul@114 658
paul@188 659
        page.table(cellspacing=5, cellpadding=5, id="calendar")
paul@188 660
        self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns)
paul@171 661
        self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns)
paul@162 662
        page.table.close()
paul@114 663
paul@196 664
        # End the form region.
paul@196 665
paul@196 666
        page.form.close()
paul@196 667
paul@188 668
    def show_calendar_participant_headings(self, group_types, group_sources, group_columns):
paul@186 669
paul@186 670
        """
paul@186 671
        Show headings for the participants and other scheduling contributors,
paul@188 672
        defined by 'group_types', 'group_sources' and 'group_columns'.
paul@186 673
        """
paul@186 674
paul@185 675
        page = self.page
paul@185 676
paul@188 677
        page.colgroup(span=1, id="columns-timeslot")
paul@186 678
paul@188 679
        for group_type, columns in zip(group_types, group_columns):
paul@191 680
            page.colgroup(span=max(columns, 1), id="columns-%s" % group_type)
paul@186 681
paul@185 682
        page.thead()
paul@185 683
        page.tr()
paul@185 684
        page.th("", class_="emptyheading")
paul@185 685
paul@193 686
        for group_type, source, columns in zip(group_types, group_sources, group_columns):
paul@193 687
            page.th(source,
paul@193 688
                class_=(group_type == "request" and "requestheading" or "participantheading"),
paul@193 689
                colspan=max(columns, 1))
paul@185 690
paul@185 691
        page.tr.close()
paul@185 692
        page.thead.close()
paul@185 693
paul@171 694
    def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns):
paul@186 695
paul@186 696
        """
paul@186 697
        Show calendar days, defined by a collection of 'days', the contributing
paul@186 698
        period information as 'partitioned_groups' (partitioned by day), the
paul@186 699
        'partitioned_group_types' indicating the kind of contribution involved,
paul@186 700
        and the 'group_columns' defining the number of columns in each group.
paul@186 701
        """
paul@186 702
paul@162 703
        page = self.page
paul@162 704
paul@191 705
        # Determine the number of columns required. Where participants provide
paul@191 706
        # no columns for events, one still needs to be provided for the
paul@191 707
        # participant itself.
paul@147 708
paul@191 709
        all_columns = sum([max(columns, 1) for columns in group_columns])
paul@191 710
paul@191 711
        # Determine the days providing time slots.
paul@191 712
paul@162 713
        all_days = days.items()
paul@162 714
        all_days.sort()
paul@162 715
paul@162 716
        # Produce a heading and time points for each day.
paul@162 717
paul@162 718
        for day, points in all_days:
paul@186 719
            page.thead()
paul@114 720
            page.tr()
paul@171 721
            page.th(class_="dayheading", colspan=all_columns+1)
paul@153 722
            page.add(self.format_date(day, "full"))
paul@114 723
            page.th.close()
paul@153 724
            page.tr.close()
paul@186 725
            page.thead.close()
paul@114 726
paul@162 727
            groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups]
paul@162 728
paul@186 729
            page.tbody()
paul@171 730
            self.show_calendar_points(points, groups_for_day, partitioned_group_types, group_columns)
paul@186 731
            page.tbody.close()
paul@185 732
paul@171 733
    def show_calendar_points(self, points, groups, group_types, group_columns):
paul@186 734
paul@186 735
        """
paul@186 736
        Show the time 'points' along with period information from the given
paul@186 737
        'groups', having the indicated 'group_types', each with the number of
paul@186 738
        columns given by 'group_columns'.
paul@186 739
        """
paul@186 740
paul@162 741
        page = self.page
paul@162 742
paul@162 743
        # Produce a row for each time point.
paul@162 744
paul@162 745
        points = list(points)
paul@162 746
        points.sort()
paul@162 747
paul@162 748
        for point in points:
paul@162 749
            continuation = point == get_start_of_day(point)
paul@153 750
paul@162 751
            page.tr()
paul@162 752
            page.th(class_="timeslot")
paul@196 753
            self._time_point(point)
paul@196 754
            page.span(self.format_time(point, "long"), class_="timepoint")
paul@162 755
            page.th.close()
paul@162 756
paul@162 757
            # Obtain slots for the time point from each group.
paul@162 758
paul@171 759
            for columns, slots, group_type in zip(group_columns, groups, group_types):
paul@162 760
                active = slots and slots.get(point)
paul@162 761
paul@191 762
                # Where no periods exist for the given time interval, generate
paul@191 763
                # an empty cell. Where a participant provides no periods at all,
paul@191 764
                # the colspan is adjusted to be 1, not 0.
paul@191 765
paul@162 766
                if not active:
paul@196 767
                    page.td(class_="empty container", colspan=max(columns, 1))
paul@196 768
                    self._empty_slot(point)
paul@196 769
                    page.td.close()
paul@162 770
                    continue
paul@162 771
paul@162 772
                slots = slots.items()
paul@162 773
                slots.sort()
paul@162 774
                spans = get_spans(slots)
paul@162 775
paul@162 776
                # Show a column for each active period.
paul@117 777
paul@153 778
                for t in active:
paul@185 779
                    if t and len(t) >= 2:
paul@185 780
                        start, end, uid, key = get_freebusy_details(t)
paul@185 781
                        span = spans[key]
paul@171 782
paul@171 783
                        # Produce a table cell only at the start of the period
paul@171 784
                        # or when continued at the start of a day.
paul@171 785
paul@153 786
                        if point == start or continuation:
paul@153 787
paul@195 788
                            has_continued = continuation and point != start
paul@195 789
                            will_continue = not ends_on_same_day(point, end)
paul@195 790
                            css = " ".join(
paul@195 791
                                ["event"] +
paul@195 792
                                (has_continued and ["continued"] or []) +
paul@195 793
                                (will_continue and ["continues"] or [])
paul@195 794
                                )
paul@195 795
paul@189 796
                            # Only anchor the first cell of events.
paul@189 797
paul@189 798
                            if point == start:
paul@195 799
                                page.td(class_=css, rowspan=span, id="%s-%s" % (group_type, uid))
paul@189 800
                            else:
paul@195 801
                                page.td(class_=css, rowspan=span)
paul@171 802
paul@153 803
                            obj = self._get_object(uid)
paul@185 804
paul@185 805
                            if not obj:
paul@185 806
                                page.span("")
paul@185 807
                            else:
paul@153 808
                                details = self._get_details(obj)
paul@164 809
                                summary = get_value(details, "SUMMARY")
paul@171 810
paul@171 811
                                # Only link to events if they are not being
paul@171 812
                                # updated by requests.
paul@171 813
paul@171 814
                                if uid in self._get_requests() and group_type != "request":
paul@189 815
                                    page.span(summary)
paul@164 816
                                else:
paul@171 817
                                    href = "%s/%s" % (self.env.get_url().rstrip("/"), uid)
paul@189 818
                                    page.a(summary, href=href)
paul@171 819
paul@153 820
                            page.td.close()
paul@153 821
                    else:
paul@196 822
                        page.td(class_="empty container")
paul@196 823
                        self._empty_slot(point)
paul@196 824
                        page.td.close()
paul@114 825
paul@166 826
                # Pad with empty columns.
paul@166 827
paul@166 828
                i = columns - len(active)
paul@166 829
                while i > 0:
paul@166 830
                    i -= 1
paul@196 831
                    page.td(class_="empty container")
paul@196 832
                    self._empty_slot(point)
paul@196 833
                    page.td.close()
paul@166 834
paul@162 835
            page.tr.close()
paul@114 836
paul@196 837
    def _time_point(self, point):
paul@196 838
        pointstr = format_datetime(point)
paul@196 839
        self.page.input(name="start", type="radio", value=pointstr, id="start-%s" % pointstr, class_="newevent")
paul@196 840
paul@196 841
    def _empty_slot(self, point):
paul@196 842
        page = self.page
paul@196 843
        pointstr = format_datetime(point)
paul@196 844
        page.label("Start a new event at this time", class_="newevent popup", for_="start-%s" % pointstr)
paul@196 845
paul@69 846
    def select_action(self):
paul@69 847
paul@69 848
        "Select the desired action and show the result."
paul@69 849
paul@121 850
        path_info = self.env.get_path_info().strip("/")
paul@121 851
paul@69 852
        if not path_info:
paul@114 853
            self.show_calendar()
paul@121 854
        elif self.show_object(path_info):
paul@70 855
            pass
paul@70 856
        else:
paul@70 857
            self.no_page()
paul@69 858
paul@82 859
    def __call__(self):
paul@69 860
paul@69 861
        "Interpret a request and show an appropriate response."
paul@69 862
paul@69 863
        if not self.user:
paul@69 864
            self.no_user()
paul@69 865
        else:
paul@69 866
            self.select_action()
paul@69 867
paul@70 868
        # Write the headers and actual content.
paul@70 869
paul@69 870
        print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding
paul@69 871
        print >>self.out
paul@69 872
        self.out.write(unicode(self.page).encode(self.encoding))
paul@69 873
paul@69 874
if __name__ == "__main__":
paul@128 875
    Manager()()
paul@69 876
paul@69 877
# vim: tabstop=4 expandtab shiftwidth=4