imip-agent

Annotated imiptools/client.py

787:9cf10fe21c3a
2015-09-28 Paul Boddie Separated attendee/recurrence manipulation from presentation, introducing form field dictionary updates from form period/date objects, also simplifying the processing of attendees, removing filtering operations during editing. imipweb-client-simplification
paul@441 1
#!/usr/bin/env python
paul@441 2
paul@441 3
"""
paul@441 4
Common calendar client utilities.
paul@441 5
paul@441 6
Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>
paul@441 7
paul@441 8
This program is free software; you can redistribute it and/or modify it under
paul@441 9
the terms of the GNU General Public License as published by the Free Software
paul@441 10
Foundation; either version 3 of the License, or (at your option) any later
paul@441 11
version.
paul@441 12
paul@441 13
This program is distributed in the hope that it will be useful, but WITHOUT
paul@441 14
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
paul@441 15
FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
paul@441 16
details.
paul@441 17
paul@441 18
You should have received a copy of the GNU General Public License along with
paul@441 19
this program.  If not, see <http://www.gnu.org/licenses/>.
paul@441 20
"""
paul@441 21
paul@740 22
from datetime import datetime, timedelta
paul@749 23
from imiptools import config
paul@606 24
from imiptools.data import Object, get_address, get_uri, get_window_end, \
paul@606 25
                           is_new_object, make_freebusy, to_part, \
paul@604 26
                           uri_dict, uri_items, uri_values
paul@669 27
from imiptools.dates import check_permitted_values, format_datetime, get_default_timezone, \
paul@759 28
                            get_duration, get_time, get_timestamp
paul@606 29
from imiptools.period import can_schedule, remove_period, \
paul@606 30
                             remove_additional_periods, remove_affected_period, \
paul@606 31
                             update_freebusy
paul@443 32
from imiptools.profile import Preferences
paul@604 33
import imip_store
paul@441 34
paul@443 35
class Client:
paul@443 36
paul@443 37
    "Common handler and manager methods."
paul@443 38
paul@467 39
    default_window_size = 100
paul@729 40
    organiser_methods = "ADD", "CANCEL", "DECLINECOUNTER", "PUBLISH", "REQUEST"
paul@467 41
paul@639 42
    def __init__(self, user, messenger=None, store=None, publisher=None, preferences_dir=None):
paul@728 43
paul@728 44
        """
paul@728 45
        Initialise a calendar client with the current 'user', plus any
paul@728 46
        'messenger', 'store' and 'publisher' objects, indicating any specific
paul@728 47
        'preferences_dir'.
paul@728 48
        """
paul@728 49
paul@443 50
        self.user = user
paul@601 51
        self.messenger = messenger
paul@604 52
        self.store = store or imip_store.FileStore()
paul@604 53
paul@604 54
        try:
paul@604 55
            self.publisher = publisher or imip_store.FilePublisher()
paul@604 56
        except OSError:
paul@604 57
            self.publisher = None
paul@604 58
paul@639 59
        self.preferences_dir = preferences_dir
paul@443 60
        self.preferences = None
paul@443 61
paul@730 62
    # Store-related methods.
paul@730 63
paul@730 64
    def acquire_lock(self):
paul@730 65
        self.store.acquire_lock(self.user)
paul@730 66
paul@730 67
    def release_lock(self):
paul@730 68
        self.store.release_lock(self.user)
paul@730 69
paul@730 70
    # Preferences-related methods.
paul@730 71
paul@443 72
    def get_preferences(self):
paul@467 73
        if not self.preferences and self.user:
paul@639 74
            self.preferences = Preferences(self.user, self.preferences_dir)
paul@443 75
        return self.preferences
paul@443 76
paul@443 77
    def get_tzid(self):
paul@443 78
        prefs = self.get_preferences()
paul@467 79
        return prefs and prefs.get("TZID") or get_default_timezone()
paul@443 80
paul@443 81
    def get_window_size(self):
paul@443 82
        prefs = self.get_preferences()
paul@443 83
        try:
paul@467 84
            return prefs and int(prefs.get("window_size")) or self.default_window_size
paul@443 85
        except (TypeError, ValueError):
paul@467 86
            return self.default_window_size
paul@443 87
paul@443 88
    def get_window_end(self):
paul@443 89
        return get_window_end(self.get_tzid(), self.get_window_size())
paul@443 90
paul@667 91
    def is_participating(self):
paul@748 92
paul@748 93
        "Return participation in the calendar system."
paul@748 94
paul@667 95
        prefs = self.get_preferences()
paul@749 96
        return prefs and prefs.get("participating", config.PARTICIPATING_DEFAULT) != "no" or False
paul@667 97
paul@443 98
    def is_sharing(self):
paul@748 99
paul@748 100
        "Return whether free/busy information is being generally shared."
paul@748 101
paul@467 102
        prefs = self.get_preferences()
paul@749 103
        return prefs and prefs.get("freebusy_sharing", config.SHARING_DEFAULT) == "share" or False
