imip-agent

Annotated imip_manager.py

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