imip-agent

Annotated imip_manager.py

189:fe97f6930553
2015-01-29 Paul Boddie Highlight event targets in the calendar.
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@153 36
                            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@188 267
        self.page.init(title=title, charset=self.encoding, css="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.form(method="POST")
paul@185 494
paul@185 495
        self.page.p("Participants for scheduling:")
paul@185 496
paul@185 497
        for i, participant in enumerate(participants):
paul@185 498
            self.page.p()
paul@185 499
            self.page.input(name="participants", type="text", value=participant)
paul@185 500
            self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove")
paul@185 501
            self.page.p.close()
paul@185 502
paul@185 503
        self.page.p()
paul@185 504
        self.page.input(name="participants", type="text")
paul@185 505
        self.page.input(name="add-participant", type="submit", value="Add")
paul@185 506
        self.page.p.close()
paul@185 507
paul@185 508
        self.page.form.close()
paul@185 509
paul@185 510
        self.page.div.close()
paul@185 511
paul@185 512
        return participants
paul@185 513
paul@121 514
    # Full page output methods.
paul@70 515
paul@121 516
    def show_object(self, path_info):
paul@70 517
paul@121 518
        "Show an object request using the given 'path_info' for the current user."
paul@70 519
paul@121 520
        uid = self._get_uid(path_info)
paul@121 521
        obj = self._get_object(uid)
paul@121 522
paul@121 523
        if not obj:
paul@70 524
            return False
paul@70 525
paul@123 526
        is_request = uid in self._get_requests()
paul@155 527
        handled = self.handle_request(uid, obj, is_request)
paul@77 528
paul@123 529
        if handled:
paul@123 530
            return True
paul@73 531
paul@123 532
        self.new_page(title="Event")
paul@79 533
paul@121 534
        self.show_object_on_page(uid, obj)
paul@73 535
paul@155 536
        self.show_request_form(obj, is_request and not handled)
paul@73 537
paul@70 538
        return True
paul@70 539
paul@114 540
    def show_calendar(self):
paul@114 541
paul@114 542
        "Show the calendar for the current user."
paul@114 543
paul@114 544
        self.new_page(title="Calendar")
paul@162 545
        page = self.page
paul@162 546
paul@121 547
        self.show_requests_on_page()
paul@185 548
        participants = self.show_participants_on_page()
paul@114 549
paul@114 550
        freebusy = self.store.get_freebusy(self.user)
paul@114 551
paul@114 552
        if not freebusy:
paul@114 553
            page.p("No events scheduled.")
paul@114 554
            return
paul@114 555
paul@154 556
        # Obtain the user's timezone.
paul@147 557
paul@147 558
        prefs = self.get_preferences()
paul@153 559
        tzid = prefs.get("TZID", "UTC")
paul@147 560
paul@114 561
        # Day view: start at the earliest known day and produce days until the
paul@114 562
        # latest known day, perhaps with expandable sections of empty days.
paul@114 563
paul@114 564
        # Month view: start at the earliest known month and produce months until
paul@114 565
        # the latest known month, perhaps with expandable sections of empty
paul@114 566
        # months.
paul@114 567
paul@114 568
        # Details of users to invite to new events could be superimposed on the
paul@114 569
        # calendar.
paul@114 570
paul@185 571
        # Requests are listed and linked to their tentative positions in the
paul@185 572
        # calendar. Other participants are also shown.
paul@185 573
paul@185 574
        request_summary = self._get_request_summary()
paul@185 575
paul@185 576
        period_groups = [request_summary, freebusy]
paul@185 577
        period_group_types = ["request", "freebusy"]
paul@185 578
        period_group_sources = ["Pending requests", "Your schedule"]
paul@185 579
paul@187 580
        for i, participant in enumerate(participants):
paul@185 581
            period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant)))
paul@187 582
            period_group_types.append("freebusy-part%d" % i)
paul@185 583
            period_group_sources.append(participant)
paul@114 584
paul@162 585
        groups = []
paul@162 586
        group_columns = []
paul@185 587
        group_types = period_group_types
paul@185 588
        group_sources = period_group_sources
paul@162 589
        all_points = set()
paul@162 590
paul@162 591
        # Obtain time point information for each group of periods.
paul@162 592
paul@185 593
        for periods in period_groups:
paul@162 594
            periods = convert_periods(periods, tzid)
paul@162 595
paul@162 596
            # Get the time scale with start and end points.
paul@162 597
paul@162 598
            scale = get_scale(periods)
paul@162 599
paul@162 600
            # Get the time slots for the periods.
paul@162 601
paul@162 602
            slots = get_slots(scale)
paul@162 603
paul@162 604
            # Add start of day time points for multi-day periods.
