imip-agent

Annotated imiptools/client.py

886:8a3994e54ea4
2015-10-20 Paul Boddie Permit the selection of a same-day ending while still allowing time adjustments.
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@864 26
                           uri_dict, uri_item, uri_items, uri_parts, uri_values
paul@669 27
from imiptools.dates import check_permitted_values, format_datetime, get_default_timezone, \
paul@809 28
                            get_duration, 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@791 77
    def get_user_attributes(self):
paul@791 78
        prefs = self.get_preferences()
paul@791 79
        return prefs and prefs.get_all(["CN"]) or {}
paul@791 80
paul@443 81
    def get_tzid(self):
paul@443 82
        prefs = self.get_preferences()
paul@467 83
        return prefs and prefs.get("TZID") or get_default_timezone()
paul@443 84
paul@443 85
    def get_window_size(self):
paul@443 86
        prefs = self.get_preferences()
paul@443 87
        try:
paul@467 88
            return prefs and int(prefs.get("window_size")) or self.default_window_size
paul@443 89
        except (TypeError, ValueError):
paul@467 90
            return self.default_window_size
paul@443 91
paul@443 92
    def get_window_end(self):
paul@443 93
        return get_window_end(self.get_tzid(), self.get_window_size())
paul@443 94
paul@667 95
    def is_participating(self):
paul@748 96
paul@748 97
        "Return participation in the calendar system."
paul@748 98
paul@667 99
        prefs = self.get_preferences()
paul@749 100
        return prefs and prefs.get("participating", config.PARTICIPATING_DEFAULT) != "no" or False
paul@667 101
paul@443 102
    def is_sharing(self):
paul@748 103
paul@748 104
        "Return whether free/busy information is being generally shared."
paul@748 105
paul@467 106
        prefs = self.get_preferences()
paul@749 107
        return prefs and prefs.get("freebusy_sharing", config.SHARING_DEFAULT) == "share" or False
paul@443 108
paul@443 109
    def is_bundling(self):
paul@748 110
paul@748 111
        "Return whether free/busy information is being bundled in messages."
paul@748 112
paul@467 113
        prefs = self.get_preferences()
paul@749 114
        return prefs and prefs.get("freebusy_bundling", config.BUNDLING_DEFAULT) == "always" or False
paul@467 115
paul@467 116
    def is_notifying(self):
paul@748 117
paul@748 118
        "Return whether recipients are notified about free/busy payloads."
paul@748 119
paul@467 120
        prefs = self.get_preferences()
paul@749 121
        return prefs and prefs.get("freebusy_messages", config.NOTIFYING_DEFAULT) == "notify" or False
paul@443 122
paul@748 123
    def is_publishing(self):
paul@748 124
paul@748 125
        "Return whether free/busy information is being published as Web resources."
paul@748 126
paul@748 127
        prefs = self.get_preferences()
paul@749 128
        return prefs and prefs.get("freebusy_publishing", config.PUBLISHING_DEFAULT) == "publish" or False
paul@748 129
paul@688 130
    def is_refreshing(self):
paul@748 131
paul@748 132
        "Return whether a recipient supports requests to refresh event details."
paul@748 133
paul@688 134
        prefs = self.get_preferences()
paul@749 135
        return prefs and prefs.get("event_refreshing", config.REFRESHING_DEFAULT) == "always" or False
paul@688 136
paul@734 137
    def allow_add(self):
paul@734 138
        return self.get_add_method_response() in ("add", "refresh")
paul@734 139
paul@734 140
    def get_add_method_response(self):
paul@728 141
        prefs = self.get_preferences()
paul@749 142
        return prefs and prefs.get("add_method_response", config.ADD_RESPONSE_DEFAULT) or "refresh"
paul@734 143
paul@740 144
    def get_offer_period(self):
paul@740 145
paul@759 146
        "Decode a specification in the iCalendar duration format."
paul@740 147
paul@740 148
        prefs = self.get_preferences()
paul@749 149
        duration = prefs and prefs.get("freebusy_offers", config.FREEBUSY_OFFER_DEFAULT)
paul@740 150
paul@759 151
        # NOTE: Should probably report an error somehow if None.
paul@740 152
paul@759 153
        return duration and get_duration(duration) or None
paul@740 154
paul@734 155
    def get_organiser_replacement(self):
paul@734 156
        prefs = self.get_preferences()
paul@749 157
        return prefs and prefs.get("organiser_replacement", config.ORGANISER_REPLACEMENT_DEFAULT) or "attendee"
paul@728 158
paul@668 159
    def have_manager(self):
paul@749 160
        return config.MANAGER_INTERFACE
paul@668 161
paul@669 162
    def get_permitted_values(self):
paul@655 163
paul@655 164
        """
paul@655 165
        Decode a specification of one of the following forms...
paul@655 166
paul@655 167
        <minute values>
paul@655 168
        <hour values>:<minute values>
paul@655 169
        <hour values>:<minute values>:<second values>
paul@655 170
paul@655 171
        ...with each list of values being comma-separated.
paul@655 172
        """
paul@655 173
paul@655 174
        prefs = self.get_preferences()
paul@669 175
        permitted_values = prefs and prefs.get("permitted_times")
paul@669 176
        if permitted_values:
paul@655 177
            try:
paul@655 178
                l = []
paul@669 179
                for component in permitted_values.split(":")[:3]:
paul@655 180
                    if component:
paul@655 181
                        l.append(map(int, component.split(",")))
paul@655 182
                    else:
paul@655 183
                        l.append(None)
paul@655 184
paul@655 185
            # NOTE: Should probably report an error somehow.
paul@655 186
paul@655 187
            except ValueError:
paul@655 188
                return None
paul@655 189
            else:
