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