imip-agent

Annotated imip_manager.py

308:47759fe379ca
2015-02-10 Paul Boddie Introduced initial support for removing attendees, although CANCEL messages are yet to be implemented for uninvited attendees.
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@232 27
from datetime import date, datetime, timedelta
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@213 33
from imiptools.content import Handler
paul@301 34
from imiptools.data import get_address, get_uri, make_freebusy, Object, to_part
paul@286 35
from imiptools.dates import format_datetime, format_time, get_date, get_datetime, \
paul@291 36
                            get_datetime_item, get_default_timezone, \
paul@241 37
                            get_end_of_day, get_start_of_day, get_start_of_next_day, \
paul@274 38
                            get_timestamp, ends_on_same_day, to_timezone
paul@83 39
from imiptools.mail import Messenger
paul@279 40
from imiptools.period import add_day_start_points, add_empty_days, add_slots, \
paul@279 41
                             convert_periods, get_freebusy_details, \
paul@162 42
                             get_scale, have_conflict, get_slots, get_spans, \
paul@305 43
                             partition_by_day, remove_from_freebusy, update_freebusy, \
paul@305 44
                             _update_freebusy
paul@147 45
from imiptools.profile import Preferences
paul@213 46
import imip_store
paul@69 47
import markup
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@305 111
class Common:
paul@305 112
paul@305 113
    "Common handler and manager methods."
paul@305 114
paul@305 115
    def __init__(self, user):
paul@305 116
        self.user = user
paul@305 117
        self.preferences = None
paul@305 118
paul@305 119
    def get_preferences(self):
paul@305 120
        if not self.preferences:
paul@305 121
            self.preferences = Preferences(self.user)
paul@305 122
        return self.preferences
paul@305 123
paul@305 124
    def get_tzid(self):
paul@305 125
        prefs = self.get_preferences()
paul@305 126
        return prefs.get("TZID") or get_default_timezone()
paul@305 127
paul@305 128
class ManagerHandler(Handler, Common):
paul@79 129
paul@121 130
    """
paul@121 131
    A content handler for use by the manager, as opposed to operating within the
paul@121 132
    mail processing pipeline.
paul@121 133
    """
paul@79 134
paul@121 135
    def __init__(self, obj, user, messenger):
paul@224 136
        Handler.__init__(self, messenger=messenger)
paul@305 137
        Common.__init__(self, user)
paul@305 138
paul@224 139
        self.set_object(obj)
paul@82 140
paul@79 141
    # Communication methods.
paul@79 142
paul@253 143
    def send_message(self, method, sender, for_organiser):
paul@79 144
paul@79 145
        """
paul@207 146
        Create a full calendar object employing the given 'method', and send it
paul@253 147
        to the appropriate recipients, also sending a copy to the 'sender'. The
paul@253 148
        'for_organiser' value indicates whether the organiser is sending this
paul@253 149
        message.
paul@79 150
        """
paul@79 151
paul@219 152
        parts = [self.obj.to_part(method)]
paul@207 153
paul@260 154
        # As organiser, send an invitation to attendees, excluding oneself if
paul@260 155
        # also attending. The updated event will be saved by the outgoing
paul@260 156
        # handler.
paul@260 157
paul@308 158
        organiser = self.obj.get_value("ORGANIZER")
paul@308 159
        attendees = self.obj.get_values("ATTENDEE")
paul@308 160
paul@253 161
        if for_organiser:
paul@308 162
            recipients = [get_address(attendee) for attendee in attendees if attendee != self.user]
paul@207 163
        else:
paul@308 164
            recipients = [get_address(organiser)]
paul@207 165
paul@219 166
        # Bundle free/busy information if appropriate.
paul@219 167
paul@219 168
        preferences = Preferences(self.user)
paul@219 169
paul@219 170
        if preferences.get("freebusy_sharing") == "share" and \
paul@219 171
           preferences.get("freebusy_bundling") == "always":
paul@219 172
paul@222 173
            # Invent a unique identifier.
paul@222 174
paul@222 175
            utcnow = get_timestamp()
paul@222 176
            uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user))
paul@222 177
paul@222 178
            freebusy = self.store.get_freebusy(self.user)
paul@305 179
paul@305 180
            # Replace the non-updated free/busy details for this event with
paul@305 181
            # newer details (since the outgoing handler updates this user's
paul@305 182
            # free/busy details).
paul@305 183
paul@305 184
            tzid = self.get_tzid()
paul@305 185
paul@305 186
            _update_freebusy(freebusy, self.obj.get_periods_for_freebusy(tzid),
paul@305 187
                self.obj.get_value("TRANSP") or "OPAQUE", self.obj.get_value("UID"))
paul@305 188
paul@292 189
            user_attr = self.messenger and self.messenger.sender != get_address(self.user) and \
paul@292 190
                {"SENT-BY" : get_uri(self.messenger.sender)} or {}
paul@292 191
paul@292 192
            parts.append(to_part("PUBLISH", [
paul@292 193
                make_freebusy(freebusy, uid, self.user, user_attr)
paul@292 194
                ]))
paul@219 195
paul@219 196
        message = self.messenger.make_outgoing_message(parts, recipients, outgoing_bcc=sender)
paul@207 197
        self.messenger.sendmail(recipients, message.as_string(), outgoing_bcc=sender)
paul@79 198
paul@79 199
    # Action methods.
paul@79 200
paul@266 201
    def process_received_request(self, update=False):
paul@79 202
paul@79 203
        """
paul@266 204
        Process the current request for the given 'user'. Return whether any
paul@79 205
        action was taken.
paul@155 206
paul@155 207
        If 'update' is given, the sequence number will be incremented in order
paul@155 208
        to override any previous response.
paul@79 209
        """
paul@79 210
paul@266 211
        # Reply only on behalf of this user.
paul@79 212
paul@213 213
        for attendee, attendee_attr in self.obj.get_items("ATTENDEE"):
paul@79 214
paul@79 215
            if attendee == self.user:
paul@266 216
                if attendee_attr.has_key("RSVP"):
paul@266 217
                    del attendee_attr["RSVP"]
paul@128 218
                if self.messenger and self.messenger.sender != get_address(attendee):
paul@128 219
                    attendee_attr["SENT-BY"] = get_uri(self.messenger.sender)
paul@213 220
                self.obj["ATTENDEE"] = [(attendee, attendee_attr)]
paul@273 221
paul@158 222
                self.update_dtstamp()
paul@273 223
                self.set_sequence(update)
paul@155 224
paul@253 225
                self.send_message("REPLY", get_address(attendee), for_organiser=False)
paul@79 226
paul@79 227
                return True
paul@79 228
paul@79 229
        return False
paul@79 230
paul@308 231
    def process_created_request(self, method, update=False, removed=None):
paul@207 232
paul@207 233
        """
paul@207 234
        Process the current request for the given 'user', sending a created
paul@255 235
        request of the given 'method' to attendees. Return whether any action
paul@255 236
        was taken.
paul@207 237
paul@207 238
        If 'update' is given, the sequence number will be incremented in order
paul@207 239
        to override any previous message.
paul@308 240
paul@308 241
        If 'removed' is specified, a list of participants to be removed is
paul@308 242
        provided.
paul@207 243
        """
paul@207 244
paul@213 245
        organiser, organiser_attr = self.obj.get_item("ORGANIZER")
paul@213 246
paul@213 247
        if self.messenger and self.messenger.sender != get_address(organiser):
paul@213 248
            organiser_attr["SENT-BY"] = get_uri(self.messenger.sender)
paul@273 249
paul@308 250
        if removed:
paul@308 251
            attendee_map = self.obj.get_value_map("ATTENDEE")
paul@308 252
            self.obj["ATTENDEE"] = [(attendee, attendee_attr)
paul@308 253
                for (attendee, attendee_attr) in attendee_map.items()
paul@308 254
                if attendee not in removed]
paul@308 255
paul@207 256
        self.update_dtstamp()
paul@273 257
        self.set_sequence(update)
paul@207 258
paul@308 259
        self.send_message(method, get_address(organiser), for_organiser=True)
paul@308 260
paul@207 261
        return True
paul@207 262
paul@305 263
class Manager(Common):
paul@69 264
paul@69 265
    "A simple manager application."
paul@69 266
paul@82 267
    def __init__(self, messenger=None):
paul@82 268
        self.messenger = messenger or Messenger()
paul@212 269
        self.encoding = "utf-8"
paul@212 270
        self.env = CGIEnvironment(self.encoding)