paul@655 190
                l = (len(l) < 2 and [None] or []) + l + (len(l) < 3 and [None] or [])
paul@655 191
                return l
paul@655 192
        else:
paul@655 193
            return None
paul@655 194
paul@581 195
    # Common operations on calendar data.
paul@581 196
paul@851 197
    def update_senders(self, obj=None):
paul@851 198
paul@851 199
        """
paul@851 200
        Update sender details in 'obj', or the current object if not indicated,
paul@851 201
        removing SENT-BY attributes for attendees other than the current user if
paul@851 202
        those attributes give the URI of the calendar system.
paul@851 203
        """
paul@851 204
paul@851 205
        obj = obj or self.obj
paul@851 206
        calendar_uri = get_uri(self.messenger.sender)
paul@851 207
        for attendee, attendee_attr in uri_items(obj.get_items("ATTENDEE")):
paul@851 208
            if attendee != self.user:
paul@851 209
                if attendee_attr.get("SENT-BY") == calendar_uri:
paul@851 210
                    del attendee_attr["SENT-BY"]
paul@851 211
            else:
paul@851 212
                attendee_attr["SENT-BY"] = calendar_uri
paul@851 213
paul@584 214
    def update_sender(self, attr):
paul@584 215
paul@584 216
        "Update the SENT-BY attribute of the 'attr' sender metadata."
paul@584 217
paul@584 218
        if self.messenger and self.messenger.sender != get_address(self.user):
paul@584 219
            attr["SENT-BY"] = get_uri(self.messenger.sender)
paul@584 220
paul@847 221
    def get_sending_attendee(self):
paul@847 222
paul@847 223
        "Return the attendee who sent the current object."
paul@847 224
paul@851 225
        calendar_uri = get_uri(self.messenger.sender)
paul@847 226
        for attendee, attendee_attr in uri_items(self.obj.get_items("ATTENDEE")):
paul@847 227
            if attendee_attr.get("SENT-BY") == calendar_uri:
paul@847 228
                return get_uri(attendee)
paul@847 229
        return None
paul@847 230
paul@606 231
    def get_periods(self, obj):
paul@606 232
paul@606 233
        """
paul@606 234
        Return periods for the given 'obj'. Interpretation of periods can depend
paul@606 235
        on the time zone, which is obtained for the current user.
paul@606 236
        """
paul@606 237
paul@606 238
        return obj.get_periods(self.get_tzid(), self.get_window_end())
paul@606 239
paul@606 240
    # Store operations.
paul@606 241
paul@766 242
    def get_stored_object(self, uid, recurrenceid, section=None, username=None):
paul@606 243
paul@606 244
        """
paul@606 245
        Return the stored object for the current user, with the given 'uid' and
paul@766 246
        'recurrenceid' from the given 'section' and for the given 'username' (if
paul@766 247
        specified), or from the standard object collection otherwise.
paul@606 248
        """
paul@606 249
paul@755 250
        if section == "counters":
paul@766 251
            fragment = self.store.get_counter(self.user, username, uid, recurrenceid)
paul@755 252
        else:
paul@858 253
            fragment = self.store.get_event(self.user, uid, recurrenceid, section)
paul@606 254
        return fragment and Object(fragment)
paul@606 255
paul@604 256
    # Free/busy operations.
paul@604 257
paul@606 258
    def get_freebusy_part(self, freebusy=None):
paul@604 259
paul@604 260
        """
paul@606 261
        Return a message part containing free/busy information for the user,
paul@606 262
        either specified as 'freebusy' or obtained from the store directly.
paul@604 263
        """
paul@604 264
paul@604 265
        if self.is_sharing() and self.is_bundling():
paul@604 266
paul@604 267
            # Invent a unique identifier.
paul@604 268
paul@604 269
            utcnow = get_timestamp()
paul@604 270
            uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user))
paul@604 271
paul@606 272
            freebusy = freebusy or self.store.get_freebusy(self.user)
paul@604 273
paul@604 274
            user_attr = {}
paul@604 275
            self.update_sender(user_attr)
paul@604 276
            return to_part("PUBLISH", [make_freebusy(freebusy, uid, self.user, user_attr)])
paul@604 277
paul@604 278
        return None
paul@604 279
paul@740 280
    def update_freebusy(self, freebusy, periods, transp, uid, recurrenceid, summary, organiser, expires=None):
paul@606 281
paul@606 282
        """
paul@606 283
        Update the 'freebusy' collection with the given 'periods', indicating a
paul@606 284
        'transp' status, explicit 'uid' and 'recurrenceid' to indicate either a
paul@606 285
        recurrence or the parent event. The 'summary' and 'organiser' must also
paul@606 286
        be provided.
paul@740 287
paul@740 288
        An optional 'expires' datetime string can be provided to tag a free/busy
paul@740 289
        offer.
paul@606 290
        """
paul@606 291
paul@740 292
        update_freebusy(freebusy, periods, transp, uid, recurrenceid, summary, organiser, expires)
paul@606 293
paul@864 294
    # Preparation of messages communicating the state of events.
paul@864 295
paul@864 296
    def get_message_parts(self, obj, method, attendee=None):
paul@864 297
paul@864 298
        """
paul@864 299
        Return a tuple containing a list of methods and a list of message parts,
paul@864 300
        with the parts collectively describing the given object 'obj' and its
paul@864 301
        recurrences, using 'method' as the means of publishing details (with
paul@864 302
        CANCEL being used to retract or remove details).
paul@864 303
paul@864 304
        If 'attendee' is indicated, the attendee's participation will be taken
paul@864 305
        into account when generating the description.
paul@864 306
        """
paul@864 307
paul@864 308
        # Assume that the outcome will be composed of requests and
paul@864 309
        # cancellations. It would not seem completely bizarre to produce