paul@443 104
paul@443 105
    def is_bundling(self):
paul@748 106
paul@748 107
        "Return whether free/busy information is being bundled in messages."
paul@748 108
paul@467 109
        prefs = self.get_preferences()
paul@749 110
        return prefs and prefs.get("freebusy_bundling", config.BUNDLING_DEFAULT) == "always" or False
paul@467 111
paul@467 112
    def is_notifying(self):
paul@748 113
paul@748 114
        "Return whether recipients are notified about free/busy payloads."
paul@748 115
paul@467 116
        prefs = self.get_preferences()
paul@749 117
        return prefs and prefs.get("freebusy_messages", config.NOTIFYING_DEFAULT) == "notify" or False
paul@443 118
paul@748 119
    def is_publishing(self):
paul@748 120
paul@748 121
        "Return whether free/busy information is being published as Web resources."
paul@748 122
paul@748 123
        prefs = self.get_preferences()
paul@749 124
        return prefs and prefs.get("freebusy_publishing", config.PUBLISHING_DEFAULT) == "publish" or False
paul@748 125
paul@688 126
    def is_refreshing(self):
paul@748 127
paul@748 128
        "Return whether a recipient supports requests to refresh event details."
paul@748 129
paul@688 130
        prefs = self.get_preferences()
paul@749 131
        return prefs and prefs.get("event_refreshing", config.REFRESHING_DEFAULT) == "always" or False
paul@688 132
paul@734 133
    def allow_add(self):
paul@734 134
        return self.get_add_method_response() in ("add", "refresh")
paul@734 135
paul@734 136
    def get_add_method_response(self):
paul@728 137
        prefs = self.get_preferences()
paul@749 138
        return prefs and prefs.get("add_method_response", config.ADD_RESPONSE_DEFAULT) or "refresh"
paul@734 139
paul@740 140
    def get_offer_period(self):
paul@740 141
paul@759 142
        "Decode a specification in the iCalendar duration format."
paul@740 143
paul@740 144
        prefs = self.get_preferences()
paul@749 145
        duration = prefs and prefs.get("freebusy_offers", config.FREEBUSY_OFFER_DEFAULT)
paul@740 146
paul@759 147
        # NOTE: Should probably report an error somehow if None.
paul@740 148
paul@759 149
        return duration and get_duration(duration) or None
paul@740 150
paul@734 151
    def get_organiser_replacement(self):
paul@734 152
        prefs = self.get_preferences()
paul@749 153
        return prefs and prefs.get("organiser_replacement", config.ORGANISER_REPLACEMENT_DEFAULT) or "attendee"
paul@728 154
paul@668 155
    def have_manager(self):
paul@749 156
        return config.MANAGER_INTERFACE
paul@668 157
paul@669 158
    def get_permitted_values(self):
paul@655 159
paul@655 160
        """
paul@655 161
        Decode a specification of one of the following forms...
paul@655 162
paul@655 163
        <minute values>
paul@655 164
        <hour values>:<minute values>
paul@655 165
        <hour values>:<minute values>:<second values>
paul@655 166
paul@655 167
        ...with each list of values being comma-separated.
paul@655 168
        """
paul@655 169
paul@655 170
        prefs = self.get_preferences()
paul@669 171
        permitted_values = prefs and prefs.get("permitted_times")
paul@669 172
        if permitted_values:
paul@655 173
            try:
paul@655 174
                l = []
paul@669 175
                for component in permitted_values.split(":")[:3]:
paul@655 176
                    if component:
paul@655 177
                        l.append(map(int, component.split(",")))
paul@655 178
                    else:
paul@655 179
                        l.append(None)
paul@655 180
paul@655 181
            # NOTE: Should probably report an error somehow.
paul@655 182
paul@655 183
            except ValueError:
paul@655 184
                return None
paul@655 185
            else:
paul@655 186
                l = (len(l) < 2 and [None] or []) + l + (len(l) < 3 and [None] or [])
paul@655 187
                return l
paul@655 188
        else:
paul@655 189
            return None
paul@655 190
paul@581 191
    # Common operations on calendar data.
paul@581 192
paul@619 193
    def update_attendees(self, obj, attendees, removed):
paul@619 194
paul@619 195
        """
paul@619 196
        Update the attendees in 'obj' with the given 'attendees' and 'removed'
paul@619 197
        attendee lists. A list is returned containing the attendees whose
paul@619 198
        attendance should be cancelled.
paul@619 199
        """
paul@619 200
paul@619 201
        to_cancel = []
paul@619 202
paul@619 203
        existing_attendees = uri_values(obj.get_values("ATTENDEE") or [])
paul@619 204
        added = set(attendees).difference(existing_attendees)
paul@619 205
paul@619 206
        if added or removed:
paul@619 207
            attendees = uri_items(obj.get_items("ATTENDEE") or [])
paul@619 208
            sequence = obj.get_value("SEQUENCE")
paul@619 209
paul@619 210
            if removed:
paul@619 211
                remaining = []