paul@212 271
paul@69 272
        user = self.env.get_user()
paul@305 273
        Common.__init__(self, user and get_uri(user) or None)
paul@305 274
paul@149 275
        self.locale = None
paul@121 276
        self.requests = None
paul@121 277
paul@69 278
        self.out = self.env.get_output()
paul@69 279
        self.page = markup.page()
paul@69 280
paul@77 281
        self.store = imip_store.FileStore()
paul@162 282
        self.objects = {}
paul@77 283
paul@77 284
        try:
paul@77 285
            self.publisher = imip_store.FilePublisher()
paul@77 286
        except OSError:
paul@77 287
            self.publisher = None
paul@77 288
paul@121 289
    def _get_uid(self, path_info):
paul@121 290
        return path_info.lstrip("/").split("/", 1)[0]
paul@121 291
paul@117 292
    def _get_object(self, uid):
paul@162 293
        if self.objects.has_key(uid):
paul@162 294
            return self.objects[uid]
paul@162 295
paul@301 296
        fragment = uid and self.store.get_event(self.user, uid) or None
paul@213 297
        obj = self.objects[uid] = fragment and Object(fragment)
paul@121 298
        return obj
paul@121 299
paul@121 300
    def _get_requests(self):
paul@121 301
        if self.requests is None:
paul@121 302
            self.requests = self.store.get_requests(self.user)
paul@121 303
        return self.requests
paul@117 304
paul@162 305
    def _get_request_summary(self):
paul@162 306
        summary = []
paul@162 307
        for uid in self._get_requests():
paul@162 308
            obj = self._get_object(uid)
paul@162 309
            if obj:
paul@162 310
                summary.append((
paul@213 311
                    obj.get_value("DTSTART"),
paul@213 312
                    obj.get_value("DTEND"),
paul@162 313
                    uid
paul@162 314
                    ))
paul@162 315
        return summary
paul@162 316
paul@147 317
    # Preference methods.
paul@147 318
paul@149 319
    def get_user_locale(self):
paul@149 320
        if not self.locale:
paul@149 321
            self.locale = self.get_preferences().get("LANG", "C")
paul@149 322
        return self.locale
paul@147 323
paul@162 324
    # Prettyprinting of dates and times.
paul@162 325
paul@149 326
    def format_date(self, dt, format):
paul@149 327
        return self._format_datetime(babel.dates.format_date, dt, format)
paul@149 328
paul@149 329
    def format_time(self, dt, format):
paul@149 330
        return self._format_datetime(babel.dates.format_time, dt, format)
paul@149 331
paul@149 332
    def format_datetime(self, dt, format):
paul@232 333
        return self._format_datetime(
paul@232 334
            isinstance(dt, datetime) and babel.dates.format_datetime or babel.dates.format_date,
paul@232 335
            dt, format)
paul@232 336
paul@149 337
    def _format_datetime(self, fn, dt, format):
paul@149 338
        return fn(dt, format=format, locale=self.get_user_locale())
paul@149 339
paul@78 340
    # Data management methods.
paul@78 341
paul@78 342
    def remove_request(self, uid):
paul@105 343
        return self.store.dequeue_request(self.user, uid)
paul@78 344
paul@234 345
    def remove_event(self, uid):
paul@234 346
        return self.store.remove_event(self.user, uid)
paul@234 347
paul@296 348
    def update_freebusy(self, uid, obj):
paul@296 349
        tzid = self.get_tzid()
paul@296 350
        freebusy = self.store.get_freebusy(self.user)
paul@296 351
        update_freebusy(freebusy, self.user, obj.get_periods_for_freebusy(tzid),
paul@296 352
            obj.get_value("TRANSP"), uid, self.store)
paul@296 353
paul@296 354
    def remove_from_freebusy(self, uid):
paul@296 355
        freebusy = self.store.get_freebusy(self.user)
paul@296 356
        remove_from_freebusy(freebusy, self.user, uid, self.store)
paul@296 357
paul@78 358
    # Presentation methods.
paul@78 359
paul@69 360
    def new_page(self, title):
paul@192 361
        self.page.init(title=title, charset=self.encoding, css=self.env.new_url("styles.css"))
paul@69 362
paul@69 363
    def status(self, code, message):
paul@123 364
        self.header("Status", "%s %s" % (code, message))
paul@123 365
paul@123 366
    def header(self, header, value):
paul@123 367
        print >>self.out, "%s: %s" % (header, value)
paul@69 368
paul@69 369
    def no_user(self):
paul@69 370
        self.status(403, "Forbidden")
paul@69 371
        self.new_page(title="Forbidden")
paul@69 372
        self.page.p("You are not logged in and thus cannot access scheduling requests.")
paul@69 373
paul@70 374
    def no_page(self):
paul@70 375
        self.status(404, "Not Found")
paul@70 376
        self.new_page(title="Not Found")
paul@70 377
        self.page.p("No page is provided at the given address.")
paul@70 378
paul@123 379
    def redirect(self, url):
paul@123 380
        self.status(302, "Redirect")
paul@123 381
        self.header("Location", url)
paul@123 382
        self.new_page(title="Redirect")
paul@123 383
        self.page.p("Redirecting to: %s" % url)
paul@123 384
paul@246 385
    # Request logic methods.
paul@121 386
paul@202 387
    def handle_newevent(self):
paul@202 388
paul@207 389
        """
paul@207 390
        Handle any new event operation, creating a new event and redirecting to
paul@207 391
        the event page for further activity.
paul@207 392
        """
paul@202 393
paul@202 394
        # Handle a submitted form.
paul@202 395
paul@202 396
        args = self.env.get_args()
paul@202 397
paul@202 398
        if not args.has_key("newevent"):
paul@202 399
            return
paul@202 400
paul@202 401
        # Create a new event using the available information.
paul@202 402
paul@236 403
        slots = args.get("slot", [])
paul@202 404
        participants = args.get("participants", [])
paul@202 405
paul@236 406
        if not slots:
paul@202 407
            return
paul@202 408
paul@273 409
        # Obtain the user's timezone.
paul@273 410
paul@273 411
        tzid = self.get_tzid()
paul@273 412
paul@236 413
        # Coalesce the selected slots.
paul@236 414
paul@236 415
        slots.sort()
paul@236 416
        coalesced = []
paul@236 417
        last = None
paul@236 418
paul@236 419
        for slot in slots:
paul@236 420
            start, end = slot.split("-")
paul@273 421
            start = get_datetime(start, {"TZID" : tzid})
paul@273 422
            end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid)
paul@248 423
paul@236 424
            if last:
paul@248 425
                last_start, last_end = last
paul@248 426
paul@248 427
                # Merge adjacent dates and datetimes.
paul@248 428
paul@273 429
                if start == last_end or get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid):
paul@248 430
                    last = last_start, end
paul@236 431
                    continue
paul@248 432
paul@248 433
                # Handle datetimes within dates.
paul@248 434
                # Datetime periods are within single days and are therefore
paul@248 435
                # discarded.
paul@248 436
paul@273 437
                elif get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid):
paul@248 438
                    continue
paul@248 439
paul@248 440
                # Add separate dates and datetimes.
paul@248 441
paul@236 442
                else:
paul@236 443
                    coalesced.append(last)
paul@248 444
paul@236 445
            last = start, end
paul@236 446
paul@236 447
        if last:
paul@236 448
            coalesced.append(last)
paul@202 449
paul@202 450
        # Invent a unique identifier.
paul@202 451
paul@222 452
        utcnow = get_timestamp()
paul@202 453
        uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user))
paul@202 454
paul@236 455
        # Define a single occurrence if only one coalesced slot exists.
paul@236 456
        # Otherwise, many occurrences are defined.
paul@202 457
paul@236 458
        for i, (start, end) in enumerate(coalesced):
paul@236 459
            this_uid = "%s-%s" % (uid, i)
paul@236 460
paul@252 461
            start_value, start_attr = get_datetime_item(start, tzid)
paul@252 462
            end_value, end_attr = get_datetime_item(end, tzid)
paul@239 463
paul@236 464
            # Create a calendar object and store it as a request.
paul@236 465
paul@236 466
            record = []
paul@236 467
            rwrite = record.append
paul@202 468
paul@236 469
            rwrite(("UID", {}, this_uid))
paul@236 470
            rwrite(("SUMMARY", {}, "New event at %s" % utcnow))
paul@236 471
            rwrite(("DTSTAMP", {}, utcnow))
paul@239 472
            rwrite(("DTSTART", start_attr, start_value))