paul@864 310
        # publishing messages if a refresh message was unprovoked.
paul@864 311
paul@864 312
        responses = []
paul@864 313
        methods = set()
paul@864 314
paul@864 315
        # Get the parent event, add SENT-BY details to the organiser.
paul@864 316
paul@864 317
        if not attendee or self.is_participating(attendee, obj=obj):
paul@864 318
            organiser, organiser_attr = uri_item(obj.get_item("ORGANIZER"))
paul@864 319
            self.update_sender(organiser_attr)
paul@864 320
            responses.append(obj.to_part(method))
paul@864 321
            methods.add(method)
paul@864 322
paul@864 323
        # Get recurrences for parent events.
paul@864 324
paul@864 325
        if not self.recurrenceid:
paul@864 326
paul@864 327
            # Collect active and cancelled recurrences.
paul@864 328
paul@864 329
            for rl, section, rmethod in [
paul@864 330
                (self.store.get_active_recurrences(self.user, self.uid), None, method),
paul@864 331
                (self.store.get_cancelled_recurrences(self.user, self.uid), "cancellations", "CANCEL"),
paul@864 332
                ]:
paul@864 333
paul@864 334
                for recurrenceid in rl:
paul@864 335
paul@864 336
                    # Get the recurrence, add SENT-BY details to the organiser.
paul@864 337
paul@864 338
                    obj = self.get_stored_object(self.uid, recurrenceid, section)
paul@864 339
paul@864 340
                    if not attendee or self.is_participating(attendee, obj=obj):
paul@864 341
                        organiser, organiser_attr = uri_item(obj.get_item("ORGANIZER"))
paul@864 342
                        self.update_sender(organiser_attr)
paul@864 343
                        responses.append(obj.to_part(rmethod))
paul@864 344
                        methods.add(rmethod)
paul@864 345
paul@864 346
        return methods, responses
paul@864 347
paul@864 348
    def get_unscheduled_parts(self, periods):
paul@864 349
paul@864 350
        "Return message parts describing unscheduled 'periods'."
paul@864 351
paul@864 352
        unscheduled_parts = []
paul@864 353
paul@864 354
        if periods:
paul@864 355
            obj = self.obj.copy()
paul@864 356
            obj.remove_all(["RRULE", "RDATE", "DTSTART", "DTEND", "DURATION"])
paul@864 357
paul@864 358
            for p in periods:
paul@864 359
                if not p.origin:
paul@864 360
                    continue
paul@864 361
                obj["RECURRENCE-ID"] = obj["DTSTART"] = [(format_datetime(p.get_start()), p.get_start_attr())]
paul@864 362
                obj["DTEND"] = [(format_datetime(p.get_end()), p.get_end_attr())]
paul@864 363
                unscheduled_parts.append(obj.to_part("CANCEL"))
paul@864 364
paul@864 365
        return unscheduled_parts
paul@864 366
paul@601 367
class ClientForObject(Client):
paul@601 368
paul@601 369
    "A client maintaining a specific object."
paul@601 370
paul@639 371
    def __init__(self, obj, user, messenger=None, store=None, publisher=None, preferences_dir=None):
paul@639 372
        Client.__init__(self, user, messenger, store, publisher, preferences_dir)
paul@601 373
        self.set_object(obj)
paul@601 374
paul@601 375
    def set_object(self, obj):
paul@606 376
paul@606 377
        "Set the current object to 'obj', obtaining metadata details."
paul@606 378
paul@601 379
        self.obj = obj
paul@601 380
        self.uid = obj and self.obj.get_uid()
paul@601 381
        self.recurrenceid = obj and self.obj.get_recurrenceid()
paul@601 382
        self.sequence = obj and self.obj.get_value("SEQUENCE")
paul@601 383
        self.dtstamp = obj and self.obj.get_value("DTSTAMP")
paul@601 384
paul@729 385
    def set_identity(self, method):
paul@729 386
paul@729 387
        """
paul@729 388
        Set the current user for the current object in the context of the given
paul@729 389
        'method'. It is usually set when initialising the handler, using the
paul@729 390
        recipient details, but outgoing messages do not reference the recipient
paul@729 391
        in this way.
paul@729 392
        """
paul@729 393
paul@729 394
        pass
paul@729 395
paul@727 396
    def is_usable(self, method=None):
paul@720 397
paul@727 398
        "Return whether the current object is usable with the given 'method'."
paul@720 399
paul@720 400
        return True
paul@720 401
paul@818 402
    def is_organiser(self):
paul@818 403
paul@818 404
        """
paul@818 405
        Return whether the current user is the organiser in the current object.
paul@818 406
        """
paul@818 407
paul@818 408
        return get_uri(self.obj.get_value("ORGANIZER")) == self.user
paul@818 409
paul@604 410
    # Object update methods.
paul@601 411
paul@676 412
    def update_recurrenceid(self):
paul@676 413
paul@676 414
        """
paul@676 415
        Update the RECURRENCE-ID in the current object, initialising it from
paul@676 416
        DTSTART.
paul@676 417
        """
paul@676 418
paul@680 419
        self.obj["RECURRENCE-ID"] = [self.obj.get_item("DTSTART")]
paul@676 420
        self.recurrenceid = self.obj.get_recurrenceid()
paul@676 421
paul@809 422
    def update_dtstamp(self, obj=None):
paul@601 423
paul@809 424
        "Update the DTSTAMP in the current object or any given object 'obj'."
paul@809 425
paul@809 426
        obj = obj or self.obj
paul@809 427
        self.dtstamp = obj.update_dtstamp()
paul@601 428
paul@809 429
    def update_sequence(self, increment=False, obj=None):
