imip-agent

Annotated imiptools/handlers/__init__.py

421:f658ca7505b2
2015-03-22 Paul Boddie Moved methods around.
paul@418 1
#!/usr/bin/env python
paul@418 2
paul@418 3
"""
paul@418 4
General handler support for incoming calendar objects.
paul@418 5
paul@418 6
Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>
paul@418 7
paul@418 8
This program is free software; you can redistribute it and/or modify it under
paul@418 9
the terms of the GNU General Public License as published by the Free Software
paul@418 10
Foundation; either version 3 of the License, or (at your option) any later
paul@418 11
version.
paul@418 12
paul@418 13
This program is distributed in the hope that it will be useful, but WITHOUT
paul@418 14
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
paul@418 15
FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
paul@418 16
details.
paul@418 17
paul@418 18
You should have received a copy of the GNU General Public License along with
paul@418 19
this program.  If not, see <http://www.gnu.org/licenses/>.
paul@418 20
"""
paul@418 21
paul@418 22
from datetime import datetime
paul@418 23
from email.mime.text import MIMEText
paul@418 24
from imiptools.config import MANAGER_PATH, MANAGER_URL
paul@418 25
from imiptools.data import Object, \
paul@418 26
                           get_address, get_uri, get_value, get_window_end, \
paul@418 27
                           is_new_object, uri_dict, uri_item, uri_values
paul@418 28
from imiptools.dates import format_datetime, get_default_timezone, to_timezone
paul@418 29
from imiptools.period import can_schedule, remove_period, \
paul@418 30
                             remove_additional_periods, remove_affected_period, \
paul@418 31
                             update_freebusy
paul@418 32
from imiptools.profile import Preferences
paul@418 33
from socket import gethostname
paul@418 34
import imip_store
paul@418 35
paul@418 36
# References to the Web interface.
paul@418 37
paul@418 38
def get_manager_url():
paul@418 39
    url_base = MANAGER_URL or "http://%s/" % gethostname()
paul@418 40
    return "%s/%s" % (url_base.rstrip("/"), MANAGER_PATH.lstrip("/"))
paul@418 41
paul@418 42
def get_object_url(uid, recurrenceid=None):
paul@418 43
    return "%s/%s%s" % (
paul@418 44
        get_manager_url().rstrip("/"), uid,
paul@418 45
        recurrenceid and "/%s" % recurrenceid or ""
paul@418 46
        )
paul@418 47
paul@418 48
class Handler:
paul@418 49
paul@418 50
    "General handler support."
paul@418 51
paul@418 52
    def __init__(self, senders=None, recipient=None, messenger=None):
paul@418 53
paul@418 54
        """
paul@418 55
        Initialise the handler with the calendar 'obj' and the 'senders' and
paul@418 56
        'recipient' of the object (if specifically indicated).
paul@418 57
        """
paul@418 58
paul@418 59
        self.senders = senders and set(map(get_address, senders))
paul@418 60
        self.recipient = recipient and get_address(recipient)
paul@418 61
        self.messenger = messenger
paul@418 62
paul@418 63
        self.results = []
paul@418 64
        self.outgoing_methods = set()
paul@418 65
paul@418 66
        self.obj = None
paul@418 67
        self.uid = None
paul@418 68
        self.recurrenceid = None
paul@418 69
        self.sequence = None
paul@418 70
        self.dtstamp = None
paul@418 71
paul@418 72
        self.store = imip_store.FileStore()
paul@418 73
paul@418 74
        try:
paul@418 75
            self.publisher = imip_store.FilePublisher()
paul@418 76
        except OSError:
paul@418 77
            self.publisher = None
paul@418 78
paul@418 79
    def set_object(self, obj):
paul@418 80
        self.obj = obj
paul@418 81
        self.uid = self.obj.get_value("UID")
paul@418 82
        self.recurrenceid = format_datetime(self.obj.get_utc_datetime("RECURRENCE-ID"))
paul@418 83
        self.sequence = self.obj.get_value("SEQUENCE")
paul@418 84
        self.dtstamp = self.obj.get_value("DTSTAMP")
paul@418 85
paul@418 86
    def wrap(self, text, link=True):
paul@418 87
paul@418 88
        "Wrap any valid message for passing to the recipient."
paul@418 89
paul@418 90
        texts = []
paul@418 91
        texts.append(text)
paul@418 92
        if link:
paul@418 93
            texts.append("If your mail program cannot handle this "
paul@418 94
                         "message, you may view the details here:\n\n%s" %
paul@418 95
                         get_object_url(self.uid, self.recurrenceid))
paul@418 96
paul@418 97
        return self.add_result(None, None, MIMEText("\n".join(texts)))
paul@418 98
paul@418 99
    # Result registration.
paul@418 100
paul@418 101
    def add_result(self, method, outgoing_recipients, part):
paul@418 102
paul@418 103
        """
paul@418 104
        Record a result having the given 'method', 'outgoing_recipients' and
paul@418 105
        message part.
paul@418 106
        """
paul@418 107
paul@418 108
        if outgoing_recipients:
paul@418 109
            self.outgoing_methods.add(method)
paul@418 110
        self.results.append((outgoing_recipients, part))
paul@418 111
paul@418 112
    def get_results(self):
paul@418 113
        return self.results
paul@418 114
paul@418 115
    def get_outgoing_methods(self):
paul@418 116
        return self.outgoing_methods
paul@418 117
paul@418 118
    # Convenience methods for modifying free/busy collections.
paul@418 119
paul@418 120
    def remove_from_freebusy(self, freebusy):
paul@418 121
paul@418 122
        "Remove this event from the given 'freebusy' collection."
paul@418 123
paul@418 124
        remove_period(freebusy, self.uid, self.recurrenceid)
paul@418 125
paul@418 126
    def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None):
paul@418 127
paul@418 128
        """
paul@418 129
        Remove from 'freebusy' any original recurrence from parent free/busy
paul@418 130
        details for the current object, if the current object is a specific
paul@418 131
        additional recurrence. Otherwise, remove all additional recurrence
paul@418 132
        information corresponding to 'recurrenceids', or if omitted, all
paul@418 133
        recurrences.
paul@418 134
        """
paul@418 135
paul@418 136
        if self.recurrenceid:
paul@418 137
            remove_affected_period(freebusy, self.uid, self.recurrenceid)
paul@418 138
        else:
paul@418 139
            # Remove obsolete recurrence periods.
paul@418 140
paul@418 141
            remove_additional_periods(freebusy, self.uid, recurrenceids)
paul@418 142
paul@418 143
            # Remove original periods affected by additional recurrences.
paul@418 144
paul@418 145
            if recurrenceids:
paul@418 146
                for recurrenceid in recurrenceids:
paul@418 147
                    remove_affected_period(freebusy, self.uid, recurrenceid)
paul@418 148
paul@418 149
    def _update_freebusy(self, freebusy, periods, recurrenceid, transp=None):
paul@418 150
paul@418 151
        """
paul@418 152
        Update the 'freebusy' collection with the given 'periods', indicating an
paul@418 153
        explicit 'recurrenceid' to affect either a recurrence or the parent
paul@418 154
        event.
paul@418 155
        """
paul@418 156
paul@418 157
        update_freebusy(freebusy, periods,
paul@418 158
            transp or self.obj.get_value("TRANSP"),
paul@418 159
            self.uid, recurrenceid,
paul@418 160
            self.obj.get_value("SUMMARY"),
paul@418 161
            self.obj.get_value("ORGANIZER"))
paul@418 162
paul@418 163
    def update_freebusy(self, freebusy, periods, transp=None):
paul@418 164
paul@418 165
        """
paul@418 166
        Update the 'freebusy' collection for this event with the given
paul@418 167
        'periods'.
paul@418 168
        """
paul@418 169
paul@418 170
        self._update_freebusy(freebusy, periods, self.recurrenceid, transp)
paul@418 171
paul@418 172
    def update_freebusy_for_participant(self, freebusy, periods, attr, for_organiser=False):
paul@418 173
paul@418 174
        """
paul@418 175
        Update the 'freebusy' collection using the given 'periods', subject to
paul@418 176
        the 'attr' provided for the participant, indicating whether this is
paul@418 177
        being generated 'for_organiser' or not.
paul@418 178
        """
paul@418 179
paul@418 180
        # Organisers employ a special transparency.
paul@418 181
paul@418 182
        if for_organiser or attr.get("PARTSTAT") != "DECLINED":
paul@418 183
            self.update_freebusy(freebusy, periods, transp=(for_organiser and "ORG" or None))
paul@418 184
        else:
paul@418 185
            self.remove_from_freebusy(freebusy)
paul@418 186
paul@418 187
    # Convenience methods for updating stored free/busy information.
paul@418 188
paul@418 189
    def update_freebusy_from_participant(self, user, participant_item, for_organiser):