paul@239 473
            rwrite(("DTEND", end_attr, end_value))
paul@236 474
            rwrite(("ORGANIZER", {}, self.user))
paul@202 475
paul@236 476
            for participant in participants:
paul@236 477
                if not participant:
paul@236 478
                    continue
paul@236 479
                participant = get_uri(participant)
paul@253 480
                rwrite(("ATTENDEE", {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}, participant))
paul@202 481
paul@236 482
            obj = ("VEVENT", {}, record)
paul@236 483
paul@236 484
            self.store.set_event(self.user, this_uid, obj)
paul@236 485
            self.store.queue_request(self.user, this_uid)
paul@202 486
paul@236 487
        # Redirect to the object (or the first of the objects), where instead of
paul@236 488
        # attendee controls, there will be organiser controls.
paul@236 489
paul@236 490
        self.redirect(self.env.new_url("%s-0" % uid))
paul@202 491
paul@286 492
    def handle_request(self, uid, obj):
paul@121 493
paul@299 494
        """
paul@299 495
        Handle actions involving the given 'uid' and 'obj' object, returning an
paul@299 496
        error if one occurred, or None if the request was successfully handled.
paul@299 497
        """
paul@121 498
paul@121 499
        # Handle a submitted form.
paul@121 500
paul@121 501
        args = self.env.get_args()
paul@299 502
paul@299 503
        # Get the possible actions.
paul@299 504
paul@299 505
        reply = args.has_key("reply")
paul@299 506
        discard = args.has_key("discard")
paul@299 507
        invite = args.has_key("invite")
paul@299 508
        cancel = args.has_key("cancel")
paul@299 509
        save = args.has_key("save")
paul@299 510
paul@299 511
        have_action = reply or discard or invite or cancel or save
paul@299 512
paul@299 513
        if not have_action:
paul@299 514
            return ["action"]
paul@121 515
paul@212 516
        # Update the object.
paul@212 517
paul@212 518
        if args.has_key("summary"):
paul@213 519
            obj["SUMMARY"] = [(args["summary"][0], {})]
paul@212 520
paul@308 521
        organisers = obj.get_value_map("ORGANIZER")
paul@308 522
        attendees = obj.get_value_map("ATTENDEE")
paul@308 523
paul@257 524
        if args.has_key("partstat"):
paul@286 525
            for d in attendees, organisers:
paul@286 526
                if d.has_key(self.user):
paul@286 527
                    d[self.user]["PARTSTAT"] = args["partstat"][0]
paul@286 528
                    if d[self.user].has_key("RSVP"):
paul@286 529
                        del d[self.user]["RSVP"]
paul@286 530
paul@286 531
        is_organiser = obj.get_value("ORGANIZER") == self.user
paul@286 532
paul@308 533
        # Obtain any participants to be removed.
paul@308 534
paul@308 535
        removed = args.get("remove")
paul@308 536
paul@286 537
        # Obtain the user's timezone and process datetime values.
paul@286 538
paul@286 539
        update = False
paul@286 540
paul@286 541
        if is_organiser:
paul@300 542
            dtend_enabled = args.get("dtend-control", [None])[0] == "enable"
paul@300 543
            dttimes_enabled = args.get("dttimes-control", [None])[0] == "enable"
paul@300 544
paul@300 545
            t = self.handle_date_controls("dtstart", dttimes_enabled)
paul@286 546
            if t:
paul@290 547
                dtstart, attr = t
paul@300 548
                update = self.set_datetime_in_object(dtstart, attr.get("TZID"), "DTSTART", obj) or update
paul@286 549
            else:
paul@299 550
                return ["dtstart"]
paul@290 551
paul@290 552
            # Handle specified end datetimes.
paul@290 553
paul@300 554
            if dtend_enabled:
paul@300 555
                t = self.handle_date_controls("dtend", dttimes_enabled)
paul@290 556
                if t:
paul@290 557
                    dtend, attr = t
paul@290 558
paul@290 559
                    # Convert end dates to iCalendar "next day" dates.
paul@286 560
paul@290 561
                    if not isinstance(dtend, datetime):
paul@290 562
                        dtend += timedelta(1)
paul@300 563
                    update = self.set_datetime_in_object(dtend, attr.get("TZID"), "DTEND", obj) or update
paul@290 564
                else:
paul@299 565
                    return ["dtend"]
paul@290 566
paul@299 567
            # Otherwise, treat the end date as the start date. Datetimes are
paul@299 568
            # handled by making the event occupy the rest of the day.
paul@290 569
paul@286 570
            else:
paul@299 571
                dtend = dtstart + timedelta(1)
paul@290 572
                if isinstance(dtstart, datetime):
paul@299 573
                    dtend = get_start_of_day(dtend, attr["TZID"])
paul@300 574
                update = self.set_datetime_in_object(dtend, attr.get("TZID"), "DTEND", obj) or update
paul@286 575
paul@290 576
            if dtstart >= dtend:
paul@299 577
                return ["dtstart", "dtend"]
paul@257 578
paul@212 579
        # Process any action.
paul@212 580
paul@299 581
        handled = True
paul@121 582
paul@266 583
        if reply or invite or cancel:
paul@121 584
paul@212 585
            handler = ManagerHandler(obj, self.user, self.messenger)
paul@121 586
paul@212 587
            # Process the object and remove it from the list of requests.
paul@121 588
paul@266 589
            if reply and handler.process_received_request(update) or \
paul@308 590
               is_organiser and (invite or cancel) and \
paul@308 591
               handler.process_created_request(invite and "REQUEST" or "CANCEL", update, removed):
paul@121 592
paul@121 593
                self.remove_request(uid)
paul@121 594
paul@257 595
        # Save single user events.
paul@121 596
paul@257 597
        elif save:
paul@257 598
            self.store.set_event(self.user, uid, obj.to_node())
paul@296 599
            self.update_freebusy(uid, obj)
paul@257 600
            self.remove_request(uid)
paul@121 601
paul@257 602
        # Remove the request and the object.
paul@257 603
paul@257 604
        elif discard:
paul@296 605
            self.remove_from_freebusy(uid)
paul@234 606
            self.remove_event(uid)
paul@121 607
            self.remove_request(uid)
paul@121 608
paul@121 609
        else:
paul@123 610
            handled = False
paul@121 611
paul@212 612
        # Upon handling an action, redirect to the main page.
paul@212 613
paul@123 614
        if handled:
paul@123 615
            self.redirect(self.env.get_path())
paul@123 616
paul@299 617
        return None
paul@121 618
paul@300 619
    def handle_date_controls(self, name, with_time=True):
paul@155 620
paul@155 621
        """
paul@286 622
        Handle date control information for fields starting with 'name',
paul@290 623
        returning a (datetime, attr) tuple or None if the fields cannot be used
paul@286 624
        to construct a datetime object.
paul@155 625
        """
paul@155 626
paul@286 627
        args = self.env.get_args()
paul@286 628
paul@286 629
        if args.has_key("%s-date" % name):
paul@286 630
            date = args["%s-date" % name][0]
paul@300 631
paul@300 632
            if with_time:
paul@300 633
                hour = args.get("%s-hour" % name, [None])[0]
paul@300 634
                minute = args.get("%s-minute" % name, [None])[0]
paul@300 635
                second = args.get("%s-second" % name, [None])[0]
paul@300 636
                tzid = args.get("%s-tzid" % name, [self.get_tzid()])[0]
paul@286 637
paul@300 638
                time = (hour or minute or second) and "T%s%s%s" % (hour, minute, second) or ""
paul@300 639
                value = "%s%s" % (date, time)
paul@300 640
                attr = {"TZID" : tzid, "VALUE" : "DATE-TIME"}
paul@300 641
                dt = get_datetime(value, attr)
paul@300 642
            else:
paul@300 643
                attr = {"VALUE" : "DATE"}
paul@300 644
                dt = get_datetime(date)
paul@300 645
paul@286 646
            if dt:
paul@290 647
                return dt, attr
paul@286 648
paul@286 649
        return None
paul@286 650
paul@286 651
    def set_datetime_in_object(self, dt, tzid, property, obj):
paul@286 652
paul@286 653
        """
paul@286 654
        Set 'dt' and 'tzid' for the given 'property' in 'obj', returning whether
paul@286 655
        an update has occurred.
paul@286 656
        """
paul@286 657
paul@286 658
        if dt:
paul@286 659
            old_value = obj.get_value(property)
paul@286 660
            obj[property] = [get_datetime_item(dt, tzid)]