paul@601 430
paul@809 431
        "Update the SEQUENCE in the current object or any given object 'obj'."
paul@601 432
paul@809 433
        obj = obj or self.obj
paul@809 434
        obj.update_sequence(increment)
paul@601 435
paul@606 436
    def merge_attendance(self, attendees):
paul@606 437
paul@606 438
        """
paul@606 439
        Merge attendance from the current object's 'attendees' into the version
paul@606 440
        stored for the current user.
paul@606 441
        """
paul@606 442
paul@606 443
        obj = self.get_stored_object_version()
paul@606 444
paul@739 445
        if not obj or not self.have_new_object():
paul@606 446
            return False
paul@606 447
paul@606 448
        # Get attendee details in a usable form.
paul@606 449
paul@606 450
        attendee_map = uri_dict(obj.get_value_map("ATTENDEE"))
paul@606 451
paul@606 452
        for attendee, attendee_attr in attendees.items():
paul@606 453
paul@836 454
            # Update attendance in the loaded object for any recognised
paul@836 455
            # attendees.
paul@606 456
paul@836 457
            if attendee_map.has_key(attendee):
paul@836 458
                attendee_map[attendee] = attendee_attr
paul@606 459
paul@606 460
        # Set the new details and store the object.
paul@606 461
paul@606 462
        obj["ATTENDEE"] = attendee_map.items()
paul@606 463
paul@744 464
        # Set a specific recurrence or the complete event if not an additional
paul@744 465
        # occurrence.
paul@606 466
paul@829 467
        return self.store.set_event(self.user, self.uid, self.recurrenceid, obj.to_node())
paul@606 468
paul@818 469
    def update_attendees(self, attendees, removed):
paul@818 470
paul@818 471
        """
paul@818 472
        Update the attendees in the current object with the given 'attendees'
paul@818 473
        and 'removed' attendee lists.
paul@818 474
paul@818 475
        A tuple is returned containing two items: a list of the attendees whose
paul@818 476
        attendance is being proposed (in a counter-proposal), a list of the
paul@818 477
        attendees whose attendance should be cancelled.
paul@818 478
        """
paul@818 479
paul@818 480
        to_cancel = []
paul@818 481
paul@818 482
        existing_attendees = uri_items(self.obj.get_items("ATTENDEE") or [])
paul@818 483
        existing_attendees_map = dict(existing_attendees)
paul@818 484
paul@818 485
        # Added attendees are those from the supplied collection not already
paul@818 486
        # present in the object.
paul@818 487
paul@818 488
        added = set(uri_values(attendees)).difference([uri for uri, attr in existing_attendees])
paul@818 489
paul@818 490
        # NOTE: When countering, no removals will occur, but additions might.
paul@818 491
paul@818 492
        if added or removed:
paul@818 493
paul@818 494
            # The organiser can remove existing attendees.
paul@818 495
paul@818 496
            if removed and self.is_organiser():
paul@818 497
                remaining = []
paul@818 498
paul@818 499
                for attendee, attendee_attr in existing_attendees:
paul@818 500
                    if attendee in removed:
paul@818 501
paul@818 502
                        # Only when an event has not been published can
paul@818 503
                        # attendees be silently removed.
paul@818 504
paul@818 505
                        if obj.is_shared():
paul@818 506
                            to_cancel.append((attendee, attendee_attr))
paul@818 507
                    else:
paul@818 508
                        remaining.append((attendee, attendee_attr))
paul@818 509
paul@818 510
                existing_attendees = remaining
paul@818 511
paul@818 512
            # Attendees (when countering) must only include the current user and
paul@818 513
            # any added attendees.
paul@818 514
paul@818 515
            elif not self.is_organiser():
paul@818 516
                existing_attendees = []
paul@818 517
paul@818 518
            # Both organisers and attendees (when countering) can add attendees.
paul@818 519
paul@818 520
            if added:
paul@818 521
paul@818 522
                # Obtain a mapping from URIs to name details.
paul@818 523
paul@818 524
                attendee_map = dict([(attendee_uri, cn) for cn, attendee_uri in uri_parts(attendees)])
paul@818 525
paul@818 526
                for attendee in added:
paul@818 527
                    attendee = attendee.strip()
paul@818 528
                    if attendee:
paul@818 529
                        cn = attendee_map.get(attendee)
paul@818 530
                        attendee_attr = {"CN" : cn} or {}
paul@818 531
paul@818 532
                        # Only the organiser can reset the participation attributes.
paul@818 533
paul@818 534
                        if self.is_organiser():
paul@818 535
                            attendee_attr.update({"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"})
paul@818 536
paul@818 537
                        existing_attendees.append((attendee, attendee_attr))
paul@818 538
paul@818 539
            # Attendees (when countering) must only include the current user and
paul@818 540
            # any added attendees.
paul@818 541
paul@818 542
            if not self.is_organiser() and self.user not in existing_attendees:
paul@818 543
                user_attr = self.get_user_attributes()
paul@818 544
                user_attr.update(existing_attendees_map.get(self.user) or {})
paul@818 545
                existing_attendees.append((self.user, user_attr))
paul@818 546
paul@818 547
            self.obj["ATTENDEE"] = existing_attendees
paul@818 548
paul@818 549
        return added, to_cancel
paul@818 550
paul@818 551
    def update_participation(self, partstat=None):
paul@818 552
paul@818 553
        """
paul@818 554
        Update the participation in the current object of the user with the
paul@818 555
        given 'partstat'.
paul@818 556
        """
paul@818 557
paul@818 558
        attendee_attr = uri_dict(self.obj.get_value_map("ATTENDEE")).get(self.user)
paul@818 559
        if not attendee_attr:
paul@818 560
            return None
paul@818 561
        if partstat:
paul@818 562
            attendee_attr["PARTSTAT"] = partstat
paul@818 563
        if attendee_attr.has_key("RSVP"):
paul@818 564
            del attendee_attr["RSVP"]
paul@818 565
        self.update_sender(attendee_attr)
paul@818 566
        return attendee_attr
paul@818 567
paul@606 568
    # Object-related tests.
paul@606 569
paul@728 570
    def is_recognised_organiser(self, organiser):
paul@728 571
paul@728 572
        """
paul@728 573
        Return whether the given 'organiser' is recognised from
paul@728 574
        previously-received details. If no stored details exist, True is
paul@728 575
        returned.
paul@728 576
        """
paul@728 577
paul@728 578
        obj = self.get_stored_object_version()
paul@728 579
        if obj:
paul@728 580
            stored_organiser = get_uri(obj.get_value("ORGANIZER"))
paul@728 581
            return stored_organiser == organiser
paul@728 582
        else:
paul@728 583
            return True
paul@728 584
paul@728 585
    def is_recognised_attendee(self, attendee):
paul@728 586
paul@728 587
        """
paul@728 588
        Return whether the given 'attendee' is recognised from
paul@728 589
        previously-received details. If no stored details exist, True is
paul@728 590
        returned.
paul@728 591
        """
paul@728 592
paul@728 593
        obj = self.get_stored_object_version()
paul@728 594
        if obj:
paul@728 595
            stored_attendees = uri_dict(obj.get_value_map("ATTENDEE"))
paul@728 596
            return stored_attendees.has_key(attendee)
paul@728 597
        else:
paul@728 598
            return True
paul@728 599
paul@694 600
    def get_attendance(self, user=None, obj=None):
paul@606 601
paul@606 602
        """
paul@606 603
        Return the attendance attributes for 'user', or the current user if
paul@606 604
        'user' is not specified.
paul@606 605
        """
paul@606 606
paul@694 607
        attendees = uri_dict((obj or self.obj).get_value_map("ATTENDEE"))
paul@697 608
        return attendees.get(user or self.user)
paul@606 609
paul@694 610
    def is_participating(self, user, as_organiser=False, obj=None):
paul@609 611
paul@609 612
        """
paul@609 613
        Return whether, subject to the 'user' indicating an identity and the
paul@609 614
        'as_organiser' status of that identity, the user concerned is actually
paul@609 615
        participating in the current object event.
paul@609 616
        """
paul@609 617
paul@697 618
        # Use any attendee property information for an organiser, not the
paul@697 619
        # organiser property attributes.
paul@697 620
paul@694 621
        attr = self.get_attendance(user, obj=obj)
paul@697 622
        return as_organiser or attr is not None and not attr or attr and attr.get("PARTSTAT") != "DECLINED"
paul@609 623
paul@609 624
    def get_overriding_transparency(self, user, as_organiser=False):
paul@609 625
paul@609 626
        """
paul@609 627
        Return the overriding transparency to be associated with the free/busy
paul@609 628
        records for an event, subject to the 'user' indicating an identity and
paul@609 629
        the 'as_organiser' status of that identity.
paul@609 630
paul@609 631
        Where an identity is only an organiser and not attending, "ORG" is
paul@609 632
        returned. Otherwise, no overriding transparency is defined and None is
paul@609 633
        returned.
paul@609 634
        """
paul@609 635
paul@609 636
        attr = self.get_attendance(user)
paul@609 637
        return as_organiser and not (attr and attr.get("PARTSTAT")) and "ORG" or None
paul@609 638
paul@606 639
    def can_schedule(self, freebusy, periods):
paul@606 640
paul@606 641
        """
paul@606 642
        Indicate whether within 'freebusy' the given 'periods' can be scheduled.
paul@606 643
        """
paul@606 644
paul@606 645
        return can_schedule(freebusy, periods, self.uid, self.recurrenceid)
paul@606 646
paul@739 647
    def have_new_object(self, strict=True):
paul@606 648
paul@606 649
        """
paul@739 650
        Return whether the current object is new to the current user.
paul@739 651
paul@739 652
        If 'strict' is specified and is a false value, the DTSTAMP test will be
paul@739 653
        ignored. This is useful in handling responses from attendees from
paul@739 654
        clients (like Claws Mail) that erase time information from DTSTAMP and
paul@739 655
        make it invalid.
paul@606 656
        """
paul@606 657
paul@739 658
        obj = self.get_stored_object_version()
paul@606 659
paul@606 660
        # If found, compare SEQUENCE and potentially DTSTAMP.
paul@606 661
paul@606 662
        if obj:
paul@606 663
            sequence = obj.get_value("SEQUENCE")
paul@606 664
            dtstamp = obj.get_value("DTSTAMP")
paul@606 665
paul@606 666
            # If the request refers to an older version of the object, ignore
paul@606 667
            # it.
paul@606 668
paul@682 669
            return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, not strict)
paul@606 670
paul@606 671
        return True
paul@606 672
paul@672 673
    def possibly_recurring_indefinitely(self):
paul@672 674
paul@672 675
        "Return whether the object recurs indefinitely."
paul@672 676
paul@672 677
        # Obtain the stored object to make sure that recurrence information
paul@672 678
        # is not being ignored. This might happen if a client sends a
paul@672 679
        # cancellation without the complete set of properties, for instance.
paul@672 680
paul@672 681
        return self.obj.possibly_recurring_indefinitely() or \
paul@672 682
               self.get_stored_object_version() and \
paul@672 683
               self.get_stored_object_version().possibly_recurring_indefinitely()
paul@672 684
paul@655 685
    # Constraint application on event periods.
paul@655 686
paul@655 687
    def check_object(self):
paul@655 688
paul@655 689
        "Check the object against any scheduling constraints."
