imip-agent

Annotated imip_manager.py

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