paul@286 661
            return format_datetime(dt) != old_value
paul@286 662
paul@286 663
        return False
paul@286 664
paul@286 665
    # Page fragment methods.
paul@286 666
paul@286 667
    def show_request_controls(self, obj):
paul@286 668
paul@286 669
        "Show form controls for a request concerning 'obj'."
paul@286 670
paul@212 671
        page = self.page
paul@212 672
paul@213 673
        is_organiser = obj.get_value("ORGANIZER") == self.user
paul@207 674
paul@253 675
        attendees = obj.get_value_map("ATTENDEE")
paul@253 676
        is_attendee = attendees.has_key(self.user)
paul@253 677
        attendee_attr = attendees.get(self.user)
paul@121 678
paul@276 679
        is_request = obj.get_value("UID") in self._get_requests()
paul@276 680
paul@257 681
        have_other_attendees = len(attendees) > (is_attendee and 1 or 0)
paul@257 682
paul@257 683
        # Show appropriate options depending on the role of the user.
paul@257 684
paul@257 685
        if is_attendee and not is_organiser:
paul@286 686
            page.p("An action is required for this request:")
paul@253 687
paul@255 688
            page.p()
paul@266 689
            page.input(name="reply", type="submit", value="Reply")
paul@255 690
            page.add(" ")
paul@255 691
            page.input(name="discard", type="submit", value="Discard")
paul@255 692
            page.p.close()
paul@207 693
paul@255 694
        if is_organiser:
paul@257 695
            if have_other_attendees:
paul@286 696
                page.p("As organiser, you can perform the following:")
paul@255 697
paul@257 698
                page.p()
paul@257 699
                page.input(name="invite", type="submit", value="Invite")
paul@257 700
                page.add(" ")
paul@276 701
                if is_request:
paul@276 702
                    page.input(name="discard", type="submit", value="Discard")
paul@276 703
                else:
paul@276 704
                    page.input(name="cancel", type="submit", value="Cancel")
paul@257 705
                page.p.close()
paul@257 706
            else:
paul@257 707
                page.p()
paul@257 708
                page.input(name="save", type="submit", value="Save")
paul@276 709
                page.add(" ")
paul@276 710
                page.input(name="discard", type="submit", value="Discard")
paul@257 711
                page.p.close()
paul@207 712
paul@287 713
    property_items = [
paul@287 714
        ("SUMMARY", "Summary"),
paul@287 715
        ("DTSTART", "Start"),
paul@287 716
        ("DTEND", "End"),
paul@287 717
        ("ORGANIZER", "Organiser"),
paul@287 718
        ("ATTENDEE", "Attendee"),
paul@287 719
        ]
paul@210 720
paul@257 721
    partstat_items = [
paul@257 722
        ("NEEDS-ACTION", "Not confirmed"),
paul@257 723
        ("ACCEPTED", "Attending"),
paul@259 724
        ("TENTATIVE", "Tentatively attending"),
paul@257 725
        ("DECLINED", "Not attending"),
paul@277 726
        ("DELEGATED", "Delegated"),
paul@257 727
        ]
paul@257 728
paul@299 729
    def show_object_on_page(self, uid, obj, error=None):
paul@121 730
paul@121 731
        """
paul@121 732
        Show the calendar object with the given 'uid' and representation 'obj'
paul@299 733
        on the current page. If 'error' is given, show a suitable message.
paul@121 734
        """
paul@121 735
paul@210 736
        page = self.page
paul@212 737
        page.form(method="POST")
paul@210 738
paul@154 739
        # Obtain the user's timezone.
paul@154 740
paul@244 741
        tzid = self.get_tzid()
paul@121 742
paul@290 743
        # Provide controls to change the displayed object.
paul@290 744
paul@290 745
        args = self.env.get_args()
paul@300 746
        dtend_control = args.get("dtend-control", [None])[0]
paul@300 747
        dttimes_control = args.get("dttimes-control", [None])[0]
paul@300 748
        with_time = dttimes_control == "enable"
paul@290 749
paul@300 750
        t = self.handle_date_controls("dtstart", with_time)
paul@290 751
        if t:
paul@290 752
            dtstart, dtstart_attr = t
paul@290 753
        else:
paul@290 754
            dtstart, dtstart_attr = obj.get_datetime_item("DTSTART")
paul@290 755
paul@297 756
        if dtend_control == "enable":
paul@300 757
            t = self.handle_date_controls("dtend", with_time)
paul@290 758
            if t:
paul@290 759
                dtend, dtend_attr = t
paul@297 760
            else:
paul@297 761
                dtend, dtend_attr = None, {}
paul@297 762
        elif dtend_control == "disable":
paul@297 763
            dtend, dtend_attr = None, {}
paul@297 764
        else:
paul@290 765
            dtend, dtend_attr = obj.get_datetime_item("DTEND")
paul@290 766
paul@290 767
        # Change end dates to refer to the actual dates, not the iCalendar
paul@290 768
        # "next day" dates.
paul@290 769
paul@290 770
        if dtend and not isinstance(dtend, datetime):
paul@290 771
            dtend -= timedelta(1)
paul@290 772
paul@297 773
        # Show the end datetime controls if already active or if an object needs
paul@297 774
        # them.
paul@297 775
paul@300 776
        dtend_enabled = dtend_control == "enable" or isinstance(dtend, datetime) or dtstart != dtend
paul@300 777
        dttimes_enabled = dttimes_control == "enable" or isinstance(dtstart, datetime) or isinstance(dtend, datetime)
paul@297 778
paul@300 779
        if dtend_enabled:
paul@290 780
            page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable", checked="checked")
paul@290 781
            page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable")
paul@290 782
        else:
paul@290 783
            page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable")
paul@290 784
            page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable", checked="checked")
paul@290 785
paul@300 786
        if dttimes_enabled:
paul@300 787
            page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable", checked="checked")
paul@300 788
            page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable")
paul@300 789
        else:
paul@300 790
            page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable")
paul@300 791
            page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable", checked="checked")
paul@300 792
paul@121 793
        # Provide a summary of the object.
paul@121 794
paul@230 795
        page.table(class_="object", cellspacing=5, cellpadding=5)
paul@212 796
        page.thead()
paul@212 797
        page.tr()
paul@286 798
        page.th("Event", class_="mainheading", colspan=2)
paul@212 799
        page.tr.close()
paul@212 800
        page.thead.close()
paul@212 801
        page.tbody()
paul@121 802
paul@269 803
        is_organiser = obj.get_value("ORGANIZER") == self.user
paul@269 804
paul@287 805
        for name, label in self.property_items:
paul@210 806
            page.tr()
paul@210 807
paul@210 808
            # Handle datetimes specially.
paul@210 809
paul@147 810
            if name in ["DTSTART", "DTEND"]:
paul@299 811
                field = name.lower()
paul@290 812
paul@299 813
                page.th(label, class_="objectheading %s%s" % (field, error and field in error and " error" or ""))
paul@290 814
paul@297 815
                # Obtain the datetime.
paul@297 816
paul@290 817
                if name == "DTSTART":
paul@290 818
                    dt, attr, event_tzid = dtstart, dtstart_attr, dtstart_attr.get("TZID", tzid)
paul@297 819
paul@297 820
                # Where no end datetime exists, use the start datetime as the
paul@297 821
                # basis of any potential datetime specified if dt-control is
paul@297 822
                # set.
paul@297 823
paul@290 824
                else:
paul@293 825
                    dt, attr, event_tzid = dtend or dtstart, dtend_attr or dtstart_attr, (dtend_attr or dtstart_attr).get("TZID", tzid)
paul@293 826
paul@300 827
                # Show controls for editing as organiser.
paul@286 828
paul@286 829
                if is_organiser:
paul@300 830
                    value = format_datetime(dt)
paul@300 831
paul@299 832
                    page.td(class_="objectvalue %s" % field)
paul@290 833
                    if name == "DTEND":
paul@300 834
                        page.div(class_="dt disabled")
paul@290 835
                        page.label("Specify end date", for_="dtend-enable", class_="enable")
paul@290 836
                        page.div.close()
paul@290 837
paul@300 838
                    page.div(class_="dt enabled")
paul@299 839
                    self._show_date_controls(field, value, attr, tzid)
paul@300 840
                    if name == "DTSTART":
paul@300 841
                        page.label("Specify times", for_="dttimes-enable", class_="time disabled enable")
paul@300 842
                        page.label("Specify dates only", for_="dttimes-disable", class_="time enabled disable")
paul@300 843
                    elif name == "DTEND":
