imip-agent

Annotated imipweb/resource.py

907:916dc605c2ec
2015-10-22 Paul Boddie Prepare recipients from the object involved. Fixed "uninvitation" cancellations.
paul@446 1
#!/usr/bin/env python
paul@446 2
paul@446 3
"""
paul@446 4
Common resource functionality for Web calendar clients.
paul@446 5
paul@446 6
Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>
paul@446 7
paul@446 8
This program is free software; you can redistribute it and/or modify it under
paul@446 9
the terms of the GNU General Public License as published by the Free Software
paul@446 10
Foundation; either version 3 of the License, or (at your option) any later
paul@446 11
version.
paul@446 12
paul@446 13
This program is distributed in the hope that it will be useful, but WITHOUT
paul@446 14
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
paul@446 15
FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
paul@446 16
details.
paul@446 17
paul@446 18
You should have received a copy of the GNU General Public License along with
paul@446 19
this program.  If not, see <http://www.gnu.org/licenses/>.
paul@446 20
"""
paul@446 21
paul@764 22
from datetime import datetime, timedelta
paul@756 23
from imiptools.client import Client, ClientForObject
paul@807 24
from imiptools.data import get_address, get_uri, uri_item, uri_values
paul@764 25
from imiptools.dates import format_datetime, get_recurrence_start_point, to_date
paul@678 26
from imiptools.period import remove_period, remove_affected_period
paul@845 27
from imipweb.data import event_period_from_period, form_period_from_period, \
paul@845 28
                         FormDate, PeriodError
paul@446 29
from imipweb.env import CGIEnvironment
paul@777 30
from urllib import urlencode
paul@446 31
import babel.dates
paul@446 32
import imip_store
paul@446 33
import markup
paul@764 34
import pytz
paul@446 35
paul@756 36
class Resource:
paul@446 37
paul@756 38
    "A Web application resource."
paul@446 39
paul@446 40
    def __init__(self, resource=None):
paul@756 41
paul@756 42
        """
paul@756 43
        Initialise a resource, allowing it to share the environment of any given
paul@756 44
        existing 'resource'.
paul@756 45
        """
paul@756 46
paul@446 47
        self.encoding = "utf-8"
paul@446 48
        self.env = CGIEnvironment(self.encoding)
paul@446 49
paul@756 50
        self.objects = {}
paul@446 51
        self.locale = None
paul@446 52
        self.requests = None
paul@446 53
paul@446 54
        self.out = resource and resource.out or self.env.get_output()
paul@446 55
        self.page = resource and resource.page or markup.page()
paul@446 56
        self.html_ids = None
paul@446 57
paul@446 58
    # Presentation methods.
paul@446 59
paul@446 60
    def new_page(self, title):
paul@446 61
        self.page.init(title=title, charset=self.encoding, css=self.env.new_url("styles.css"))
paul@446 62
        self.html_ids = set()
paul@446 63
paul@446 64
    def status(self, code, message):
paul@446 65
        self.header("Status", "%s %s" % (code, message))
paul@446 66
paul@446 67
    def header(self, header, value):
paul@446 68
        print >>self.out, "%s: %s" % (header, value)
paul@446 69
paul@446 70
    def no_user(self):
paul@446 71
        self.status(403, "Forbidden")
paul@446 72
        self.new_page(title="Forbidden")
paul@446 73
        self.page.p("You are not logged in and thus cannot access scheduling requests.")
paul@446 74
paul@446 75
    def no_page(self):
paul@446 76
        self.status(404, "Not Found")
paul@446 77
        self.new_page(title="Not Found")
paul@446 78
        self.page.p("No page is provided at the given address.")
paul@446 79
paul@446 80
    def redirect(self, url):
paul@446 81
        self.status(302, "Redirect")
paul@446 82
        self.header("Location", url)
paul@446 83
        self.new_page(title="Redirect")
paul@446 84
        self.page.p("Redirecting to: %s" % url)
paul@446 85
paul@875 86
    def link_to(self, uid=None, recurrenceid=None, args=None):
paul@755 87
paul@755 88
        """
paul@875 89
        Return a link to a resource, being an object with any given 'uid' and
paul@875 90
        'recurrenceid', or the main resource otherwise.
paul@875 91
paul@763 92
        See get_identifiers for the decoding of such links.
paul@777 93
paul@777 94
        If 'args' is specified, the given dictionary is encoded and included.
paul@755 95
        """
paul@755 96
paul@875 97
        path = []
paul@875 98
        if uid:
paul@875 99
            path.append(uid)
paul@446 100
        if recurrenceid:
paul@755 101
            path.append(recurrenceid)
paul@777 102
        return "%s%s" % (self.env.new_url("/".join(path)), args and ("?%s" % urlencode(args)) or "")
