1.1 --- a/imiptools/client.py Sun Jul 26 02:01:24 2015 +0200
1.2 +++ b/imiptools/client.py Sun Jul 26 23:43:26 2015 +0200
1.3 @@ -20,12 +20,15 @@
1.4 """
1.5
1.6 from datetime import datetime
1.7 -from imiptools.data import get_address, get_uri, get_window_end, \
1.8 - make_freebusy, to_part, \
1.9 +from imiptools.data import Object, get_address, get_uri, get_window_end, \
1.10 + is_new_object, make_freebusy, to_part, \
1.11 uri_dict, uri_items, uri_values
1.12 from imiptools.dates import format_datetime, get_default_timezone, \
1.13 - get_timestamp, to_timezone
1.14 -from imiptools.period import update_freebusy
1.15 + get_recurrence_start_point, get_timestamp, \
1.16 + to_timezone
1.17 +from imiptools.period import can_schedule, remove_period, \
1.18 + remove_additional_periods, remove_affected_period, \
1.19 + update_freebusy
1.20 from imiptools.profile import Preferences
1.21 import imip_store
1.22
1.23 @@ -123,10 +126,30 @@
1.24
1.25 # Common operations on calendar data.
1.26
1.27 - def is_participating(self, attr, as_organiser=False):
1.28 + def is_participating(self, user, as_organiser=False):
1.29 +
1.30 + """
1.31 + Return whether, subject to the 'user' indicating an identity and the
1.32 + 'as_organiser' status of that identity, the user concerned is actually
1.33 + participating in the current object event.
1.34 + """
1.35 +
1.36 + attr = self.get_attendance(user)
1.37 return as_organiser or not attr or attr.get("PARTSTAT") != "DECLINED"
1.38
1.39 - def get_overriding_transparency(self, attr, as_organiser=False):
1.40 + def get_overriding_transparency(self, user, as_organiser=False):
1.41 +
1.42 + """
1.43 + Return the overriding transparency to be associated with the free/busy
1.44 + records for an event, subject to the 'user' indicating an identity and
1.45 + the 'as_organiser' status of that identity.
1.46 +
1.47 + Where an identity is only an organiser and not attending, "ORG" is
1.48 + returned. Otherwise, no overriding transparency is defined and None is
1.49 + returned.
1.50 + """
1.51 +
1.52 + attr = self.get_attendance(user)
1.53 return as_organiser and not (attr and attr.get("PARTSTAT")) and "ORG" or None
1.54
1.55 def update_participation(self, obj, partstat=None):
1.56 @@ -152,12 +175,34 @@
1.57 if self.messenger and self.messenger.sender != get_address(self.user):
1.58 attr["SENT-BY"] = get_uri(self.messenger.sender)
1.59
1.60 + def get_periods(self, obj):
1.61 +
1.62 + """
1.63 + Return periods for the given 'obj'. Interpretation of periods can depend
1.64 + on the time zone, which is obtained for the current user.
1.65 + """
1.66 +
1.67 + return obj.get_periods(self.get_tzid(), self.get_window_end())
1.68 +
1.69 + # Store operations.
1.70 +
1.71 + def get_stored_object(self, uid, recurrenceid):
1.72 +
1.73 + """
1.74 + Return the stored object for the current user, with the given 'uid' and
1.75 + 'recurrenceid'.
1.76 + """
1.77 +
1.78 + fragment = self.store.get_event(self.user, uid, recurrenceid)
1.79 + return fragment and Object(fragment)
1.80 +
1.81 # Free/busy operations.
1.82
1.83 - def get_freebusy_part(self):
1.84 + def get_freebusy_part(self, freebusy=None):
1.85
1.86 """
1.87 - Return a message part containing free/busy information for the user.
1.88 + Return a message part containing free/busy information for the user,
1.89 + either specified as 'freebusy' or obtained from the store directly.
1.90 """
1.91
1.92 if self.is_sharing() and self.is_bundling():
1.93 @@ -167,7 +212,7 @@
1.94 utcnow = get_timestamp()
1.95 uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user))
1.96
1.97 - freebusy = self.store.get_freebusy(self.user)
1.98 + freebusy = freebusy or self.store.get_freebusy(self.user)
1.99
1.100 user_attr = {}
1.101 self.update_sender(user_attr)
1.102 @@ -175,6 +220,17 @@
1.103
1.104 return None
1.105
1.106 + def update_freebusy(self, freebusy, periods, transp, uid, recurrenceid, summary, organiser):
1.107 +
1.108 + """
1.109 + Update the 'freebusy' collection with the given 'periods', indicating a
1.110 + 'transp' status, explicit 'uid' and 'recurrenceid' to indicate either a
1.111 + recurrence or the parent event. The 'summary' and 'organiser' must also
1.112 + be provided.
1.113 + """
1.114 +
1.115 + update_freebusy(freebusy, periods, transp, self.uid, recurrenceid, summary, organiser)
1.116 +
1.117 class ClientForObject(Client):
1.118
1.119 "A client maintaining a specific object."
1.120 @@ -184,51 +240,15 @@
1.121 self.set_object(obj)
1.122
1.123 def set_object(self, obj):
1.124 +
1.125 + "Set the current object to 'obj', obtaining metadata details."
1.126 +
1.127 self.obj = obj
1.128 self.uid = obj and self.obj.get_uid()
1.129 self.recurrenceid = obj and self.obj.get_recurrenceid()
1.130 self.sequence = obj and self.obj.get_value("SEQUENCE")
1.131 self.dtstamp = obj and self.obj.get_value("DTSTAMP")
1.132
1.133 - def _update_freebusy(self, freebusy, periods, recurrenceid, transp=None):
1.134 -
1.135 - """
1.136 - Update the 'freebusy' collection with the given 'periods', indicating an
1.137 - explicit 'recurrenceid' to affect either a recurrence or the parent
1.138 - event.
1.139 - """
1.140 -
1.141 - update_freebusy(freebusy, periods,
1.142 - transp or self.obj.get_value("TRANSP") or "OPAQUE",
1.143 - self.uid, recurrenceid,
1.144 - self.obj.get_value("SUMMARY"),
1.145 - self.obj.get_value("ORGANIZER"))
1.146 -
1.147 - def update_freebusy(self, freebusy, periods, transp=None):
1.148 -
1.149 - """
1.150 - Update the 'freebusy' collection for this event with the given
1.151 - 'periods'.
1.152 - """
1.153 -
1.154 - self._update_freebusy(freebusy, periods, self.recurrenceid, transp)
1.155 -
1.156 - def update_freebusy_for_participant(self, freebusy, periods, attr, for_organiser=False):
1.157 -
1.158 - """
1.159 - Update the 'freebusy' collection using the given 'periods', subject to
1.160 - the 'attr' provided for the participant, indicating whether this is
1.161 - being generated 'for_organiser' or not.
1.162 - """
1.163 -
1.164 - # Organisers employ a special transparency if not attending.
1.165 -
1.166 - if self.is_participating(attr, for_organiser):
1.167 - self.update_freebusy(freebusy, periods,
1.168 - transp=self.get_overriding_transparency(attr, for_organiser))
1.169 - else:
1.170 - self.remove_from_freebusy(freebusy)
1.171 -
1.172 # Object update methods.
1.173
1.174 def update_dtstamp(self):
1.175 @@ -246,4 +266,278 @@
1.176 sequence = self.obj.get_value("SEQUENCE") or "0"
1.177 self.obj["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})]
1.178
1.179 + def merge_attendance(self, attendees):
1.180 +
1.181 + """
1.182 + Merge attendance from the current object's 'attendees' into the version
1.183 + stored for the current user.
1.184 + """
1.185 +
1.186 + obj = self.get_stored_object_version()
1.187 +
1.188 + if not obj or not self.have_new_object(obj):
1.189 + return False
1.190 +
1.191 + # Get attendee details in a usable form.
1.192 +
1.193 + attendee_map = uri_dict(obj.get_value_map("ATTENDEE"))
1.194 +
1.195 + for attendee, attendee_attr in attendees.items():
1.196 +
1.197 + # Update attendance in the loaded object.
1.198 +
1.199 + attendee_map[attendee] = attendee_attr
1.200 +
1.201 + # Set the new details and store the object.
1.202 +
1.203 + obj["ATTENDEE"] = attendee_map.items()
1.204 +
1.205 + # Set the complete event if not an additional occurrence.
1.206 +
1.207 + event = obj.to_node()
1.208 + self.store.set_event(self.user, self.uid, self.recurrenceid, event)
1.209 +
1.210 + return True
1.211 +
1.212 + # Object-related tests.
1.213 +
1.214 + def get_attendance(self, user=None):
1.215 +
1.216 + """
1.217 + Return the attendance attributes for 'user', or the current user if
1.218 + 'user' is not specified.
1.219 + """
1.220 +
1.221 + attendees = uri_dict(self.obj.get_value_map("ATTENDEE"))
1.222 + return attendees.get(user or self.user) or {}
1.223 +
1.224 + def is_attendee(self, identity, obj=None):
1.225 +
1.226 + """
1.227 + Return whether 'identity' is an attendee in the current object, or in
1.228 + 'obj' if specified.
1.229 + """
1.230 +
1.231 + return identity in uri_values((obj or self.obj).get_values("ATTENDEE"))
1.232 +
1.233 + def can_schedule(self, freebusy, periods):
1.234 +
1.235 + """
1.236 + Indicate whether within 'freebusy' the given 'periods' can be scheduled.
1.237 + """
1.238 +
1.239 + return can_schedule(freebusy, periods, self.uid, self.recurrenceid)
1.240 +
1.241 + def have_new_object(self, obj=None):
1.242 +
1.243 + """
1.244 + Return whether the current object is new to the current user (or if the
1.245 + given 'obj' is new).
1.246 + """
1.247 +
1.248 + obj = obj or self.get_stored_object_version()
1.249 +
1.250 + # If found, compare SEQUENCE and potentially DTSTAMP.
1.251 +
1.252 + if obj:
1.253 + sequence = obj.get_value("SEQUENCE")
1.254 + dtstamp = obj.get_value("DTSTAMP")
1.255 +
1.256 + # If the request refers to an older version of the object, ignore
1.257 + # it.
1.258 +
1.259 + return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp,
1.260 + self.is_partstat_updated(obj))
1.261 +
1.262 + return True
1.263 +
1.264 + def is_partstat_updated(self, obj):
1.265 +
1.266 + """
1.267 + Return whether the participant status has been updated in the current
1.268 + object in comparison to the given 'obj'.
1.269 +
1.270 + NOTE: Some clients like Claws Mail erase time information from DTSTAMP
1.271 + NOTE: and make it invalid. Thus, such attendance information may also be
1.272 + NOTE: incorporated into any new object assessment.
1.273 + """
1.274 +
1.275 + old_attendees = uri_dict(obj.get_value_map("ATTENDEE"))
1.276 + new_attendees = uri_dict(self.obj.get_value_map("ATTENDEE"))
1.277 +
1.278 + for attendee, attr in old_attendees.items():
1.279 + old_partstat = attr.get("PARTSTAT")
1.280 + new_attr = new_attendees.get(attendee)
1.281 + new_partstat = new_attr and new_attr.get("PARTSTAT")
1.282 +
1.283 + if old_partstat == "NEEDS-ACTION" and new_partstat and \
1.284 + new_partstat != old_partstat:
1.285 +
1.286 + return True
1.287 +
1.288 + return False
1.289 +
1.290 + # Object retrieval.
1.291 +
1.292 + def get_stored_object_version(self):
1.293 +
1.294 + """
1.295 + Return the stored object to which the current object refers for the
1.296 + current user.
1.297 + """
1.298 +
1.299 + return self.get_stored_object(self.uid, self.recurrenceid)
1.300 +
1.301 + def get_definitive_object(self, from_organiser):
1.302 +
1.303 + """
1.304 + Return an object considered definitive for the current transaction,
1.305 + using 'from_organiser' to select the current transaction's object if
1.306 + true, or selecting a stored object if false.
1.307 + """
1.308 +
1.309 + return from_organiser and self.obj or self.get_stored_object_version()
1.310 +
1.311 + def get_parent_object(self):
1.312 +
1.313 + """
1.314 + Return the parent object to which the current object refers for the
1.315 + current user.
1.316 + """
1.317 +
1.318 + return self.recurrenceid and self.get_stored_object(self.uid, None) or None
1.319 +
1.320 + # Convenience methods for modifying free/busy collections.
1.321 +
1.322 + def get_recurrence_start_point(self, recurrenceid):
1.323 +
1.324 + "Get 'recurrenceid' in a form suitable for matching free/busy entries."
1.325 +
1.326 + tzid = self.obj.get_tzid() or self.get_tzid()
1.327 + return get_recurrence_start_point(recurrenceid, tzid)
1.328 +
1.329 + def remove_from_freebusy(self, freebusy):
1.330 +
1.331 + "Remove this event from the given 'freebusy' collection."
1.332 +
1.333 + if not remove_period(freebusy, self.uid, self.recurrenceid) and self.recurrenceid:
1.334 + remove_affected_period(freebusy, self.uid, self.get_recurrence_start_point(self.recurrenceid))
1.335 +
1.336 + def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None):
1.337 +
1.338 + """
1.339 + Remove from 'freebusy' any original recurrence from parent free/busy
1.340 + details for the current object, if the current object is a specific
1.341 + additional recurrence. Otherwise, remove all additional recurrence
1.342 + information corresponding to 'recurrenceids', or if omitted, all
1.343 + recurrences.
1.344 + """
1.345 +
1.346 + if self.recurrenceid:
1.347 + recurrenceid = self.get_recurrence_start_point(self.recurrenceid)
1.348 + remove_affected_period(freebusy, self.uid, recurrenceid)
1.349 + else:
1.350 + # Remove obsolete recurrence periods.
1.351 +
1.352 + remove_additional_periods(freebusy, self.uid, recurrenceids)
1.353 +
1.354 + # Remove original periods affected by additional recurrences.
1.355 +
1.356 + if recurrenceids:
1.357 + for recurrenceid in recurrenceids:
1.358 + recurrenceid = self.get_recurrence_start_point(recurrenceid)
1.359 + remove_affected_period(freebusy, self.uid, recurrenceid)
1.360 +
1.361 + def update_freebusy(self, freebusy, user, for_organiser):
1.362 +
1.363 + """
1.364 + Update the 'freebusy' collection for this event with the periods and
1.365 + transparency associated with the current object, subject to the 'user'
1.366 + identity and the attendance details provided for them, indicating
1.367 + whether the update is 'for_organiser' or not.
1.368 + """
1.369 +
1.370 + # Obtain the stored object if the current object is not issued by the
1.371 + # organiser. Attendees do not have the opportunity to redefine the
1.372 + # periods.
1.373 +
1.374 + obj = self.get_definitive_object(for_organiser)
1.375 + if not obj:
1.376 + return
1.377 +
1.378 + # Obtain the affected periods.
1.379 +
1.380 + periods = self.get_periods(obj)
1.381 +
1.382 + # Define an overriding transparency, the indicated event transparency,
1.383 + # or the default transparency for the free/busy entry.
1.384 +
1.385 + transp = self.get_overriding_transparency(user, for_organiser) or \
1.386 + obj.get_value("TRANSP") or \
1.387 + "OPAQUE"
1.388 +
1.389 + # Perform the low-level update.
1.390 +
1.391 + Client.update_freebusy(self, freebusy, periods, transp,
1.392 + self.uid, self.recurrenceid,
1.393 + obj.get_value("SUMMARY"),
1.394 + obj.get_value("ORGANIZER"))
1.395 +
1.396 + def update_freebusy_for_participant(self, freebusy, user, for_organiser=False,
1.397 + updating_other=False):
1.398 +
1.399 + """
1.400 + Update the 'freebusy' collection using the given 'periods', involving
1.401 + the given 'user', indicating whether the update is 'for_organiser' or
1.402 + not, and whether it is 'updating_other' (meaning another user's
1.403 + details).
1.404 + """
1.405 +
1.406 + # Record in the free/busy details unless a non-participating attendee.
1.407 + # Use any attendee information for an organiser, not the organiser's own
1.408 + # attributes.
1.409 +
1.410 + if self.is_participating(user, for_organiser and not updating_other):
1.411 + self.update_freebusy(freebusy, user, for_organiser)
1.412 + else:
1.413 + self.remove_from_freebusy(freebusy)
1.414 +
1.415 + # Convenience methods for updating stored free/busy information received
1.416 + # from other users.
1.417 +
1.418 + def update_freebusy_from_participant(self, user, for_organiser):
1.419 +
1.420 + """
1.421 + For the current user, record the free/busy information for another
1.422 + 'user', indicating whether the update is 'for_organiser' or not, thus
1.423 + maintaining a separate record of their free/busy details.
1.424 + """
1.425 +
1.426 + # A user does not store free/busy information for themself as another
1.427 + # party.
1.428 +
1.429 + if user == self.user:
1.430 + return
1.431 +
1.432 + freebusy = self.store.get_freebusy_for_other(self.user, user)
1.433 + self.update_freebusy_for_participant(freebusy, user, for_organiser, True)
1.434 +
1.435 + # Tidy up any obsolete recurrences.
1.436 +
1.437 + self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid))
1.438 + self.store.set_freebusy_for_other(self.user, freebusy, user)
1.439 +
1.440 + def update_freebusy_from_organiser(self, organiser):
1.441 +
1.442 + "For the current user, record free/busy information from 'organiser'."
1.443 +
1.444 + self.update_freebusy_from_participant(organiser, True)
1.445 +
1.446 + def update_freebusy_from_attendees(self, attendees):
1.447 +
1.448 + "For the current user, record free/busy information from 'attendees'."
1.449 +
1.450 + for attendee in attendees.keys():
1.451 + self.update_freebusy_from_participant(attendee, False)
1.452 +
1.453 # vim: tabstop=4 expandtab shiftwidth=4