1.1 --- a/imiptools/handlers/__init__.py Sun Mar 22 18:36:34 2015 +0100
1.2 +++ b/imiptools/handlers/__init__.py Sun Mar 22 18:37:13 2015 +0100
1.3 @@ -0,0 +1,488 @@
1.4 +#!/usr/bin/env python
1.5 +
1.6 +"""
1.7 +General handler support for incoming calendar objects.
1.8 +
1.9 +Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>
1.10 +
1.11 +This program is free software; you can redistribute it and/or modify it under
1.12 +the terms of the GNU General Public License as published by the Free Software
1.13 +Foundation; either version 3 of the License, or (at your option) any later
1.14 +version.
1.15 +
1.16 +This program is distributed in the hope that it will be useful, but WITHOUT
1.17 +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
1.18 +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
1.19 +details.
1.20 +
1.21 +You should have received a copy of the GNU General Public License along with
1.22 +this program. If not, see <http://www.gnu.org/licenses/>.
1.23 +"""
1.24 +
1.25 +from datetime import datetime
1.26 +from email.mime.text import MIMEText
1.27 +from imiptools.config import MANAGER_PATH, MANAGER_URL
1.28 +from imiptools.data import Object, \
1.29 + get_address, get_uri, get_value, get_window_end, \
1.30 + is_new_object, uri_dict, uri_item, uri_values
1.31 +from imiptools.dates import format_datetime, get_default_timezone, to_timezone
1.32 +from imiptools.period import can_schedule, remove_period, \
1.33 + remove_additional_periods, remove_affected_period, \
1.34 + update_freebusy
1.35 +from imiptools.profile import Preferences
1.36 +from socket import gethostname
1.37 +import imip_store
1.38 +
1.39 +# References to the Web interface.
1.40 +
1.41 +def get_manager_url():
1.42 + url_base = MANAGER_URL or "http://%s/" % gethostname()
1.43 + return "%s/%s" % (url_base.rstrip("/"), MANAGER_PATH.lstrip("/"))
1.44 +
1.45 +def get_object_url(uid, recurrenceid=None):
1.46 + return "%s/%s%s" % (
1.47 + get_manager_url().rstrip("/"), uid,
1.48 + recurrenceid and "/%s" % recurrenceid or ""
1.49 + )
1.50 +
1.51 +class Handler:
1.52 +
1.53 + "General handler support."
1.54 +
1.55 + def __init__(self, senders=None, recipient=None, messenger=None):
1.56 +
1.57 + """
1.58 + Initialise the handler with the calendar 'obj' and the 'senders' and
1.59 + 'recipient' of the object (if specifically indicated).
1.60 + """
1.61 +
1.62 + self.senders = senders and set(map(get_address, senders))
1.63 + self.recipient = recipient and get_address(recipient)
1.64 + self.messenger = messenger
1.65 +
1.66 + self.results = []
1.67 + self.outgoing_methods = set()
1.68 +
1.69 + self.obj = None
1.70 + self.uid = None
1.71 + self.recurrenceid = None
1.72 + self.sequence = None
1.73 + self.dtstamp = None
1.74 +
1.75 + self.store = imip_store.FileStore()
1.76 +
1.77 + try:
1.78 + self.publisher = imip_store.FilePublisher()
1.79 + except OSError:
1.80 + self.publisher = None
1.81 +
1.82 + def set_object(self, obj):
1.83 + self.obj = obj
1.84 + self.uid = self.obj.get_value("UID")
1.85 + self.recurrenceid = format_datetime(self.obj.get_utc_datetime("RECURRENCE-ID"))
1.86 + self.sequence = self.obj.get_value("SEQUENCE")
1.87 + self.dtstamp = self.obj.get_value("DTSTAMP")
1.88 +
1.89 + def wrap(self, text, link=True):
1.90 +
1.91 + "Wrap any valid message for passing to the recipient."
1.92 +
1.93 + texts = []
1.94 + texts.append(text)
1.95 + if link:
1.96 + texts.append("If your mail program cannot handle this "
1.97 + "message, you may view the details here:\n\n%s" %
1.98 + get_object_url(self.uid, self.recurrenceid))
1.99 +
1.100 + return self.add_result(None, None, MIMEText("\n".join(texts)))
1.101 +
1.102 + # Result registration.
1.103 +
1.104 + def add_result(self, method, outgoing_recipients, part):
1.105 +
1.106 + """
1.107 + Record a result having the given 'method', 'outgoing_recipients' and
1.108 + message part.
1.109 + """
1.110 +
1.111 + if outgoing_recipients:
1.112 + self.outgoing_methods.add(method)
1.113 + self.results.append((outgoing_recipients, part))
1.114 +
1.115 + def get_results(self):
1.116 + return self.results
1.117 +
1.118 + def get_outgoing_methods(self):
1.119 + return self.outgoing_methods
1.120 +
1.121 + # Convenience methods for modifying free/busy collections.
1.122 +
1.123 + def remove_from_freebusy(self, freebusy):
1.124 +
1.125 + "Remove this event from the given 'freebusy' collection."
1.126 +
1.127 + remove_period(freebusy, self.uid, self.recurrenceid)
1.128 +
1.129 + def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None):
1.130 +
1.131 + """
1.132 + Remove from 'freebusy' any original recurrence from parent free/busy
1.133 + details for the current object, if the current object is a specific
1.134 + additional recurrence. Otherwise, remove all additional recurrence
1.135 + information corresponding to 'recurrenceids', or if omitted, all
1.136 + recurrences.
1.137 + """
1.138 +
1.139 + if self.recurrenceid:
1.140 + remove_affected_period(freebusy, self.uid, self.recurrenceid)
1.141 + else:
1.142 + # Remove obsolete recurrence periods.
1.143 +
1.144 + remove_additional_periods(freebusy, self.uid, recurrenceids)
1.145 +
1.146 + # Remove original periods affected by additional recurrences.
1.147 +
1.148 + if recurrenceids:
1.149 + for recurrenceid in recurrenceids:
1.150 + remove_affected_period(freebusy, self.uid, recurrenceid)
1.151 +
1.152 + def _update_freebusy(self, freebusy, periods, recurrenceid, transp=None):
1.153 +
1.154 + """
1.155 + Update the 'freebusy' collection with the given 'periods', indicating an
1.156 + explicit 'recurrenceid' to affect either a recurrence or the parent
1.157 + event.
1.158 + """
1.159 +
1.160 + update_freebusy(freebusy, periods,
1.161 + transp or self.obj.get_value("TRANSP"),
1.162 + self.uid, recurrenceid,
1.163 + self.obj.get_value("SUMMARY"),
1.164 + self.obj.get_value("ORGANIZER"))
1.165 +
1.166 + def update_freebusy(self, freebusy, periods, transp=None):
1.167 +
1.168 + """
1.169 + Update the 'freebusy' collection for this event with the given
1.170 + 'periods'.
1.171 + """
1.172 +
1.173 + self._update_freebusy(freebusy, periods, self.recurrenceid, transp)
1.174 +
1.175 + def update_freebusy_for_participant(self, freebusy, periods, attr, for_organiser=False):
1.176 +
1.177 + """
1.178 + Update the 'freebusy' collection using the given 'periods', subject to
1.179 + the 'attr' provided for the participant, indicating whether this is
1.180 + being generated 'for_organiser' or not.
1.181 + """
1.182 +
1.183 + # Organisers employ a special transparency.
1.184 +
1.185 + if for_organiser or attr.get("PARTSTAT") != "DECLINED":
1.186 + self.update_freebusy(freebusy, periods, transp=(for_organiser and "ORG" or None))
1.187 + else:
1.188 + self.remove_from_freebusy(freebusy)
1.189 +
1.190 + # Convenience methods for updating stored free/busy information.
1.191 +
1.192 + def update_freebusy_from_participant(self, user, participant_item, for_organiser):
1.193 +
1.194 + """
1.195 + For the given 'user', record the free/busy information for the
1.196 + 'participant_item' (a value plus attributes) representing a different
1.197 + identity, thus maintaining a separate record of their free/busy details.
1.198 + """
1.199 +
1.200 + participant, participant_attr = participant_item
1.201 +
1.202 + if participant == user:
1.203 + return
1.204 +
1.205 + freebusy = self.store.get_freebusy_for_other(user, participant)
1.206 + tzid = self.get_tzid(user)
1.207 + window_end = get_window_end(tzid)
1.208 + periods = self.obj.get_periods_for_freebusy(tzid, window_end)
1.209 +
1.210 + # Record in the free/busy details unless a non-participating attendee.
1.211 +
1.212 + self.update_freebusy_for_participant(freebusy, periods, participant_attr,
1.213 + for_organiser and self.is_not_attendee(participant, self.obj))
1.214 +
1.215 + self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(user, self.uid))
1.216 + self.store.set_freebusy_for_other(user, freebusy, participant)
1.217 +
1.218 + def update_freebusy_from_organiser(self, attendee, organiser_item):
1.219 +
1.220 + """
1.221 + For the 'attendee', record free/busy information from the
1.222 + 'organiser_item' (a value plus attributes).
1.223 + """
1.224 +
1.225 + self.update_freebusy_from_participant(attendee, organiser_item, True)
1.226 +
1.227 + def update_freebusy_from_attendees(self, organiser, attendees):
1.228 +
1.229 + "For the 'organiser', record free/busy information from 'attendees'."
1.230 +
1.231 + for attendee_item in attendees.items():
1.232 + self.update_freebusy_from_participant(organiser, attendee_item, False)
1.233 +
1.234 + # Logic, filtering and access to calendar structures and other data.
1.235 +
1.236 + def is_not_attendee(self, identity, obj):
1.237 +
1.238 + "Return whether 'identity' is not an attendee in 'obj'."
1.239 +
1.240 + return identity not in uri_values(obj.get_values("ATTENDEE"))
1.241 +
1.242 + def can_schedule(self, freebusy, periods):
1.243 + return can_schedule(freebusy, periods, self.uid, self.recurrenceid)
1.244 +
1.245 + def filter_by_senders(self, mapping):
1.246 +
1.247 + """
1.248 + Return a list of items from 'mapping' filtered using sender information.
1.249 + """
1.250 +
1.251 + if self.senders:
1.252 +
1.253 + # Get a mapping from senders to identities.
1.254 +
1.255 + identities = self.get_sender_identities(mapping)
1.256 +
1.257 + # Find the senders that are valid.
1.258 +
1.259 + senders = map(get_address, identities)
1.260 + valid = self.senders.intersection(senders)
1.261 +
1.262 + # Return the true identities.
1.263 +
1.264 + return [identities[get_uri(address)] for address in valid]
1.265 + else:
1.266 + return mapping
1.267 +
1.268 + def filter_by_recipient(self, mapping):
1.269 +
1.270 + """
1.271 + Return a list of items from 'mapping' filtered using recipient
1.272 + information.
1.273 + """
1.274 +
1.275 + if self.recipient:
1.276 + addresses = set(map(get_address, mapping))
1.277 + return map(get_uri, addresses.intersection([self.recipient]))
1.278 + else:
1.279 + return mapping
1.280 +
1.281 + def require_organiser(self, from_organiser=True):
1.282 +
1.283 + """
1.284 + Return the organiser for the current object, filtered for the sender or
1.285 + recipient of interest. Return None if no identities are eligible.
1.286 +
1.287 + The organiser identity is normalized.
1.288 + """
1.289 +
1.290 + organiser_item = uri_item(self.obj.get_item("ORGANIZER"))
1.291 +
1.292 + # Only provide details for an organiser who sent/receives the message.
1.293 +
1.294 + organiser_filter_fn = from_organiser and self.filter_by_senders or self.filter_by_recipient
1.295 +
1.296 + if not organiser_filter_fn(dict([organiser_item])):
1.297 + return None
1.298 +
1.299 + return organiser_item
1.300 +
1.301 + def require_attendees(self, from_organiser=True):
1.302 +
1.303 + """
1.304 + Return the attendees for the current object, filtered for the sender or
1.305 + recipient of interest. Return None if no identities are eligible.
1.306 +
1.307 + The attendee identities are normalized.
1.308 + """
1.309 +
1.310 + attendee_map = uri_dict(self.obj.get_value_map("ATTENDEE"))
1.311 +
1.312 + # Only provide details for attendees who sent/receive the message.
1.313 +
1.314 + attendee_filter_fn = from_organiser and self.filter_by_recipient or self.filter_by_senders
1.315 +
1.316 + attendees = {}
1.317 + for attendee in attendee_filter_fn(attendee_map):
1.318 + attendees[attendee] = attendee_map[attendee]
1.319 +
1.320 + return attendees
1.321 +
1.322 + def require_organiser_and_attendees(self, from_organiser=True):
1.323 +
1.324 + """
1.325 + Return the organiser and attendees for the current object, filtered for
1.326 + the recipient of interest. Return None if no identities are eligible.
1.327 +
1.328 + Organiser and attendee identities are normalized.
1.329 + """
1.330 +
1.331 + organiser_item = self.require_organiser(from_organiser)
1.332 + attendees = self.require_attendees(from_organiser)
1.333 +
1.334 + if not attendees or not organiser_item:
1.335 + return None
1.336 +
1.337 + return organiser_item, attendees
1.338 +
1.339 + def get_sender_identities(self, mapping):
1.340 +
1.341 + """
1.342 + Return a mapping from actual senders to the identities for which they
1.343 + have provided data, extracting this information from the given
1.344 + 'mapping'.
1.345 + """
1.346 +
1.347 + senders = {}
1.348 +
1.349 + for value, attr in mapping.items():
1.350 + sent_by = attr.get("SENT-BY")
1.351 + if sent_by:
1.352 + senders[get_uri(sent_by)] = value
1.353 + else:
1.354 + senders[value] = value
1.355 +
1.356 + return senders
1.357 +
1.358 + def _get_object(self, user, uid, recurrenceid):
1.359 +
1.360 + """
1.361 + Return the stored object for the given 'user', 'uid' and 'recurrenceid'.
1.362 + """
1.363 +
1.364 + fragment = self.store.get_event(user, uid, recurrenceid)
1.365 + return fragment and Object(fragment)
1.366 +
1.367 + def get_object(self, user):
1.368 +
1.369 + """
1.370 + Return the stored object to which the current object refers for the
1.371 + given 'user'.
1.372 + """
1.373 +
1.374 + return self._get_object(user, self.uid, self.recurrenceid)
1.375 +
1.376 + def get_parent_object(self, user):
1.377 +
1.378 + """
1.379 + Return the parent object to which the current object refers for the
1.380 + given 'user'.
1.381 + """
1.382 +
1.383 + return self.recurrenceid and self._get_object(user, self.uid, None) or None
1.384 +
1.385 + def have_new_object(self, attendee, obj=None):
1.386 +
1.387 + """
1.388 + Return whether the current object is new to the 'attendee' (or if the
1.389 + given 'obj' is new).
1.390 + """
1.391 +
1.392 + obj = obj or self.get_object(attendee)
1.393 +
1.394 + # If found, compare SEQUENCE and potentially DTSTAMP.
1.395 +
1.396 + if obj:
1.397 + sequence = obj.get_value("SEQUENCE")
1.398 + dtstamp = obj.get_value("DTSTAMP")
1.399 +
1.400 + # If the request refers to an older version of the object, ignore
1.401 + # it.
1.402 +
1.403 + return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp,
1.404 + self.is_partstat_updated(obj))
1.405 +
1.406 + return True
1.407 +
1.408 + def is_partstat_updated(self, obj):
1.409 +
1.410 + """
1.411 + Return whether the participant status has been updated in the current
1.412 + object in comparison to the given 'obj'.
1.413 +
1.414 + NOTE: Some clients like Claws Mail erase time information from DTSTAMP
1.415 + NOTE: and make it invalid. Thus, such attendance information may also be
1.416 + NOTE: incorporated into any new object assessment.
1.417 + """
1.418 +
1.419 + old_attendees = uri_dict(obj.get_value_map("ATTENDEE"))
1.420 + new_attendees = uri_dict(self.obj.get_value_map("ATTENDEE"))
1.421 +
1.422 + for attendee, attr in old_attendees.items():
1.423 + old_partstat = attr.get("PARTSTAT")
1.424 + new_attr = new_attendees.get(attendee)
1.425 + new_partstat = new_attr and new_attr.get("PARTSTAT")
1.426 +
1.427 + if old_partstat == "NEEDS-ACTION" and new_partstat and \
1.428 + new_partstat != old_partstat:
1.429 +
1.430 + return True
1.431 +
1.432 + return False
1.433 +
1.434 + def merge_attendance(self, attendees, identity):
1.435 +
1.436 + """
1.437 + Merge attendance from the current object's 'attendees' into the version
1.438 + stored for the given 'identity'.
1.439 + """
1.440 +
1.441 + obj = self.get_object(identity)
1.442 +
1.443 + if not obj or not self.have_new_object(identity, obj=obj):
1.444 + return False
1.445 +
1.446 + # Get attendee details in a usable form.
1.447 +
1.448 + attendee_map = uri_dict(obj.get_value_map("ATTENDEE"))
1.449 +
1.450 + for attendee, attendee_attr in attendees.items():
1.451 +
1.452 + # Update attendance in the loaded object.
1.453 +
1.454 + attendee_map[attendee] = attendee_attr
1.455 +
1.456 + # Set the new details and store the object.
1.457 +
1.458 + obj["ATTENDEE"] = attendee_map.items()
1.459 +
1.460 + # Set the complete event if not an additional occurrence.
1.461 +
1.462 + event = obj.to_node()
1.463 + recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID"))
1.464 +
1.465 + self.store.set_event(identity, self.uid, self.recurrenceid, event)
1.466 +
1.467 + return True
1.468 +
1.469 + def update_dtstamp(self):
1.470 +
1.471 + "Update the DTSTAMP in the current object."
1.472 +
1.473 + dtstamp = self.obj.get_utc_datetime("DTSTAMP")
1.474 + utcnow = to_timezone(datetime.utcnow(), "UTC")
1.475 + self.obj["DTSTAMP"] = [(format_datetime(dtstamp > utcnow and dtstamp or utcnow), {})]
1.476 +
1.477 + def set_sequence(self, increment=False):
1.478 +
1.479 + "Update the SEQUENCE in the current object."
1.480 +
1.481 + sequence = self.obj.get_value("SEQUENCE") or "0"
1.482 + self.obj["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})]
1.483 +
1.484 + def get_tzid(self, identity):
1.485 +
1.486 + "Return the time regime applicable for the given 'identity'."
1.487 +
1.488 + preferences = Preferences(identity)
1.489 + return preferences.get("TZID") or get_default_timezone()
1.490 +
1.491 +# vim: tabstop=4 expandtab shiftwidth=4