paul@446 103
paul@623 104
    # Access to objects.
paul@623 105
paul@755 106
    def get_identifiers(self, path_info):
paul@755 107
paul@755 108
        """
paul@755 109
        Return identifiers provided by 'path_info', potentially encoded by
paul@755 110
        'link_to'.
paul@755 111
        """
paul@755 112
paul@446 113
        parts = path_info.lstrip("/").split("/")
paul@755 114
paul@755 115
        # UID only.
paul@755 116
paul@446 117
        if len(parts) == 1:
paul@763 118
            return parts[0], None
paul@755 119
paul@763 120
        # UID and RECURRENCE-ID.
paul@755 121
paul@446 122
        else:
paul@763 123
            return parts[:2]
paul@446 124
paul@769 125
    def _get_object(self, uid, recurrenceid=None, section=None, username=None):
paul@769 126
        if self.objects.has_key((uid, recurrenceid, section, username)):
paul@769 127
            return self.objects[(uid, recurrenceid, section, username)]
paul@446 128
paul@769 129
        obj = self.objects[(uid, recurrenceid, section, username)] = self.get_stored_object(uid, recurrenceid, section, username)
paul@446 130
        return obj
paul@446 131
paul@446 132
    def _get_recurrences(self, uid):
paul@446 133
        return self.store.get_recurrences(self.user, uid)
paul@446 134
paul@694 135
    def _get_active_recurrences(self, uid):
paul@694 136
        return self.store.get_active_recurrences(self.user, uid)
paul@694 137
paul@446 138
    def _get_requests(self):
paul@446 139
        if self.requests is None:
paul@699 140
            self.requests = self.store.get_requests(self.user)
paul@446 141
        return self.requests
paul@446 142
paul@755 143
    def _have_request(self, uid, recurrenceid=None, type=None, strict=False):
paul@755 144
        return self.store.have_request(self._get_requests(), uid, recurrenceid, type, strict)
paul@751 145
paul@818 146
    def _is_request(self):
paul@818 147
        return self._have_request(self.uid, self.recurrenceid)
paul@818 148
paul@766 149
    def _get_counters(self, uid, recurrenceid=None):
paul@766 150
        return self.store.get_counters(self.user, uid, recurrenceid)
paul@766 151
paul@446 152
    def _get_request_summary(self):
paul@630 153
paul@630 154
        "Return a list of periods comprising the request summary."
paul@630 155
paul@446 156
        summary = []
paul@630 157
paul@751 158
        for uid, recurrenceid, request_type in self._get_requests():
paul@769 159
paul@769 160
            # Obtain either normal objects or counter-proposals.
paul@769 161
paul@769 162
            if not request_type:
paul@769 163
                objs = [self._get_object(uid, recurrenceid)]
paul@769 164
            elif request_type == "COUNTER":
paul@769 165
                objs = []
paul@769 166
                for attendee in self.store.get_counters(self.user, uid, recurrenceid):
paul@769 167
                    objs.append(self._get_object(uid, recurrenceid, "counters", attendee))
paul@446 168
paul@769 169
            # For each object, obtain the periods involved.
paul@769 170
paul@769 171
            for obj in objs:
paul@769 172
                if obj:
paul@855 173
                    recurrenceids = self._get_recurrences(uid)
paul@446 174
paul@769 175
                    # Obtain only active periods, not those replaced by redefined
paul@769 176
                    # recurrences, converting to free/busy periods.
paul@769 177
paul@769 178
                    for p in obj.get_active_periods(recurrenceids, self.get_tzid(), self.get_window_end()):
paul@769 179
                        summary.append(obj.get_freebusy_period(p))
paul@446 180
paul@446 181
        return summary
paul@446 182
paul@446 183
    # Preference methods.
paul@446 184
paul@446 185
    def get_user_locale(self):
paul@446 186
        if not self.locale:
paul@446 187
            self.locale = self.get_preferences().get("LANG", "en")
paul@446 188
        return self.locale
paul@446 189
paul@446 190
    # Prettyprinting of dates and times.
paul@446 191
paul@446 192
    def format_date(self, dt, format):
paul@446 193
        return self._format_datetime(babel.dates.format_date, dt, format)
paul@446 194
paul@446 195
    def format_time(self, dt, format):
paul@446 196
        return self._format_datetime(babel.dates.format_time, dt, format)
paul@446 197
paul@446 198
    def format_datetime(self, dt, format):
paul@446 199
        return self._format_datetime(
paul@446 200
            isinstance(dt, datetime) and babel.dates.format_datetime or babel.dates.format_date,
paul@446 201
            dt, format)