paul@619 212
paul@619 213
                for attendee, attendee_attr in attendees:
paul@619 214
                    if attendee in removed:
paul@619 215
paul@619 216
                        # Without a sequence number, assume that the event has not
paul@619 217
                        # been published and that attendees can be silently removed.
paul@619 218
paul@619 219
                        if sequence is not None:
paul@619 220
                            to_cancel.append((attendee, attendee_attr))
paul@619 221
                    else:
paul@619 222
                        remaining.append((attendee, attendee_attr))
paul@619 223
paul@619 224
                attendees = remaining
paul@619 225
paul@619 226
            if added:
paul@619 227
                for attendee in added:
paul@619 228
                    attendee = attendee.strip()
paul@619 229
                    if attendee:
paul@619 230
                        attendees.append((get_uri(attendee), {"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"}))
paul@619 231
paul@619 232
            obj["ATTENDEE"] = attendees
paul@619 233
paul@619 234
        return to_cancel
paul@619 235
paul@582 236
    def update_participation(self, obj, partstat=None):
paul@581 237
paul@581 238
        """
paul@581 239
        Update the participation in 'obj' of the user with the given 'partstat'.
paul@581 240
        """
paul@581 241
paul@581 242
        attendee_attr = uri_dict(obj.get_value_map("ATTENDEE")).get(self.user)
paul@582 243
        if not attendee_attr:
paul@582 244
            return None
paul@582 245
        if partstat:
paul@582 246
            attendee_attr["PARTSTAT"] = partstat
paul@581 247
        if attendee_attr.has_key("RSVP"):
paul@581 248
            del attendee_attr["RSVP"]
paul@584 249
        self.update_sender(attendee_attr)
paul@581 250
        return attendee_attr
paul@581 251
paul@584 252
    def update_sender(self, attr):
paul@584 253
paul@584 254
        "Update the SENT-BY attribute of the 'attr' sender metadata."
paul@584 255
paul@584 256
        if self.messenger and self.messenger.sender != get_address(self.user):
paul@584 257
            attr["SENT-BY"] = get_uri(self.messenger.sender)
paul@584 258
paul@606 259
    def get_periods(self, obj):
paul@606 260
paul@606 261
        """
paul@606 262
        Return periods for the given 'obj'. Interpretation of periods can depend
paul@606 263
        on the time zone, which is obtained for the current user.
paul@606 264
        """
paul@606 265
paul@606 266
        return obj.get_periods(self.get_tzid(), self.get_window_end())
paul@606 267
paul@606 268
    # Store operations.
paul@606 269
paul@766 270
    def get_stored_object(self, uid, recurrenceid, section=None, username=None):
paul@606 271
paul@606 272
        """
paul@606 273
        Return the stored object for the current user, with the given 'uid' and
paul@766 274
        'recurrenceid' from the given 'section' and for the given 'username' (if
paul@766 275
        specified), or from the standard object collection otherwise.
paul@606 276
        """
paul@606 277
paul@755 278
        if section == "counters":
paul@766 279
            fragment = self.store.get_counter(self.user, username, uid, recurrenceid)
paul@755 280
        else:
paul@755 281
            fragment = self.store.get_event(self.user, uid, recurrenceid)
paul@606 282
        return fragment and Object(fragment)
paul@606 283
paul@604 284
    # Free/busy operations.
paul@604 285
paul@606 286
    def get_freebusy_part(self, freebusy=None):
paul@604 287
paul@604 288
        """
paul@606 289
        Return a message part containing free/busy information for the user,
paul@606 290
        either specified as 'freebusy' or obtained from the store directly.
paul@604 291
        """
paul@604 292
paul@604 293
        if self.is_sharing() and self.is_bundling():
paul@604 294
paul@604 295
            # Invent a unique identifier.
paul@604 296
paul@604 297
            utcnow = get_timestamp()
paul@604 298
            uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user))
paul@604 299
paul@606 300
            freebusy = freebusy or self.store.get_freebusy(self.user)
paul@604 301
paul@604 302
            user_attr = {}
paul@604 303
            self.update_sender(user_attr)
paul@604 304
            return to_part("PUBLISH", [make_freebusy(freebusy, uid, self.user, user_attr)])
paul@604 305
paul@604 306
        return None
paul@604 307
paul@740 308
    def update_freebusy(self, freebusy, periods, transp, uid, recurrenceid, summary, organiser, expires=None):
paul@606 309
paul@606 310
        """
paul@606 311
        Update the 'freebusy' collection with the given 'periods', indicating a
paul@606 312
        'transp' status, explicit 'uid' and 'recurrenceid' to indicate either a
paul@606 313
        recurrence or the parent event. The 'summary' and 'organiser' must also
paul@606 314
        be provided.
paul@740 315
paul@740 316
        An optional 'expires' datetime string can be provided to tag a free/busy
paul@740 317
        offer.
paul@606 318
        """
paul@606 319
paul@740 320
        update_freebusy(freebusy, periods, transp, uid, recurrenceid, summary, organiser, expires)
