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 [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 senders[get_uri(sent_by)] = value 334 else: 335 senders[value] = value 336 337 return senders 338 339 def _get_object(self, uid, recurrenceid): 340 341 """ 342 Return the stored object for the current user, with the given 'uid' and 343 'recurrenceid'. 344 """ 345 346 fragment = self.store.get_event(self.user, uid, recurrenceid) 347 return fragment and Object(fragment) 348 349 def get_object(self): 350 351 """ 352 Return the stored object to which the current object refers for the 353 current user. 354 """ 355 356 return self._get_object(self.uid, self.recurrenceid) 357 358 def get_definitive_object(self, from_organiser): 359 360 """ 361 Return an object considered definitive for the current transaction, 362 using 'from_organiser' to select the current transaction's object if 363 true, or selecting a stored object if false. 364 """ 365 366 return from_organiser and self.obj or self.get_object() 367 368 def get_parent_object(self): 369 370 """ 371 Return the parent object to which the current object refers for the 372 current user. 373 """ 374 375 return self.recurrenceid and self._get_object(self.uid, None) or None 376 377 def have_new_object(self, obj=None): 378 379 """ 380 Return whether the current object is new to the current user (or if the 381 given 'obj' is new). 382 """ 383 384 obj = obj or self.get_object() 385 386 # If found, compare SEQUENCE and potentially DTSTAMP. 387 388 if obj: 389 sequence = obj.get_value("SEQUENCE") 390 dtstamp = obj.get_value("DTSTAMP") 391 392 # If the request refers to an older version of the object, ignore 393 # it. 394 395 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, 396 self.is_partstat_updated(obj)) 397 398 return True 399 400 def is_partstat_updated(self, obj): 401 402 """ 403 Return whether the participant status has been updated in the current 404 object in comparison to the given 'obj'. 405 406 NOTE: Some clients like Claws Mail erase time information from DTSTAMP 407 NOTE: and make it invalid. Thus, such attendance information may also be 408 NOTE: incorporated into any new object assessment. 409 """ 410 411 old_attendees = uri_dict(obj.get_value_map("ATTENDEE")) 412 new_attendees = uri_dict(self.obj.get_value_map("ATTENDEE")) 413 414 for attendee, attr in old_attendees.items(): 415 old_partstat = attr.get("PARTSTAT") 416 new_attr = new_attendees.get(attendee) 417 new_partstat = new_attr and new_attr.get("PARTSTAT") 418 419 if old_partstat == "NEEDS-ACTION" and new_partstat and \ 420 new_partstat != old_partstat: 421 422 return True 423 424 return False 425 426 def merge_attendance(self, attendees): 427 428 """ 429 Merge attendance from the current object's 'attendees' into the version 430 stored for the current user. 431 """ 432 433 obj = self.get_object() 434 435 if not obj or not self.have_new_object(obj): 436 return False 437 438 # Get attendee details in a usable form. 439 440 attendee_map = uri_dict(obj.get_value_map("ATTENDEE")) 441 442 for attendee, attendee_attr in attendees.items(): 443 444 # Update attendance in the loaded object. 445 446 attendee_map[attendee] = attendee_attr 447 448 # Set the new details and store the object. 449 450 obj["ATTENDEE"] = attendee_map.items() 451 452 # Set the complete event if not an additional occurrence. 453 454 event = obj.to_node() 455 self.store.set_event(self.user, self.uid, self.recurrenceid, event) 456 457 return True 458 459 # vim: tabstop=4 expandtab shiftwidth=4