paul@446 202
paul@446 203
    def _format_datetime(self, fn, dt, format):
paul@446 204
        return fn(dt, format=format, locale=self.get_user_locale())
paul@446 205
paul@756 206
class ResourceClient(Resource, Client):
paul@446 207
paul@756 208
    "A Web application resource and calendar client."
paul@446 209
paul@756 210
    def __init__(self, resource=None):
paul@756 211
        Resource.__init__(self, resource)
paul@756 212
        user = self.env.get_user()
paul@756 213
        Client.__init__(self, user and get_uri(user) or None)
paul@446 214
paul@756 215
class ResourceClientForObject(Resource, ClientForObject):
paul@447 216
paul@756 217
    "A Web application resource and calendar client for a specific object."
paul@672 218
paul@807 219
    def __init__(self, resource=None, messenger=None):
paul@756 220
        Resource.__init__(self, resource)
paul@756 221
        user = self.env.get_user()
paul@807 222
        ClientForObject.__init__(self, None, user and get_uri(user) or None, messenger)
paul@807 223
paul@807 224
    # Communication methods.
paul@807 225
paul@907 226
    def send_message(self, parts, sender, obj, from_organiser, bcc_sender):
paul@807 227
paul@807 228
        """
paul@862 229
        Send the given 'parts' to the appropriate recipients, also sending a
paul@907 230
        copy to the 'sender'. The 'obj' together with the 'from_organiser' value
paul@907 231
        (which indicates whether the organiser is sending this message) are used
paul@907 232
        to determine the recipients of the message.
paul@807 233
        """
paul@807 234
paul@807 235
        # As organiser, send an invitation to attendees, excluding oneself if
paul@807 236
        # also attending. The updated event will be saved by the outgoing
paul@807 237
        # handler.
paul@807 238
paul@907 239
        organiser = get_uri(obj.get_value("ORGANIZER"))
paul@907 240
        attendees = uri_values(obj.get_values("ATTENDEE"))
paul@807 241
paul@807 242
        if from_organiser:
paul@807 243
            recipients = [get_address(attendee) for attendee in attendees if attendee != self.user]
paul@807 244
        else:
paul@807 245
            recipients = [get_address(organiser)]
paul@807 246
paul@807 247
        # Since the outgoing handler updates this user's free/busy details,
paul@807 248
        # the stored details will probably not have the updated details at
paul@807 249
        # this point, so we update our copy for serialisation as the bundled
paul@807 250
        # free/busy object.
paul@807 251
paul@807 252
        freebusy = self.store.get_freebusy(self.user)
paul@807 253
        self.update_freebusy(freebusy, self.user, from_organiser)
paul@807 254
paul@807 255
        # Bundle free/busy information if appropriate.
paul@807 256
paul@807 257
        part = self.get_freebusy_part(freebusy)
paul@807 258
        if part:
paul@807 259
            parts.append(part)
paul@807 260
paul@907 261
        if recipients or bcc_sender:
paul@907 262
            self._send_message(sender, recipients, parts, bcc_sender)
paul@809 263
paul@864 264
    def _send_message(self, sender, recipients, parts, bcc_sender):
paul@809 265
paul@835 266
        """
paul@835 267
        Send a message, explicitly specifying the 'sender' as an outgoing BCC
paul@835 268
        recipient since the generic calendar user will be the actual sender.
paul@835 269
        """
paul@807 270
paul@870 271
        if not bcc_sender:
paul@864 272
            message = self.messenger.make_outgoing_message(parts, recipients)
paul@864 273
            self.messenger.sendmail(recipients, message.as_string())
paul@864 274
        else:
paul@864 275
            message = self.messenger.make_outgoing_message(parts, recipients, outgoing_bcc=sender)
paul@864 276
            self.messenger.sendmail(recipients, message.as_string(), outgoing_bcc=sender)
paul@807 277
paul@864 278
    def send_message_to_self(self, parts):
paul@835 279
paul@835 280
        "Send a message composed of the given 'parts' to the given user."
paul@835 281
paul@835 282
        sender = get_address(self.user)
paul@835 283
        message = self.messenger.make_outgoing_message(parts, [sender])
paul@835 284
        self.messenger.sendmail([sender], message.as_string())
paul@835 285
paul@807 286
    # Action methods.
paul@807 287
paul@809 288
    def process_declined_counter(self, attendee):
paul@809 289
paul@809 290
        "Process a declined counter-proposal."
paul@809 291
paul@809 292
        # Obtain the counter-proposal for the attendee.
paul@809 293
paul@809 294
        obj = self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee)
paul@809 295
        if not obj:
paul@809 296
            return False
paul@809 297
paul@809 298
        method = "DECLINECOUNTER"