paul@290 844
                        page.label("End on same day", for_="dtend-disable", class_="disable")
paul@290 845
                    page.div.close()
paul@290 846
paul@286 847
                    page.td.close()
paul@300 848
paul@300 849
                # Show a label as attendee.
paul@300 850
paul@286 851
                else:
paul@300 852
                    page.td(self.format_datetime(dt, "full"))
paul@286 853
paul@210 854
                page.tr.close()
paul@210 855
paul@212 856
            # Handle the summary specially.
paul@212 857
paul@212 858
            elif name == "SUMMARY":
paul@290 859
                value = args.get("summary", [obj.get_value(name)])[0]
paul@290 860
paul@212 861
                page.th(label, class_="objectheading")
paul@286 862
                page.td()
paul@269 863
                if is_organiser:
paul@269 864
                    page.input(name="summary", type="text", value=value, size=80)
paul@269 865
                else:
paul@269 866
                    page.add(value)
paul@212 867
                page.td.close()
paul@212 868
                page.tr.close()
paul@212 869
paul@210 870
            # Handle potentially many values.
paul@210 871
paul@147 872
            else:
paul@213 873
                items = obj.get_items(name)
paul@233 874
                if not items:
paul@233 875
                    continue
paul@233 876
paul@210 877
                page.th(label, class_="objectheading", rowspan=len(items))
paul@210 878
paul@210 879
                first = True
paul@210 880
paul@308 881
                for i, (value, attr) in enumerate(items):
paul@210 882
                    if not first:
paul@210 883
                        page.tr()
paul@210 884
                    else:
paul@210 885
                        first = False
paul@121 886
paul@277 887
                    if name in ("ATTENDEE", "ORGANIZER"):
paul@265 888
                        page.td(class_="objectattribute")
paul@265 889
                        page.add(value)
paul@286 890
                        page.add(" ")
paul@210 891
paul@210 892
                        partstat = attr.get("PARTSTAT")
paul@286 893
                        if value == self.user and (not is_organiser or name == "ORGANIZER"):
paul@257 894
                            self._show_menu("partstat", partstat, self.partstat_items)
paul@265 895
                        else:
paul@286 896
                            page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat")
paul@308 897
paul@308 898
                        if is_organiser and name == "ATTENDEE":
paul@308 899
                            page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove")
paul@308 900
                            page.label("Remove", for_="remove-%d" % i, class_="remove")
paul@308 901
                            page.label("Uninvited", for_="remove-%d" % i, class_="removed")
paul@308 902
paul@265 903
                    else:
paul@286 904
                        page.td(class_="objectattribute")
paul@265 905
                        page.add(value)
paul@210 906
paul@210 907
                    page.td.close()
paul@210 908
                    page.tr.close()
paul@210 909
paul@212 910
        page.tbody.close()
paul@210 911
        page.table.close()
paul@121 912
paul@307 913
        self.show_conflicting_events(uid, obj)
paul@307 914
        self.show_request_controls(obj)
paul@307 915
paul@307 916
        page.form.close()
paul@307 917
paul@307 918
    def show_conflicting_events(self, uid, obj):
paul@307 919
paul@307 920
        """
paul@307 921
        Show conflicting events for the object having the given 'uid' and
paul@307 922
        representation 'obj'.
paul@307 923
        """
paul@307 924
paul@307 925
        page = self.page
paul@307 926
paul@307 927
        # Obtain the user's timezone.
paul@307 928
paul@307 929
        tzid = self.get_tzid()
paul@307 930
paul@213 931
        dtstart = format_datetime(obj.get_utc_datetime("DTSTART"))
paul@213 932
        dtend = format_datetime(obj.get_utc_datetime("DTEND"))
paul@121 933
paul@121 934
        # Indicate whether there are conflicting events.
paul@121 935
paul@121 936
        freebusy = self.store.get_freebusy(self.user)
paul@121 937
paul@121 938
        if freebusy:
paul@121 939
paul@121 940
            # Obtain any time zone details from the suggested event.
paul@121 941
paul@213 942
            _dtstart, attr = obj.get_item("DTSTART")
paul@154 943
            tzid = attr.get("TZID", tzid)
paul@121 944
paul@121 945
            # Show any conflicts.
paul@121 946
paul@302 947
            conflicts = [t for t in have_conflict(freebusy, [(dtstart, dtend)], True) if t[2] != uid]
paul@154 948
paul@302 949
            if conflicts:
paul@302 950
                page.p("This event conflicts with others:")
paul@154 951
paul@302 952
                page.table(cellspacing=5, cellpadding=5, class_="conflicts")
paul@302 953
                page.thead()
paul@302 954
                page.tr()
paul@302 955
                page.th("Event")
paul@302 956
                page.th("Start")
paul@302 957
                page.th("End")
paul@302 958
                page.tr.close()
paul@302 959
                page.thead.close()
paul@302 960
                page.tbody()
paul@302 961
paul@302 962
                for t in conflicts:
paul@302 963
                    start, end, found_uid = t[:3]
paul@302 964
paul@302 965
                    # Provide details of any conflicting event.
paul@302 966
paul@302 967
                    start = self.format_datetime(to_timezone(get_datetime(start), tzid), "long")
paul@302 968
                    end = self.format_datetime(to_timezone(get_datetime(end), tzid), "long")
paul@302 969
paul@302 970
                    page.tr()
paul@154 971
paul@154 972
                    # Show the event summary for the conflicting event.
paul@154 973
paul@302 974
                    page.td()
paul@302 975
paul@154 976
                    found_obj = self._get_object(found_uid)
paul@154 977
                    if found_obj:
paul@213 978
                        page.a(found_obj.get_value("SUMMARY"), href=self.env.new_url(found_uid))
paul@302 979
                    else:
paul@302 980
                        page.add("No details available")
paul@302 981
paul@302 982
                    page.td.close()
paul@302 983
paul@302 984
                    page.td(start)
paul@302 985
                    page.td(end)
paul@302 986
paul@302 987
                    page.tr.close()
paul@302 988
paul@302 989
                page.tbody.close()
paul@302 990
                page.table.close()
paul@121 991
paul@121 992
    def show_requests_on_page(self):
paul@69 993
paul@69 994
        "Show requests for the current user."
paul@69 995
paul@69 996
        # NOTE: This list could be more informative, but it is envisaged that
paul@69 997
        # NOTE: the requests would be visited directly anyway.
paul@69 998
paul@121 999
        requests = self._get_requests()
paul@70 1000
paul@185 1001
        self.page.div(id="pending-requests")
paul@185 1002
paul@80 1003
        if requests:
paul@114 1004
            self.page.p("Pending requests:")
paul@114 1005
paul@80 1006
            self.page.ul()
paul@69 1007
paul@80 1008
            for request in requests:
paul@165 1009
                obj = self._get_object(request)
paul@165 1010
                if obj:
paul@165 1011
                    self.page.li()
paul@213 1012
                    self.page.a(obj.get_value("SUMMARY"), href="#request-%s" % request)
paul@165 1013
                    self.page.li.close()
paul@80 1014
paul@80 1015
            self.page.ul.close()
paul@80 1016
paul@80 1017
        else:
paul@80 1018
            self.page.p("There are no pending requests.")
paul@69 1019
paul@185 1020
        self.page.div.close()
paul@185 1021
paul@185 1022
    def show_participants_on_page(self):
paul@185 1023
paul@185 1024
        "Show participants for scheduling purposes."
paul@185 1025
paul@185 1026
        args = self.env.get_args()
paul@185 1027
        participants = args.get("participants", [])
paul@185 1028
paul@185 1029
        try:
paul@185 1030
            for name, value in args.items():
paul@185 1031
                if name.startswith("remove-participant-"):
paul@185 1032
                    i = int(name[len("remove-participant-"):])
paul@185 1033
                    del participants[i]
paul@185 1034
                    break
paul@185 1035
        except ValueError:
paul@185 1036
            pass
paul@185 1037
paul@185 1038
        # Trim empty participants.
paul@185 1039
paul@185 1040
        while participants and not participants[-1].strip():
paul@185 1041
            participants.pop()
paul@185 1042
paul@185 1043
        # Show any specified participants together with controls to remove and
paul@185 1044
        # add participants.
paul@185 1045
paul@185 1046
        self.page.div(id="participants")
paul@185 1047
paul@185 1048
        self.page.p("Participants for scheduling:")
paul@185 1049
paul@185 1050
        for i, participant in enumerate(participants):
paul@185 1051
            self.page.p()