paul@655 690
paul@669 691
        permitted_values = self.get_permitted_values()
paul@669 692
        if not permitted_values:
paul@655 693
            return None
paul@655 694
paul@655 695
        invalid = []
paul@655 696
paul@660 697
        for period in self.obj.get_periods(self.get_tzid()):
paul@655 698
            start = period.get_start()
paul@655 699
            end = period.get_end()
paul@669 700
            start_errors = check_permitted_values(start, permitted_values)
paul@669 701
            end_errors = check_permitted_values(end, permitted_values)
paul@656 702
            if start_errors or end_errors:
paul@656 703
                invalid.append((period.origin, start_errors, end_errors))
paul@655 704
paul@655 705
        return invalid
paul@655 706
paul@660 707
    def correct_object(self):
paul@655 708
paul@660 709
        "Correct the object according to any scheduling constraints."
paul@655 710
paul@669 711
        permitted_values = self.get_permitted_values()
paul@669 712
        return permitted_values and self.obj.correct_object(self.get_tzid(), permitted_values)
paul@655 713
paul@606 714
    # Object retrieval.
paul@606 715
paul@606 716
    def get_stored_object_version(self):
paul@606 717
paul@606 718
        """
paul@606 719
        Return the stored object to which the current object refers for the
paul@606 720
        current user.
paul@606 721
        """
paul@606 722
paul@606 723
        return self.get_stored_object(self.uid, self.recurrenceid)
paul@606 724
paul@704 725
    def get_definitive_object(self, as_organiser):
paul@606 726
paul@606 727
        """
paul@606 728
        Return an object considered definitive for the current transaction,
paul@704 729
        using 'as_organiser' to select the current transaction's object if
paul@704 730
        false, or selecting a stored object if true.
paul@606 731
        """
paul@606 732
paul@704 733
        return not as_organiser and self.obj or self.get_stored_object_version()
paul@606 734
paul@606 735
    def get_parent_object(self):
paul@606 736
paul@606 737
        """
paul@606 738
        Return the parent object to which the current object refers for the
paul@606 739
        current user.
paul@606 740
        """
paul@606 741
paul@606 742
        return self.recurrenceid and self.get_stored_object(self.uid, None) or None
paul@606 743
paul@864 744
    def revert_cancellations(self, periods):
paul@864 745
paul@864 746
        """
paul@864 747
        Restore cancelled recurrences corresponding to any of the given
paul@864 748
        'periods'.
paul@864 749
        """
paul@864 750
paul@864 751
        for recurrenceid in self.store.get_cancelled_recurrences(self.user, self.uid):
paul@864 752
            obj = self.get_stored_object(self.uid, recurrenceid, "cancellations")
paul@864 753
            if set(self.get_periods(obj)).intersection(periods):
paul@864 754
                self.store.remove_cancellation(self.user, self.uid, recurrenceid)
paul@864 755
paul@606 756
    # Convenience methods for modifying free/busy collections.
paul@606 757
paul@606 758
    def get_recurrence_start_point(self, recurrenceid):
paul@606 759
paul@606 760
        "Get 'recurrenceid' in a form suitable for matching free/busy entries."
paul@606 761
paul@627 762
        return self.obj.get_recurrence_start_point(recurrenceid, self.get_tzid())
paul@606 763
paul@606 764
    def remove_from_freebusy(self, freebusy):
paul@606 765
paul@606 766
        "Remove this event from the given 'freebusy' collection."
paul@606 767
paul@606 768
        if not remove_period(freebusy, self.uid, self.recurrenceid) and self.recurrenceid:
paul@606 769
            remove_affected_period(freebusy, self.uid, self.get_recurrence_start_point(self.recurrenceid))
paul@606 770
paul@606 771
    def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None):
paul@606 772
paul@606 773
        """
paul@606 774
        Remove from 'freebusy' any original recurrence from parent free/busy
paul@606 775
        details for the current object, if the current object is a specific
paul@606 776
        additional recurrence. Otherwise, remove all additional recurrence
paul@606 777
        information corresponding to 'recurrenceids', or if omitted, all
paul@606 778
        recurrences.
paul@606 779
        """
paul@606 780
paul@606 781
        if self.recurrenceid:
paul@606 782
            recurrenceid = self.get_recurrence_start_point(self.recurrenceid)
paul@606 783
            remove_affected_period(freebusy, self.uid, recurrenceid)
paul@606 784
        else:
paul@606 785
            # Remove obsolete recurrence periods.
paul@606 786
paul@606 787
            remove_additional_periods(freebusy, self.uid, recurrenceids)
paul@606 788
paul@606 789
            # Remove original periods affected by additional recurrences.
paul@606 790
paul@606 791
            if recurrenceids:
paul@606 792
                for recurrenceid in recurrenceids:
paul@606 793
                    recurrenceid = self.get_recurrence_start_point(recurrenceid)
paul@606 794
                    remove_affected_period(freebusy, self.uid, recurrenceid)
paul@606 795
paul@740 796
    def update_freebusy(self, freebusy, user, as_organiser, offer=False):
paul@606 797
paul@606 798
        """
paul@606 799
        Update the 'freebusy' collection for this event with the periods and
paul@606 800
        transparency associated with the current object, subject to the 'user'
paul@606 801
        identity and the attendance details provided for them, indicating
paul@704 802
        whether the update is being done 'as_organiser' (for the organiser of
paul@676 803
        an event) or not.
paul@740 804
paul@740 805
        If 'offer' is set to a true value, any free/busy updates will be tagged
paul@740 806
        with an expiry time.
paul@606 807
        """
paul@606 808
paul@606 809
        # Obtain the stored object if the current object is not issued by the
paul@606 810
        # organiser. Attendees do not have the opportunity to redefine the