paul@851 299
        self.update_senders(obj=obj)
paul@809 300
        obj.update_dtstamp()
paul@809 301
        obj.update_sequence(False)
paul@864 302
        self._send_message(get_address(self.user), [get_address(attendee)], [obj.to_part(method)], True)
paul@809 303
        return True
paul@809 304
paul@818 305
    def process_received_request(self, changed=False):
paul@807 306
paul@807 307
        """
paul@807 308
        Process the current request for the current user. Return whether any
paul@818 309
        action was taken. If 'changed' is set to a true value, or if 'attendees'
paul@818 310
        is specified and differs from the stored attendees, a counter-proposal
paul@818 311
        will be sent instead of a reply.
paul@807 312
        """
paul@807 313
paul@807 314
        # Reply only on behalf of this user.
paul@807 315
paul@818 316
        attendee_attr = self.update_participation()
paul@807 317
paul@807 318
        if not attendee_attr:
paul@807 319
            return False
paul@807 320
paul@818 321
        if not changed:
paul@818 322
            self.obj["ATTENDEE"] = [(self.user, attendee_attr)]
paul@848 323
        else:
paul@851 324
            self.update_senders()
paul@818 325
paul@807 326
        self.update_dtstamp()
paul@809 327
        self.update_sequence(False)
paul@907 328
        self.send_message([self.obj.to_part(changed and "COUNTER" or "REPLY")], get_address(self.user), self.obj, False, True)
paul@807 329
        return True
paul@807 330
paul@807 331
    def process_created_request(self, method, to_cancel=None, to_unschedule=None):
paul@807 332
paul@807 333
        """
paul@807 334
        Process the current request, sending a created request of the given
paul@807 335
        'method' to attendees. Return whether any action was taken.
paul@807 336
paul@807 337
        If 'to_cancel' is specified, a list of participants to be sent cancel
paul@807 338
        messages is provided.
paul@807 339
paul@807 340
        If 'to_unschedule' is specified, a list of periods to be unscheduled is
paul@807 341
        provided.
paul@807 342
        """
paul@807 343
paul@807 344
        # Here, the organiser should be the current user.
paul@807 345
paul@807 346
        organiser, organiser_attr = uri_item(self.obj.get_item("ORGANIZER"))
paul@807 347
paul@807 348
        self.update_sender(organiser_attr)
paul@851 349
        self.update_senders()
paul@807 350
        self.update_dtstamp()
paul@809 351
        self.update_sequence(True)
paul@807 352
paul@864 353
        if method == "REQUEST":
paul@864 354
            methods, parts = self.get_message_parts(self.obj, "REQUEST")
paul@807 355
paul@864 356
            # Add message parts with cancelled occurrence information.
paul@807 357
paul@864 358
            unscheduled_parts = self.get_unscheduled_parts(to_unschedule)
paul@807 359
paul@864 360
            # Send the updated event, along with a cancellation for each of the
paul@864 361
            # unscheduled occurrences.
paul@864 362
paul@907 363
            self.send_message(parts + unscheduled_parts, get_address(organiser), self.obj, True, False)
paul@807 364
paul@864 365
            # Since the organiser can update the SEQUENCE but this can leave any
paul@864 366
            # mail/calendar client lagging, issue a PUBLISH message to the
paul@864 367
            # user's address.
paul@807 368
paul@864 369
            methods, parts = self.get_message_parts(self.obj, "PUBLISH")
paul@864 370
            self.send_message_to_self(parts + unscheduled_parts)
paul@807 371
paul@807 372
        # When cancelling, replace the attendees with those for whom the event
paul@807 373
        # is now cancelled.
paul@807 374
paul@864 375
        if method == "CANCEL" or to_cancel:
paul@864 376
            if to_cancel:
paul@864 377
                obj = self.obj.copy()
paul@864 378
                obj["ATTENDEE"] = to_cancel
paul@864 379
            else:
paul@864 380
                obj = self.obj
paul@807 381
paul@807 382
            # Send a cancellation to all uninvited attendees.
paul@807 383
paul@864 384
            parts = [obj.to_part("CANCEL")]
paul@907 385
            self.send_message(parts, get_address(organiser), obj, True, False)
paul@807 386
paul@864 387
            # Issue a CANCEL message to the user's address.
paul@835 388
paul@907 389
            if method == "CANCEL":
paul@907 390
                self.send_message_to_self(parts)
paul@835 391
paul@807 392
        return True
paul@446 393
paul@764 394
class FormUtilities:
paul@764 395
paul@765 396
    "Utility methods resource mix-in."
paul@764 397
paul@813 398
    def prefixed_args(self, prefix, convert=None):
