imip-agent

Annotated imip_manager.py

212:7dc75bb19e91
2015-01-31 Paul Boddie Introduced Unicode conversion of form field values; restructured the event form and controls to enclose event details, initially handling the summary property. Made the event display more presentable.
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@202 27
from datetime import datetime
paul@149 28
import babel.dates
paul@149 29
import cgi, os, sys
paul@69 30
paul@146 31
sys.path.append(LIBRARY_PATH)
paul@69 32
paul@153 33
from imiptools.content import Handler, get_address, \
paul@210 34
                              get_item, get_items, get_uri, get_utc_datetime, \
paul@210 35
                              get_value, get_value_map, get_values, \
paul@210 36
                              parse_object, to_part
paul@153 37
from imiptools.dates import format_datetime, get_datetime, get_start_of_day, \
paul@202 38
                            get_end_of_day, ends_on_same_day, to_timezone
paul@83 39
from imiptools.mail import Messenger
paul@162 40
from imiptools.period import add_day_start_points, add_slots, convert_periods, \
paul@185 41
                             get_freebusy_details, \
paul@162 42
                             get_scale, have_conflict, get_slots, get_spans, \
paul@162 43
                             partition_by_day
paul@147 44
from imiptools.profile import Preferences
paul@77 45
from vCalendar import to_node
paul@69 46
import markup
paul@69 47
import imip_store
paul@69 48
paul@69 49
getenv = os.environ.get
paul@69 50
setenv = os.environ.__setitem__
paul@69 51
paul@69 52
class CGIEnvironment:
paul@69 53
paul@69 54
    "A CGI-compatible environment."
paul@69 55
paul@212 56
    def __init__(self, charset=None):
paul@212 57
        self.charset = charset
paul@69 58
        self.args = None
paul@69 59
        self.method = None
paul@69 60
        self.path = None
paul@69 61
        self.path_info = None
paul@69 62
        self.user = None
paul@69 63
paul@69 64
    def get_args(self):
paul@69 65
        if self.args is None:
paul@69 66
            if self.get_method() != "POST":
paul@69 67
                setenv("QUERY_STRING", "")
paul@212 68
            args = cgi.parse(keep_blank_values=True)
paul@212 69
paul@212 70
            if not self.charset:
paul@212 71
                self.args = args
paul@212 72
            else:
paul@212 73
                self.args = {}
paul@212 74
                for key, values in args.items():
paul@212 75
                    self.args[key] = [unicode(value, self.charset) for value in values]
paul@212 76
paul@69 77
        return self.args
paul@69 78
paul@69 79
    def get_method(self):
paul@69 80
        if self.method is None:
paul@69 81
            self.method = getenv("REQUEST_METHOD") or "GET"
paul@69 82
        return self.method
paul@69 83
paul@69 84
    def get_path(self):
paul@69 85
        if self.path is None:
paul@69 86
            self.path = getenv("SCRIPT_NAME") or ""
paul@69 87
        return self.path
paul@69 88
paul@69 89
    def get_path_info(self):
paul@69 90
        if self.path_info is None:
paul@69 91
            self.path_info = getenv("PATH_INFO") or ""
paul@69 92
        return self.path_info
paul@69 93
paul@69 94
    def get_user(self):
paul@69 95
        if self.user is None:
paul@69 96
            self.user = getenv("REMOTE_USER") or ""
paul@69 97
        return self.user
paul@69 98
paul@69 99
    def get_output(self):
paul@69 100
        return sys.stdout
paul@69 101
paul@69 102
    def get_url(self):
paul@69 103
        path = self.get_path()
paul@69 104
        path_info = self.get_path_info()
paul@69 105
        return "%s%s" % (path.rstrip("/"), path_info)
paul@69 106
paul@154 107
    def new_url(self, path_info):
paul@154 108
        path = self.get_path()
paul@154 109
        return "%s/%s" % (path.rstrip("/"), path_info.lstrip("/"))
paul@154 110
paul@79 111
class ManagerHandler(Handler):
paul@79 112
paul@121 113
    """
paul@121 114
    A content handler for use by the manager, as opposed to operating within the
paul@121 115
    mail processing pipeline.
paul@121 116
    """
paul@79 117
paul@121 118
    def __init__(self, obj, user, messenger):
paul@121 119
        details, details_attr = obj.values()[0]
paul@79 120
        Handler.__init__(self, details)
paul@121 121
        self.obj = obj
paul@79 122
        self.user = user
paul@82 123
        self.messenger = messenger
paul@82 124
paul@207 125
        self.organiser = self.get_value("ORGANIZER")
paul@207 126
        self.attendees = self.get_values("ATTENDEE")
paul@79 127
paul@79 128
    # Communication methods.
paul@79 129
paul@207 130
    def send_message(self, method, sender):
paul@79 131
paul@79 132
        """
paul@207 133
        Create a full calendar object employing the given 'method', and send it
paul@207 134
        to the appropriate recipients, also sending a copy to the 'sender'.
paul@79 135
        """
paul@79 136
paul@121 137
        node = to_node(self.obj)
paul@207 138
        part = to_part(method, [node])
paul@207 139
paul@207 140
        if self.user == self.organiser:
paul@207 141
            recipients = map(get_address, self.attendees)
paul@207 142
        else:
paul@207 143
            recipients = [get_address(self.organiser)]
paul@207 144
paul@207 145
        message = self.messenger.make_outgoing_message([part], recipients, outgoing_bcc=sender)
paul@207 146
        self.messenger.sendmail(recipients, message.as_string(), outgoing_bcc=sender)
paul@79 147
paul@79 148
    # Action methods.
paul@79 149
paul@207 150
    def process_received_request(self, accept, update=False):