paul@606 321
paul@601 322
class ClientForObject(Client):
paul@601 323
paul@601 324
    "A client maintaining a specific object."
paul@601 325
paul@639 326
    def __init__(self, obj, user, messenger=None, store=None, publisher=None, preferences_dir=None):
paul@639 327
        Client.__init__(self, user, messenger, store, publisher, preferences_dir)
paul@601 328
        self.set_object(obj)
paul@601 329
paul@601 330
    def set_object(self, obj):
paul@606 331
paul@606 332
        "Set the current object to 'obj', obtaining metadata details."
paul@606 333
paul@601 334
        self.obj = obj
paul@601 335
        self.uid = obj and self.obj.get_uid()
paul@601 336
        self.recurrenceid = obj and self.obj.get_recurrenceid()
paul@601 337
        self.sequence = obj and self.obj.get_value("SEQUENCE")
paul@601 338
        self.dtstamp = obj and self.obj.get_value("DTSTAMP")
paul@601 339
paul@729 340
    def set_identity(self, method):
paul@729 341
paul@729 342
        """
paul@729 343
        Set the current user for the current object in the context of the given
paul@729 344
        'method'. It is usually set when initialising the handler, using the
paul@729 345
        recipient details, but outgoing messages do not reference the recipient
paul@729 346
        in this way.
paul@729 347
        """
paul@729 348
paul@729 349
        pass
paul@729 350
paul@727 351
    def is_usable(self, method=None):
paul@720 352
paul@727 353
        "Return whether the current object is usable with the given 'method'."
paul@720 354
paul@720 355
        return True
paul@720 356
paul@604 357
    # Object update methods.
paul@601 358
paul@676 359
    def update_recurrenceid(self):
paul@676 360
paul@676 361
        """
paul@676 362
        Update the RECURRENCE-ID in the current object, initialising it from
paul@676 363
        DTSTART.
paul@676 364
        """
paul@676 365
paul@680 366
        self.obj["RECURRENCE-ID"] = [self.obj.get_item("DTSTART")]
paul@676 367
        self.recurrenceid = self.obj.get_recurrenceid()
paul@676 368
paul@601 369
    def update_dtstamp(self):
paul@601 370
paul@601 371
        "Update the DTSTAMP in the current object."
paul@601 372
paul@601 373
        dtstamp = self.obj.get_utc_datetime("DTSTAMP")
paul@759 374
        utcnow = get_time()
paul@720 375
        self.dtstamp = format_datetime(dtstamp and dtstamp > utcnow and dtstamp or utcnow)
paul@676 376
        self.obj["DTSTAMP"] = [(self.dtstamp, {})]
paul@601 377
paul@601 378
    def set_sequence(self, increment=False):
paul@601 379
paul@601 380
        "Update the SEQUENCE in the current object."
paul@601 381
paul@601 382
        sequence = self.obj.get_value("SEQUENCE") or "0"
