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