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