paul@79 151
paul@79 152
        """
paul@79 153
        Process the current request for the given 'user', accepting any request
paul@79 154
        when 'accept' is true, declining requests otherwise. Return whether any
paul@79 155
        action was taken.
paul@155 156
paul@155 157
        If 'update' is given, the sequence number will be incremented in order
paul@155 158
        to override any previous response.
paul@79 159
        """
paul@79 160
paul@79 161
        # When accepting or declining, do so only on behalf of this user,
paul@79 162
        # preserving any other attributes set as an attendee.
paul@79 163
paul@79 164
        for attendee, attendee_attr in self.get_items("ATTENDEE"):
paul@79 165
paul@79 166
            if attendee == self.user:
paul@79 167
                attendee_attr["PARTSTAT"] = accept and "ACCEPTED" or "DECLINED"
paul@128 168
                if self.messenger and self.messenger.sender != get_address(attendee):
paul@128 169
                    attendee_attr["SENT-BY"] = get_uri(self.messenger.sender)
paul@79 170
                self.details["ATTENDEE"] = [(attendee, attendee_attr)]
paul@155 171
                if update:
paul@155 172
                    sequence = self.get_value("SEQUENCE") or "0"
paul@155 173
                    self.details["SEQUENCE"] = [(str(int(sequence) + 1), {})]
paul@158 174
                self.update_dtstamp()
paul@155 175
paul@207 176
                self.send_message("REPLY", get_address(attendee))
paul@79 177
paul@79 178
                return True
paul@79 179
paul@79 180
        return False
paul@79 181
paul@207 182
    def process_created_request(self, update=False):
paul@207 183
paul@207 184
        """
paul@207 185
        Process the current request for the given 'user', sending a created
paul@207 186
        request to attendees. Return whether any action was taken.
paul@207 187
paul@207 188
        If 'update' is given, the sequence number will be incremented in order
paul@207 189
        to override any previous message.
paul@207 190
        """
paul@207 191
paul@207 192
        if update:
paul@207 193
            sequence = self.get_value("SEQUENCE") or "0"
paul@207 194
            self.details["SEQUENCE"] = [(str(int(sequence) + 1), {})]
paul@207 195
        self.update_dtstamp()
paul@207 196
paul@207 197
        self.send_message("REQUEST", get_address(self.organiser))
paul@207 198
paul@207 199
        return True
paul@207 200
paul@69 201
class Manager:
paul@69 202
paul@69 203
    "A simple manager application."
paul@69 204
paul@82 205
    def __init__(self, messenger=None):
paul@82 206
        self.messenger = messenger or Messenger()
paul@82 207
paul@212 208
        self.encoding = "utf-8"
paul@212 209
        self.env = CGIEnvironment(self.encoding)
paul@212 210
paul@69 211
        user = self.env.get_user()
paul@77 212
        self.user = user and get_uri(user) or None
paul@147 213
        self.preferences = None
paul@149 214
        self.locale = None
paul@121 215
        self.requests = None
paul@121 216
paul@69 217
        self.out = self.env.get_output()
paul@69 218
        self.page = markup.page()
paul@69 219
paul@77 220
        self.store = imip_store.FileStore()
paul@162 221
        self.objects = {}
paul@77 222
paul@77 223
        try:
paul@77 224
            self.publisher = imip_store.FilePublisher()
paul@77 225
        except OSError:
paul@77 226
            self.publisher = None
paul@77 227
paul@121 228
    def _get_uid(self, path_info):
paul@121 229
        return path_info.lstrip("/").split("/", 1)[0]
paul@121 230
paul@117 231
    def _get_object(self, uid):
paul@162 232
        if self.objects.has_key(uid):
paul@162 233
            return self.objects[uid]
paul@162 234
paul@117 235
        f = uid and self.store.get_event(self.user, uid) or None
paul@117 236
paul@117 237
        if not f:
paul@117 238
            return None
paul@117 239
paul@162 240
        self.objects[uid] = obj = parse_object(f, "utf-8")
paul@117 241
paul@117 242
        if not obj:
paul@117 243
            return None
paul@117 244
paul@121 245
        return obj
paul@121 246
paul@121 247
    def _get_details(self, obj):
paul@121 248
        details, details_attr = obj.values()[0]
paul@121 249
        return details
paul@121 250
paul@121 251
    def _get_requests(self):
paul@121 252
        if self.requests is None:
paul@121 253
            self.requests = self.store.get_requests(self.user)
paul@121 254
        return self.requests
paul@117 255
paul@162 256
    def _get_request_summary(self):
paul@162 257
        summary = []
paul@162 258
        for uid in self._get_requests():
paul@162 259
            obj = self._get_object(uid)
paul@162 260
            if obj:
paul@162 261
                details = self._get_details(obj)
paul@162 262
                summary.append((
paul@162 263
                    get_value(details, "DTSTART"),
paul@162 264
                    get_value(details, "DTEND"),
paul@162 265
                    uid
paul@162 266
                    ))
paul@162 267
        return summary
paul@162 268
paul@147 269
    # Preference methods.
paul@147 270
paul@149 271
    def get_user_locale(self):
paul@149 272
        if not self.locale:
paul@149 273
            self.locale = self.get_preferences().get("LANG", "C")
paul@149 274
        return self.locale
paul@147 275
paul@147 276
    def get_preferences(self):
paul@147 277
        if not self.preferences:
paul@147 278
            self.preferences = Preferences(self.user)
paul@147 279
        return self.preferences
paul@147 280
paul@162 281
    # Prettyprinting of dates and times.
paul@162 282
paul@149 283
    def format_date(self, dt, format):
paul@149 284
        return self._format_datetime(babel.dates.format_date, dt, format)
paul@149 285
paul@149 286
    def format_time(self, dt, format):
paul@149 287
        return self._format_datetime(babel.dates.format_time, dt, format)
paul@149 288
paul@149 289
    def format_datetime(self, dt, format):
