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