imip-agent

Annotated imip_manager.py

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