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