paul@601 383
        self.obj["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})]
paul@601 384
paul@606 385
    def merge_attendance(self, attendees):
paul@606 386
paul@606 387
        """
paul@606 388
        Merge attendance from the current object's 'attendees' into the version
paul@606 389
        stored for the current user.
paul@606 390
        """
paul@606 391
paul@606 392
        obj = self.get_stored_object_version()
paul@606 393
paul@739 394
        if not obj or not self.have_new_object():
paul@606 395
            return False
paul@606 396
paul@606 397
        # Get attendee details in a usable form.
paul@606 398
paul@606 399
        attendee_map = uri_dict(obj.get_value_map("ATTENDEE"))
paul@606 400
paul@606 401
        for attendee, attendee_attr in attendees.items():
paul@606 402
paul@606 403
            # Update attendance in the loaded object.
paul@606 404
paul@606 405
            attendee_map[attendee] = attendee_attr
paul@606 406
paul@606 407
        # Set the new details and store the object.
paul@606 408
paul@606 409
        obj["ATTENDEE"] = attendee_map.items()
paul@606 410
paul@744 411
        # Set a specific recurrence or the complete event if not an additional
paul@744 412
        # occurrence.
paul@606 413
paul@736 414
        self.store.set_event(self.user, self.uid, self.recurrenceid, obj.to_node())
paul@606 415
paul@606 416
        return True
paul@606 417
paul@606 418
    # Object-related tests.
paul@606 419
paul@728 420
    def is_recognised_organiser(self, organiser):
paul@728 421
paul@728 422
        """
paul@728 423
        Return whether the given 'organiser' is recognised from
paul@728 424
        previously-received details. If no stored details exist, True is
paul@728 425
        returned.
paul@728 426
        """
paul@728 427
paul@728 428
        obj = self.get_stored_object_version()
paul@728 429
        if obj:
paul@728 430
            stored_organiser = get_uri(obj.get_value("ORGANIZER"))
paul@728 431
            return stored_organiser == organiser
paul@728 432
        else:
paul@728 433
            return True
paul@728 434
paul@728 435
    def is_recognised_attendee(self, attendee):
paul@728 436
paul@728 437
        """
paul@728 438
        Return whether the given 'attendee' is recognised from
paul@728 439
        previously-received details. If no stored details exist, True is
paul@728 440
        returned.
paul@728 441
        """
paul@728 442
paul@728 443
        obj = self.get_stored_object_version()
paul@728 444
        if obj:
paul@728 445
            stored_attendees = uri_dict(obj.get_value_map("ATTENDEE"))
paul@728 446
            return stored_attendees.has_key(attendee)
paul@728 447
        else:
paul@728 448
            return True
paul@728 449
paul@694 450
    def get_attendance(self, user=None, obj=None):
paul@606 451
paul@606 452
        """
paul@606 453
        Return the attendance attributes for 'user', or the current user if
paul@606 454
        'user' is not specified.
paul@606 455
        """
paul@606 456
paul@694 457
        attendees = uri_dict((obj or self.obj).get_value_map("ATTENDEE"))
paul@697 458
        return attendees.get(user or self.user)
paul@606 459
paul@694 460
    def is_participating(self, user, as_organiser=False, obj=None):
paul@609 461
paul@609 462
        """
paul@609 463
        Return whether, subject to the 'user' indicating an identity and the
paul@609 464
        'as_organiser' status of that identity, the user concerned is actually
paul@609 465
        participating in the current object event.
paul@609 466
        """
paul@609 467
paul@697 468
        # Use any attendee property information for an organiser, not the
paul@697 469
        # organiser property attributes.
paul@697 470
paul@694 471
        attr = self.get_attendance(user, obj=obj)
paul@697 472
        return as_organiser or attr is not None and not attr or attr and attr.get("PARTSTAT") != "DECLINED"
paul@609 473
paul@609 474
    def get_overriding_transparency(self, user, as_organiser=False):
paul@609 475
paul@609 476
        """
paul@609 477
        Return the overriding transparency to be associated with the free/busy
paul@609 478
        records for an event, subject to the 'user' indicating an identity and
paul@609 479
        the 'as_organiser' status of that identity.
paul@609 480
paul@609 481
        Where an identity is only an organiser and not attending, "ORG" is
paul@609 482
        returned. Otherwise, no overriding transparency is defined and None is
paul@609 483
        returned.
paul@609 484
        """
paul@609 485
paul@609 486
        attr = self.get_attendance(user)
paul@609 487
        return as_organiser and not (attr and attr.get("PARTSTAT")) and "ORG" or None
paul@609 488
paul@606 489
    def can_schedule(self, freebusy, periods):
paul@606 490
paul@606 491
        """
paul@606 492
        Indicate whether within 'freebusy' the given 'periods' can be scheduled.
paul@606 493
        """
paul@606 494
paul@606 495
        return can_schedule(freebusy, periods, self.uid, self.recurrenceid)
paul@606 496
paul@739 497
    def have_new_object(self, strict=True):
paul@606 498
paul@606 499
        """
paul@739 500
        Return whether the current object is new to the current user.
paul@739 501
paul@739 502
        If 'strict' is specified and is a false value, the DTSTAMP test will be
paul@739 503
        ignored. This is useful in handling responses from attendees from
paul@739 504
        clients (like Claws Mail) that erase time information from DTSTAMP and
paul@739 505
        make it invalid.
paul@606 506
        """
paul@606 507
paul@739 508
        obj = self.get_stored_object_version()
paul@606 509
paul@606 510
        # If found, compare SEQUENCE and potentially DTSTAMP.
paul@606 511
paul@606 512
        if obj:
paul@606 513
            sequence = obj.get_value("SEQUENCE")
paul@606 514
            dtstamp = obj.get_value("DTSTAMP")
paul@606 515
paul@606 516
            # If the request refers to an older version of the object, ignore
paul@606 517
            # it.
paul@606 518
paul@682 519
            return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, not strict)
paul@606 520
paul@606 521
        return True
paul@606 522
paul@672 523
    def possibly_recurring_indefinitely(self):
paul@672 524
paul@672 525
        "Return whether the object recurs indefinitely."
paul@672 526
paul@672 527
        # Obtain the stored object to make sure that recurrence information
paul@672 528
        # is not being ignored. This might happen if a client sends a
paul@672 529
        # cancellation without the complete set of properties, for instance.
paul@672 530
paul@672 531
        return self.obj.possibly_recurring_indefinitely() or \
paul@672 532
               self.get_stored_object_version() and \
paul@672 533
               self.get_stored_object_version().possibly_recurring_indefinitely()
paul@672 534
paul@655 535
    # Constraint application on event periods.
paul@655 536
paul@655 537
    def check_object(self):
paul@655 538
paul@655 539
        "Check the object against any scheduling constraints."
paul@655 540
paul@669 541
        permitted_values = self.get_permitted_values()
paul@669 542
        if not permitted_values:
paul@655 543
            return None