paul@185 1052
            self.page.input(name="participants", type="text", value=participant)
paul@185 1053
            self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove")
paul@185 1054
            self.page.p.close()
paul@185 1055
paul@185 1056
        self.page.p()
paul@185 1057
        self.page.input(name="participants", type="text")
paul@185 1058
        self.page.input(name="add-participant", type="submit", value="Add")
paul@185 1059
        self.page.p.close()
paul@185 1060
paul@185 1061
        self.page.div.close()
paul@185 1062
paul@185 1063
        return participants
paul@185 1064
paul@121 1065
    # Full page output methods.
paul@70 1066
paul@121 1067
    def show_object(self, path_info):
paul@70 1068
paul@121 1069
        "Show an object request using the given 'path_info' for the current user."
paul@70 1070
paul@121 1071
        uid = self._get_uid(path_info)
paul@121 1072
        obj = self._get_object(uid)
paul@121 1073
paul@121 1074
        if not obj:
paul@70 1075
            return False
paul@70 1076
paul@299 1077
        error = self.handle_request(uid, obj)
paul@77 1078
paul@299 1079
        if not error:
paul@123 1080
            return True
paul@73 1081
paul@123 1082
        self.new_page(title="Event")
paul@299 1083
        self.show_object_on_page(uid, obj, error)
paul@73 1084
paul@70 1085
        return True
paul@70 1086
paul@114 1087
    def show_calendar(self):
paul@114 1088
paul@114 1089
        "Show the calendar for the current user."
paul@114 1090
paul@202 1091
        handled = self.handle_newevent()
paul@202 1092
paul@114 1093
        self.new_page(title="Calendar")
paul@162 1094
        page = self.page
paul@162 1095
paul@196 1096
        # Form controls are used in various places on the calendar page.
paul@196 1097
paul@196 1098
        page.form(method="POST")
paul@196 1099
paul@121 1100
        self.show_requests_on_page()
paul@185 1101
        participants = self.show_participants_on_page()
paul@114 1102
paul@196 1103
        # Show a button for scheduling a new event.
paul@196 1104
paul@230 1105
        page.p(class_="controls")
paul@196 1106
        page.input(name="newevent", type="submit", value="New event", id="newevent")
paul@258 1107
        page.input(name="reset", type="submit", value="Clear selections", id="reset")
paul@196 1108
        page.p.close()
paul@196 1109
paul@280 1110
        # Show controls for hiding empty days and busy slots.
paul@203 1111
        # The positioning of the control, paragraph and table are important here.
paul@203 1112
paul@288 1113
        page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D")
paul@282 1114
        page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B")
paul@203 1115
paul@230 1116
        page.p(class_="controls")
paul@237 1117
        page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable")
paul@237 1118
        page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable")
paul@288 1119
        page.label("Show empty days", for_="showdays", class_="showdays disable")
paul@288 1120
        page.label("Hide empty days", for_="showdays", class_="showdays enable")
paul@203 1121
        page.p.close()
paul@203 1122
paul@114 1123
        freebusy = self.store.get_freebusy(self.user)
paul@114 1124
paul@114 1125
        if not freebusy:
paul@114 1126
            page.p("No events scheduled.")
paul@114 1127
            return
paul@114 1128
paul@154 1129
        # Obtain the user's timezone.
paul@147 1130
paul@244 1131
        tzid = self.get_tzid()
paul@147 1132
paul@114 1133
        # Day view: start at the earliest known day and produce days until the
paul@114 1134
        # latest known day, perhaps with expandable sections of empty days.
paul@114 1135
paul@114 1136
        # Month view: start at the earliest known month and produce months until
paul@114 1137
        # the latest known month, perhaps with expandable sections of empty
paul@114 1138
        # months.
paul@114 1139
paul@114 1140
        # Details of users to invite to new events could be superimposed on the
paul@114 1141
        # calendar.
paul@114 1142
paul@185 1143
        # Requests are listed and linked to their tentative positions in the
paul@185 1144
        # calendar. Other participants are also shown.
paul@185 1145
paul@185 1146
        request_summary = self._get_request_summary()
paul@185 1147
paul@185 1148
        period_groups = [request_summary, freebusy]
paul@185 1149
        period_group_types = ["request", "freebusy"]
paul@185 1150
        period_group_sources = ["Pending requests", "Your schedule"]
paul@185 1151
paul@187 1152
        for i, participant in enumerate(participants):
paul@185 1153
            period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant)))
paul@187 1154
            period_group_types.append("freebusy-part%d" % i)
paul@185 1155
            period_group_sources.append(participant)
paul@114 1156
paul@162 1157
        groups = []
paul@162 1158
        group_columns = []
paul@185 1159
        group_types = period_group_types
paul@185 1160
        group_sources = period_group_sources
paul@162 1161
        all_points = set()
paul@162 1162
paul@162 1163
        # Obtain time point information for each group of periods.
paul@162 1164
paul@185 1165
        for periods in period_groups:
paul@162 1166
            periods = convert_periods(periods, tzid)
paul@162 1167
paul@162 1168
            # Get the time scale with start and end points.
paul@162 1169
paul@162 1170
            scale = get_scale(periods)
paul@162 1171
paul@162 1172
            # Get the time slots for the periods.
paul@162 1173
paul@162 1174
            slots = get_slots(scale)
paul@162 1175
paul@162 1176
            # Add start of day time points for multi-day periods.
paul@162 1177
paul@244 1178
            add_day_start_points(slots, tzid)
paul@162 1179
paul@162 1180
            # Record the slots and all time points employed.
paul@162 1181
paul@162 1182
            groups.append(slots)
paul@201 1183
            all_points.update([point for point, active in slots])
paul@162 1184
paul@162 1185
        # Partition the groups into days.
paul@162 1186
paul@162 1187
        days = {}
paul@162 1188
        partitioned_groups = []
paul@171 1189
        partitioned_group_types = []
paul@185 1190
        partitioned_group_sources = []
paul@162 1191
paul@185 1192
        for slots, group_type, group_source in zip(groups, group_types, group_sources):
paul@162 1193
paul@162 1194
            # Propagate time points to all groups of time slots.
paul@162 1195
paul@162 1196
            add_slots(slots, all_points)
paul@162 1197
paul@162 1198
            # Count the number of columns employed by the group.
paul@162 1199
paul@162 1200
            columns = 0
paul@162 1201
paul@162 1202
            # Partition the time slots by day.
paul@162 1203
paul@162 1204
            partitioned = {}
paul@162 1205
paul@162 1206
            for day, day_slots in partition_by_day(slots).items():
paul@201 1207
                intervals = []
paul@201 1208
                last = None
paul@201 1209
paul@201 1210
                for point, active in day_slots:
paul@201 1211
                    columns = max(columns, len(active))
paul@201 1212
                    if last:
paul@201 1213
                        intervals.append((last, point))
paul@201 1214
                    last = point
paul@201 1215
paul@201 1216
                if last:
paul@201 1217
                    intervals.append((last, None))
paul@162 1218
paul@162 1219
                if not days.has_key(day):
paul@162 1220
                    days[day] = set()
paul@162 1221
paul@162 1222
                # Convert each partition to a mapping from points to active
paul@162 1223
                # periods.
paul@162 1224
paul@201 1225
                partitioned[day] = dict(day_slots)
paul@201 1226
paul@201 1227
                # Record the divisions or intervals within each day.
paul@201 1228
paul@201 1229
                days[day].update(intervals)
paul@162 1230
paul@194 1231
            if group_type != "request" or columns:
paul@194 1232
                group_columns.append(columns)
paul@194 1233
                partitioned_groups.append(partitioned)
paul@194 1234
                partitioned_group_types.append(group_type)
paul@194 1235
                partitioned_group_sources.append(group_source)
paul@114 1236
paul@279 1237
        # Add empty days.
paul@279 1238
paul@283 1239
        add_empty_days(days, tzid)
paul@279 1240
paul@279 1241
        # Show the controls permitting day selection.
paul@279 1242
paul@243 1243
        self.show_calendar_day_controls(days)
paul@243 1244
paul@279 1245
        # Show the calendar itself.
paul@279 1246
paul@230 1247
        page.table(cellspacing=5, cellpadding=5, class_="calendar")
paul@188 1248
        self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns)
paul@171 1249
        self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns)
paul@162 1250
        page.table.close()
paul@114 1251
paul@196 1252
        # End the form region.
paul@196 1253
paul@196 1254
        page.form.close()
paul@196 1255
paul@246 1256
    # More page fragment methods.
