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