paul@655 544
paul@655 545
        invalid = []
paul@655 546
paul@660 547
        for period in self.obj.get_periods(self.get_tzid()):
paul@655 548
            start = period.get_start()
paul@655 549
            end = period.get_end()
paul@669 550
            start_errors = check_permitted_values(start, permitted_values)
paul@669 551
            end_errors = check_permitted_values(end, permitted_values)
paul@656 552
            if start_errors or end_errors:
paul@656 553
                invalid.append((period.origin, start_errors, end_errors))
paul@655 554
paul@655 555
        return invalid
paul@655 556
paul@660 557
    def correct_object(self):
paul@655 558
paul@660 559
        "Correct the object according to any scheduling constraints."
paul@655 560
paul@669 561
        permitted_values = self.get_permitted_values()
paul@669 562
        return permitted_values and self.obj.correct_object(self.get_tzid(), permitted_values)
paul@655 563
paul@606 564
    # Object retrieval.
paul@606 565
paul@606 566
    def get_stored_object_version(self):
paul@606 567
paul@606 568
        """
paul@606 569
        Return the stored object to which the current object refers for the
paul@606 570
        current user.
paul@606 571
        """
paul@606 572
paul@606 573
        return self.get_stored_object(self.uid, self.recurrenceid)
paul@606 574
paul@704 575
    def get_definitive_object(self, as_organiser):
paul@606 576
paul@606 577
        """
paul@606 578
        Return an object considered definitive for the current transaction,
paul@704 579
        using 'as_organiser' to select the current transaction's object if
paul@704 580
        false, or selecting a stored object if true.
paul@606 581
        """
paul@606 582
paul@704 583
        return not as_organiser and self.obj or self.get_stored_object_version()
paul@606 584
paul@606 585
    def get_parent_object(self):
paul@606 586
paul@606 587
        """
paul@606 588
        Return the parent object to which the current object refers for the
paul@606 589
        current user.
paul@606 590
        """
paul@606 591
paul@606 592
        return self.recurrenceid and self.get_stored_object(self.uid, None) or None
paul@606 593
paul@606 594
    # Convenience methods for modifying free/busy collections.
paul@606 595
paul@606 596
    def get_recurrence_start_point(self, recurrenceid):
paul@606 597
paul@606 598
        "Get 'recurrenceid' in a form suitable for matching free/busy entries."
paul@606 599
paul@627 600
        return self.obj.get_recurrence_start_point(recurrenceid, self.get_tzid())
paul@606 601
paul@606 602
    def remove_from_freebusy(self, freebusy):
paul@606 603
paul@606 604
        "Remove this event from the given 'freebusy' collection."
paul@606 605
paul@606 606
        if not remove_period(freebusy, self.uid, self.recurrenceid) and self.recurrenceid:
paul@606 607
            remove_affected_period(freebusy, self.uid, self.get_recurrence_start_point(self.recurrenceid))
paul@606 608
paul@606 609
    def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None):
paul@606 610
paul@606 611
        """
paul@606 612
        Remove from 'freebusy' any original recurrence from parent free/busy
paul@606 613
        details for the current object, if the current object is a specific
paul@606 614
        additional recurrence. Otherwise, remove all additional recurrence
paul@606 615
        information corresponding to 'recurrenceids', or if omitted, all
paul@606 616
        recurrences.
paul@606 617
        """
paul@606 618
paul@606 619
        if self.recurrenceid:
paul@606 620
            recurrenceid = self.get_recurrence_start_point(self.recurrenceid)
paul@606 621
            remove_affected_period(freebusy, self.uid, recurrenceid)
paul@606 622
        else:
paul@606 623
            # Remove obsolete recurrence periods.
paul@606 624
paul@606 625
            remove_additional_periods(freebusy, self.uid, recurrenceids)
paul@606 626
paul@606 627
            # Remove original periods affected by additional recurrences.
paul@606 628
paul@606 629
            if recurrenceids:
paul@606 630
                for recurrenceid in recurrenceids:
paul@606 631
                    recurrenceid = self.get_recurrence_start_point(recurrenceid)
paul@606 632
                    remove_affected_period(freebusy, self.uid, recurrenceid)
paul@606 633
paul@740 634
    def update_freebusy(self, freebusy, user, as_organiser, offer=False):
paul@606 635
paul@606 636
        """
paul@606 637
        Update the 'freebusy' collection for this event with the periods and
paul@606 638
        transparency associated with the current object, subject to the 'user'
paul@606 639
        identity and the attendance details provided for them, indicating
paul@704 640
        whether the update is being done 'as_organiser' (for the organiser of
paul@676 641
        an event) or not.
paul@740 642
paul@740 643
        If 'offer' is set to a true value, any free/busy updates will be tagged
paul@740 644
        with an expiry time.
paul@606 645
        """
paul@606 646
paul@606 647
        # Obtain the stored object if the current object is not issued by the
paul@606 648
        # organiser. Attendees do not have the opportunity to redefine the
