imip-agent

Annotated imiptools/handlers/__init__.py

427:d6466d09eb7e
2015-03-24 Paul Boddie Introduced some support for editing recurrence periods in events, employing common methods to handle datetime controls. Updated the stylesheet to use checkboxes instead of radio buttons to configure period end and time details, so that a collection of controls may be used for a collection of recurrence periods with the controls having the same field name.
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