paul@162 605
paul@162 606
            add_day_start_points(slots)
paul@162 607
paul@162 608
            # Record the slots and all time points employed.
paul@162 609
paul@162 610
            groups.append(slots)
paul@162 611
            all_points.update([point for point, slot in slots])
paul@162 612
paul@162 613
        # Partition the groups into days.
paul@162 614
paul@162 615
        days = {}
paul@162 616
        partitioned_groups = []
paul@171 617
        partitioned_group_types = []
paul@185 618
        partitioned_group_sources = []
paul@162 619
paul@185 620
        for slots, group_type, group_source in zip(groups, group_types, group_sources):
paul@162 621
paul@162 622
            # Propagate time points to all groups of time slots.
paul@162 623
paul@162 624
            add_slots(slots, all_points)
paul@162 625
paul@162 626
            # Count the number of columns employed by the group.
paul@162 627
paul@162 628
            columns = 0
paul@162 629
paul@162 630
            # Partition the time slots by day.
paul@162 631
paul@162 632
            partitioned = {}
paul@162 633
paul@162 634
            for day, day_slots in partition_by_day(slots).items():
paul@162 635
                columns = max(columns, max(map(lambda i: len(i[1]), day_slots)))
paul@162 636
paul@162 637
                if not days.has_key(day):
paul@162 638
                    days[day] = set()
paul@162 639
paul@162 640
                # Convert each partition to a mapping from points to active
paul@162 641
                # periods.
paul@162 642
paul@162 643
                day_slots = dict(day_slots)
paul@162 644
                partitioned[day] = day_slots
paul@162 645
                days[day].update(day_slots.keys())
paul@162 646
paul@162 647
            if partitioned:
paul@171 648
                group_columns.append(columns)
paul@162 649
                partitioned_groups.append(partitioned)
paul@171 650
                partitioned_group_types.append(group_type)
paul@185 651
                partitioned_group_sources.append(group_source)
paul@114 652
paul@188 653
        page.table(cellspacing=5, cellpadding=5, id="calendar")
paul@188 654
        self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns)
paul@171 655
        self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns)
paul@162 656
        page.table.close()
paul@114 657
paul@188 658
    def show_calendar_participant_headings(self, group_types, group_sources, group_columns):
paul@186 659
paul@186 660
        """
paul@186 661
        Show headings for the participants and other scheduling contributors,
paul@188 662
        defined by 'group_types', 'group_sources' and 'group_columns'.
paul@186 663
        """
paul@186 664
paul@185 665
        page = self.page
paul@185 666
paul@188 667
        page.colgroup(span=1, id="columns-timeslot")
paul@186 668
paul@188 669
        for group_type, columns in zip(group_types, group_columns):
paul@188 670
            page.colgroup(span=columns, id="columns-%s" % group_type)
paul@186 671
paul@185 672
        page.thead()
paul@185 673
        page.tr()
paul@185 674
        page.th("", class_="emptyheading")
paul@185 675
paul@186 676
        for source, columns in zip(group_sources, group_columns):
paul@185 677
            page.th(source, class_="participantheading", colspan=columns)
paul@185 678
paul@185 679
        page.tr.close()
paul@185 680
        page.thead.close()
paul@185 681
paul@171 682
    def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns):
paul@186 683
paul@186 684
        """
paul@186 685
        Show calendar days, defined by a collection of 'days', the contributing
paul@186 686
        period information as 'partitioned_groups' (partitioned by day), the
paul@186 687
        'partitioned_group_types' indicating the kind of contribution involved,
paul@186 688
        and the 'group_columns' defining the number of columns in each group.
paul@186 689
        """
paul@186 690
paul@162 691
        page = self.page
paul@162 692
paul@162 693
        # Determine the number of columns required, the days providing time
paul@162 694
        # slots.
paul@147 695
paul@162 696
        all_columns = sum(group_columns)
paul@162 697
        all_days = days.items()
paul@162 698
        all_days.sort()
paul@162 699
paul@162 700
        # Produce a heading and time points for each day.
paul@162 701
paul@162 702
        for day, points in all_days:
paul@186 703
            page.thead()
paul@114 704
            page.tr()
paul@171 705
            page.th(class_="dayheading", colspan=all_columns+1)
paul@153 706
            page.add(self.format_date(day, "full"))
paul@114 707
            page.th.close()
paul@153 708
            page.tr.close()
paul@186 709
            page.thead.close()
paul@114 710
paul@162 711
            groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups]
paul@162 712
paul@186 713
            page.tbody()
paul@171 714
            self.show_calendar_points(points, groups_for_day, partitioned_group_types, group_columns)