paul@246 1257
paul@243 1258
    def show_calendar_day_controls(self, days):
paul@243 1259
paul@243 1260
        "Show controls for the given 'days' in the calendar."
paul@243 1261
paul@243 1262
        page = self.page
paul@243 1263
        slots = self.env.get_args().get("slot", [])
paul@243 1264
paul@243 1265
        for day in days:
paul@243 1266
            value, identifier = self._day_value_and_identifier(day)
paul@243 1267
            self._slot_selector(value, identifier, slots)
paul@243 1268
paul@243 1269
        # Generate a dynamic stylesheet to allow day selections to colour
paul@243 1270
        # specific days.
paul@243 1271
        # NOTE: The style details need to be coordinated with the static
paul@243 1272
        # NOTE: stylesheet.
paul@243 1273
paul@243 1274
        page.style(type="text/css")
paul@243 1275
paul@243 1276
        for day in days:
paul@243 1277
            daystr = format_datetime(day)
paul@243 1278
            page.add("""\
paul@249 1279
input.newevent.selector#day-%s-:checked ~ table label.day.day-%s,
paul@249 1280
input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s {
paul@243 1281
    background-color: #5f4;
paul@243 1282
    text-decoration: underline;
paul@243 1283
}
paul@243 1284
""" % (daystr, daystr, daystr, daystr))
paul@243 1285
paul@243 1286
        page.style.close()
paul@243 1287
paul@188 1288
    def show_calendar_participant_headings(self, group_types, group_sources, group_columns):
paul@186 1289
paul@186 1290
        """
paul@186 1291
        Show headings for the participants and other scheduling contributors,
paul@188 1292
        defined by 'group_types', 'group_sources' and 'group_columns'.
paul@186 1293
        """
paul@186 1294
paul@185 1295
        page = self.page
paul@185 1296
paul@188 1297
        page.colgroup(span=1, id="columns-timeslot")
paul@186 1298
paul@188 1299
        for group_type, columns in zip(group_types, group_columns):
paul@191 1300
            page.colgroup(span=max(columns, 1), id="columns-%s" % group_type)
paul@186 1301
paul@185 1302
        page.thead()
paul@185 1303
        page.tr()
paul@185 1304
        page.th("", class_="emptyheading")
paul@185 1305
paul@193 1306
        for group_type, source, columns in zip(group_types, group_sources, group_columns):
paul@193 1307
            page.th(source,
paul@193 1308
                class_=(group_type == "request" and "requestheading" or "participantheading"),
paul@193 1309
                colspan=max(columns, 1))
paul@185 1310
paul@185 1311
        page.tr.close()
paul@185 1312
        page.thead.close()
paul@185 1313
paul@171 1314
    def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns):
paul@186 1315
paul@186 1316
        """
paul@186 1317
        Show calendar days, defined by a collection of 'days', the contributing
paul@186 1318
        period information as 'partitioned_groups' (partitioned by day), the
paul@186 1319
        'partitioned_group_types' indicating the kind of contribution involved,
paul@186 1320
        and the 'group_columns' defining the number of columns in each group.
paul@186 1321
        """
paul@186 1322
paul@162 1323
        page = self.page
paul@162 1324
paul@191 1325
        # Determine the number of columns required. Where participants provide
paul@191 1326
        # no columns for events, one still needs to be provided for the
paul@191 1327
        # participant itself.
paul@147 1328
paul@191 1329
        all_columns = sum([max(columns, 1) for columns in group_columns])
paul@191 1330
paul@191 1331
        # Determine the days providing time slots.
paul@191 1332
paul@162 1333
        all_days = days.items()
paul@162 1334
        all_days.sort()
paul@162 1335
paul@162 1336
        # Produce a heading and time points for each day.
paul@162 1337
paul@201 1338
        for day, intervals in all_days:
paul@279 1339
            groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups]
paul@279 1340
            is_empty = True
paul@279 1341
paul@279 1342
            for slots in groups_for_day:
paul@279 1343
                if not slots:
paul@279 1344
                    continue
paul@279 1345
paul@279 1346
                for active in slots.values():
paul@279 1347
                    if active:
paul@279 1348
                        is_empty = False
paul@279 1349
                        break
paul@279 1350
paul@282 1351
            page.thead(class_="separator%s" % (is_empty and " empty" or ""))
paul@282 1352
            page.tr()
paul@243 1353
            page.th(class_="dayheading container", colspan=all_columns+1)
paul@239 1354
            self._day_heading(day)
paul@114 1355
            page.th.close()
paul@153 1356
            page.tr.close()
paul@186 1357
            page.thead.close()
paul@114 1358
paul@282 1359
            page.tbody(class_="points%s" % (is_empty and " empty" or ""))
paul@280 1360
            self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns)
paul@186 1361
            page.tbody.close()
paul@185 1362
paul@280 1363
    def show_calendar_points(self, intervals, groups, group_types, group_columns):
paul@186 1364
paul@186 1365
        """
paul@201 1366
        Show the time 'intervals' along with period information from the given
paul@186 1367
        'groups', having the indicated 'group_types', each with the number of
paul@186 1368
        columns given by 'group_columns'.
paul@186 1369
        """
paul@186 1370
paul@162 1371
        page = self.page
paul@162 1372
paul@244 1373
        # Obtain the user's timezone.
paul@244 1374
paul@244 1375
        tzid = self.get_tzid()
paul@244 1376
paul@203 1377
        # Produce a row for each interval.
paul@162 1378
paul@201 1379
        intervals = list(intervals)
paul@201 1380
        intervals.sort()
paul@162 1381
paul@201 1382
        for point, endpoint in intervals:
paul@244 1383
            continuation = point == get_start_of_day(point, tzid)
paul@153 1384
paul@203 1385
            # Some rows contain no period details and are marked as such.
paul@203 1386
paul@283 1387
            have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None)
paul@203 1388
paul@203 1389
            css = " ".join(
paul@203 1390
                ["slot"] +
paul@231 1391
                (have_active and ["busy"] or ["empty"]) +
paul@203 1392
                (continuation and ["daystart"] or [])
paul@203 1393
                )
paul@203 1394
paul@203 1395
            page.tr(class_=css)
paul@162 1396
            page.th(class_="timeslot")
paul@201 1397
            self._time_point(point, endpoint)
paul@162 1398
            page.th.close()
paul@162 1399
paul@162 1400
            # Obtain slots for the time point from each group.
paul@162 1401
paul@171 1402
            for columns, slots, group_type in zip(group_columns, groups, group_types):
paul@162 1403
                active = slots and slots.get(point)
paul@162 1404
paul@191 1405
                # Where no periods exist for the given time interval, generate
paul@191 1406
                # an empty cell. Where a participant provides no periods at all,
paul@191 1407
                # the colspan is adjusted to be 1, not 0.
paul@191 1408
paul@162 1409
                if not active:
paul@196 1410
                    page.td(class_="empty container", colspan=max(columns, 1))
paul@201 1411
                    self._empty_slot(point, endpoint)
paul@196 1412
                    page.td.close()
paul@162 1413
                    continue
paul@162 1414
paul@162 1415
                slots = slots.items()
paul@162 1416
                slots.sort()
paul@162 1417
                spans = get_spans(slots)
paul@162 1418
paul@278 1419
                empty = 0
paul@278 1420
paul@162 1421
                # Show a column for each active period.
paul@117 1422
paul@153 1423
                for t in active:
paul@185 1424
                    if t and len(t) >= 2:
paul@278 1425
paul@278 1426
                        # Flush empty slots preceding this one.
paul@278 1427
paul@278 1428
                        if empty:
paul@278 1429
                            page.td(class_="empty container", colspan=empty)
paul@278 1430
                            self._empty_slot(point, endpoint)
paul@278 1431
                            page.td.close()
paul@278 1432
                            empty = 0
paul@278 1433
paul@185 1434
                        start, end, uid, key = get_freebusy_details(t)
paul@185 1435
                        span = spans[key]
paul@171 1436
paul@171 1437
                        # Produce a table cell only at the start of the period
paul@171 1438
                        # or when continued at the start of a day.
paul@171 1439
paul@153 1440
                        if point == start or continuation:
paul@153 1441
paul@275 1442
                            obj = self._get_object(uid)
paul@275 1443
paul@195 1444
                            has_continued = continuation and point != start
paul@244 1445
                            will_continue = not ends_on_same_day(point, end, tzid)
paul@291 1446
                            is_organiser = obj and obj.get_value("ORGANIZER") == self.user