paul@149 290
        return self._format_datetime(babel.dates.format_datetime, dt, format)
paul@149 291
paul@149 292
    def _format_datetime(self, fn, dt, format):
paul@149 293
        return fn(dt, format=format, locale=self.get_user_locale())
paul@149 294
paul@78 295
    # Data management methods.
paul@78 296
paul@78 297
    def remove_request(self, uid):
paul@105 298
        return self.store.dequeue_request(self.user, uid)
paul@78 299
paul@78 300
    # Presentation methods.
paul@78 301
paul@69 302
    def new_page(self, title):
paul@192 303
        self.page.init(title=title, charset=self.encoding, css=self.env.new_url("styles.css"))
paul@69 304
paul@69 305
    def status(self, code, message):
paul@123 306
        self.header("Status", "%s %s" % (code, message))
paul@123 307
paul@123 308
    def header(self, header, value):
paul@123 309
        print >>self.out, "%s: %s" % (header, value)
paul@69 310
paul@69 311
    def no_user(self):
paul@69 312
        self.status(403, "Forbidden")
paul@69 313
        self.new_page(title="Forbidden")
paul@69 314
        self.page.p("You are not logged in and thus cannot access scheduling requests.")
paul@69 315
paul@70 316
    def no_page(self):
paul@70 317
        self.status(404, "Not Found")
paul@70 318
        self.new_page(title="Not Found")
paul@70 319
        self.page.p("No page is provided at the given address.")
paul@70 320
paul@123 321
    def redirect(self, url):
paul@123 322
        self.status(302, "Redirect")
paul@123 323
        self.header("Location", url)
paul@123 324
        self.new_page(title="Redirect")
paul@123 325
        self.page.p("Redirecting to: %s" % url)
paul@123 326
paul@121 327
    # Request logic and page fragment methods.
paul@121 328
paul@202 329
    def handle_newevent(self):
paul@202 330
paul@207 331
        """
paul@207 332
        Handle any new event operation, creating a new event and redirecting to
paul@207 333
        the event page for further activity.
paul@207 334
        """
paul@202 335
paul@202 336
        # Handle a submitted form.
paul@202 337
paul@202 338
        args = self.env.get_args()
paul@202 339
paul@202 340
        if not args.has_key("newevent"):
paul@202 341
            return
paul@202 342
paul@202 343
        # Create a new event using the available information.
paul@202 344
paul@202 345
        slot = args.get("slot", [None])[0]
paul@202 346
        participants = args.get("participants", [])
paul@202 347
paul@202 348
        if not slot:
paul@202 349
            return
paul@202 350
paul@202 351
        start, end = slot.split("-")
paul@202 352
paul@202 353
        # Obtain the user's timezone.
paul@202 354
paul@202 355
        prefs = self.get_preferences()
paul@202 356
        tzid = prefs.get("TZID", "UTC")
paul@202 357
paul@202 358
        # Invent a unique identifier.
paul@202 359
paul@202 360
        utcnow = format_datetime(to_timezone(datetime.utcnow(), "UTC"))
paul@202 361
        uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user))
paul@202 362
paul@202 363
        # Create a calendar object and store it as a request.
paul@202 364
paul@202 365
        record = []
paul@202 366
        rwrite = record.append
paul@202 367
paul@202 368
        rwrite(("UID", {}, uid))
paul@202 369
        rwrite(("SUMMARY", {}, "New event at %s" % utcnow))
paul@202 370
        rwrite(("DTSTAMP", {}, utcnow))
paul@207 371
        rwrite(("DTSTART", {"VALUE" : "DATE-TIME", "TZID" : tzid}, start))
paul@207 372
        rwrite(("DTEND", {"VALUE" : "DATE-TIME", "TZID" : tzid}, end or
paul@202 373
            format_datetime(get_end_of_day(get_datetime(start, {"TZID" : tzid})))
paul@202 374
            ))
paul@202 375
        rwrite(("ORGANIZER", {}, self.user))
paul@202 376
paul@202 377
        for participant in participants:
paul@202 378
            if not participant:
paul@202 379
                continue
paul@202 380
            participant = get_uri(participant)
paul@202 381
            if participant != self.user:
paul@202 382
                rwrite(("ATTENDEE", {}, participant))
paul@202 383
paul@202 384
        obj = ("VEVENT", {}, record)
paul@202 385
paul@202 386
        self.store.set_event(self.user, uid, obj)
paul@202 387
        self.store.queue_request(self.user, uid)
paul@202 388
paul@202 389
        # Redirect to the object, where instead of attendee controls,
paul@202 390
        # there will be organiser controls.
paul@202 391
paul@202 392
        self.redirect(self.env.new_url(uid))
paul@202 393
paul@212 394
    def handle_request(self, uid, obj, queued):
paul@121 395
paul@155 396
        """
paul@212 397
        Handle actions involving the given 'uid' and 'obj' object, where
paul@155 398
        'queued' indicates that the object has not yet been handled.
paul@155 399
        """
paul@121 400
paul@121 401
        # Handle a submitted form.
paul@121 402
paul@121 403
        args = self.env.get_args()
paul@123 404
        handled = True
paul@121 405
paul@212 406
        # Update the object.
paul@212 407
paul@212 408
        if args.has_key("summary"):
paul@212 409
            details = self._get_details(obj)
paul@212 410
            details["SUMMARY"] = [(args["summary"][0], {})]
paul@212 411
paul@212 412
        # Process any action.
paul@212 413
paul@121 414
        accept = args.has_key("accept")
paul@121 415
        decline = args.has_key("decline")
paul@207 416
        invite = args.has_key("invite")
paul@155 417
        update = not queued and args.has_key("update")
paul@121 418
paul@207 419
        if accept or decline or invite:
paul@121 420
paul@212 421
            handler = ManagerHandler(obj, self.user, self.messenger)
paul@121 422
paul@212 423
            # Process the object and remove it from the list of requests.
paul@121 424
paul@207 425
            if (accept or decline) and handler.process_received_request(accept, update) or \
paul@207 426
                invite and handler.process_created_request(update):
paul@121 427
paul@121 428
                self.remove_request(uid)
paul@121 429
paul@207 430
        elif args.has_key("discard"):
paul@121 431
paul@121 432
            # Remove the request from the list.
paul@121 433
paul@121 434
            self.remove_request(uid)
paul@121 435
paul@121 436
        else:
paul@123 437
            handled = False
paul@121 438
paul@212 439
        # Upon handling an action, redirect to the main page.
paul@212 440
paul@123 441
        if handled:
paul@123 442
            self.redirect(self.env.get_path())
paul@123 443
paul@123 444
        return handled
paul@121 445
paul@212 446
    def show_request_controls(self, obj, needs_action):
paul@155 447
paul@155 448
        """
paul@212 449
        Show form controls for a request concerning 'obj', indicating whether
paul@212 450
        action is needed if 'needs_action' is specified as a true value.
paul@155 451
        """
paul@155 452
paul@212 453
        page = self.page
paul@212 454
paul@155 455
        details = self._get_details(obj)
paul@207 456
        is_organiser = get_value(details, "ORGANIZER") == self.user
paul@207 457
paul@207 458
        if not is_organiser:
paul@207 459
            attendees = get_value_map(details, "ATTENDEE")
paul@207 460
            attendee_attr = attendees.get(self.user)
paul@121 461
paul@207 462
            if attendee_attr:
paul@207 463
                partstat = attendee_attr.get("PARTSTAT")
paul@207 464
                if partstat == "ACCEPTED":
paul@212 465
                    page.p("This request has been accepted.")
paul@207 466
                elif partstat == "DECLINED":
paul@212 467
                    page.p("This request has been declined.")
paul@207 468
                else:
paul@212 469
                    page.p("This request has not yet been dealt with.")
paul@121 470
paul@155 471
        if needs_action:
paul@212 472
            page.p("An action is required for this request:")
paul@155 473
        else:
paul@212 474
            page.p("This request can be updated as follows:")
paul@155 475
paul@212 476
        page.p()
paul@207 477
paul@207 478
        # Show appropriate options depending on the role of the user.
paul@207 479
paul@207 480
        if is_organiser:
paul@212 481
            page.input(name="invite", type="submit", value="Invite")
paul@207 482
        else:
paul@212 483
            page.input(name="accept", type="submit", value="Accept")
paul@212 484
            page.add(" ")
paul@212 485
            page.input(name="decline", type="submit", value="Decline")
paul@207 486
paul@212 487
        page.add(" ")
paul@212 488
        page.input(name="discard", type="submit", value="Discard")
paul@207 489
paul@207 490
        # Updated objects need to have details updated upon sending.
paul@207 491
paul@155 492
        if not needs_action:
paul@212 493
            page.input(name="update", type="hidden", value="true")
paul@207 494
paul@212 495
        page.p.close()
paul@121 496
paul@210 497
    object_labels = {
paul@210 498
        "SUMMARY"   : "Summary",
paul@210 499
        "DTSTART"   : "Start",
paul@210 500
        "DTEND"     : "End",
paul@210 501
        "ORGANIZER" : "Organiser",
paul@210 502
        "ATTENDEE"  : "Attendee",
paul@210 503
        }
paul@210 504
paul@212 505
    def show_object_on_page(self, uid, obj, needs_action):
paul@121 506
paul@121 507
        """
paul@121 508
        Show the calendar object with the given 'uid' and representation 'obj'
paul@121 509
        on the current page.
paul@121 510
        """
paul@121 511
paul@210 512
        page = self.page
paul@212 513
        page.form(method="POST")
paul@210 514
paul@154 515
        # Obtain the user's timezone.
paul@154 516
paul@154 517
        prefs = self.get_preferences()
paul@154 518
        tzid = prefs.get("TZID", "UTC")
paul@121 519
paul@121 520
        # Provide a summary of the object.
paul@121 521
paul@154 522
        details = self._get_details(obj)
paul@154 523
paul@212 524
        page.table(id="object", cellspacing=5, cellpadding=5)
paul@212 525
        page.thead()
paul@212 526
        page.tr()
paul@212 527
        page.th("Event", class_="mainheading", colspan=2)
paul@212 528
        page.tr.close()
paul@212 529
        page.thead.close()
paul@212 530
        page.tbody()
paul@121 531
paul@121 532
        for name in ["SUMMARY", "DTSTART", "DTEND", "ORGANIZER", "ATTENDEE"]:
paul@210 533
            page.tr()
paul@210 534
paul@210 535
            label = self.object_labels.get(name, name)
paul@210 536
paul@210 537
            # Handle datetimes specially.
paul@210 538
paul@147 539
            if name in ["DTSTART", "DTEND"]:
paul@147 540
                value, attr = get_item(details, name)
paul@154 541
                tzid = attr.get("TZID", tzid)
paul@149 542
                value = self.format_datetime(to_timezone(get_datetime(value), tzid), "full")
paul@210 543
                page.th(label, class_="objectheading")
paul@210 544
                page.td(value)
paul@210 545
                page.tr.close()
paul@210 546
paul@212 547
            # Handle the summary specially.
paul@212 548
paul@212 549
            elif name == "SUMMARY":
paul@212 550
                value = get_value(details, name)
paul@212 551
                page.th(label, class_="objectheading")
paul@212 552
                page.td()
paul@212 553
                page.input(name="summary", type="text", value=value, size=80)
paul@212 554
                page.td.close()
paul@212 555
                page.tr.close()
