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