imip-agent

Annotated imip_manager.py

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