paul@212 556
paul@210 557
            # Handle potentially many values.
paul@210 558
paul@147 559
            else:
paul@210 560
                items = get_items(details, name)
paul@210 561
                page.th(label, class_="objectheading", rowspan=len(items))
paul@210 562
paul@210 563
                first = True
paul@210 564
paul@210 565
                for value, attr in items:
paul@210 566
                    if not first:
paul@210 567
                        page.tr()
paul@210 568
                    else:
paul@210 569
                        first = False
paul@121 570
paul@210 571
                    page.td()
paul@210 572
                    page.add(value)
paul@210 573
paul@210 574
                    if name == "ATTENDEE":
paul@210 575
                        partstat = attr.get("PARTSTAT")
paul@210 576
                        if partstat:
paul@210 577
                            page.add(" (%s)" % partstat)
paul@210 578
paul@210 579
                    page.td.close()
paul@210 580
                    page.tr.close()
paul@210 581
paul@212 582
        page.tbody.close()
paul@210 583
        page.table.close()
paul@121 584
paul@121 585
        dtstart = format_datetime(get_utc_datetime(details, "DTSTART"))
paul@121 586
        dtend = format_datetime(get_utc_datetime(details, "DTEND"))
paul@121 587
paul@121 588
        # Indicate whether there are conflicting events.
paul@121 589
paul@121 590
        freebusy = self.store.get_freebusy(self.user)
paul@121 591
paul@121 592
        if freebusy:
paul@121 593
paul@121 594
            # Obtain any time zone details from the suggested event.
paul@121 595
paul@121 596
            _dtstart, attr = get_item(details, "DTSTART")
paul@154 597
            tzid = attr.get("TZID", tzid)
paul@121 598
paul@121 599
            # Show any conflicts.
paul@121 600
paul@121 601
            for t in have_conflict(freebusy, [(dtstart, dtend)], True):
paul@121 602
                start, end, found_uid = t[:3]
paul@154 603
paul@154 604
                # Provide details of any conflicting event.
paul@154 605
paul@121 606
                if uid != found_uid:
paul@149 607
                    start = self.format_datetime(to_timezone(get_datetime(start), tzid), "full")
paul@149 608
                    end = self.format_datetime(to_timezone(get_datetime(end), tzid), "full")
paul@210 609
                    page.p("Event conflicts with another from %s to %s: " % (start, end))
paul@154 610
paul@154 611
                    # Show the event summary for the conflicting event.
paul@154 612
paul@154 613
                    found_obj = self._get_object(found_uid)
paul@154 614
                    if found_obj:
paul@154 615
                        found_details = self._get_details(found_obj)
paul@210 616
                        page.a(get_value(found_details, "SUMMARY"), href=self.env.new_url(found_uid))
paul@121 617
paul@212 618
        self.show_request_controls(obj, needs_action)
paul@212 619
        page.form.close()
paul@212 620
paul@121 621
    def show_requests_on_page(self):
paul@69 622
paul@69 623
        "Show requests for the current user."
paul@69 624
paul@69 625
        # NOTE: This list could be more informative, but it is envisaged that
paul@69 626
        # NOTE: the requests would be visited directly anyway.
paul@69 627
paul@121 628
        requests = self._get_requests()
paul@70 629
paul@185 630
        self.page.div(id="pending-requests")
paul@185 631
paul@80 632
        if requests:
paul@114 633
            self.page.p("Pending requests:")
paul@114 634
paul@80 635
            self.page.ul()
paul@69 636
paul@80 637
            for request in requests:
paul@165 638
                obj = self._get_object(request)
paul@165 639
                if obj:
paul@165 640
                    details = self._get_details(obj)
paul@165 641
                    self.page.li()
paul@171 642
                    self.page.a(get_value(details, "SUMMARY"), href="#request-%s" % request)
paul@165 643
                    self.page.li.close()
paul@80 644
paul@80 645
            self.page.ul.close()
paul@80 646
paul@80 647
        else:
paul@80 648
            self.page.p("There are no pending requests.")
paul@69 649
paul@185 650
        self.page.div.close()
paul@185 651
paul@185 652
    def show_participants_on_page(self):
paul@185 653
paul@185 654
        "Show participants for scheduling purposes."
paul@185 655
paul@185 656
        args = self.env.get_args()
paul@185 657
        participants = args.get("participants", [])
paul@185 658
paul@185 659
        try:
paul@185 660
            for name, value in args.items():
paul@185 661
                if name.startswith("remove-participant-"):
paul@185 662
                    i = int(name[len("remove-participant-"):])
paul@185 663
                    del participants[i]
paul@185 664
                    break
paul@185 665
        except ValueError:
paul@185 666
            pass
paul@185 667
paul@185 668
        # Trim empty participants.
paul@185 669
paul@185 670
        while participants and not participants[-1].strip():
paul@185 671
            participants.pop()
paul@185 672
paul@185 673
        # Show any specified participants together with controls to remove and
paul@185 674
        # add participants.
paul@185 675
paul@185 676
        self.page.div(id="participants")
paul@185 677
paul@185 678
        self.page.p("Participants for scheduling:")
paul@185 679
paul@185 680
        for i, participant in enumerate(participants):
paul@185 681
            self.page.p()
paul@185 682
            self.page.input(name="participants", type="text", value=participant)
paul@185 683
            self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove")
paul@185 684
            self.page.p.close()
paul@185 685
paul@185 686
        self.page.p()
paul@185 687
        self.page.input(name="participants", type="text")
paul@185 688
        self.page.input(name="add-participant", type="submit", value="Add")
paul@185 689
        self.page.p.close()
paul@185 690
paul@185 691
        self.page.div.close()
paul@185 692
paul@185 693
        return participants
paul@185 694
paul@121 695
    # Full page output methods.