paul@418 190
paul@418 191
        """
paul@418 192
        For the given 'user', record the free/busy information for the
paul@418 193
        'participant_item' (a value plus attributes) representing a different
paul@418 194
        identity, thus maintaining a separate record of their free/busy details.
paul@418 195
        """
paul@418 196
paul@418 197
        participant, participant_attr = participant_item
paul@418 198
paul@418 199
        if participant == user:
paul@418 200
            return
paul@418 201
paul@418 202
        freebusy = self.store.get_freebusy_for_other(user, participant)
paul@418 203
        tzid = self.get_tzid(user)
paul@418 204
        window_end = get_window_end(tzid)
paul@419 205
paul@419 206
        # Obtain the stored object if the current object is not issued by the
paul@419 207
        # organiser.
paul@419 208
paul@419 209
        obj = for_organiser and self.obj or self.get_object(user)
paul@419 210
        if not obj:
paul@419 211
            return
paul@419 212
paul@419 213
        # Obtain the affected periods.
paul@419 214
paul@419 215
        periods = obj.get_periods_for_freebusy(tzid, window_end)
paul@418 216
paul@418 217
        # Record in the free/busy details unless a non-participating attendee.
paul@418 218
paul@418 219
        self.update_freebusy_for_participant(freebusy, periods, participant_attr,
paul@418 220
            for_organiser and self.is_not_attendee(participant, self.obj))
paul@418 221
paul@418 222
        self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(user, self.uid))
paul@418 223
        self.store.set_freebusy_for_other(user, freebusy, participant)
paul@418 224
paul@418 225
    def update_freebusy_from_organiser(self, attendee, organiser_item):
paul@418 226
paul@418 227
        """
paul@418 228
        For the 'attendee', record free/busy information from the
paul@418 229
        'organiser_item' (a value plus attributes).
paul@418 230
        """
paul@418 231
paul@418 232
        self.update_freebusy_from_participant(attendee, organiser_item, True)
paul@418 233
paul@418 234
    def update_freebusy_from_attendees(self, organiser, attendees):
paul@418 235
paul@418 236
        "For the 'organiser', record free/busy information from 'attendees'."
paul@418 237
paul@418 238
        for attendee_item in attendees.items():
paul@418 239
            self.update_freebusy_from_participant(organiser, attendee_item, False)
paul@418 240
paul@418 241
    # Logic, filtering and access to calendar structures and other data.
paul@418 242
paul@418 243
    def is_not_attendee(self, identity, obj):
paul@418 244
paul@418 245
        "Return whether 'identity' is not an attendee in 'obj'."
paul@418 246
paul@418 247
        return identity not in uri_values(obj.get_values("ATTENDEE"))
paul@418 248
paul@418 249
    def can_schedule(self, freebusy, periods):
paul@418 250
        return can_schedule(freebusy, periods, self.uid, self.recurrenceid)
paul@418 251
paul@418 252
    def filter_by_senders(self, mapping):
paul@418 253
paul@418 254
        """
paul@418 255
        Return a list of items from 'mapping' filtered using sender information.
paul@418 256
        """
paul@418 257
paul@418 258
        if self.senders:
paul@418 259
paul@418 260
            # Get a mapping from senders to identities.
paul@418 261
paul@418 262
            identities = self.get_sender_identities(mapping)
paul@418 263
paul@418 264
            # Find the senders that are valid.
paul@418 265
paul@418 266
            senders = map(get_address, identities)
paul@418 267
            valid = self.senders.intersection(senders)
paul@418 268
paul@418 269
            # Return the true identities.
paul@418 270
paul@418 271
            return [identities[get_uri(address)] for address in valid]
paul@418 272
        else:
paul@418 273
            return mapping
paul@418 274
paul@418 275
    def filter_by_recipient(self, mapping):
paul@418 276
paul@418 277
        """
paul@418 278
        Return a list of items from 'mapping' filtered using recipient
paul@418 279
        information.
paul@418 280
        """
paul@418 281
paul@418 282
        if self.recipient:
paul@418 283
            addresses = set(map(get_address, mapping))
paul@418 284
            return map(get_uri, addresses.intersection([self.recipient]))
paul@418 285
        else:
paul@418 286
            return mapping
paul@418 287
paul@418 288
    def require_organiser(self, from_organiser=True):