paul@275 1447
paul@195 1448
                            css = " ".join(
paul@195 1449
                                ["event"] +
paul@195 1450
                                (has_continued and ["continued"] or []) +
paul@275 1451
                                (will_continue and ["continues"] or []) +
paul@275 1452
                                (is_organiser and ["organising"] or ["attending"])
paul@195 1453
                                )
paul@195 1454
paul@189 1455
                            # Only anchor the first cell of events.
paul@189 1456
paul@189 1457
                            if point == start:
paul@195 1458
                                page.td(class_=css, rowspan=span, id="%s-%s" % (group_type, uid))
paul@189 1459
                            else:
paul@195 1460
                                page.td(class_=css, rowspan=span)
paul@171 1461
paul@185 1462
                            if not obj:
paul@291 1463
                                page.span("(Participant is busy)")
paul@185 1464
                            else:
paul@213 1465
                                summary = obj.get_value("SUMMARY")
paul@171 1466
paul@171 1467
                                # Only link to events if they are not being
paul@171 1468
                                # updated by requests.
paul@171 1469
paul@171 1470
                                if uid in self._get_requests() and group_type != "request":
paul@189 1471
                                    page.span(summary)
paul@164 1472
                                else:
paul@171 1473
                                    href = "%s/%s" % (self.env.get_url().rstrip("/"), uid)
paul@189 1474
                                    page.a(summary, href=href)
paul@171 1475
paul@153 1476
                            page.td.close()
paul@153 1477
                    else:
paul@278 1478
                        empty += 1
paul@114 1479
paul@166 1480
                # Pad with empty columns.
paul@166 1481
paul@278 1482
                empty = columns - len(active)
paul@278 1483
paul@278 1484
                if empty:
paul@278 1485
                    page.td(class_="empty container", colspan=empty)
paul@201 1486
                    self._empty_slot(point, endpoint)
paul@196 1487
                    page.td.close()
paul@166 1488
paul@162 1489
            page.tr.close()
paul@114 1490
paul@239 1491
    def _day_heading(self, day):
paul@243 1492
paul@243 1493
        """
paul@243 1494
        Generate a heading for 'day' of the following form:
paul@243 1495
paul@243 1496
        <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label>
paul@243 1497
        """
paul@243 1498
paul@239 1499
        page = self.page
paul@243 1500
        daystr = format_datetime(day)
paul@239 1501
        value, identifier = self._day_value_and_identifier(day)
paul@243 1502
        page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier)
paul@239 1503
paul@201 1504
    def _time_point(self, point, endpoint):
paul@243 1505
paul@243 1506
        """
paul@243 1507
        Generate headings for the 'point' to 'endpoint' period of the following
paul@243 1508
        form:
paul@243 1509
paul@243 1510
        <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label>
paul@243 1511
        <span class="endpoint">10:00:00 CET</span>
paul@243 1512
        """
paul@243 1513
paul@201 1514
        page = self.page
paul@244 1515
        tzid = self.get_tzid()
paul@243 1516
        daystr = format_datetime(point.date())
paul@201 1517
        value, identifier = self._slot_value_and_identifier(point, endpoint)
paul@238 1518
        slots = self.env.get_args().get("slot", [])
paul@239 1519
        self._slot_selector(value, identifier, slots)
paul@243 1520
        page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier)
paul@244 1521
        page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint")
paul@239 1522
paul@239 1523
    def _slot_selector(self, value, identifier, slots):
paul@258 1524
        reset = self.env.get_args().has_key("reset")
paul@239 1525
        page = self.page
paul@258 1526
        if not reset and value in slots:
paul@249 1527
            page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked")
paul@202 1528
        else:
paul@249 1529
            page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector")
paul@201 1530
paul@201 1531
    def _empty_slot(self, point, endpoint):
paul@197 1532
        page = self.page
paul@201 1533
        value, identifier = self._slot_value_and_identifier(point, endpoint)
paul@236 1534
        page.label("Select/deselect period", class_="newevent popup", for_=identifier)
paul@196 1535
paul@239 1536
    def _day_value_and_identifier(self, day):
paul@239 1537
        value = "%s-" % format_datetime(day)
paul@239 1538
        identifier = "day-%s" % value
paul@239 1539
        return value, identifier
paul@239 1540
paul@201 1541
    def _slot_value_and_identifier(self, point, endpoint):
paul@202 1542
        value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "")
paul@201 1543
        identifier = "slot-%s" % value
paul@201 1544
        return value, identifier
paul@196 1545
paul@286 1546
    def _show_menu(self, name, default, items):
paul@257 1547
        page = self.page
paul@286 1548
        values = self.env.get_args().get(name, [default])
paul@257 1549
        page.select(name=name)
paul@257 1550
        for v, label in items:
paul@257 1551
            if v in values:
paul@257 1552
                page.option(label, value=v, selected="selected")
paul@257 1553
            else:
paul@257 1554
                page.option(label, value=v)
paul@257 1555
        page.select.close()
paul@257 1556
paul@286 1557
    def _show_date_controls(self, name, default, attr, tzid):
paul@286 1558
paul@286 1559
        """
paul@286 1560
        Show date controls for a field with the given 'name' and 'default' value
paul@286 1561
        and 'attr', with the given 'tzid' being used if no other time regime
paul@286 1562
        information is provided.
paul@286 1563
        """
paul@286 1564
paul@286 1565
        page = self.page
paul@286 1566
        args = self.env.get_args()
paul@286 1567
paul@286 1568
        event_tzid = attr.get("TZID", tzid)
paul@286 1569
        dt = get_datetime(default, attr)
paul@286 1570
paul@286 1571
        # Show dates for up to one week around the current date.
paul@286 1572
paul@286 1573
        base = get_date(dt)
paul@286 1574
        items = []
paul@286 1575
        for i in range(-7, 8):
paul@286 1576
            d = base + timedelta(i)
paul@286 1577
            items.append((format_datetime(d), self.format_date(d, "full")))
paul@286 1578
paul@286 1579
        self._show_menu("%s-date" % name, format_datetime(base), items)
paul@286 1580
paul@286 1581
        # Show time details.
paul@286 1582
paul@300 1583
        dt_time = isinstance(dt, datetime) and dt or None
paul@300 1584
        hour = args.get("%s-hour" % name, "%02d" % (dt_time and dt_time.hour or 0))
paul@300 1585
        minute = args.get("%s-minute" % name, "%02d" % (dt_time and dt_time.minute or 0))
paul@300 1586
        second = args.get("%s-second" % name, "%02d" % (dt_time and dt_time.second or 0))
paul@300 1587
paul@300 1588
        page.span(class_="time enabled")
paul@300 1589
        page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2)
paul@300 1590
        page.add(":")
paul@300 1591
        page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2)
paul@300 1592
        page.add(":")
paul@300 1593
        page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2)
paul@300 1594
        page.add(" ")
paul@300 1595
        self._show_menu("%s-tzid" % name, event_tzid,
paul@300 1596
            [(event_tzid, event_tzid)] + (
paul@300 1597
            event_tzid != tzid and [(tzid, tzid)] or []
paul@300 1598
            ))
paul@300 1599
        page.span.close()
paul@286 1600
paul@246 1601
    # Incoming HTTP request direction.
paul@246 1602
paul@69 1603
    def select_action(self):
paul@69 1604
paul@69 1605
        "Select the desired action and show the result."
paul@69 1606
paul@121 1607
        path_info = self.env.get_path_info().strip("/")
paul@121 1608
paul@69 1609
        if not path_info:
paul@114 1610
            self.show_calendar()
paul@121 1611
        elif self.show_object(path_info):
paul@70 1612
            pass
paul@70 1613
        else:
paul@70 1614
            self.no_page()
paul@69 1615
paul@82 1616
    def __call__(self):
paul@69 1617
paul@69 1618
        "Interpret a request and show an appropriate response."
paul@69 1619
paul@69 1620
        if not self.user:
paul@69 1621
            self.no_user()
paul@69 1622
        else:
paul@69 1623
            self.select_action()
paul@69 1624
paul@70 1625
        # Write the headers and actual content.
paul@70 1626
paul@69 1627
        print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding
paul@69 1628
        print >>self.out
paul@69 1629
        self.out.write(unicode(self.page).encode(self.encoding))
paul@69 1630
paul@69 1631
if __name__ == "__main__":
paul@128 1632
    Manager()()
paul@69 1633
paul@69 1634
# vim: tabstop=4 expandtab shiftwidth=4