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 206 # Obtain the stored object if the current object is not issued by the 207 # organiser. 208 209 obj = for_organiser and self.obj or self.get_object(user) 210 if not obj: 211 return 212 213 # Obtain the affected periods. 214 215 periods = obj.get_periods_for_freebusy(tzid, window_end) 216 217 # Record in the free/busy details unless a non-participating attendee. 218 219 self.update_freebusy_for_participant(freebusy, periods, participant_attr, 220 for_organiser and self.is_not_attendee(participant, self.obj)) 221 222 self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(user, self.uid)) 223 self.store.set_freebusy_for_other(user, freebusy, participant) 224 225 def update_freebusy_from_organiser(self, attendee, organiser_item): 226 227 """ 228 For the 'attendee', record free/busy information from the 229 'organiser_item' (a value plus attributes). 230 """ 231 232 self.update_freebusy_from_participant(attendee, organiser_item, True) 233 234 def update_freebusy_from_attendees(self, organiser, attendees): 235 236 "For the 'organiser', record free/busy information from 'attendees'." 237 238 for attendee_item in attendees.items(): 239 self.update_freebusy_from_participant(organiser, attendee_item, False) 240 241 # Logic, filtering and access to calendar structures and other data. 242 243 def is_not_attendee(self, identity, obj): 244 245 "Return whether 'identity' is not an attendee in 'obj'." 246 247 return identity not in uri_values(obj.get_values("ATTENDEE")) 248 249 def can_schedule(self, freebusy, periods): 250 return can_schedule(freebusy, periods, self.uid, self.recurrenceid) 251 252 def filter_by_senders(self, mapping): 253 254 """ 255 Return a list of items from 'mapping' filtered using sender information. 256 """ 257 258 if self.senders: 259 260 # Get a mapping from senders to identities. 261 262 identities = self.get_sender_identities(mapping) 263 264 # Find the senders that are valid. 265 266 senders = map(get_address, identities) 267 valid = self.senders.intersection(senders) 268 269 # Return the true identities. 270 271 return [identities[get_uri(address)] for address in valid] 272 else: 273 return mapping 274 275 def filter_by_recipient(self, mapping): 276 277 """ 278 Return a list of items from 'mapping' filtered using recipient 279 information. 280 """ 281 282 if self.recipient: 283 addresses = set(map(get_address, mapping)) 284 return map(get_uri, addresses.intersection([self.recipient])) 285 else: 286 return mapping 287 288 def require_organiser(self, from_organiser=True): 289 290 """ 291 Return the organiser for the current object, filtered for the sender or 292 recipient of interest. Return None if no identities are eligible. 293 294 The organiser identity is normalized. 295 """ 296 297 organiser_item = uri_item(self.obj.get_item("ORGANIZER")) 298 299 # Only provide details for an organiser who sent/receives the message. 300 301 organiser_filter_fn = from_organiser and self.filter_by_senders or self.filter_by_recipient 302 303 if not organiser_filter_fn(dict([organiser_item])): 304 return None 305 306 return organiser_item 307 308 def require_attendees(self, from_organiser=True): 309 310 """ 311 Return the attendees for the current object, filtered for the sender or 312 recipient of interest. Return None if no identities are eligible. 313 314 The attendee identities are normalized. 315 """ 316 317 attendee_map = uri_dict(self.obj.get_value_map("ATTENDEE")) 318 319 # Only provide details for attendees who sent/receive the message. 320 321 attendee_filter_fn = from_organiser and self.filter_by_recipient or self.filter_by_senders 322 323 attendees = {} 324 for attendee in attendee_filter_fn(attendee_map): 325 attendees[attendee] = attendee_map[attendee] 326 327 return attendees 328 329 def require_organiser_and_attendees(self, from_organiser=True): 330 331 """ 332 Return the organiser and attendees for the current object, filtered for 333 the recipient of interest. Return None if no identities are eligible. 334 335 Organiser and attendee identities are normalized. 336 """ 337 338 organiser_item = self.require_organiser(from_organiser) 339 attendees = self.require_attendees(from_organiser) 340 341 if not attendees or not organiser_item: 342 return None 343 344 return organiser_item, attendees 345 346 def get_sender_identities(self, mapping): 347 348 """ 349 Return a mapping from actual senders to the identities for which they 350 have provided data, extracting this information from the given 351 'mapping'. 352 """ 353 354 senders = {} 355 356 for value, attr in mapping.items(): 357 sent_by = attr.get("SENT-BY") 358 if sent_by: 359 senders[get_uri(sent_by)] = value 360 else: 361 senders[value] = value 362 363 return senders 364 365 def _get_object(self, user, uid, recurrenceid): 366 367 """ 368 Return the stored object for the given 'user', 'uid' and 'recurrenceid'. 369 """ 370 371 fragment = self.store.get_event(user, uid, recurrenceid) 372 return fragment and Object(fragment) 373 374 def get_object(self, user): 375 376 """ 377 Return the stored object to which the current object refers for the 378 given 'user'. 379 """ 380 381 return self._get_object(user, self.uid, self.recurrenceid) 382 383 def get_parent_object(self, user): 384 385 """ 386 Return the parent object to which the current object refers for the 387 given 'user'. 388 """ 389 390 return self.recurrenceid and self._get_object(user, self.uid, None) or None 391 392 def have_new_object(self, attendee, obj=None): 393 394 """ 395 Return whether the current object is new to the 'attendee' (or if the 396 given 'obj' is new). 397 """ 398 399 obj = obj or self.get_object(attendee) 400 401 # If found, compare SEQUENCE and potentially DTSTAMP. 402 403 if obj: 404 sequence = obj.get_value("SEQUENCE") 405 dtstamp = obj.get_value("DTSTAMP") 406 407 # If the request refers to an older version of the object, ignore 408 # it. 409 410 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, 411 self.is_partstat_updated(obj)) 412 413 return True 414 415 def is_partstat_updated(self, obj): 416 417 """ 418 Return whether the participant status has been updated in the current 419 object in comparison to the given 'obj'. 420 421 NOTE: Some clients like Claws Mail erase time information from DTSTAMP 422 NOTE: and make it invalid. Thus, such attendance information may also be 423 NOTE: incorporated into any new object assessment. 424 """ 425 426 old_attendees = uri_dict(obj.get_value_map("ATTENDEE")) 427 new_attendees = uri_dict(self.obj.get_value_map("ATTENDEE")) 428 429 for attendee, attr in old_attendees.items(): 430 old_partstat = attr.get("PARTSTAT") 431 new_attr = new_attendees.get(attendee) 432 new_partstat = new_attr and new_attr.get("PARTSTAT") 433 434 if old_partstat == "NEEDS-ACTION" and new_partstat and \ 435 new_partstat != old_partstat: 436 437 return True 438 439 return False 440 441 def merge_attendance(self, attendees, identity): 442 443 """ 444 Merge attendance from the current object's 'attendees' into the version 445 stored for the given 'identity'. 446 """ 447 448 obj = self.get_object(identity) 449 450 if not obj or not self.have_new_object(identity, obj=obj): 451 return False 452 453 # Get attendee details in a usable form. 454 455 attendee_map = uri_dict(obj.get_value_map("ATTENDEE")) 456 457 for attendee, attendee_attr in attendees.items(): 458 459 # Update attendance in the loaded object. 460 461 attendee_map[attendee] = attendee_attr 462 463 # Set the new details and store the object. 464 465 obj["ATTENDEE"] = attendee_map.items() 466 467 # Set the complete event if not an additional occurrence. 468 469 event = obj.to_node() 470 recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) 471 472 self.store.set_event(identity, self.uid, self.recurrenceid, event) 473 474 return True 475 476 def update_dtstamp(self): 477 478 "Update the DTSTAMP in the current object." 479 480 dtstamp = self.obj.get_utc_datetime("DTSTAMP") 481 utcnow = to_timezone(datetime.utcnow(), "UTC") 482 self.obj["DTSTAMP"] = [(format_datetime(dtstamp > utcnow and dtstamp or utcnow), {})] 483 484 def set_sequence(self, increment=False): 485 486 "Update the SEQUENCE in the current object." 487 488 sequence = self.obj.get_value("SEQUENCE") or "0" 489 self.obj["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})] 490 491 def get_tzid(self, identity): 492 493 "Return the time regime applicable for the given 'identity'." 494 495 preferences = Preferences(identity) 496 return preferences.get("TZID") or get_default_timezone() 497 498 # vim: tabstop=4 expandtab shiftwidth=4