paul@606 649
        # periods.
paul@606 650
paul@704 651
        obj = self.get_definitive_object(as_organiser)
paul@606 652
        if not obj:
paul@606 653
            return
paul@606 654
paul@606 655
        # Obtain the affected periods.
paul@606 656
paul@606 657
        periods = self.get_periods(obj)
paul@606 658
paul@606 659
        # Define an overriding transparency, the indicated event transparency,
paul@606 660
        # or the default transparency for the free/busy entry.
paul@606 661
paul@704 662
        transp = self.get_overriding_transparency(user, as_organiser) or \
paul@606 663
                 obj.get_value("TRANSP") or \
paul@606 664
                 "OPAQUE"
paul@606 665
paul@740 666
        # Calculate any expiry time. If no offer period is defined, do not
paul@740 667
        # record the offer periods.
paul@740 668
paul@740 669
        if offer:
paul@740 670
            offer_period = self.get_offer_period()
paul@740 671
            if offer_period:
paul@759 672
                expires = get_timestamp(offer_period)
paul@740 673
            else:
paul@740 674
                return
paul@740 675
        else:
paul@740 676
            expires = None
paul@740 677
paul@606 678
        # Perform the low-level update.
paul@606 679
paul@606 680
        Client.update_freebusy(self, freebusy, periods, transp,
paul@606 681
            self.uid, self.recurrenceid,
paul@606 682
            obj.get_value("SUMMARY"),
paul@740 683
            obj.get_value("ORGANIZER"),
paul@740 684
            expires)
paul@606 685
paul@606 686
    def update_freebusy_for_participant(self, freebusy, user, for_organiser=False,
paul@740 687
                                        updating_other=False, offer=False):
paul@606 688
paul@606 689
        """
paul@695 690
        Update the 'freebusy' collection for the given 'user', indicating
paul@695 691
        whether the update is 'for_organiser' (being done for the organiser of
paul@695 692
        an event) or not, and whether it is 'updating_other' (meaning another
paul@695 693
        user's details).
paul@740 694
paul@740 695
        If 'offer' is set to a true value, any free/busy updates will be tagged
paul@740 696
        with an expiry time.
paul@606 697
        """
paul@606 698
paul@606 699
        # Record in the free/busy details unless a non-participating attendee.
paul@697 700
        # Remove periods for non-participating attendees.
paul@606 701
paul@744 702
        if offer or self.is_participating(user, for_organiser and not updating_other):
paul@704 703
            self.update_freebusy(freebusy, user,
paul@704 704
                for_organiser and not updating_other or
paul@740 705
                not for_organiser and updating_other,
paul@740 706
                offer
paul@704 707
                )
paul@606 708
        else:
paul@606 709
            self.remove_from_freebusy(freebusy)
paul@606 710
paul@697 711
    def remove_freebusy_for_participant(self, freebusy, user, for_organiser=False,
paul@697 712
                                        updating_other=False):
paul@697 713
paul@697 714
        """
paul@697 715
        Remove details from the 'freebusy' collection for the given 'user',
paul@697 716
        indicating whether the modification is 'for_organiser' (being done for
paul@697 717
        the organiser of an event) or not, and whether it is 'updating_other'
paul@697 718
        (meaning another user's details).
paul@697 719
        """
paul@697 720
paul@697 721
        # Remove from the free/busy details if a specified attendee.
paul@697 722
paul@697 723
        if self.is_participating(user, for_organiser and not updating_other):
paul@697 724
            self.remove_from_freebusy(freebusy)
paul@697 725
paul@606 726
    # Convenience methods for updating stored free/busy information received
paul@606 727
    # from other users.
paul@606 728
paul@697 729
    def update_freebusy_from_participant(self, user, for_organiser, fn=None):
paul@606 730
paul@606 731
        """
paul@606 732
        For the current user, record the free/busy information for another
paul@606 733
        'user', indicating whether the update is 'for_organiser' or not, thus
paul@606 734
        maintaining a separate record of their free/busy details.
paul@606 735
        """
paul@606 736
paul@697 737
        fn = fn or self.update_freebusy_for_participant
paul@697 738
paul@606 739
        # A user does not store free/busy information for themself as another
paul@606 740
        # party.
paul@606 741
paul@606 742
        if user == self.user:
paul@606 743
            return
paul@606 744
paul@730 745
        self.acquire_lock()
paul@702 746
        try:
paul@730 747
            freebusy = self.store.get_freebusy_for_other(self.user, user)
paul@702 748
            fn(freebusy, user, for_organiser, True)
paul@702 749
paul@702 750
            # Tidy up any obsolete recurrences.
paul@606 751
paul@702 752
            self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid))
paul@730 753
            self.store.set_freebusy_for_other(self.user, freebusy, user)
paul@606 754
paul@702 755
        finally:
paul@730 756
            self.release_lock()
paul@606 757
paul@606 758
    def update_freebusy_from_organiser(self, organiser):
paul@606 759
paul@606 760
        "For the current user, record free/busy information from 'organiser'."