paul@813 399
paul@813 400
        """
paul@813 401
        Return values for all arguments having the given 'prefix' in their
paul@813 402
        names, removing the prefix to obtain each value from the argument name
paul@813 403
        itself. The 'convert' callable can be specified to perform a conversion
paul@813 404
        (to int, for example).
paul@813 405
        """
paul@813 406
paul@813 407
        args = self.env.get_args()
paul@813 408
paul@813 409
        values = []
paul@813 410
        for name in args.keys():
paul@813 411
            if name.startswith(prefix):
paul@813 412
                value = name[len(prefix):]
paul@813 413
                if convert:
paul@813 414
                    try:
paul@813 415
                        value = convert(value)
paul@813 416
                    except ValueError:
paul@813 417
                        pass
paul@813 418
                values.append(value)
paul@813 419
        return values
paul@813 420
paul@764 421
    def control(self, name, type, value, selected=False, **kw):
paul@764 422
paul@764 423
        """
paul@764 424
        Show a control with the given 'name', 'type' and 'value', with
paul@764 425
        'selected' indicating whether it should be selected (checked or
paul@764 426
        equivalent), and with keyword arguments setting other properties.
paul@764 427
        """
paul@764 428
paul@764 429
        page = self.page
paul@779 430
        if type in ("checkbox", "radio") and selected:
paul@852 431
            page.input(name=name, type=type, value=value, checked="checked", **kw)
paul@764 432
        else:
paul@764 433
            page.input(name=name, type=type, value=value, **kw)
paul@764 434
paul@764 435
    def menu(self, name, default, items, class_="", index=None):
paul@764 436
paul@764 437
        """
paul@764 438
        Show a select menu having the given 'name', set to the given 'default',
paul@764 439
        providing the given (value, label) 'items', and employing the given CSS
paul@764 440
        'class_' if specified.
paul@764 441
        """
paul@764 442
paul@764 443
        page = self.page
paul@764 444
        values = self.env.get_args().get(name, [default])
paul@764 445
        if index is not None:
paul@764 446
            values = values[index:]
paul@764 447
            values = values and values[0:1] or [default]
paul@764 448
paul@764 449
        page.select(name=name, class_=class_)
paul@764 450
        for v, label in items:
paul@764 451
            if v is None:
paul@764 452
                continue
paul@764 453
            if v in values:
paul@764 454
                page.option(label, value=v, selected="selected")
paul@764 455
            else:
paul@764 456
                page.option(label, value=v)
paul@764 457
        page.select.close()
paul@764 458
paul@764 459
    def date_controls(self, name, default, index=None, show_tzid=True, read_only=False):
paul@764 460
paul@764 461
        """
paul@764 462
        Show date controls for a field with the given 'name' and 'default' form
paul@764 463
        date value.
paul@764 464
paul@764 465
        If 'index' is specified, default field values will be overridden by the
paul@764 466
        element from a collection of existing form values with the specified
paul@764 467
        index; otherwise, field values will be overridden by a single form
paul@764 468
        value.
paul@764 469
paul@764 470
        If 'show_tzid' is set to a false value, the time zone menu will not be
paul@764 471
        provided.
paul@764 472
paul@764 473
        If 'read_only' is set to a true value, the controls will be hidden and
paul@764 474
        labels will be employed instead.
paul@764 475
        """
paul@764 476
paul@764 477
        page = self.page
paul@764 478
paul@764 479
        # Show dates for up to one week around the current date.
paul@764 480
paul@886 481
        page.span(class_="date enabled")
paul@886 482
paul@764 483
        dt = default.as_datetime()
paul@764 484
        if not dt:
paul@764 485
            dt = date.today()
paul@764 486
paul@764 487
        base = to_date(dt)
paul@764 488
paul@764 489
        # Show a date label with a hidden field if read-only.
paul@764 490
paul@764 491
        if read_only:
paul@764 492
            self.control("%s-date" % name, "hidden", format_datetime(base))
paul@764 493
            page.span(self.format_date(base, "long"))
paul@764 494
paul@764 495
        # Show dates for up to one week around the current date.
paul@764 496
        # NOTE: Support paging to other dates.
paul@764 497
paul@764 498
        else:
paul@764 499
            items = []
paul@764 500
            for i in range(-7, 8):
paul@764 501
                d = base + timedelta(i)
paul@764 502
                items.append((format_datetime(d), self.format_date(d, "full")))
paul@764 503
            self.menu("%s-date" % name, format_datetime(base), items, index=index)
paul@764 504
paul@886 505
        page.span.close()
paul@886 506
paul@764 507
        # Show time details.
paul@764 508
paul@764 509
        page.span(class_="time enabled")
paul@764 510
paul@764 511
        if read_only:
