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