paul@186 715
            page.tbody.close()
paul@185 716
paul@171 717
    def show_calendar_points(self, points, groups, group_types, group_columns):
paul@186 718
paul@186 719
        """
paul@186 720
        Show the time 'points' along with period information from the given
paul@186 721
        'groups', having the indicated 'group_types', each with the number of
paul@186 722
        columns given by 'group_columns'.
paul@186 723
        """
paul@186 724
paul@162 725
        page = self.page
paul@162 726
paul@162 727
        # Produce a row for each time point.
paul@162 728
paul@162 729
        points = list(points)
paul@162 730
        points.sort()
paul@162 731
paul@162 732
        for point in points:
paul@162 733
            continuation = point == get_start_of_day(point)
paul@153 734
paul@162 735
            page.tr()
paul@162 736
            page.th(class_="timeslot")
paul@162 737
            page.add(self.format_time(point, "long"))
paul@162 738
            page.th.close()
paul@162 739
paul@162 740
            # Obtain slots for the time point from each group.
paul@162 741
paul@171 742
            for columns, slots, group_type in zip(group_columns, groups, group_types):
paul@162 743
                active = slots and slots.get(point)
paul@162 744
paul@162 745
                if not active:
paul@188 746
                    page.td("", class_="empty", colspan=columns)
paul@162 747
                    continue
paul@162 748
paul@162 749
                slots = slots.items()
paul@162 750
                slots.sort()
paul@162 751
                spans = get_spans(slots)
paul@162 752
paul@162 753
                # Show a column for each active period.
paul@117 754
paul@153 755
                for t in active:
paul@185 756
                    if t and len(t) >= 2:
paul@185 757
                        start, end, uid, key = get_freebusy_details(t)
paul@185 758
                        span = spans[key]
paul@171 759
paul@171 760
                        # Produce a table cell only at the start of the period
paul@171 761
                        # or when continued at the start of a day.
paul@171 762
paul@153 763
                        if point == start or continuation:
paul@153 764
paul@189 765
                            # Only anchor the first cell of events.
paul@189 766
paul@189 767
                            if point == start:
paul@189 768
                                page.td(class_="event", rowspan=span, id="%s-%s" % (group_type, uid))
paul@189 769
                            else:
paul@189 770
                                page.td(class_="event", rowspan=span)
paul@171 771
paul@153 772
                            obj = self._get_object(uid)
paul@185 773
paul@185 774
                            if not obj:
paul@185 775
                                page.span("")
paul@185 776
                            else:
paul@153 777
                                details = self._get_details(obj)
paul@164 778
                                summary = get_value(details, "SUMMARY")
paul@171 779
paul@171 780
                                # Only link to events if they are not being
paul@171 781
                                # updated by requests.
paul@171 782
paul@171 783
                                if uid in self._get_requests() and group_type != "request":
paul@189 784
                                    page.span(summary)
paul@164 785
                                else:
paul@171 786
                                    href = "%s/%s" % (self.env.get_url().rstrip("/"), uid)
paul@189 787
                                    page.a(summary, href=href)
paul@171 788
paul@153 789
                            page.td.close()
paul@153 790
                    else:
paul@188 791
                        page.td("", class_="empty")
paul@114 792
paul@166 793
                # Pad with empty columns.
paul@166 794
paul@166 795
                i = columns - len(active)
paul@166 796
                while i > 0:
paul@166 797
                    i -= 1
paul@188 798
                    page.td("", class_="empty")
paul@166 799
paul@162 800
            page.tr.close()
paul@114 801
paul@69 802
    def select_action(self):
paul@69 803
paul@69 804
        "Select the desired action and show the result."
paul@69 805
paul@121 806
        path_info = self.env.get_path_info().strip("/")
paul@121 807
paul@69 808
        if not path_info:
paul@114 809
            self.show_calendar()
paul@121 810
        elif self.show_object(path_info):
paul@70 811
            pass
paul@70 812
        else:
paul@70 813
            self.no_page()
paul@69 814
paul@82 815
    def __call__(self):
paul@69 816
paul@69 817
        "Interpret a request and show an appropriate response."
paul@69 818
paul@69 819
        if not self.user:
paul@69 820
            self.no_user()
paul@69 821
        else:
paul@69 822
            self.select_action()
paul@69 823
paul@70 824
        # Write the headers and actual content.
paul@70 825
paul@69 826
        print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding
paul@69 827
        print >>self.out
paul@69 828
        self.out.write(unicode(self.page).encode(self.encoding))
paul@69 829
paul@69 830
if __name__ == "__main__":
paul@128 831
    Manager()()
paul@69 832
paul@69 833
# vim: tabstop=4 expandtab shiftwidth=4