imip-agent

Annotated imip_manager.py

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