paul@70 696
paul@121 697
    def show_object(self, path_info):
paul@70 698
paul@121 699
        "Show an object request using the given 'path_info' for the current user."
paul@70 700
paul@121 701
        uid = self._get_uid(path_info)
paul@121 702
        obj = self._get_object(uid)
paul@121 703
paul@121 704
        if not obj:
paul@70 705
            return False
paul@70 706
paul@123 707
        is_request = uid in self._get_requests()
paul@155 708
        handled = self.handle_request(uid, obj, is_request)
paul@77 709
paul@123 710
        if handled:
paul@123 711
            return True
paul@73 712
paul@123 713
        self.new_page(title="Event")
paul@212 714
        self.show_object_on_page(uid, obj, is_request and not handled)
paul@73 715
paul@70 716
        return True
paul@70 717
paul@114 718
    def show_calendar(self):
paul@114 719
paul@114 720
        "Show the calendar for the current user."
paul@114 721
paul@202 722
        handled = self.handle_newevent()
paul@202 723
paul@114 724
        self.new_page(title="Calendar")
paul@162 725
        page = self.page
paul@162 726
paul@196 727
        # Form controls are used in various places on the calendar page.
paul@196 728
paul@196 729
        page.form(method="POST")
paul@196 730
paul@121 731
        self.show_requests_on_page()
paul@185 732
        participants = self.show_participants_on_page()
paul@114 733
paul@196 734
        # Show a button for scheduling a new event.
paul@196 735
paul@196 736
        page.p()
paul@196 737
        page.input(name="newevent", type="submit", value="New event", id="newevent")
paul@196 738
        page.p.close()
paul@196 739
paul@203 740
        # Show a control for hiding empty slots.
paul@203 741
        # The positioning of the control, paragraph and table are important here.
paul@203 742
paul@203 743
        page.input(name="hideslots", type="checkbox", value="hide", id="hideslots")
paul@203 744
paul@203 745
        page.p()
paul@203 746
        page.label("Hide unused time periods", for_="hideslots", class_="enable")
paul@203 747
        page.label("Show unused time periods", for_="hideslots", class_="disable")
paul@203 748
        page.p.close()
paul@203 749
paul@114 750
        freebusy = self.store.get_freebusy(self.user)
paul@114 751
paul@114 752
        if not freebusy:
paul@114 753
            page.p("No events scheduled.")
paul@114 754
            return
paul@114 755
paul@154 756
        # Obtain the user's timezone.
paul@147 757
paul@147 758
        prefs = self.get_preferences()
paul@153 759
        tzid = prefs.get("TZID", "UTC")
paul@147 760
paul@114 761
        # Day view: start at the earliest known day and produce days until the
paul@114 762
        # latest known day, perhaps with expandable sections of empty days.
paul@114 763
paul@114 764
        # Month view: start at the earliest known month and produce months until
paul@114 765
        # the latest known month, perhaps with expandable sections of empty
paul@114 766
        # months.
paul@114 767
paul@114 768
        # Details of users to invite to new events could be superimposed on the
paul@114 769
        # calendar.
paul@114 770
paul@185 771
        # Requests are listed and linked to their tentative positions in the
paul@185 772
        # calendar. Other participants are also shown.
paul@185 773
paul@185 774
        request_summary = self._get_request_summary()
paul@185 775
paul@185 776
        period_groups = [request_summary, freebusy]
paul@185 777
        period_group_types = ["request", "freebusy"]
paul@185 778
        period_group_sources = ["Pending requests", "Your schedule"]
paul@185 779
paul@187 780
        for i, participant in enumerate(participants):
paul@185 781
            period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant)))
paul@187 782
            period_group_types.append("freebusy-part%d" % i)
paul@185 783
            period_group_sources.append(participant)
paul@114 784
paul@162 785
        groups = []
paul@162 786
        group_columns = []
paul@185 787
        group_types = period_group_types
paul@185 788
        group_sources = period_group_sources
paul@162 789
        all_points = set()
paul@162 790
paul@162 791
        # Obtain time point information for each group of periods.
paul@162 792
paul@185 793
        for periods in period_groups:
paul@162 794
            periods = convert_periods(periods, tzid)
paul@162 795
paul@162 796
            # Get the time scale with start and end points.
paul@162 797
paul@162 798
            scale = get_scale(periods)
paul@162 799
paul@162 800
            # Get the time slots for the periods.
paul@162 801
paul@162 802
            slots = get_slots(scale)
paul@162 803
paul@162 804
            # Add start of day time points for multi-day periods.
paul@162 805
paul@162 806
            add_day_start_points(slots)
paul@162 807
paul@162 808
            # Record the slots and all time points employed.
paul@162 809
paul@162 810
            groups.append(slots)
paul@201 811
            all_points.update([point for point, active in slots])
paul@162 812
paul@162 813
        # Partition the groups into days.
paul@162 814
paul@162 815
        days = {}
paul@162 816
        partitioned_groups = []
paul@171 817
        partitioned_group_types = []
paul@185 818
        partitioned_group_sources = []
paul@162 819
paul@185 820
        for slots, group_type, group_source in zip(groups, group_types, group_sources):
paul@162 821
paul@162 822
            # Propagate time points to all groups of time slots.
paul@162 823
paul@162 824
            add_slots(slots, all_points)
paul@162 825
paul@162 826
            # Count the number of columns employed by the group.
paul@162 827
paul@162 828
            columns = 0
paul@162 829
paul@162 830
            # Partition the time slots by day.
paul@162 831
paul@162 832
            partitioned = {}
paul@162 833
paul@162 834
            for day, day_slots in partition_by_day(slots).items():
paul@201 835
                intervals = []
paul@201 836
                last = None
paul@201 837
paul@201 838
                for point, active in day_slots:
paul@201 839
                    columns = max(columns, len(active))
