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_recurrence_start, 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 to_recurrence_start(self, recurrenceid): 124 125 "Get 'recurrenceid' in a form suitable for matching free/busy entries." 126 127 return to_recurrence_start(recurrenceid, self.get_tzid()) 128 129 def remove_from_freebusy(self, freebusy): 130 131 "Remove this event from the given 'freebusy' collection." 132 133 remove_period(freebusy, self.uid, self.recurrenceid) 134 135 def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None): 136 137 """ 138 Remove from 'freebusy' any original recurrence from parent free/busy 139 details for the current object, if the current object is a specific 140 additional recurrence. Otherwise, remove all additional recurrence 141 information corresponding to 'recurrenceids', or if omitted, all 142 recurrences. 143 """ 144 145 if self.recurrenceid: 146 recurrenceid = self.to_recurrence_start(self.recurrenceid) 147 remove_affected_period(freebusy, self.uid, recurrenceid) 148 else: 149 # Remove obsolete recurrence periods. 150 151 remove_additional_periods(freebusy, self.uid, recurrenceids) 152 153 # Remove original periods affected by additional recurrences. 154 155 if recurrenceids: 156 for recurrenceid in recurrenceids: 157 recurrenceid = self.to_recurrence_start(recurrenceid) 158 remove_affected_period(freebusy, self.uid, recurrenceid) 159 160 def _update_freebusy(self, freebusy, periods, recurrenceid, transp=None): 161 162 """ 163 Update the 'freebusy' collection with the given 'periods', indicating an 164 explicit 'recurrenceid' to affect either a recurrence or the parent 165 event. 166 """ 167 168 update_freebusy(freebusy, periods, 169 transp or self.obj.get_value("TRANSP"), 170 self.uid, recurrenceid, 171 self.obj.get_value("SUMMARY"), 172 self.obj.get_value("ORGANIZER")) 173 174 def update_freebusy(self, freebusy, periods, transp=None): 175 176 """ 177 Update the 'freebusy' collection for this event with the given 178 'periods'. 179 """ 180 181 self._update_freebusy(freebusy, periods, self.recurrenceid, transp) 182 183 def update_freebusy_for_participant(self, freebusy, periods, attr, for_organiser=False): 184 185 """ 186 Update the 'freebusy' collection using the given 'periods', subject to 187 the 'attr' provided for the participant, indicating whether this is 188 being generated 'for_organiser' or not. 189 """ 190 191 # Organisers employ a special transparency if not attending. 192 193 if for_organiser or not attr or attr.get("PARTSTAT") != "DECLINED": 194 self.update_freebusy(freebusy, periods, transp=( 195 for_organiser and not attr.get("PARTSTAT") and "ORG" or None)) 196 else: 197 self.remove_from_freebusy(freebusy) 198 199 # Convenience methods for updating stored free/busy information. 200 201 def update_freebusy_from_participant(self, participant_item, for_organiser): 202 203 """ 204 For the calendar user, record the free/busy information for the 205 'participant_item' (a value plus attributes) representing a different 206 identity, thus maintaining a separate record of their free/busy details. 207 """ 208 209 participant, participant_attr = participant_item 210 211 if participant == self.user: 212 return 213 214 freebusy = self.store.get_freebusy_for_other(self.user, participant) 215 216 # Obtain the stored object if the current object is not issued by the 217 # organiser. 218 219 obj = for_organiser and self.obj or self.get_object() 220 if not obj: 221 return 222 223 # Obtain the affected periods. 224 225 periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()) 226 227 # Record in the free/busy details unless a non-participating attendee. 228 # Use any attendee information for an organiser, not the organiser's own 229 # attributes. 230 231 if for_organiser: 232 participant_attr = obj.get_value_map("ATTENDEE").get(participant) 233 234 self.update_freebusy_for_participant(freebusy, periods, participant_attr, 235 for_organiser and not self.is_attendee(participant)) 236 237 # Tidy up any obsolete recurrences. 238 239 self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid)) 240 self.store.set_freebusy_for_other(self.user, freebusy, participant) 241 242 def update_freebusy_from_organiser(self, organiser_item): 243 244 """ 245 For the current user, record free/busy information from the 246 'organiser_item' (a value plus attributes). 247 """ 248 249 self.update_freebusy_from_participant(organiser_item, True) 250 251 def update_freebusy_from_attendees(self, attendees): 252 253 "For the current user, record free/busy information from 'attendees'." 254 255 for attendee_item in attendees.items(): 256 self.update_freebusy_from_participant(attendee_item, False) 257 258 # Logic, filtering and access to calendar structures and other data. 259 260 def is_attendee(self, identity, obj=None): 261 262 """ 263 Return whether 'identity' is an attendee in the current object, or in 264 'obj' if specified. 265 """ 266 267 return identity in uri_values((obj or self.obj).get_values("ATTENDEE")) 268 269 def can_schedule(self, freebusy, periods): 270 return can_schedule(freebusy, periods, self.uid, self.recurrenceid) 271 272 def filter_by_senders(self, mapping): 273 274 """ 275 Return a list of items from 'mapping' filtered using sender information. 276 """ 277 278 if self.senders: 279 280 # Get a mapping from senders to identities. 281 282 identities = self.get_sender_identities(mapping) 283 284 # Find the senders that are valid. 285 286 senders = map(get_address, identities) 287 valid = self.senders.intersection(senders) 288 289 # Return the true identities. 290 291 return [identities[get_uri(address)] for address in valid] 292 else: 293 return mapping 294 295 def filter_by_recipient(self, mapping): 296 297 """ 298 Return a list of items from 'mapping' filtered using recipient 299 information. 300 """ 301 302 if self.recipient: 303 addresses = set(map(get_address, mapping)) 304 return map(get_uri, addresses.intersection([self.recipient])) 305 else: 306 return mapping 307 308 def require_organiser(self, from_organiser=True): 309 310 """ 311 Return the organiser for the current object, filtered for the sender or 312 recipient of interest. Return None if no identities are eligible. 313 314 The organiser identity is normalized. 315 """ 316 317 organiser_item = uri_item(self.obj.get_item("ORGANIZER")) 318 319 # Only provide details for an organiser who sent/receives the message. 320 321 organiser_filter_fn = from_organiser and self.filter_by_senders or self.filter_by_recipient 322 323 if not organiser_filter_fn(dict([organiser_item])): 324 return None 325 326 return organiser_item 327 328 def require_attendees(self, from_organiser=True): 329 330 """ 331 Return the attendees for the current object, filtered for the sender or 332 recipient of interest. Return None if no identities are eligible. 333 334 The attendee identities are normalized. 335 """ 336 337 attendee_map = uri_dict(self.obj.get_value_map("ATTENDEE")) 338 339 # Only provide details for attendees who sent/receive the message. 340 341 attendee_filter_fn = from_organiser and self.filter_by_recipient or self.filter_by_senders 342 343 attendees = {} 344 for attendee in attendee_filter_fn(attendee_map): 345 attendees[attendee] = attendee_map[attendee] 346 347 return attendees 348 349 def require_organiser_and_attendees(self, from_organiser=True): 350 351 """ 352 Return the organiser and attendees for the current object, filtered for 353 the recipient of interest. Return None if no identities are eligible. 354 355 Organiser and attendee identities are normalized. 356 """ 357 358 organiser_item = self.require_organiser(from_organiser) 359 attendees = self.require_attendees(from_organiser) 360 361 if not attendees or not organiser_item: 362 return None 363 364 return organiser_item, attendees 365 366 def get_sender_identities(self, mapping): 367 368 """ 369 Return a mapping from actual senders to the identities for which they 370 have provided data, extracting this information from the given 371 'mapping'. 372 """ 373 374 senders = {} 375 376 for value, attr in mapping.items(): 377 sent_by = attr.get("SENT-BY") 378 if sent_by: 379 senders[get_uri(sent_by)] = value 380 else: 381 senders[value] = value 382 383 return senders 384 385 def _get_object(self, uid, recurrenceid): 386 387 """ 388 Return the stored object for the current user, with the given 'uid' and 389 'recurrenceid'. 390 """ 391 392 fragment = self.store.get_event(self.user, uid, recurrenceid) 393 return fragment and Object(fragment) 394 395 def get_object(self): 396 397 """ 398 Return the stored object to which the current object refers for the 399 current user. 400 """ 401 402 return self._get_object(self.uid, self.recurrenceid) 403 404 def get_parent_object(self): 405 406 """ 407 Return the parent object to which the current object refers for the 408 current user. 409 """ 410 411 return self.recurrenceid and self._get_object(self.uid, None) or None 412 413 def have_new_object(self, obj=None): 414 415 """ 416 Return whether the current object is new to the current user (or if the 417 given 'obj' is new). 418 """ 419 420 obj = obj or self.get_object() 421 422 # If found, compare SEQUENCE and potentially DTSTAMP. 423 424 if obj: 425 sequence = obj.get_value("SEQUENCE") 426 dtstamp = obj.get_value("DTSTAMP") 427 428 # If the request refers to an older version of the object, ignore 429 # it. 430 431 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, 432 self.is_partstat_updated(obj)) 433 434 return True 435 436 def is_partstat_updated(self, obj): 437 438 """ 439 Return whether the participant status has been updated in the current 440 object in comparison to the given 'obj'. 441 442 NOTE: Some clients like Claws Mail erase time information from DTSTAMP 443 NOTE: and make it invalid. Thus, such attendance information may also be 444 NOTE: incorporated into any new object assessment. 445 """ 446 447 old_attendees = uri_dict(obj.get_value_map("ATTENDEE")) 448 new_attendees = uri_dict(self.obj.get_value_map("ATTENDEE")) 449 450 for attendee, attr in old_attendees.items(): 451 old_partstat = attr.get("PARTSTAT") 452 new_attr = new_attendees.get(attendee) 453 new_partstat = new_attr and new_attr.get("PARTSTAT") 454 455 if old_partstat == "NEEDS-ACTION" and new_partstat and \ 456 new_partstat != old_partstat: 457 458 return True 459 460 return False 461 462 def merge_attendance(self, attendees): 463 464 """ 465 Merge attendance from the current object's 'attendees' into the version 466 stored for the current user. 467 """ 468 469 obj = self.get_object() 470 471 if not obj or not self.have_new_object(obj): 472 return False 473 474 # Get attendee details in a usable form. 475 476 attendee_map = uri_dict(obj.get_value_map("ATTENDEE")) 477 478 for attendee, attendee_attr in attendees.items(): 479 480 # Update attendance in the loaded object. 481 482 attendee_map[attendee] = attendee_attr 483 484 # Set the new details and store the object. 485 486 obj["ATTENDEE"] = attendee_map.items() 487 488 # Set the complete event if not an additional occurrence. 489 490 event = obj.to_node() 491 self.store.set_event(self.user, self.uid, self.recurrenceid, event) 492 493 return True 494 495 def update_dtstamp(self): 496 497 "Update the DTSTAMP in the current object." 498 499 dtstamp = self.obj.get_utc_datetime("DTSTAMP") 500 utcnow = to_timezone(datetime.utcnow(), "UTC") 501 self.obj["DTSTAMP"] = [(format_datetime(dtstamp > utcnow and dtstamp or utcnow), {})] 502 503 def set_sequence(self, increment=False): 504 505 "Update the SEQUENCE in the current object." 506 507 sequence = self.obj.get_value("SEQUENCE") or "0" 508 self.obj["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})] 509 510 # vim: tabstop=4 expandtab shiftwidth=4