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