paul@201 840
                    if last:
paul@201 841
                        intervals.append((last, point))
paul@201 842
                    last = point
paul@201 843
paul@201 844
                if last:
paul@201 845
                    intervals.append((last, None))
paul@162 846
paul@162 847
                if not days.has_key(day):
paul@162 848
                    days[day] = set()
paul@162 849
paul@162 850
                # Convert each partition to a mapping from points to active
paul@162 851
                # periods.
paul@162 852
paul@201 853
                partitioned[day] = dict(day_slots)
paul@201 854
paul@201 855
                # Record the divisions or intervals within each day.
paul@201 856
paul@201 857
                days[day].update(intervals)
paul@162 858
paul@194 859
            if group_type != "request" or columns:
paul@194 860
                group_columns.append(columns)
paul@194 861
                partitioned_groups.append(partitioned)
paul@194 862
                partitioned_group_types.append(group_type)
paul@194 863
                partitioned_group_sources.append(group_source)
paul@114 864
paul@188 865
        page.table(cellspacing=5, cellpadding=5, id="calendar")
paul@188 866
        self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns)
paul@171 867
        self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns)
paul@162 868
        page.table.close()
paul@114 869
paul@196 870
        # End the form region.
paul@196 871
paul@196 872
        page.form.close()
paul@196 873
paul@188 874
    def show_calendar_participant_headings(self, group_types, group_sources, group_columns):
paul@186 875
paul@186 876
        """
paul@186 877
        Show headings for the participants and other scheduling contributors,
paul@188 878
        defined by 'group_types', 'group_sources' and 'group_columns'.
paul@186 879
        """
paul@186 880
paul@185 881
        page = self.page
paul@185 882
paul@188 883
        page.colgroup(span=1, id="columns-timeslot")
paul@186 884
paul@188 885
        for group_type, columns in zip(group_types, group_columns):
paul@191 886
            page.colgroup(span=max(columns, 1), id="columns-%s" % group_type)
paul@186 887
paul@185 888
        page.thead()
paul@185 889
        page.tr()
paul@185 890
        page.th("", class_="emptyheading")
paul@185 891
paul@193 892
        for group_type, source, columns in zip(group_types, group_sources, group_columns):
paul@193 893
            page.th(source,
paul@193 894
                class_=(group_type == "request" and "requestheading" or "participantheading"),
paul@193 895
                colspan=max(columns, 1))
paul@185 896
paul@185 897
        page.tr.close()
paul@185 898
        page.thead.close()
paul@185 899
paul@171 900
    def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns):
paul@186 901
paul@186 902
        """
paul@186 903
        Show calendar days, defined by a collection of 'days', the contributing
paul@186 904
        period information as 'partitioned_groups' (partitioned by day), the
paul@186 905
        'partitioned_group_types' indicating the kind of contribution involved,
paul@186 906
        and the 'group_columns' defining the number of columns in each group.
paul@186 907
        """
paul@186 908
paul@162 909
        page = self.page
paul@162 910
paul@191 911
        # Determine the number of columns required. Where participants provide
paul@191 912
        # no columns for events, one still needs to be provided for the
paul@191 913
        # participant itself.
paul@147 914
paul@191 915
        all_columns = sum([max(columns, 1) for columns in group_columns])
paul@191 916
paul@191 917
        # Determine the days providing time slots.
paul@191 918
paul@162 919
        all_days = days.items()
paul@162 920
        all_days.sort()
paul@162 921
paul@162 922
        # Produce a heading and time points for each day.
paul@162 923
paul@201 924
        for day, intervals in all_days:
paul@186 925
            page.thead()
paul@114 926
            page.tr()
paul@171 927
            page.th(class_="dayheading", colspan=all_columns+1)
paul@153 928
            page.add(self.format_date(day, "full"))
paul@114 929
            page.th.close()
paul@153 930
            page.tr.close()
paul@186 931
            page.thead.close()
paul@114 932
paul@162 933
            groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups]
paul@162 934
paul@186 935
            page.tbody()
paul@201 936
            self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns)
paul@186 937
            page.tbody.close()
paul@185 938
paul@201 939
    def show_calendar_points(self, intervals, groups, group_types, group_columns):
paul@186 940
paul@186 941
        """
paul@201 942
        Show the time 'intervals' along with period information from the given
paul@186 943
        'groups', having the indicated 'group_types', each with the number of
paul@186 944
        columns given by 'group_columns'.
paul@186 945
        """
paul@186 946
paul@162 947
        page = self.page
paul@162 948
paul@203 949
        # Produce a row for each interval.
paul@162 950
paul@201 951
        intervals = list(intervals)
paul@201 952
        intervals.sort()
paul@162 953
paul@201 954
        for point, endpoint in intervals:
paul@162 955
            continuation = point == get_start_of_day(point)
paul@153 956
paul@203 957
            # Some rows contain no period details and are marked as such.
paul@203 958
paul@203 959
            have_active = reduce(lambda x, y: x or y, [slots.get(point) for slots in groups], None)
paul@203 960
paul@203 961
            css = " ".join(
paul@203 962
                ["slot"] +
paul@203 963
                (not have_active and ["empty"] or []) +
paul@203 964
                (continuation and ["daystart"] or [])
paul@203 965
                )
paul@203 966
paul@203 967
            page.tr(class_=css)
paul@162 968
            page.th(class_="timeslot")
paul@201 969
            self._time_point(point, endpoint)
paul@162 970
            page.th.close()
paul@162 971
paul@162 972
            # Obtain slots for the time point from each group.
paul@162 973
paul@171 974
            for columns, slots, group_type in zip(group_columns, groups, group_types):
paul@162 975
                active = slots and slots.get(point)
paul@162 976
paul@191 977
                # Where no periods exist for the given time interval, generate