paul@606 761
paul@606 762
        self.update_freebusy_from_participant(organiser, True)
paul@606 763
paul@606 764
    def update_freebusy_from_attendees(self, attendees):
paul@606 765
paul@606 766
        "For the current user, record free/busy information from 'attendees'."
paul@606 767
paul@606 768
        for attendee in attendees.keys():
paul@606 769
            self.update_freebusy_from_participant(attendee, False)
paul@606 770
paul@697 771
    def remove_freebusy_from_organiser(self, organiser):
paul@697 772
paul@697 773
        "For the current user, remove free/busy information from 'organiser'."
paul@697 774
paul@697 775
        self.update_freebusy_from_participant(organiser, True, self.remove_freebusy_for_participant)
paul@697 776
paul@697 777
    def remove_freebusy_from_attendees(self, attendees):
paul@697 778
paul@697 779
        "For the current user, remove free/busy information from 'attendees'."
paul@697 780
paul@697 781
        for attendee in attendees.keys():
paul@697 782
            self.update_freebusy_from_participant(attendee, False, self.remove_freebusy_for_participant)
paul@697 783
paul@756 784
    # Convenience methods for updating free/busy details at the event level.
paul@756 785
paul@756 786
    def update_event_in_freebusy(self, for_organiser=True):
paul@756 787
paul@756 788
        """
paul@756 789
        Update free/busy information when handling an object, doing so for the
paul@756 790
        organiser of an event if 'for_organiser' is set to a true value.
paul@756 791
        """
paul@756 792
paul@756 793
        freebusy = self.store.get_freebusy(self.user)
paul@756 794
paul@756 795
        # Obtain the attendance attributes for this user, if available.
paul@756 796
paul@756 797
        self.update_freebusy_for_participant(freebusy, self.user, for_organiser)
paul@756 798
paul@756 799
        # Remove original recurrence details replaced by additional
paul@756 800
        # recurrences, as well as obsolete additional recurrences.
paul@756 801
paul@756 802
        self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid))
paul@756 803
        self.store.set_freebusy(self.user, freebusy)
paul@756 804
paul@756 805
        if self.publisher and self.is_sharing() and self.is_publishing():
paul@756 806
            self.publisher.set_freebusy(self.user, freebusy)
paul@756 807
paul@756 808
        # Update free/busy provider information if the event may recur
paul@756 809
        # indefinitely.
paul@756 810
paul@756 811
        if self.possibly_recurring_indefinitely():
paul@756 812
            self.store.append_freebusy_provider(self.user, self.obj)
paul@756 813
paul@756 814
        return True
paul@756 815
paul@756 816
    def remove_event_from_freebusy(self):
paul@756 817
paul@756 818
        "Remove free/busy information when handling an object."
paul@756 819
paul@756 820
        freebusy = self.store.get_freebusy(self.user)
paul@756 821
paul@756 822
        self.remove_from_freebusy(freebusy)
paul@756 823
        self.remove_freebusy_for_recurrences(freebusy)
paul@756 824
        self.store.set_freebusy(self.user, freebusy)
paul@756 825
paul@756 826
        if self.publisher and self.is_sharing() and self.is_publishing():
paul@756 827
            self.publisher.set_freebusy(self.user, freebusy)
paul@756 828
paul@756 829
        # Update free/busy provider information if the event may recur
paul@756 830
        # indefinitely.
paul@756 831
paul@756 832
        if self.possibly_recurring_indefinitely():
paul@756 833
            self.store.remove_freebusy_provider(self.user, self.obj)
paul@756 834
paul@756 835
    def update_event_in_freebusy_offers(self):
paul@756 836
paul@756 837
        "Update free/busy offers when handling an object."
paul@756 838
paul@756 839
        freebusy = self.store.get_freebusy_offers(self.user)
paul@756 840
paul@756 841
        # Obtain the attendance attributes for this user, if available.
paul@756 842
paul@756 843
        self.update_freebusy_for_participant(freebusy, self.user, offer=True)
paul@756 844
paul@756 845
        # Remove original recurrence details replaced by additional
paul@756 846
        # recurrences, as well as obsolete additional recurrences.
paul@756 847
paul@756 848
        self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid))
paul@756 849
        self.store.set_freebusy_offers(self.user, freebusy)
paul@756 850
paul@756 851
        return True
paul@756 852
paul@756 853
    def remove_event_from_freebusy_offers(self):
paul@756 854
paul@756 855
        "Remove free/busy offers when handling an object."
paul@756 856
paul@756 857
        freebusy = self.store.get_freebusy_offers(self.user)
paul@756 858
paul@756 859
        self.remove_from_freebusy(freebusy)
paul@756 860
        self.remove_freebusy_for_recurrences(freebusy)
paul@756 861
        self.store.set_freebusy_offers(self.user, freebusy)
paul@756 862
paul@756 863
        return True
paul@756 864
paul@441 865
# vim: tabstop=4 expandtab shiftwidth=4