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