paul@764 512
            page.span("%s:%s:%s" % (default.get_hour(), default.get_minute(), default.get_second()))
paul@764 513
            self.control("%s-hour" % name, "hidden", default.get_hour())
paul@764 514
            self.control("%s-minute" % name, "hidden", default.get_minute())
paul@764 515
            self.control("%s-second" % name, "hidden", default.get_second())
paul@764 516
        else:
paul@764 517
            self.control("%s-hour" % name, "text", default.get_hour(), maxlength=2, size=2)
paul@764 518
            page.add(":")
paul@764 519
            self.control("%s-minute" % name, "text", default.get_minute(), maxlength=2, size=2)
paul@764 520
            page.add(":")
paul@764 521
            self.control("%s-second" % name, "text", default.get_second(), maxlength=2, size=2)
paul@764 522
paul@764 523
        # Show time zone details.
paul@764 524
paul@764 525
        if show_tzid:
paul@764 526
            page.add(" ")
paul@764 527
            tzid = default.get_tzid() or self.get_tzid()
paul@764 528
paul@764 529
            # Show a label if read-only or a menu otherwise.
paul@764 530
paul@764 531
            if read_only:
paul@764 532
                self.control("%s-tzid" % name, "hidden", tzid)
paul@764 533
                page.span(tzid)
paul@764 534
            else:
paul@764 535
                self.timezone_menu("%s-tzid" % name, tzid, index)
paul@764 536
paul@764 537
        page.span.close()
paul@764 538
paul@764 539
    def timezone_menu(self, name, default, index=None):
paul@764 540
paul@764 541
        """
paul@764 542
        Show timezone controls using a menu with the given 'name', set to the
paul@764 543
        given 'default' unless a field of the given 'name' provides a value.
paul@764 544
        """
paul@764 545
paul@764 546
        entries = [(tzid, tzid) for tzid in pytz.all_timezones]
paul@764 547
        self.menu(name, default, entries, index=index)
paul@764 548
paul@765 549
class DateTimeFormUtilities:
paul@765 550
paul@765 551
    "Date/time control methods resource mix-in."
paul@765 552
paul@776 553
    # Control naming helpers.
paul@776 554
paul@776 555
    def element_identifier(self, name, index=None):
paul@776 556
        return index is not None and "%s-%d" % (name, index) or name
paul@776 557
paul@776 558
    def element_name(self, name, suffix, index=None):
paul@776 559
        return index is not None and "%s-%s" % (name, suffix) or name
paul@776 560
paul@776 561
    def element_enable(self, index=None):
paul@776 562
        return index is not None and str(index) or "enable"
paul@776 563
paul@765 564
    def show_object_datetime_controls(self, period, index=None):
paul@765 565
paul@765 566
        """
paul@765 567
        Show datetime-related controls if already active or if an object needs
paul@765 568
        them for the given 'period'. The given 'index' is used to parameterise
paul@765 569
        individual controls for dynamic manipulation.
paul@765 570
        """
paul@765 571
paul@765 572
        p = form_period_from_period(period)
paul@765 573
paul@765 574
        page = self.page
paul@765 575
        args = self.env.get_args()
paul@765 576
        _id = self.element_identifier
paul@765 577
        _name = self.element_name
paul@765 578
        _enable = self.element_enable
paul@765 579
paul@765 580
        # Add a dynamic stylesheet to permit the controls to modify the display.
paul@765 581
        # NOTE: The style details need to be coordinated with the static
paul@765 582
        # NOTE: stylesheet.
paul@765 583
paul@765 584
        if index is not None:
paul@765 585
            page.style(type="text/css")
paul@765 586
paul@765 587
            # Unlike the rules for object properties, these affect recurrence
paul@765 588
            # properties.
paul@765 589
paul@765 590
            page.add("""\
paul@765 591
input#dttimes-enable-%(index)d,
paul@765 592
input#dtend-enable-%(index)d,
paul@765 593
input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled,
paul@765 594
input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled,
paul@765 595
input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled,
paul@765 596
input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled {
paul@765 597
    display: none;
paul@886 598
}
paul@886 599
paul@886 600
input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .date.enabled,
paul@886 601
input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .date.disabled {
paul@886 602
    visibility: hidden;
paul@765 603
}""" % {"index" : index})
paul@765 604
paul@765 605
            page.style.close()
paul@765 606
paul@765 607
        self.control(
paul@765 608
            _name("dtend-control", "recur", index), "checkbox",
paul@765 609
            _enable(index), p.end_enabled,
paul@765 610
            id=_id("dtend-enable", index)
paul@765 611
            )
paul@765 612
paul@765 613
        self.control(
paul@765 614
            _name("dttimes-control", "recur", index), "checkbox",
paul@765 615
            _enable(index), p.times_enabled,
paul@765 616
            id=_id("dttimes-enable", index)
paul@765 617
            )