paul@606 811
        # periods.
paul@606 812
paul@704 813
        obj = self.get_definitive_object(as_organiser)
paul@606 814
        if not obj:
paul@606 815
            return
paul@606 816
paul@606 817
        # Obtain the affected periods.
paul@606 818
paul@606 819
        periods = self.get_periods(obj)
paul@606 820
paul@606 821
        # Define an overriding transparency, the indicated event transparency,
paul@606 822
        # or the default transparency for the free/busy entry.
paul@606 823
paul@704 824
        transp = self.get_overriding_transparency(user, as_organiser) or \
paul@606 825
                 obj.get_value("TRANSP") or \
paul@606 826
                 "OPAQUE"
paul@606 827
paul@740 828
        # Calculate any expiry time. If no offer period is defined, do not
paul@740 829
        # record the offer periods.
paul@740 830
paul@740 831
        if offer:
paul@740 832
            offer_period = self.get_offer_period()
paul@740 833
            if offer_period:
paul@759 834
                expires = get_timestamp(offer_period)
paul@740 835
            else:
paul@740 836
                return
paul@740 837
        else:
paul@740 838
            expires = None
paul@740 839
paul@606 840
        # Perform the low-level update.
paul@606 841
paul@606 842
        Client.update_freebusy(self, freebusy, periods, transp,
paul@606 843
            self.uid, self.recurrenceid,
paul@606 844
            obj.get_value("SUMMARY"),
paul@740 845
            obj.get_value("ORGANIZER"),
paul@740 846
            expires)
paul@606 847
paul@606 848
    def update_freebusy_for_participant(self, freebusy, user, for_organiser=False,
paul@740 849
                                        updating_other=False, offer=False):
paul@606 850
paul@606 851
        """
paul@695 852
        Update the 'freebusy' collection for the given 'user', indicating
paul@695 853
        whether the update is 'for_organiser' (being done for the organiser of
paul@695 854
        an event) or not, and whether it is 'updating_other' (meaning another
paul@695 855
        user's details).
paul@740 856
paul@740 857
        If 'offer' is set to a true value, any free/busy updates will be tagged
paul@740 858
        with an expiry time.
paul@606 859
        """
paul@606 860
paul@606 861
        # Record in the free/busy details unless a non-participating attendee.
paul@697 862
        # Remove periods for non-participating attendees.
paul@606 863
paul@744 864
        if offer or self.is_participating(user, for_organiser and not updating_other):
paul@704 865
            self.update_freebusy(freebusy, user,
paul@704 866
                for_organiser and not updating_other or
paul@740 867
                not for_organiser and updating_other,
paul@740 868
                offer
paul@704 869
                )
paul@606 870
        else:
paul@606 871
            self.remove_from_freebusy(freebusy)
paul@606 872
paul@697 873
    def remove_freebusy_for_participant(self, freebusy, user, for_organiser=False,
paul@697 874
                                        updating_other=False):
paul@697 875
paul@697 876
        """
paul@697 877
        Remove details from the 'freebusy' collection for the given 'user',
paul@697 878
        indicating whether the modification is 'for_organiser' (being done for
paul@697 879
        the organiser of an event) or not, and whether it is 'updating_other'
paul@697 880
        (meaning another user's details).
paul@697 881
        """
paul@697 882
paul@697 883
        # Remove from the free/busy details if a specified attendee.
paul@697 884
paul@697 885
        if self.is_participating(user, for_organiser and not updating_other):
paul@697 886
            self.remove_from_freebusy(freebusy)
paul@697 887
paul@606 888
    # Convenience methods for updating stored free/busy information received
paul@606 889
    # from other users.
paul@606 890
paul@697 891
    def update_freebusy_from_participant(self, user, for_organiser, fn=None):
paul@606 892
paul@606 893
        """
paul@606 894
        For the current user, record the free/busy information for another
paul@606 895
        'user', indicating whether the update is 'for_organiser' or not, thus
paul@606 896
        maintaining a separate record of their free/busy details.
paul@606 897
        """
paul@606 898
paul@697 899
        fn = fn or self.update_freebusy_for_participant
paul@697 900
paul@606 901
        # A user does not store free/busy information for themself as another
paul@606 902
        # party.
paul@606 903
paul@606 904
        if user == self.user:
paul@606 905
            return
paul@606 906
paul@730 907
        self.acquire_lock()
paul@702 908
        try:
paul@730 909
            freebusy = self.store.get_freebusy_for_other(self.user, user)
paul@702 910
            fn(freebusy, user, for_organiser, True)
paul@702 911
paul@702 912
            # Tidy up any obsolete recurrences.
paul@606 913
paul@702 914
            self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid))
paul@730 915
            self.store.set_freebusy_for_other(self.user, freebusy, user)
paul@606 916
paul@702 917
        finally:
paul@730 918
            self.release_lock()
paul@606 919
paul@606 920
    def update_freebusy_from_organiser(self, organiser):
paul@606 921
paul@606 922
        "For the current user, record free/busy information from 'organiser'."
paul@606 923
paul@606 924
        self.update_freebusy_from_participant(organiser, True)
paul@606 925
paul@606 926
    def update_freebusy_from_attendees(self, attendees):
paul@606 927
paul@606 928
        "For the current user, record free/busy information from 'attendees'."
paul@606 929
paul@836 930
        obj = self.get_stored_object_version()
paul@836 931
paul@836 932
        if not obj or not self.have_new_object():
paul@836 933
            return
paul@836 934
paul@836 935
        # Filter out unrecognised attendees.
paul@836 936
paul@836 937
        attendees = set(attendees).intersection(uri_values(obj.get_values("ATTENDEE")))
paul@836 938
paul@836 939
        for attendee in attendees:
paul@606 940
            self.update_freebusy_from_participant(attendee, False)
paul@606 941
paul@697 942
    def remove_freebusy_from_organiser(self, organiser):
paul@697 943
paul@697 944
        "For the current user, remove free/busy information from 'organiser'."
paul@697 945
paul@697 946
        self.update_freebusy_from_participant(organiser, True, self.remove_freebusy_for_participant)
paul@697 947
paul@697 948
    def remove_freebusy_from_attendees(self, attendees):
paul@697 949
paul@697 950
        "For the current user, remove free/busy information from 'attendees'."
paul@697 951
paul@697 952
        for attendee in attendees.keys():
paul@697 953
            self.update_freebusy_from_participant(attendee, False, self.remove_freebusy_for_participant)
paul@697 954
paul@756 955
    # Convenience methods for updating free/busy details at the event level.
paul@756 956
paul@756 957
    def update_event_in_freebusy(self, for_organiser=True):
paul@756 958
paul@756 959
        """
paul@756 960
        Update free/busy information when handling an object, doing so for the
paul@756 961
        organiser of an event if 'for_organiser' is set to a true value.
paul@756 962
        """
paul@756 963
paul@756 964
        freebusy = self.store.get_freebusy(self.user)
paul@756 965
paul@756 966
        # Obtain the attendance attributes for this user, if available.
paul@756 967
paul@756 968
        self.update_freebusy_for_participant(freebusy, self.user, for_organiser)
paul@756 969
paul@756 970
        # Remove original recurrence details replaced by additional
paul@756 971
        # recurrences, as well as obsolete additional recurrences.
paul@756 972
paul@756 973
        self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid))
paul@756 974
        self.store.set_freebusy(self.user, freebusy)
paul@756 975
paul@756 976
        if self.publisher and self.is_sharing() and self.is_publishing():
paul@756 977
            self.publisher.set_freebusy(self.user, freebusy)
paul@756 978
paul@756 979
        # Update free/busy provider information if the event may recur
paul@756 980
        # indefinitely.
paul@756 981
paul@756 982
        if self.possibly_recurring_indefinitely():
paul@756 983
            self.store.append_freebusy_provider(self.user, self.obj)
paul@756 984
paul@756 985
        return True
paul@756 986
paul@756 987
    def remove_event_from_freebusy(self):
paul@756 988
paul@756 989
        "Remove free/busy information when handling an object."
paul@756 990
paul@756 991
        freebusy = self.store.get_freebusy(self.user)
paul@756 992
paul@756 993
        self.remove_from_freebusy(freebusy)
paul@756 994
        self.remove_freebusy_for_recurrences(freebusy)
paul@756 995
        self.store.set_freebusy(self.user, freebusy)
paul@756 996
paul@756 997
        if self.publisher and self.is_sharing() and self.is_publishing():
paul@756 998
            self.publisher.set_freebusy(self.user, freebusy)
paul@756 999
paul@756 1000
        # Update free/busy provider information if the event may recur
paul@756 1001
        # indefinitely.
paul@756 1002
paul@756 1003
        if self.possibly_recurring_indefinitely():
paul@756 1004
            self.store.remove_freebusy_provider(self.user, self.obj)
paul@756 1005
paul@756 1006
    def update_event_in_freebusy_offers(self):
paul@756 1007
paul@756 1008
        "Update free/busy offers when handling an object."
paul@756 1009
paul@756 1010
        freebusy = self.store.get_freebusy_offers(self.user)
paul@756 1011
paul@756 1012
        # Obtain the attendance attributes for this user, if available.
paul@756 1013
paul@756 1014
        self.update_freebusy_for_participant(freebusy, self.user, offer=True)
paul@756 1015
paul@756 1016
        # Remove original recurrence details replaced by additional
paul@756 1017
        # recurrences, as well as obsolete additional recurrences.
paul@756 1018
paul@756 1019
        self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid))
paul@756 1020
        self.store.set_freebusy_offers(self.user, freebusy)
paul@756 1021
paul@756 1022
        return True
paul@756 1023
paul@756 1024
    def remove_event_from_freebusy_offers(self):
paul@756 1025
paul@756 1026
        "Remove free/busy offers when handling an object."
paul@756 1027
paul@756 1028
        freebusy = self.store.get_freebusy_offers(self.user)
paul@756 1029
paul@756 1030
        self.remove_from_freebusy(freebusy)
paul@756 1031
        self.remove_freebusy_for_recurrences(freebusy)
paul@756 1032
        self.store.set_freebusy_offers(self.user, freebusy)
paul@756 1033
paul@756 1034
        return True
paul@756 1035
paul@809 1036
    # Convenience methods for removing counter-proposals and updating the
paul@809 1037
    # request queue.
paul@809 1038
paul@813 1039
    def remove_request(self):
paul@813 1040
        return self.store.dequeue_request(self.user, self.uid, self.recurrenceid)
paul@813 1041
paul@813 1042
    def remove_event(self):
paul@813 1043
        return self.store.remove_event(self.user, self.uid, self.recurrenceid)
paul@813 1044
paul@809 1045
    def remove_counter(self, attendee):
paul@809 1046
        self.remove_counters([attendee])
paul@809 1047
paul@809 1048
    def remove_counters(self, attendees):
paul@809 1049
        for attendee in attendees:
paul@809 1050
            self.store.remove_counter(self.user, attendee, self.uid, self.recurrenceid)
paul@809 1051
paul@809 1052
        if not self.store.get_counters(self.user, self.uid, self.recurrenceid):
paul@809 1053
            self.store.dequeue_request(self.user, self.uid, self.recurrenceid)
paul@809 1054
paul@441 1055
# vim: tabstop=4 expandtab shiftwidth=4