paul@418 289
paul@418 290
        """
paul@418 291
        Return the organiser for the current object, filtered for the sender or
paul@418 292
        recipient of interest. Return None if no identities are eligible.
paul@418 293
paul@418 294
        The organiser identity is normalized.
paul@418 295
        """
paul@418 296
paul@418 297
        organiser_item = uri_item(self.obj.get_item("ORGANIZER"))
paul@418 298
paul@418 299
        # Only provide details for an organiser who sent/receives the message.
paul@418 300
paul@418 301
        organiser_filter_fn = from_organiser and self.filter_by_senders or self.filter_by_recipient
paul@418 302
paul@418 303
        if not organiser_filter_fn(dict([organiser_item])):
paul@418 304
            return None
paul@418 305
paul@418 306
        return organiser_item
paul@418 307
paul@418 308
    def require_attendees(self, from_organiser=True):
paul@418 309
paul@418 310
        """
paul@418 311
        Return the attendees for the current object, filtered for the sender or
paul@418 312
        recipient of interest. Return None if no identities are eligible.
paul@418 313
paul@418 314
        The attendee identities are normalized.
paul@418 315
        """
paul@418 316
paul@418 317
        attendee_map = uri_dict(self.obj.get_value_map("ATTENDEE"))
paul@418 318
paul@418 319
        # Only provide details for attendees who sent/receive the message.
paul@418 320
paul@418 321
        attendee_filter_fn = from_organiser and self.filter_by_recipient or self.filter_by_senders
paul@418 322
paul@418 323
        attendees = {}
paul@418 324
        for attendee in attendee_filter_fn(attendee_map):
paul@418 325
            attendees[attendee] = attendee_map[attendee]
paul@418 326
paul@418 327
        return attendees
paul@418 328
paul@418 329
    def require_organiser_and_attendees(self, from_organiser=True):
paul@418 330
paul@418 331
        """
paul@418 332
        Return the organiser and attendees for the current object, filtered for
paul@418 333
        the recipient of interest. Return None if no identities are eligible.
paul@418 334
paul@418 335
        Organiser and attendee identities are normalized.
paul@418 336
        """
paul@418 337
paul@418 338
        organiser_item = self.require_organiser(from_organiser)
paul@418 339
        attendees = self.require_attendees(from_organiser)
paul@418 340
paul@418 341
        if not attendees or not organiser_item:
paul@418 342
            return None
paul@418 343
paul@418 344
        return organiser_item, attendees
paul@418 345
paul@418 346
    def get_sender_identities(self, mapping):
paul@418 347
paul@418 348
        """
paul@418 349
        Return a mapping from actual senders to the identities for which they
paul@418 350
        have provided data, extracting this information from the given
paul@418 351
        'mapping'.
paul@418 352
        """
paul@418 353
paul@418 354
        senders = {}
paul@418 355
paul@418 356
        for value, attr in mapping.items():
paul@418 357
            sent_by = attr.get("SENT-BY")
paul@418 358
            if sent_by:
paul@418 359
                senders[get_uri(sent_by)] = value
paul@418 360
            else:
paul@418 361
                senders[value] = value
paul@418 362
paul@418 363
        return senders
paul@418 364
paul@418 365
    def _get_object(self, user, uid, recurrenceid):
paul@418 366
paul@418 367
        """
paul@418 368
        Return the stored object for the given 'user', 'uid' and 'recurrenceid'.
paul@418 369
        """
paul@418 370
paul@418 371
        fragment = self.store.get_event(user, uid, recurrenceid)
paul@418 372
        return fragment and Object(fragment)
paul@418 373
paul@418 374
    def get_object(self, user):
paul@418 375
paul@418 376
        """
paul@418 377
        Return the stored object to which the current object refers for the
paul@418 378
        given 'user'.
paul@418 379
        """
paul@418 380
paul@418 381
        return self._get_object(user, self.uid, self.recurrenceid)
paul@418 382
paul@418 383
    def get_parent_object(self, user):
paul@418 384
paul@418 385
        """
paul@418 386
        Return the parent object to which the current object refers for the
paul@418 387
        given 'user'.
paul@418 388
        """
paul@418 389
paul@418 390
        return self.recurrenceid and self._get_object(user, self.uid, None) or None
paul@418 391
paul@418 392
    def have_new_object(self, attendee, obj=None):
paul@418 393
paul@418 394
        """
paul@418 395
        Return whether the current object is new to the 'attendee' (or if the
paul@418 396
        given 'obj' is new).
paul@418 397
        """
paul@418 398
paul@418 399
        obj = obj or self.get_object(attendee)
