imip-agent

Annotated imip_manager.py

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