imip-agent

Annotated imip_manager.py

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