paul@765 618
paul@765 619
    def show_datetime_controls(self, formdate, show_start):
paul@765 620
paul@765 621
        """
paul@765 622
        Show datetime details from the current object for the 'formdate',
paul@765 623
        showing start details if 'show_start' is set to a true value. Details
paul@765 624
        will appear as controls for organisers and labels for attendees.
paul@765 625
        """
paul@765 626
paul@765 627
        page = self.page
paul@765 628
paul@867 629
        # Show controls for editing.
paul@765 630
paul@867 631
        page.td(class_="objectvalue dt%s" % (show_start and "start" or "end"))
paul@765 632
paul@867 633
        if show_start:
paul@867 634
            page.div(class_="dt enabled")
paul@867 635
            self.date_controls("dtstart", formdate)
paul@867 636
            page.br()
paul@867 637
            page.label("Specify times", for_="dttimes-enable", class_="time disabled enable")
paul@867 638
            page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable")
paul@867 639
            page.div.close()
paul@765 640
paul@765 641
        else:
paul@886 642
            self.date_controls("dtend", formdate)
paul@867 643
            page.div(class_="dt disabled")
paul@867 644
            page.label("Specify end date", for_="dtend-enable", class_="enable")
paul@867 645
            page.div.close()
paul@867 646
            page.div(class_="dt enabled")
paul@867 647
            page.label("End on same day", for_="dtend-enable", class_="disable")
paul@867 648
            page.div.close()
paul@867 649
paul@867 650
        page.td.close()
paul@765 651
paul@868 652
    def show_recurrence_controls(self, index, period, recurrenceid, show_start):
paul@765 653
paul@765 654
        """
paul@765 655
        Show datetime details from the current object for the recurrence having
paul@765 656
        the given 'index', with the recurrence period described by 'period',
paul@765 657
        indicating a start, end and origin of the period from the event details,
paul@868 658
        employing any 'recurrenceid' for the object to configure the displayed
paul@868 659
        information.
paul@765 660
paul@765 661
        If 'show_start' is set to a true value, the start details will be shown;
paul@765 662
        otherwise, the end details will be shown.
paul@765 663
        """
paul@765 664
paul@765 665
        page = self.page
paul@765 666
        _id = self.element_identifier
paul@765 667
        _name = self.element_name
paul@765 668
paul@845 669
        period = form_period_from_period(period)
paul@765 670
paul@867 671
        # Show controls for editing.
paul@765 672
paul@868 673
        if not period.replaced:
paul@868 674
            page.td(class_="objectvalue dt%s" % (show_start and "start" or "end"))
paul@765 675
paul@765 676
            read_only = period.origin == "RRULE"
paul@765 677
paul@765 678
            if show_start:
paul@765 679
                page.div(class_="dt enabled")
paul@845 680
                self.date_controls(_name("dtstart", "recur", index), period.get_form_start(), index=index, read_only=read_only)
paul@765 681
                if not read_only:
paul@765 682
                    page.br()
paul@765 683
                    page.label("Specify times", for_=_id("dttimes-enable", index), class_="time disabled enable")
paul@765 684
                    page.label("Specify dates only", for_=_id("dttimes-enable", index), class_="time enabled disable")
paul@765 685
                page.div.close()
paul@765 686
paul@765 687
                # Put the origin somewhere.
paul@765 688
paul@845 689
                self.control("recur-origin", "hidden", period.origin or "")
paul@868 690
                self.control("recur-replaced", "hidden", period.replaced and str(index) or "")
paul@765 691
paul@765 692
            else:
paul@845 693
                self.date_controls(_name("dtend", "recur", index), period.get_form_end(), index=index, show_tzid=False, read_only=read_only)
paul@765 694
                if not read_only:
paul@886 695
                    page.div(class_="dt disabled")
paul@886 696
                    page.label("Specify end date", for_=_id("dtend-enable", index), class_="enable")
paul@886 697
                    page.div.close()
paul@886 698
                    page.div(class_="dt enabled")
paul@765 699
                    page.label("End on same day", for_=_id("dtend-enable", index), class_="disable")
paul@886 700
                    page.div.close()
paul@765 701
paul@765 702
            page.td.close()
paul@765 703
paul@765 704
        # Show label as attendee.
paul@765 705
paul@765 706
        else:
paul@868 707
            self.show_recurrence_label(index, period, recurrenceid, show_start)
paul@765 708
paul@868 709
    def show_recurrence_label(self, index, period, recurrenceid, show_start):
