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