paul@418 400
paul@418 401
        # If found, compare SEQUENCE and potentially DTSTAMP.
paul@418 402
paul@418 403
        if obj:
paul@418 404
            sequence = obj.get_value("SEQUENCE")
paul@418 405
            dtstamp = obj.get_value("DTSTAMP")
paul@418 406
paul@418 407
            # If the request refers to an older version of the object, ignore
paul@418 408
            # it.
paul@418 409
paul@418 410
            return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp,
paul@418 411
                self.is_partstat_updated(obj))
paul@418 412
paul@418 413
        return True
paul@418 414
paul@418 415
    def is_partstat_updated(self, obj):
paul@418 416
paul@418 417
        """
paul@418 418
        Return whether the participant status has been updated in the current
paul@418 419
        object in comparison to the given 'obj'.
paul@418 420
paul@418 421
        NOTE: Some clients like Claws Mail erase time information from DTSTAMP
paul@418 422
        NOTE: and make it invalid. Thus, such attendance information may also be
paul@418 423
        NOTE: incorporated into any new object assessment.
paul@418 424
        """
paul@418 425
paul@418 426
        old_attendees = uri_dict(obj.get_value_map("ATTENDEE"))
paul@418 427
        new_attendees = uri_dict(self.obj.get_value_map("ATTENDEE"))
paul@418 428
paul@418 429
        for attendee, attr in old_attendees.items():
paul@418 430
            old_partstat = attr.get("PARTSTAT")
paul@418 431
            new_attr = new_attendees.get(attendee)
paul@418 432
            new_partstat = new_attr and new_attr.get("PARTSTAT")
paul@418 433
paul@418 434
            if old_partstat == "NEEDS-ACTION" and new_partstat and \
paul@418 435
               new_partstat != old_partstat:
paul@418 436
paul@418 437
                return True
paul@418 438
paul@418 439
        return False
paul@418 440
paul@418 441
    def merge_attendance(self, attendees, identity):
paul@418 442
paul@418 443
        """
paul@418 444
        Merge attendance from the current object's 'attendees' into the version
paul@418 445
        stored for the given 'identity'.
paul@418 446
        """
paul@418 447
paul@418 448
        obj = self.get_object(identity)
paul@418 449
paul@418 450
        if not obj or not self.have_new_object(identity, obj=obj):
paul@418 451
            return False
paul@418 452
paul@418 453
        # Get attendee details in a usable form.
paul@418 454
paul@418 455
        attendee_map = uri_dict(obj.get_value_map("ATTENDEE"))
paul@418 456
paul@418 457
        for attendee, attendee_attr in attendees.items():
paul@418 458
paul@418 459
            # Update attendance in the loaded object.
paul@418 460
paul@418 461
            attendee_map[attendee] = attendee_attr
paul@418 462
paul@418 463
        # Set the new details and store the object.
paul@418 464
paul@418 465
        obj["ATTENDEE"] = attendee_map.items()
paul@418 466
paul@418 467
        # Set the complete event if not an additional occurrence.
paul@418 468
paul@418 469
        event = obj.to_node()
paul@418 470
        recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID"))
paul@418 471
paul@418 472
        self.store.set_event(identity, self.uid, self.recurrenceid, event)
paul@418 473
paul@418 474
        return True
paul@418 475
paul@418 476
    def update_dtstamp(self):
paul@418 477
paul@418 478
        "Update the DTSTAMP in the current object."
paul@418 479
paul@418 480
        dtstamp = self.obj.get_utc_datetime("DTSTAMP")
paul@418 481
        utcnow = to_timezone(datetime.utcnow(), "UTC")
paul@418 482
        self.obj["DTSTAMP"] = [(format_datetime(dtstamp > utcnow and dtstamp or utcnow), {})]
paul@418 483
paul@418 484
    def set_sequence(self, increment=False):
paul@418 485
paul@418 486
        "Update the SEQUENCE in the current object."
paul@418 487
paul@418 488
        sequence = self.obj.get_value("SEQUENCE") or "0"
paul@418 489
        self.obj["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})]
paul@418 490
paul@418 491
    def get_tzid(self, identity):
paul@418 492
paul@418 493
        "Return the time regime applicable for the given 'identity'."
paul@418 494
paul@418 495
        preferences = Preferences(identity)
paul@418 496
        return preferences.get("TZID") or get_default_timezone()
paul@418 497
paul@418 498
# vim: tabstop=4 expandtab shiftwidth=4