paul@191 978
                # an empty cell. Where a participant provides no periods at all,
paul@191 979
                # the colspan is adjusted to be 1, not 0.
paul@191 980
paul@162 981
                if not active:
paul@196 982
                    page.td(class_="empty container", colspan=max(columns, 1))
paul@201 983
                    self._empty_slot(point, endpoint)
paul@196 984
                    page.td.close()
paul@162 985
                    continue
paul@162 986
paul@162 987
                slots = slots.items()
paul@162 988
                slots.sort()
paul@162 989
                spans = get_spans(slots)
paul@162 990
paul@162 991
                # Show a column for each active period.
paul@117 992
paul@153 993
                for t in active:
paul@185 994
                    if t and len(t) >= 2:
paul@185 995
                        start, end, uid, key = get_freebusy_details(t)
paul@185 996
                        span = spans[key]
paul@171 997
paul@171 998
                        # Produce a table cell only at the start of the period
paul@171 999
                        # or when continued at the start of a day.
paul@171 1000
paul@153 1001
                        if point == start or continuation:
paul@153 1002
paul@195 1003
                            has_continued = continuation and point != start
paul@195 1004
                            will_continue = not ends_on_same_day(point, end)
paul@195 1005
                            css = " ".join(
paul@195 1006
                                ["event"] +
paul@195 1007
                                (has_continued and ["continued"] or []) +
paul@195 1008
                                (will_continue and ["continues"] or [])
paul@195 1009
                                )
paul@195 1010
paul@189 1011
                            # Only anchor the first cell of events.
paul@189 1012
paul@189 1013
                            if point == start:
paul@195 1014
                                page.td(class_=css, rowspan=span, id="%s-%s" % (group_type, uid))
paul@189 1015
                            else:
paul@195 1016
                                page.td(class_=css, rowspan=span)
paul@171 1017
paul@153 1018
                            obj = self._get_object(uid)
paul@185 1019
paul@185 1020
                            if not obj:
paul@185 1021
                                page.span("")
paul@185 1022
                            else:
paul@153 1023
                                details = self._get_details(obj)
paul@164 1024
                                summary = get_value(details, "SUMMARY")
paul@171 1025
paul@171 1026
                                # Only link to events if they are not being
paul@171 1027
                                # updated by requests.
paul@171 1028
paul@171 1029
                                if uid in self._get_requests() and group_type != "request":
paul@189 1030
                                    page.span(summary)
paul@164 1031
                                else:
paul@171 1032
                                    href = "%s/%s" % (self.env.get_url().rstrip("/"), uid)
paul@189 1033
                                    page.a(summary, href=href)
paul@171 1034
paul@153 1035
                            page.td.close()
paul@153 1036
                    else:
paul@196 1037
                        page.td(class_="empty container")
paul@201 1038
                        self._empty_slot(point, endpoint)
paul@196 1039
                        page.td.close()
paul@114 1040
paul@166 1041
                # Pad with empty columns.
paul@166 1042
paul@166 1043
                i = columns - len(active)
paul@166 1044
                while i > 0:
paul@166 1045
                    i -= 1
paul@196 1046
                    page.td(class_="empty container")
paul@201 1047
                    self._empty_slot(point, endpoint)
paul@196 1048
                    page.td.close()
paul@166 1049
paul@162 1050
            page.tr.close()
paul@114 1051
paul@201 1052
    def _time_point(self, point, endpoint):
paul@201 1053
        page = self.page
paul@201 1054
        value, identifier = self._slot_value_and_identifier(point, endpoint)
paul@202 1055
        slot = self.env.get_args().get("slot", [None])[0]
paul@202 1056
        if slot == value:
paul@202 1057
            page.input(name="slot", type="radio", value=value, id=identifier, class_="newevent", checked="checked")
paul@202 1058
        else:
paul@202 1059
            page.input(name="slot", type="radio", value=value, id=identifier, class_="newevent")
paul@201 1060
        page.label(self.format_time(point, "long"), class_="timepoint", for_=identifier)
paul@201 1061
paul@201 1062
    def _empty_slot(self, point, endpoint):
paul@197 1063
        page = self.page
paul@201 1064
        value, identifier = self._slot_value_and_identifier(point, endpoint)
paul@201 1065
        page.label("Make a new event in this period", class_="newevent popup", for_=identifier)
paul@196 1066
paul@201 1067
    def _slot_value_and_identifier(self, point, endpoint):
paul@202 1068
        value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "")
paul@201 1069
        identifier = "slot-%s" % value
paul@201 1070
        return value, identifier
paul@196 1071
paul@69 1072
    def select_action(self):
paul@69 1073
paul@69 1074
        "Select the desired action and show the result."
paul@69 1075
paul@121 1076
        path_info = self.env.get_path_info().strip("/")
paul@121 1077
paul@69 1078
        if not path_info:
paul@114 1079
            self.show_calendar()
paul@121 1080
        elif self.show_object(path_info):
paul@70 1081
            pass
paul@70 1082
        else:
paul@70 1083
            self.no_page()
paul@69 1084
paul@82 1085
    def __call__(self):
paul@69 1086
paul@69 1087
        "Interpret a request and show an appropriate response."
paul@69 1088
paul@69 1089
        if not self.user:
paul@69 1090
            self.no_user()
paul@69 1091
        else:
paul@69 1092
            self.select_action()
paul@69 1093
paul@70 1094
        # Write the headers and actual content.
paul@70 1095
paul@69 1096
        print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding
paul@69 1097
        print >>self.out
paul@69 1098
        self.out.write(unicode(self.page).encode(self.encoding))
paul@69 1099
paul@69 1100
if __name__ == "__main__":
paul@128 1101
    Manager()()
paul@69 1102
paul@69 1103
# vim: tabstop=4 expandtab shiftwidth=4