paul@765 710
paul@765 711
        """
paul@852 712
        Show datetime details from the current object for the recurrence having
paul@852 713
        the given 'index', for the given recurrence 'period', employing any
paul@868 714
        'recurrenceid' for the object to configure the displayed information.
paul@765 715
paul@765 716
        If 'show_start' is set to a true value, the start details will be shown;
paul@765 717
        otherwise, the end details will be shown.
paul@765 718
        """
paul@765 719
paul@765 720
        page = self.page
paul@852 721
        _name = self.element_name
paul@765 722
paul@845 723
        try:
paul@845 724
            p = event_period_from_period(period)
paul@845 725
        except PeriodError, exc:
paul@845 726
            affected = False
paul@845 727
        else:
paul@845 728
            affected = p.is_affected(recurrenceid)
paul@845 729
paul@845 730
        period = form_period_from_period(period)
paul@765 731
paul@765 732
        css = " ".join([
paul@868 733
            period.replaced and "replaced" or "",
paul@845 734
            affected and "affected" or ""
paul@765 735
            ])
paul@765 736
paul@845 737
        formdate = show_start and period.get_form_start() or period.get_form_end()
paul@765 738
        dt = formdate.as_datetime()
paul@765 739
        if dt:
paul@852 740
            page.td(class_=css)
paul@852 741
            if show_start:
paul@852 742
                self.date_controls(_name("dtstart", "recur", index), period.get_form_start(), index=index, read_only=True)
paul@852 743
                self.control("recur-origin", "hidden", period.origin or "")
paul@868 744
                self.control("recur-replaced", "hidden", period.replaced and str(index) or "")
paul@852 745
            else:
paul@852 746
                self.date_controls(_name("dtend", "recur", index), period.get_form_end(), index=index, show_tzid=False, read_only=True)
paul@852 747
            page.td.close()
paul@765 748
        else:
paul@765 749
            page.td("(Unrecognised date)")
paul@765 750
paul@765 751
    def get_date_control_values(self, name, multiple=False, tzid_name=None):
paul@765 752
paul@765 753
        """
paul@787 754
        Return a form date object representing fields starting with 'name'. If
paul@787 755
        'multiple' is set to a true value, many date objects will be returned
paul@787 756
        corresponding to a collection of datetimes.
paul@787 757
paul@787 758
        If 'tzid_name' is specified, the time zone information will be acquired
paul@787 759
        from fields starting with 'tzid_name' instead of 'name'.
paul@765 760
        """
paul@765 761
paul@765 762
        args = self.env.get_args()
paul@765 763
paul@765 764
        dates = args.get("%s-date" % name, [])
paul@765 765
        hours = args.get("%s-hour" % name, [])
paul@765 766
        minutes = args.get("%s-minute" % name, [])
paul@765 767
        seconds = args.get("%s-second" % name, [])
paul@765 768
        tzids = args.get("%s-tzid" % (tzid_name or name), [])
paul@765 769
paul@765 770
        # Handle absent values by employing None values.
paul@765 771
paul@765 772
        field_values = map(None, dates, hours, minutes, seconds, tzids)
paul@765 773
paul@765 774
        if not field_values and not multiple:
paul@765 775
            all_values = FormDate()
paul@765 776
        else:
paul@765 777
            all_values = []
paul@765 778
            for date, hour, minute, second, tzid in field_values:
paul@765 779
                value = FormDate(date, hour, minute, second, tzid or self.get_tzid())
paul@765 780
paul@765 781
                # Return a single value or append to a collection of all values.
paul@765 782
paul@765 783
                if not multiple:
paul@765 784
                    return value
paul@765 785
                else:
paul@765 786
                    all_values.append(value)
paul@765 787
paul@765 788
        return all_values
paul@765 789
paul@787 790
    def set_date_control_values(self, name, formdates, tzid_name=None):
paul@787 791
paul@787 792
        """
paul@787 793
        Replace form fields starting with 'name' using the values of the given
paul@787 794
        'formdates'.
paul@787 795
paul@787 796
        If 'tzid_name' is specified, the time zone information will be stored in
paul@787 797
        fields starting with 'tzid_name' instead of 'name'.
paul@787 798
        """
paul@787 799
paul@787 800
        args = self.env.get_args()
paul@787 801
paul@787 802
        args["%s-date" % name] = [d.date for d in formdates]
paul@787 803
        args["%s-hour" % name] = [d.hour for d in formdates]
paul@787 804
        args["%s-minute" % name] = [d.minute for d in formdates]
paul@787 805
        args["%s-second" % name] = [d.second for d in formdates]
paul@787 806
        args["%s-tzid" % (tzid_name or name)] = [d.tzid for d in formdates]
paul@787 807
paul@446 808
# vim: tabstop=4 expandtab shiftwidth=4