1.1 --- a/htdocs/styles.css Thu Feb 12 22:34:48 2015 +0100
1.2 +++ b/htdocs/styles.css Sat Mar 07 00:11:44 2015 +0100
1.3 @@ -2,6 +2,7 @@
1.4
1.5 table.calendar,
1.6 table.conflicts,
1.7 +table.recurrences,
1.8 table.object {
1.9 border: 2px solid #000;
1.10 }
1.11 @@ -32,6 +33,10 @@
1.12 white-space: nowrap;
1.13 }
1.14
1.15 +th.objectheading {
1.16 + background-color: #fca;
1.17 +}
1.18 +
1.19 th.timeslot {
1.20 padding-top: 0;
1.21 vertical-align: top;
1.22 @@ -93,6 +98,14 @@
1.23 font-size: inherit;
1.24 }
1.25
1.26 +.affected {
1.27 + font-weight: bold;
1.28 +}
1.29 +
1.30 +.replaced {
1.31 + text-decoration: line-through;
1.32 +}
1.33 +
1.34 /* Selection of slots/periods for new events. */
1.35
1.36 input.newevent.selector {
2.1 --- a/imip_manager.py Thu Feb 12 22:34:48 2015 +0100
2.2 +++ b/imip_manager.py Sat Mar 07 00:11:44 2015 +0100
2.3 @@ -31,7 +31,8 @@
2.4 sys.path.append(LIBRARY_PATH)
2.5
2.6 from imiptools.content import Handler
2.7 -from imiptools.data import get_address, get_uri, make_freebusy, Object, to_part, \
2.8 +from imiptools.data import get_address, get_uri, get_window_end, make_freebusy, \
2.9 + Object, to_part, \
2.10 uri_dict, uri_item, uri_items, uri_values
2.11 from imiptools.dates import format_datetime, format_time, get_date, get_datetime, \
2.12 get_datetime_item, get_default_timezone, \
2.13 @@ -41,8 +42,8 @@
2.14 from imiptools.period import add_day_start_points, add_empty_days, add_slots, \
2.15 convert_periods, get_freebusy_details, \
2.16 get_scale, have_conflict, get_slots, get_spans, \
2.17 - partition_by_day, remove_from_freebusy, update_freebusy, \
2.18 - _update_freebusy
2.19 + partition_by_day, remove_period, remove_affected_period, \
2.20 + update_freebusy
2.21 from imiptools.profile import Preferences
2.22 import imip_store
2.23 import markup
2.24 @@ -126,7 +127,49 @@
2.25 prefs = self.get_preferences()
2.26 return prefs.get("TZID") or get_default_timezone()
2.27
2.28 -class ManagerHandler(Handler, Common):
2.29 + def get_window_size(self):
2.30 + prefs = self.get_preferences()
2.31 + try:
2.32 + return int(prefs.get("window_size"))
2.33 + except (TypeError, ValueError):
2.34 + return 100
2.35 +
2.36 + def get_window_end(self):
2.37 + return get_window_end(self.get_tzid(), self.get_window_size())
2.38 +
2.39 + def update_attendees(self, obj, added, removed):
2.40 +
2.41 + """
2.42 + Update the attendees in 'obj' with the given 'added' and 'removed'
2.43 + attendee lists. A list is returned containing the attendees whose
2.44 + attendance should be cancelled.
2.45 + """
2.46 +
2.47 + to_cancel = []
2.48 +
2.49 + if added or removed:
2.50 + attendees = uri_items(obj.get_items("ATTENDEE") or [])
2.51 +
2.52 + if removed:
2.53 + remaining = []
2.54 +
2.55 + for attendee, attendee_attr in attendees:
2.56 + if attendee in removed:
2.57 + to_cancel.append((attendee, attendee_attr))
2.58 + else:
2.59 + remaining.append((attendee, attendee_attr))
2.60 +
2.61 + attendees = remaining
2.62 +
2.63 + if added:
2.64 + for attendee in added:
2.65 + attendees.append((attendee, {"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"}))
2.66 +
2.67 + obj["ATTENDEE"] = attendees
2.68 +
2.69 + return to_cancel
2.70 +
2.71 +class ManagerHandler(Common, Handler):
2.72
2.73 """
2.74 A content handler for use by the manager, as opposed to operating within the
2.75 @@ -182,10 +225,10 @@
2.76 # newer details (since the outgoing handler updates this user's
2.77 # free/busy details).
2.78
2.79 - tzid = self.get_tzid()
2.80 -
2.81 - _update_freebusy(freebusy, self.obj.get_periods_for_freebusy(tzid),
2.82 - self.obj.get_value("TRANSP") or "OPAQUE", self.obj.get_value("UID"))
2.83 + update_freebusy(freebusy,
2.84 + self.obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()),
2.85 + self.obj.get_value("TRANSP") or "OPAQUE",
2.86 + self.uid, self.recurrenceid)
2.87
2.88 user_attr = self.messenger and self.messenger.sender != get_address(self.user) and \
2.89 {"SENT-BY" : get_uri(self.messenger.sender)} or {}
2.90 @@ -250,27 +293,9 @@
2.91 if self.messenger and self.messenger.sender != get_address(organiser):
2.92 organiser_attr["SENT-BY"] = get_uri(self.messenger.sender)
2.93
2.94 - to_cancel = []
2.95 -
2.96 - if added or removed:
2.97 - attendees = uri_items(self.obj.get_items("ATTENDEE") or [])
2.98 -
2.99 - if removed:
2.100 - remaining = []
2.101 -
2.102 - for attendee, attendee_attr in attendees:
2.103 - if attendee in removed:
2.104 - to_cancel.append((attendee, attendee_attr))
2.105 - else:
2.106 - remaining.append((attendee, attendee_attr))
2.107 -
2.108 - attendees = remaining
2.109 -
2.110 - if added:
2.111 - for attendee in added:
2.112 - attendees.append((attendee, {"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"}))
2.113 -
2.114 - self.obj["ATTENDEE"] = attendees
2.115 + # Update the attendees in the event.
2.116 +
2.117 + to_cancel = self.update_attendees(self.obj, added, removed)
2.118
2.119 self.update_dtstamp()
2.120 self.set_sequence(update)
2.121 @@ -281,6 +306,7 @@
2.122 # is now cancelled.
2.123
2.124 if to_cancel:
2.125 + remaining = self.obj["ATTENDEE"]
2.126 self.obj["ATTENDEE"] = to_cancel
2.127 self.send_message("CANCEL", get_address(organiser), for_organiser=True)
2.128
2.129 @@ -317,17 +343,24 @@
2.130 except OSError:
2.131 self.publisher = None
2.132
2.133 - def _get_uid(self, path_info):
2.134 - return path_info.lstrip("/").split("/", 1)[0]
2.135 -
2.136 - def _get_object(self, uid):
2.137 - if self.objects.has_key(uid):
2.138 - return self.objects[uid]
2.139 -
2.140 - fragment = uid and self.store.get_event(self.user, uid) or None
2.141 - obj = self.objects[uid] = fragment and Object(fragment)
2.142 + def _get_identifiers(self, path_info):
2.143 + parts = path_info.lstrip("/").split("/")
2.144 + if len(parts) == 1:
2.145 + return parts[0], None
2.146 + else:
2.147 + return parts[:2]
2.148 +
2.149 + def _get_object(self, uid, recurrenceid=None):
2.150 + if self.objects.has_key((uid, recurrenceid)):
2.151 + return self.objects[(uid, recurrenceid)]
2.152 +
2.153 + fragment = uid and self.store.get_event(self.user, uid, recurrenceid) or None
2.154 + obj = self.objects[(uid, recurrenceid)] = fragment and Object(fragment)
2.155 return obj
2.156
2.157 + def _get_recurrences(self, uid):
2.158 + return self.store.get_recurrences(self.user, uid)
2.159 +
2.160 def _get_requests(self):
2.161 if self.requests is None:
2.162 cancellations = self.store.get_cancellations(self.user)
2.163 @@ -337,18 +370,26 @@
2.164
2.165 def _get_request_summary(self):
2.166 summary = []
2.167 - for uid in self._get_requests():
2.168 - obj = self._get_object(uid)
2.169 + for uid, recurrenceid in self._get_requests():
2.170 + obj = self._get_object(uid, recurrenceid)
2.171 if obj:
2.172 - for start, end in obj.get_periods_for_freebusy(self.get_tzid()):
2.173 - summary.append((start, end, uid))
2.174 + periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end())
2.175 +
2.176 + # Subtract any recurrences from the free/busy details of a parent
2.177 + # object.
2.178 +
2.179 + recurrenceids = self._get_recurrences(uid)
2.180 +
2.181 + for start, end in periods:
2.182 + if recurrenceid or start not in recurrenceids:
2.183 + summary.append((start, end, uid, obj.get_value("TRANSP"), recurrenceid))
2.184 return summary
2.185
2.186 # Preference methods.
2.187
2.188 def get_user_locale(self):
2.189 if not self.locale:
2.190 - self.locale = self.get_preferences().get("LANG", "C")
2.191 + self.locale = self.get_preferences().get("LANG", "en")
2.192 return self.locale
2.193
2.194 # Prettyprinting of dates and times.
2.195 @@ -369,21 +410,40 @@
2.196
2.197 # Data management methods.
2.198
2.199 - def remove_request(self, uid):
2.200 - return self.store.dequeue_request(self.user, uid)
2.201 -
2.202 - def remove_event(self, uid):
2.203 - return self.store.remove_event(self.user, uid)
2.204 -
2.205 - def update_freebusy(self, uid, obj):
2.206 - tzid = self.get_tzid()
2.207 + def remove_request(self, uid, recurrenceid=None):
2.208 + return self.store.dequeue_request(self.user, uid, recurrenceid)
2.209 +
2.210 + def remove_event(self, uid, recurrenceid=None):
2.211 + return self.store.remove_event(self.user, uid, recurrenceid)
2.212 +
2.213 + def update_freebusy(self, uid, recurrenceid, obj):
2.214 +
2.215 + """
2.216 + Update stored free/busy details for the event with the given 'uid' and
2.217 + 'recurrenceid' having a representation of 'obj'.
2.218 + """
2.219 +
2.220 + is_only_organiser = self.user not in uri_values(obj.get_values("ATTENDEE"))
2.221 +
2.222 freebusy = self.store.get_freebusy(self.user)
2.223 - update_freebusy(freebusy, self.user, obj.get_periods_for_freebusy(tzid),
2.224 - obj.get_value("TRANSP"), uid, self.store)
2.225 -
2.226 - def remove_from_freebusy(self, uid):
2.227 +
2.228 + update_freebusy(freebusy,
2.229 + obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()),
2.230 + is_only_organiser and "ORG" or obj.get_value("TRANSP"),
2.231 + uid, recurrenceid)
2.232 +
2.233 + # Subtract any recurrences from the free/busy details of a parent
2.234 + # object.
2.235 +
2.236 + for recurrenceid in self._get_recurrences(uid):
2.237 + remove_affected_period(freebusy, uid, recurrenceid)
2.238 +
2.239 + self.store.set_freebusy(self.user, freebusy)
2.240 +
2.241 + def remove_from_freebusy(self, uid, recurrenceid=None):
2.242 freebusy = self.store.get_freebusy(self.user)
2.243 - remove_from_freebusy(freebusy, self.user, uid, self.store)
2.244 + remove_period(freebusy, uid, recurrenceid)
2.245 + self.store.set_freebusy(self.user, freebusy)
2.246
2.247 # Presentation methods.
2.248
2.249 @@ -412,6 +472,12 @@
2.250 self.new_page(title="Redirect")
2.251 self.page.p("Redirecting to: %s" % url)
2.252
2.253 + def link_to(self, uid, recurrenceid=None):
2.254 + if recurrenceid:
2.255 + return self.env.new_url("/".join([uid, recurrenceid]))
2.256 + else:
2.257 + return self.env.new_url(uid)
2.258 +
2.259 # Request logic methods.
2.260
2.261 def handle_newevent(self):
2.262 @@ -456,7 +522,10 @@
2.263
2.264 # Merge adjacent dates and datetimes.
2.265
2.266 - if start == last_end or get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid):
2.267 + if start == last_end or \
2.268 + not isinstance(start, datetime) and \
2.269 + get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid):
2.270 +
2.271 last = last_start, end
2.272 continue
2.273
2.274 @@ -464,7 +533,9 @@
2.275 # Datetime periods are within single days and are therefore
2.276 # discarded.
2.277
2.278 - elif get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid):
2.279 + elif not isinstance(last_start, datetime) and \
2.280 + get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid):
2.281 +
2.282 continue
2.283
2.284 # Add separate dates and datetimes.
2.285 @@ -482,48 +553,60 @@
2.286 utcnow = get_timestamp()
2.287 uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user))
2.288
2.289 + # Create a calendar object and store it as a request.
2.290 +
2.291 + record = []
2.292 + rwrite = record.append
2.293 +
2.294 # Define a single occurrence if only one coalesced slot exists.
2.295 - # Otherwise, many occurrences are defined.
2.296 -
2.297 - for i, (start, end) in enumerate(coalesced):
2.298 - this_uid = "%s-%s" % (uid, i)
2.299 -
2.300 +
2.301 + start, end = coalesced[0]
2.302 + start_value, start_attr = get_datetime_item(start, tzid)
2.303 + end_value, end_attr = get_datetime_item(end, tzid)
2.304 +
2.305 + rwrite(("UID", {}, uid))
2.306 + rwrite(("SUMMARY", {}, "New event at %s" % utcnow))
2.307 + rwrite(("DTSTAMP", {}, utcnow))
2.308 + rwrite(("DTSTART", start_attr, start_value))
2.309 + rwrite(("DTEND", end_attr, end_value))
2.310 + rwrite(("ORGANIZER", {}, self.user))
2.311 +
2.312 + participants = uri_values(filter(None, participants))
2.313 +
2.314 + for participant in participants:
2.315 + rwrite(("ATTENDEE", {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}, participant))
2.316 +
2.317 + if self.user not in participants:
2.318 + rwrite(("ATTENDEE", {"PARTSTAT" : "ACCEPTED"}, self.user))
2.319 +
2.320 + # Define additional occurrences if many slots are defined.
2.321 +
2.322 + rdates = []
2.323 +
2.324 + for start, end in coalesced[1:]:
2.325 start_value, start_attr = get_datetime_item(start, tzid)
2.326 end_value, end_attr = get_datetime_item(end, tzid)
2.327 -
2.328 - # Create a calendar object and store it as a request.
2.329 -
2.330 - record = []
2.331 - rwrite = record.append
2.332 -
2.333 - rwrite(("UID", {}, this_uid))
2.334 - rwrite(("SUMMARY", {}, "New event at %s" % utcnow))
2.335 - rwrite(("DTSTAMP", {}, utcnow))
2.336 - rwrite(("DTSTART", start_attr, start_value))
2.337 - rwrite(("DTEND", end_attr, end_value))
2.338 - rwrite(("ORGANIZER", {}, self.user))
2.339 -
2.340 - for participant in participants:
2.341 - if not participant:
2.342 - continue
2.343 - participant = get_uri(participant)
2.344 - rwrite(("ATTENDEE", {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}, participant))
2.345 -
2.346 - obj = ("VEVENT", {}, record)
2.347 -
2.348 - self.store.set_event(self.user, this_uid, obj)
2.349 - self.store.queue_request(self.user, this_uid)
2.350 + rdates.append("%s/%s" % (start_value, end_value))
2.351 +
2.352 + if rdates:
2.353 + rwrite(("RDATE", {"VALUE" : "PERIOD", "TZID" : tzid}, rdates))
2.354 +
2.355 + node = ("VEVENT", {}, record)
2.356 +
2.357 + self.store.set_event(self.user, uid, None, node=node)
2.358 + self.store.queue_request(self.user, uid)
2.359
2.360 # Redirect to the object (or the first of the objects), where instead of
2.361 # attendee controls, there will be organiser controls.
2.362
2.363 - self.redirect(self.env.new_url("%s-0" % uid))
2.364 -
2.365 - def handle_request(self, uid, obj):
2.366 + self.redirect(self.link_to(uid))
2.367 +
2.368 + def handle_request(self, uid, recurrenceid, obj):
2.369
2.370 """
2.371 - Handle actions involving the given 'uid' and 'obj' object, returning an
2.372 - error if one occurred, or None if the request was successfully handled.
2.373 + Handle actions involving the given 'uid', 'recurrenceid', and 'obj' as
2.374 + the object's representation, returning an error if one occurred, or None
2.375 + if the request was successfully handled.
2.376 """
2.377
2.378 # Handle a submitted form.
2.379 @@ -548,15 +631,13 @@
2.380 if args.has_key("summary"):
2.381 obj["SUMMARY"] = [(args["summary"][0], {})]
2.382
2.383 - organisers = uri_dict(obj.get_value_map("ORGANIZER"))
2.384 attendees = uri_dict(obj.get_value_map("ATTENDEE"))
2.385
2.386 if args.has_key("partstat"):
2.387 - for d in attendees, organisers:
2.388 - if d.has_key(self.user):
2.389 - d[self.user]["PARTSTAT"] = args["partstat"][0]
2.390 - if d[self.user].has_key("RSVP"):
2.391 - del d[self.user]["RSVP"]
2.392 + if attendees.has_key(self.user):
2.393 + attendees[self.user]["PARTSTAT"] = args["partstat"][0]
2.394 + if attendees[self.user].has_key("RSVP"):
2.395 + del attendees[self.user]["RSVP"]
2.396
2.397 is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user
2.398
2.399 @@ -621,21 +702,22 @@
2.400 is_organiser and (invite or cancel) and \
2.401 handler.process_created_request(invite and "REQUEST" or "CANCEL", update, removed, added):
2.402
2.403 - self.remove_request(uid)
2.404 + self.remove_request(uid, recurrenceid)
2.405
2.406 # Save single user events.
2.407
2.408 elif save:
2.409 - self.store.set_event(self.user, uid, obj.to_node())
2.410 - self.update_freebusy(uid, obj)
2.411 - self.remove_request(uid)
2.412 + to_cancel = self.update_attendees(obj, added, removed)
2.413 + self.store.set_event(self.user, uid, recurrenceid, node=obj.to_node())
2.414 + self.update_freebusy(uid, recurrenceid, obj=obj)
2.415 + self.remove_request(uid, recurrenceid)
2.416
2.417 # Remove the request and the object.
2.418
2.419 elif discard:
2.420 - self.remove_from_freebusy(uid)
2.421 - self.remove_event(uid)
2.422 - self.remove_request(uid)
2.423 + self.remove_from_freebusy(uid, recurrenceid)
2.424 + self.remove_event(uid, recurrenceid)
2.425 + self.remove_request(uid, recurrenceid)
2.426
2.427 else:
2.428 handled = False
2.429 @@ -707,7 +789,7 @@
2.430 attendees = uri_values((obj.get_values("ATTENDEE") or []) + args.get("attendee", []))
2.431 is_attendee = self.user in attendees
2.432
2.433 - is_request = obj.get_value("UID") in self._get_requests()
2.434 + is_request = (obj.get_value("UID"), obj.get_value("RECURRENCE-ID")) in self._get_requests()
2.435
2.436 have_other_attendees = len(attendees) > (is_attendee and 1 or 0)
2.437
2.438 @@ -757,6 +839,7 @@
2.439 ("TENTATIVE", "Tentatively attending"),
2.440 ("DECLINED", "Not attending"),
2.441 ("DELEGATED", "Delegated"),
2.442 + (None, "Not indicated"),
2.443 ]
2.444
2.445 def show_object_on_page(self, uid, obj, error=None):
2.446 @@ -769,81 +852,32 @@
2.447 page = self.page
2.448 page.form(method="POST")
2.449
2.450 + args = self.env.get_args()
2.451 +
2.452 # Obtain the user's timezone.
2.453
2.454 tzid = self.get_tzid()
2.455
2.456 - # Provide controls to change the displayed object.
2.457 -
2.458 - args = self.env.get_args()
2.459 -
2.460 - # Add or remove new attendees.
2.461 - # This does not affect the stored object.
2.462 -
2.463 - existing_attendees = uri_values(obj.get_values("ATTENDEE") or [])
2.464 - new_attendees = args.get("added", [])
2.465 - new_attendee = args.get("attendee", [""])[0]
2.466 -
2.467 - if args.has_key("add"):
2.468 - if new_attendee.strip():
2.469 - new_attendee = get_uri(new_attendee.strip())
2.470 - if new_attendee not in new_attendees and new_attendee not in existing_attendees:
2.471 - new_attendees.append(new_attendee)
2.472 - new_attendee = ""
2.473 -
2.474 - if args.has_key("removenew"):
2.475 - removed_attendee = args["removenew"][0]
2.476 - if removed_attendee in new_attendees:
2.477 - new_attendees.remove(removed_attendee)
2.478 -
2.479 - # Configure the start and end datetimes.
2.480 -
2.481 - dtend_control = args.get("dtend-control", [None])[0]
2.482 - dttimes_control = args.get("dttimes-control", [None])[0]
2.483 - with_time = dttimes_control == "enable"
2.484 -
2.485 - t = self.handle_date_controls("dtstart", with_time)
2.486 - if t:
2.487 - dtstart, dtstart_attr = t
2.488 + # Obtain basic event information, showing any necessary editing controls.
2.489 +
2.490 + is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user
2.491 +
2.492 + if is_organiser:
2.493 + (dtstart, dtstart_attr), (dtend, dtend_attr) = self.show_object_organiser_controls(obj)
2.494 + new_attendees, new_attendee = self.handle_new_attendees(obj)
2.495 else:
2.496 dtstart, dtstart_attr = obj.get_datetime_item("DTSTART")
2.497 -
2.498 - if dtend_control == "enable":
2.499 - t = self.handle_date_controls("dtend", with_time)
2.500 - if t:
2.501 - dtend, dtend_attr = t
2.502 + if obj.has_key("DTEND"):
2.503 + dtend, dtend_attr = obj.get_datetime_item("DTEND")
2.504 + elif obj.has_key("DURATION"):
2.505 + duration = obj.get_duration("DURATION")
2.506 + dtend = dtstart + duration
2.507 + dtend_attr = dtstart_attr
2.508 else:
2.509 - dtend, dtend_attr = None, {}
2.510 - elif dtend_control == "disable":
2.511 - dtend, dtend_attr = None, {}
2.512 - else:
2.513 - dtend, dtend_attr = obj.get_datetime_item("DTEND")
2.514 -
2.515 - # Change end dates to refer to the actual dates, not the iCalendar
2.516 - # "next day" dates.
2.517 -
2.518 - if dtend and not isinstance(dtend, datetime):
2.519 - dtend -= timedelta(1)
2.520 -
2.521 - # Show the end datetime controls if already active or if an object needs
2.522 - # them.
2.523 -
2.524 - dtend_enabled = dtend_control == "enable" or isinstance(dtend, datetime) or dtstart != dtend
2.525 - dttimes_enabled = dttimes_control == "enable" or isinstance(dtstart, datetime) or isinstance(dtend, datetime)
2.526 -
2.527 - if dtend_enabled:
2.528 - page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable", checked="checked")
2.529 - page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable")
2.530 - else:
2.531 - page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable")
2.532 - page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable", checked="checked")
2.533 -
2.534 - if dttimes_enabled:
2.535 - page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable", checked="checked")
2.536 - page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable")
2.537 - else:
2.538 - page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable")
2.539 - page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable", checked="checked")
2.540 + dtend, dtend_attr = dtstart, dtstart_attr
2.541 +
2.542 + new_attendees = []
2.543 + new_attendee = ""
2.544
2.545 # Provide a summary of the object.
2.546
2.547 @@ -855,8 +889,6 @@
2.548 page.thead.close()
2.549 page.tbody()
2.550
2.551 - is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user
2.552 -
2.553 for name, label in self.property_items:
2.554 page.tr()
2.555
2.556 @@ -943,7 +975,7 @@
2.557 else:
2.558 first = False
2.559
2.560 - if name in ("ATTENDEE", "ORGANIZER"):
2.561 + if name == "ATTENDEE":
2.562 value = get_uri(value)
2.563
2.564 page.td(class_="objectvalue")
2.565 @@ -951,12 +983,12 @@
2.566 page.add(" ")
2.567
2.568 partstat = attr.get("PARTSTAT")
2.569 - if value == self.user and (not is_organiser or name == "ORGANIZER"):
2.570 + if value == self.user:
2.571 self._show_menu("partstat", partstat, self.partstat_items, "partstat")
2.572 else:
2.573 page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat")
2.574
2.575 - if is_organiser and name == "ATTENDEE":
2.576 + if is_organiser:
2.577 if value in args.get("remove", []):
2.578 page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove", checked="checked")
2.579 else:
2.580 @@ -1006,26 +1038,123 @@
2.581
2.582 page.form.close()
2.583
2.584 + def handle_new_attendees(self, obj):
2.585 +
2.586 + "Add or remove new attendees. This does not affect the stored object."
2.587 +
2.588 + args = self.env.get_args()
2.589 +
2.590 + existing_attendees = uri_values(obj.get_values("ATTENDEE") or [])
2.591 + new_attendees = args.get("added", [])
2.592 + new_attendee = args.get("attendee", [""])[0]
2.593 +
2.594 + if args.has_key("add"):
2.595 + if new_attendee.strip():
2.596 + new_attendee = get_uri(new_attendee.strip())
2.597 + if new_attendee not in new_attendees and new_attendee not in existing_attendees:
2.598 + new_attendees.append(new_attendee)
2.599 + new_attendee = ""
2.600 +
2.601 + if args.has_key("removenew"):
2.602 + removed_attendee = args["removenew"][0]
2.603 + if removed_attendee in new_attendees:
2.604 + new_attendees.remove(removed_attendee)
2.605 +
2.606 + return new_attendees, new_attendee
2.607 +
2.608 + def show_object_organiser_controls(self, obj):
2.609 +
2.610 + "Provide controls to change the displayed object 'obj'."
2.611 +
2.612 + page = self.page
2.613 + args = self.env.get_args()
2.614 +
2.615 + # Configure the start and end datetimes.
2.616 +
2.617 + dtend_control = args.get("dtend-control", [None])[0]
2.618 + dttimes_control = args.get("dttimes-control", [None])[0]
2.619 + with_time = dttimes_control == "enable"
2.620 +
2.621 + t = self.handle_date_controls("dtstart", with_time)
2.622 + if t:
2.623 + dtstart, dtstart_attr = t
2.624 + else:
2.625 + dtstart, dtstart_attr = obj.get_datetime_item("DTSTART")
2.626 +
2.627 + if dtend_control == "enable":
2.628 + t = self.handle_date_controls("dtend", with_time)
2.629 + if t:
2.630 + dtend, dtend_attr = t
2.631 + else:
2.632 + dtend, dtend_attr = None, {}
2.633 + elif dtend_control == "disable":
2.634 + dtend, dtend_attr = None, {}
2.635 + elif obj.has_key("DTEND"):
2.636 + dtend, dtend_attr = obj.get_datetime_item("DTEND")
2.637 + elif obj.has_key("DURATION"):
2.638 + duration = obj.get_duration("DURATION")
2.639 + dtend = dtstart + duration
2.640 + dtend_attr = dtstart_attr
2.641 + else:
2.642 + dtend, dtend_attr = dtstart, dtstart_attr
2.643 +
2.644 + # Change end dates to refer to the actual dates, not the iCalendar
2.645 + # "next day" dates.
2.646 +
2.647 + if dtend and not isinstance(dtend, datetime):
2.648 + dtend -= timedelta(1)
2.649 +
2.650 + # Show the end datetime controls if already active or if an object needs
2.651 + # them.
2.652 +
2.653 + dtend_enabled = dtend_control == "enable" or isinstance(dtend, datetime) or dtstart != dtend
2.654 + dttimes_enabled = dttimes_control == "enable" or isinstance(dtstart, datetime) or isinstance(dtend, datetime)
2.655 +
2.656 + if dtend_enabled:
2.657 + page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable", checked="checked")
2.658 + page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable")
2.659 + else:
2.660 + page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable")
2.661 + page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable", checked="checked")
2.662 +
2.663 + if dttimes_enabled:
2.664 + page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable", checked="checked")
2.665 + page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable")
2.666 + else:
2.667 + page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable")
2.668 + page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable", checked="checked")
2.669 +
2.670 + return (dtstart, dtstart_attr), (dtend, dtend_attr)
2.671 +
2.672 def show_recurrences(self, obj):
2.673
2.674 "Show recurrences for the object having the given representation 'obj'."
2.675
2.676 page = self.page
2.677
2.678 - # Obtain the user's timezone.
2.679 -
2.680 - tzid = self.get_tzid()
2.681 -
2.682 - window_size = 100
2.683 -
2.684 - periods = obj.get_periods(self.get_tzid(), window_size)
2.685 + # Obtain any parent object if this object is a specific recurrence.
2.686 +
2.687 + uid = obj.get_value("UID")
2.688 + recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID"))
2.689 +
2.690 + if recurrenceid:
2.691 + obj = self._get_object(uid)
2.692 + if not obj:
2.693 + return
2.694 +
2.695 + page.p("This event modifies a recurring event.")
2.696 +
2.697 + # Obtain the periods associated with the event in the user's time zone.
2.698 +
2.699 + periods = obj.get_periods(self.get_tzid(), self.get_window_end())
2.700 + recurrenceids = self._get_recurrences(uid)
2.701
2.702 if len(periods) == 1:
2.703 return
2.704
2.705 - page.p("This event occurs on the following occasions within the next %d days:" % window_size)
2.706 -
2.707 - page.table(cellspacing=5, cellpadding=5, class_="conflicts")
2.708 + page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size())
2.709 +
2.710 + page.table(cellspacing=5, cellpadding=5, class_="recurrences")
2.711 page.thead()
2.712 page.tr()
2.713 page.th("Start")
2.714 @@ -1035,9 +1164,15 @@
2.715 page.tbody()
2.716
2.717 for start, end in periods:
2.718 + start_utc = format_datetime(to_timezone(start, "UTC"))
2.719 + css = " ".join([
2.720 + recurrenceids and start_utc in recurrenceids and "replaced" or "",
2.721 + recurrenceid and start_utc == recurrenceid and "affected" or ""
2.722 + ])
2.723 +
2.724 page.tr()
2.725 - page.td(self.format_datetime(start, "long"))
2.726 - page.td(self.format_datetime(end, "long"))
2.727 + page.td(self.format_datetime(start, "long"), class_=css)
2.728 + page.td(self.format_datetime(end, "long"), class_=css)
2.729 page.tr.close()
2.730
2.731 page.tbody.close()
2.732 @@ -1057,7 +1192,15 @@
2.733 tzid = self.get_tzid()
2.734
2.735 dtstart = format_datetime(obj.get_utc_datetime("DTSTART"))
2.736 - dtend = format_datetime(obj.get_utc_datetime("DTEND"))
2.737 + if obj.has_key("DTEND"):
2.738 + dtend = format_datetime(obj.get_utc_datetime("DTEND"))
2.739 + elif obj.has_key("DURATION"):
2.740 + duration = obj.get_duration("DURATION")
2.741 + dtend = format_datetime(obj.get_utc_datetime("DTSTART") + duration)
2.742 + else:
2.743 + dtend = dtstart
2.744 +
2.745 + periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end())
2.746
2.747 # Indicate whether there are conflicting events.
2.748
2.749 @@ -1072,7 +1215,7 @@
2.750
2.751 # Show any conflicts.
2.752
2.753 - conflicts = [t for t in have_conflict(freebusy, [(dtstart, dtend)], True) if t[2] != uid]
2.754 + conflicts = [t for t in have_conflict(freebusy, periods, True) if t[2] != uid]
2.755
2.756 if conflicts:
2.757 page.p("This event conflicts with others:")
2.758 @@ -1088,7 +1231,7 @@
2.759 page.tbody()
2.760
2.761 for t in conflicts:
2.762 - start, end, found_uid = t[:3]
2.763 + start, end, found_uid, transp, found_recurrenceid = t[:5]
2.764
2.765 # Provide details of any conflicting event.
2.766
2.767 @@ -1101,9 +1244,9 @@
2.768
2.769 page.td()
2.770
2.771 - found_obj = self._get_object(found_uid)
2.772 + found_obj = self._get_object(found_uid, found_recurrenceid)
2.773 if found_obj:
2.774 - page.a(found_obj.get_value("SUMMARY"), href=self.env.new_url(found_uid))
2.775 + page.a(found_obj.get_value("SUMMARY"), href=self.link_to(found_uid))
2.776 else:
2.777 page.add("No details available")
2.778
2.779 @@ -1133,11 +1276,11 @@
2.780
2.781 self.page.ul()
2.782
2.783 - for request in requests:
2.784 - obj = self._get_object(request)
2.785 + for uid, recurrenceid in requests:
2.786 + obj = self._get_object(uid, recurrenceid)
2.787 if obj:
2.788 self.page.li()
2.789 - self.page.a(obj.get_value("SUMMARY"), href="#request-%s" % request)
2.790 + self.page.a(obj.get_value("SUMMARY"), href="#request-%s-%s" % (uid, recurrenceid or ""))
2.791 self.page.li.close()
2.792
2.793 self.page.ul.close()
2.794 @@ -1196,13 +1339,13 @@
2.795
2.796 "Show an object request using the given 'path_info' for the current user."
2.797
2.798 - uid = self._get_uid(path_info)
2.799 - obj = self._get_object(uid)
2.800 + uid, recurrenceid = self._get_identifiers(path_info)
2.801 + obj = self._get_object(uid, recurrenceid)
2.802
2.803 if not obj:
2.804 return False
2.805
2.806 - error = self.handle_request(uid, obj)
2.807 + error = self.handle_request(uid, recurrenceid, obj)
2.808
2.809 if not error:
2.810 return True
2.811 @@ -1559,7 +1702,7 @@
2.812 page.td.close()
2.813 empty = 0
2.814
2.815 - start, end, uid, key = get_freebusy_details(t)
2.816 + start, end, uid, recurrenceid, key = get_freebusy_details(t)
2.817 span = spans[key]
2.818
2.819 # Produce a table cell only at the start of the period
2.820 @@ -1567,7 +1710,7 @@
2.821
2.822 if point == start or continuation:
2.823
2.824 - obj = self._get_object(uid)
2.825 + obj = self._get_object(uid, recurrenceid)
2.826
2.827 has_continued = continuation and point != start
2.828 will_continue = not ends_on_same_day(point, end, tzid)
2.829 @@ -1581,9 +1724,11 @@
2.830 )
2.831
2.832 # Only anchor the first cell of events.
2.833 + # NOTE: Need to only anchor the first period for a
2.834 + # NOTE: recurring event.
2.835
2.836 if point == start:
2.837 - page.td(class_=css, rowspan=span, id="%s-%s" % (group_type, uid))
2.838 + page.td(class_=css, rowspan=span, id="%s-%s-%s" % (group_type, uid, recurrenceid or ""))
2.839 else:
2.840 page.td(class_=css, rowspan=span)
2.841
2.842 @@ -1595,11 +1740,10 @@
2.843 # Only link to events if they are not being
2.844 # updated by requests.
2.845
2.846 - if uid in self._get_requests() and group_type != "request":
2.847 + if (uid, recurrenceid) in self._get_requests() and group_type != "request":
2.848 page.span(summary)
2.849 else:
2.850 - href = "%s/%s" % (self.env.get_url().rstrip("/"), uid)
2.851 - page.a(summary, href=href)
2.852 + page.a(summary, href=self.link_to(uid, recurrenceid))
2.853
2.854 page.td.close()
2.855 else:
2.856 @@ -1676,6 +1820,8 @@
2.857 values = self.env.get_args().get(name, [default])
2.858 page.select(name=name, class_=class_)
2.859 for v, label in items:
2.860 + if v is None:
2.861 + continue
2.862 if v in values:
2.863 page.option(label, value=v, selected="selected")
2.864 else:
3.1 --- a/imip_store.py Thu Feb 12 22:34:48 2015 +0100
3.2 +++ b/imip_store.py Sat Mar 07 00:11:44 2015 +0100
3.3 @@ -24,7 +24,7 @@
3.4 from imiptools.data import make_calendar, parse_object, to_stream
3.5 from imiptools.filesys import fix_permissions, FileBase
3.6 from os.path import exists, isfile, join
3.7 -from os import listdir, remove
3.8 +from os import listdir, remove, rmdir
3.9 from time import sleep
3.10
3.11 class FileStore(FileBase):
3.12 @@ -40,6 +40,66 @@
3.13 def release_lock(self, user):
3.14 FileBase.release_lock(self, user)
3.15
3.16 + def _set_defaults(self, t, empty_defaults):
3.17 + for i, default in empty_defaults:
3.18 + if i >= len(t):
3.19 + t += [None] * (i - len(t) + 1)
3.20 + if not t[i]:
3.21 + t[i] = default
3.22 + return t
3.23 +
3.24 + def _get_table(self, user, filename, empty_defaults=None):
3.25 +
3.26 + """
3.27 + From the file for the given 'user' having the given 'filename', return
3.28 + a list of tuples representing the file's contents.
3.29 +
3.30 + The 'empty_defaults' is a list of (index, value) tuples indicating the
3.31 + default value where a column either does not exist or provides an empty
3.32 + value.
3.33 + """
3.34 +
3.35 + self.acquire_lock(user)
3.36 + try:
3.37 + f = open(filename, "rb")
3.38 + try:
3.39 + l = []
3.40 + for line in f.readlines():
3.41 + t = line.strip().split("\t")
3.42 + if empty_defaults:
3.43 + t = self._set_defaults(t, empty_defaults)
3.44 + l.append(tuple(t))
3.45 + return l
3.46 + finally:
3.47 + f.close()
3.48 + finally:
3.49 + self.release_lock(user)
3.50 +
3.51 + def _set_table(self, user, filename, items, empty_defaults=None):
3.52 +
3.53 + """
3.54 + For the given 'user', write to the file having the given 'filename' the
3.55 + 'items'.
3.56 +
3.57 + The 'empty_defaults' is a list of (index, value) tuples indicating the
3.58 + default value where a column either does not exist or provides an empty
3.59 + value.
3.60 + """
3.61 +
3.62 + self.acquire_lock(user)
3.63 + try:
3.64 + f = open(filename, "wb")
3.65 + try:
3.66 + for item in items:
3.67 + if empty_defaults:
3.68 + item = self._set_defaults(list(item), empty_defaults)
3.69 + f.write("\t".join(item) + "\n")
3.70 + finally:
3.71 + f.close()
3.72 + fix_permissions(filename)
3.73 + finally:
3.74 + self.release_lock(user)
3.75 +
3.76 def _get_object(self, user, filename):
3.77
3.78 """
3.79 @@ -88,6 +148,17 @@
3.80
3.81 return True
3.82
3.83 + def _remove_collection(self, filename):
3.84 +
3.85 + "Remove the collection with the given 'filename'."
3.86 +
3.87 + try:
3.88 + rmdir(filename)
3.89 + except OSError:
3.90 + return False
3.91 +
3.92 + return True
3.93 +
3.94 def get_events(self, user):
3.95
3.96 "Return a list of event identifiers."
3.97 @@ -98,7 +169,20 @@
3.98
3.99 return [name for name in listdir(filename) if isfile(join(filename, name))]
3.100
3.101 - def get_event(self, user, uid):
3.102 + def get_event(self, user, uid, recurrenceid=None):
3.103 +
3.104 + """
3.105 + Get the event for the given 'user' with the given 'uid'. If
3.106 + the optional 'recurrenceid' is specified, a specific instance or
3.107 + occurrence of an event is returned.
3.108 + """
3.109 +
3.110 + if recurrenceid:
3.111 + return self.get_recurrence(user, uid, recurrenceid)
3.112 + else:
3.113 + return self.get_complete_event(user, uid)
3.114 +
3.115 + def get_complete_event(self, user, uid):
3.116
3.117 "Get the event for the given 'user' with the given 'uid'."
3.118
3.119 @@ -108,7 +192,20 @@
3.120
3.121 return self._get_object(user, filename)
3.122
3.123 - def set_event(self, user, uid, node):
3.124 + def set_event(self, user, uid, recurrenceid, node):
3.125 +
3.126 + """
3.127 + Set an event for 'user' having the given 'uid' and 'recurrenceid' (which
3.128 + if the latter is specified, a specific instance or occurrence of an
3.129 + event is referenced), using the given 'node' description.
3.130 + """
3.131 +
3.132 + if recurrenceid:
3.133 + return self.set_recurrence(user, uid, recurrenceid, node)
3.134 + else:
3.135 + return self.set_complete_event(user, uid, node)
3.136 +
3.137 + def set_complete_event(self, user, uid, node):
3.138
3.139 "Set an event for 'user' having the given 'uid' and 'node'."
3.140
3.141 @@ -118,16 +215,98 @@
3.142
3.143 return self._set_object(user, filename, node)
3.144
3.145 - def remove_event(self, user, uid):
3.146 + def remove_event(self, user, uid, recurrenceid=None):
3.147 +
3.148 + """
3.149 + Remove an event for 'user' having the given 'uid'. If the optional
3.150 + 'recurrenceid' is specified, a specific instance or occurrence of an
3.151 + event is removed.
3.152 + """
3.153 +
3.154 + if recurrenceid:
3.155 + return self.remove_recurrence(user, uid, recurrenceid)
3.156 + else:
3.157 + for recurrenceid in self.get_recurrences(user, uid) or []:
3.158 + self.remove_recurrence(user, uid, recurrenceid)
3.159 + return self.remove_complete_event(user, uid)
3.160 +
3.161 + def remove_complete_event(self, user, uid):
3.162
3.163 "Remove an event for 'user' having the given 'uid'."
3.164
3.165 + self.remove_recurrences(user, uid)
3.166 +
3.167 filename = self.get_object_in_store(user, "objects", uid)
3.168 if not filename:
3.169 return False
3.170
3.171 return self._remove_object(filename)
3.172
3.173 + def get_recurrences(self, user, uid):
3.174 +
3.175 + """
3.176 + Get additional event instances for an event of the given 'user' with the
3.177 + indicated 'uid'.
3.178 + """
3.179 +
3.180 + filename = self.get_object_in_store(user, "recurrences", uid)
3.181 + if not filename or not exists(filename):
3.182 + return []
3.183 +
3.184 + return [name for name in listdir(filename) if isfile(join(filename, name))]
3.185 +
3.186 + def get_recurrence(self, user, uid, recurrenceid):
3.187 +
3.188 + """
3.189 + For the event of the given 'user' with the given 'uid', return the
3.190 + specific recurrence indicated by the 'recurrenceid'.
3.191 + """
3.192 +
3.193 + filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid)
3.194 + if not filename or not exists(filename):
3.195 + return None
3.196 +
3.197 + return self._get_object(user, filename)
3.198 +
3.199 + def set_recurrence(self, user, uid, recurrenceid, node):
3.200 +
3.201 + "Set an event for 'user' having the given 'uid' and 'node'."
3.202 +
3.203 + filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid)
3.204 + if not filename:
3.205 + return False
3.206 +
3.207 + return self._set_object(user, filename, node)
3.208 +
3.209 + def remove_recurrence(self, user, uid, recurrenceid):
3.210 +
3.211 + """
3.212 + Remove a special recurrence from an event stored by 'user' having the
3.213 + given 'uid' and 'recurrenceid'.
3.214 + """
3.215 +
3.216 + filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid)
3.217 + if not filename:
3.218 + return False
3.219 +
3.220 + return self._remove_object(filename)
3.221 +
3.222 + def remove_recurrences(self, user, uid):
3.223 +
3.224 + """
3.225 + Remove all recurrences for an event stored by 'user' having the given
3.226 + 'uid'.
3.227 + """
3.228 +
3.229 + for recurrenceid in self.get_recurrences(user, uid):
3.230 + self.remove_recurrence(user, uid, recurrenceid)
3.231 +
3.232 + recurrences = self.get_object_in_store(user, "recurrences", uid)
3.233 + if recurrences:
3.234 + return self._remove_collection(recurrences)
3.235 +
3.236 + return True
3.237 +
3.238 def get_freebusy(self, user):
3.239
3.240 "Get free/busy details for the given 'user'."
3.241 @@ -136,7 +315,7 @@
3.242 if not filename or not exists(filename):
3.243 return []
3.244 else:
3.245 - return self._get_freebusy(user, filename)
3.246 + return self._get_table(user, filename, [(4, None)])
3.247
3.248 def get_freebusy_for_other(self, user, other):
3.249
3.250 @@ -146,24 +325,7 @@
3.251 if not filename or not exists(filename):
3.252 return []
3.253 else:
3.254 - return self._get_freebusy(user, filename)
3.255 -
3.256 - def _get_freebusy(self, user, filename):
3.257 -
3.258 - "For the given 'user', get the free/busy details from 'filename'."
3.259 -
3.260 - self.acquire_lock(user)
3.261 - try:
3.262 - f = open(filename)
3.263 - try:
3.264 - l = []
3.265 - for line in f.readlines():
3.266 - l.append(tuple(line.strip().split("\t")))
3.267 - return l
3.268 - finally:
3.269 - f.close()
3.270 - finally:
3.271 - self.release_lock(user)
3.272 + return self._get_table(user, filename, [(4, None)])
3.273
3.274 def set_freebusy(self, user, freebusy):
3.275
3.276 @@ -173,7 +335,7 @@
3.277 if not filename:
3.278 return False
3.279
3.280 - self._set_freebusy(user, filename, freebusy)
3.281 + self._set_table(user, filename, freebusy, [(3, "OPAQUE"), (4, "")])
3.282 return True
3.283
3.284 def set_freebusy_for_other(self, user, freebusy, other):
3.285 @@ -184,28 +346,9 @@
3.286 if not filename:
3.287 return False
3.288
3.289 - self._set_freebusy(user, filename, freebusy)
3.290 + self._set_table(user, filename, freebusy, [(2, ""), (3, "OPAQUE"), (4, "")])
3.291 return True
3.292
3.293 - def _set_freebusy(self, user, filename, freebusy):
3.294 -
3.295 - """
3.296 - For the given 'user', write to the file having the given 'filename' the
3.297 - 'freebusy' details.
3.298 - """
3.299 -
3.300 - self.acquire_lock(user)
3.301 - try:
3.302 - f = open(filename, "w")
3.303 - try:
3.304 - for item in freebusy:
3.305 - f.write("\t".join([(value or "OPAQUE") for value in item]) + "\n")
3.306 - finally:
3.307 - f.close()
3.308 - fix_permissions(filename)
3.309 - finally:
3.310 - self.release_lock(user)
3.311 -
3.312 def _get_requests(self, user, queue):
3.313
3.314 "Get requests for the given 'user' from the given 'queue'."
3.315 @@ -214,15 +357,7 @@
3.316 if not filename or not exists(filename):
3.317 return None
3.318
3.319 - self.acquire_lock(user)
3.320 - try:
3.321 - f = open(filename)
3.322 - try:
3.323 - return [line.strip() for line in f.readlines()]
3.324 - finally:
3.325 - f.close()
3.326 - finally:
3.327 - self.release_lock(user)
3.328 + return self._get_table(user, filename, [(1, None)])
3.329
3.330 def get_requests(self, user):
3.331
3.332 @@ -252,7 +387,7 @@
3.333 f = open(filename, "w")
3.334 try:
3.335 for request in requests:
3.336 - print >>f, request
3.337 + print >>f, "\t".join([value or "" for value in request])
3.338 finally:
3.339 f.close()
3.340 fix_permissions(filename)
3.341 @@ -273,9 +408,12 @@
3.342
3.343 return self._set_requests(user, cancellations, "cancellations")
3.344
3.345 - def _set_request(self, user, request, queue):
3.346 + def _set_request(self, user, uid, recurrenceid, queue):
3.347
3.348 - "For the given 'user', set the queued 'request' in the given 'queue'."
3.349 + """
3.350 + For the given 'user', set the queued 'uid' and 'recurrenceid' in the
3.351 + given 'queue'.
3.352 + """
3.353
3.354 filename = self.get_object_in_store(user, queue)
3.355 if not filename:
3.356 @@ -285,7 +423,7 @@
3.357 try:
3.358 f = open(filename, "a")
3.359 try:
3.360 - print >>f, request
3.361 + print >>f, "\t".join([uid, recurrenceid or ""])
3.362 finally:
3.363 f.close()
3.364 fix_permissions(filename)
3.365 @@ -294,51 +432,63 @@
3.366
3.367 return True
3.368
3.369 - def set_request(self, user, request):
3.370 + def set_request(self, user, uid, recurrenceid=None):
3.371
3.372 - "For the given 'user', set the queued 'request'."
3.373 + "For the given 'user', set the queued 'uid' and 'recurrenceid'."
3.374
3.375 - return self._set_request(user, request, "requests")
3.376 + return self._set_request(user, uid, recurrenceid, "requests")
3.377
3.378 - def set_cancellation(self, user, cancellation):
3.379 + def set_cancellation(self, user, uid, recurrenceid=None):
3.380 +
3.381 + "For the given 'user', set the queued 'uid' and 'recurrenceid'."
3.382
3.383 - "For the given 'user', set the queued 'cancellation'."
3.384 + return self._set_request(user, uid, recurrenceid, "cancellations")
3.385
3.386 - return self._set_request(user, cancellation, "cancellations")
3.387 + def queue_request(self, user, uid, recurrenceid=None):
3.388
3.389 - def queue_request(self, user, uid):
3.390 -
3.391 - "Queue a request for 'user' having the given 'uid'."
3.392 + """
3.393 + Queue a request for 'user' having the given 'uid'. If the optional
3.394 + 'recurrenceid' is specified, the request refers to a specific instance
3.395 + or occurrence of an event.
3.396 + """
3.397
3.398 requests = self.get_requests(user) or []
3.399
3.400 - if uid not in requests:
3.401 - return self.set_request(user, uid)
3.402 + if (uid, recurrenceid) not in requests:
3.403 + return self.set_request(user, uid, recurrenceid)
3.404
3.405 return False
3.406
3.407 - def dequeue_request(self, user, uid):
3.408 + def dequeue_request(self, user, uid, recurrenceid=None):
3.409
3.410 - "Dequeue a request for 'user' having the given 'uid'."
3.411 + """
3.412 + Dequeue a request for 'user' having the given 'uid'. If the optional
3.413 + 'recurrenceid' is specified, the request refers to a specific instance
3.414 + or occurrence of an event.
3.415 + """
3.416
3.417 requests = self.get_requests(user) or []
3.418
3.419 try:
3.420 - requests.remove(uid)
3.421 + requests.remove((uid, recurrenceid))
3.422 self.set_requests(user, requests)
3.423 except ValueError:
3.424 return False
3.425 else:
3.426 return True
3.427
3.428 - def cancel_event(self, user, uid):
3.429 + def cancel_event(self, user, uid, recurrenceid=None):
3.430
3.431 - "Queue an event for cancellation for 'user' having the given 'uid'."
3.432 + """
3.433 + Queue an event for cancellation for 'user' having the given 'uid'. If
3.434 + the optional 'recurrenceid' is specified, a specific instance or
3.435 + occurrence of an event is cancelled.
3.436 + """
3.437
3.438 cancellations = self.get_cancellations(user) or []
3.439
3.440 - if uid not in cancellations:
3.441 - return self.set_cancellation(user, uid)
3.442 + if (uid, recurrenceid) not in cancellations:
3.443 + return self.set_cancellation(user, uid, recurrenceid)
3.444
3.445 return False
3.446
3.447 @@ -364,7 +514,7 @@
3.448 rwrite(("UID", {}, user))
3.449 rwrite(("DTSTAMP", {}, datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")))
3.450
3.451 - for start, end, uid, transp in freebusy:
3.452 + for start, end, uid, transp, recurrenceid in freebusy:
3.453 if not transp or transp == "OPAQUE":
3.454 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join([start, end])))
3.455
4.1 --- a/imiptools/content.py Thu Feb 12 22:34:48 2015 +0100
4.2 +++ b/imiptools/content.py Sat Mar 07 00:11:44 2015 +0100
4.3 @@ -24,13 +24,13 @@
4.4 from email.mime.text import MIMEText
4.5 from imiptools.config import MANAGER_PATH, MANAGER_URL
4.6 from imiptools.data import Object, parse_object, \
4.7 - get_address, get_uri, get_value, \
4.8 - is_new_object, uri_dict, uri_item
4.9 -from imiptools.dates import format_datetime, to_timezone
4.10 + get_address, get_uri, get_value, get_window_end, \
4.11 + is_new_object, uri_dict, uri_item, uri_values
4.12 +from imiptools.dates import format_datetime, get_default_timezone, to_timezone
4.13 from imiptools.period import can_schedule, insert_period, remove_period, \
4.14 - remove_from_freebusy, \
4.15 - remove_from_freebusy_for_other, \
4.16 - update_freebusy, update_freebusy_for_other
4.17 + remove_additional_periods, remove_affected_period, \
4.18 + update_freebusy
4.19 +from imiptools.profile import Preferences
4.20 from socket import gethostname
4.21 import imip_store
4.22
4.23 @@ -92,8 +92,11 @@
4.24 url_base = MANAGER_URL or "http://%s/" % gethostname()
4.25 return "%s/%s" % (url_base.rstrip("/"), MANAGER_PATH.lstrip("/"))
4.26
4.27 -def get_object_url(uid):
4.28 - return "%s/%s" % (get_manager_url().rstrip("/"), uid)
4.29 +def get_object_url(uid, recurrenceid=None):
4.30 + return "%s/%s%s" % (
4.31 + get_manager_url().rstrip("/"), uid,
4.32 + recurrenceid and "/%s" % recurrenceid or ""
4.33 + )
4.34
4.35 class Handler:
4.36
4.37 @@ -115,6 +118,7 @@
4.38
4.39 self.obj = None
4.40 self.uid = None
4.41 + self.recurrenceid = None
4.42 self.sequence = None
4.43 self.dtstamp = None
4.44
4.45 @@ -128,6 +132,7 @@
4.46 def set_object(self, obj):
4.47 self.obj = obj
4.48 self.uid = self.obj.get_value("UID")
4.49 + self.recurrenceid = format_datetime(self.obj.get_utc_datetime("RECURRENCE-ID"))
4.50 self.sequence = self.obj.get_value("SEQUENCE")
4.51 self.dtstamp = self.obj.get_value("DTSTAMP")
4.52
4.53 @@ -140,7 +145,7 @@
4.54 if link:
4.55 texts.append("If your mail program cannot handle this "
4.56 "message, you may view the details here:\n\n%s" %
4.57 - get_object_url(self.uid))
4.58 + get_object_url(self.uid, self.recurrenceid))
4.59
4.60 return self.add_result(None, None, MIMEText("\n".join(texts)))
4.61
4.62 @@ -163,38 +168,99 @@
4.63 def get_outgoing_methods(self):
4.64 return self.outgoing_methods
4.65
4.66 - # Access to calendar structures and other data.
4.67 + # Convenience methods for modifying free/busy collections.
4.68 +
4.69 + def remove_from_freebusy(self, freebusy):
4.70 +
4.71 + "Remove this event from the given 'freebusy' collection."
4.72 +
4.73 + remove_period(freebusy, self.uid, self.recurrenceid)
4.74 +
4.75 + def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None):
4.76
4.77 - def remove_from_freebusy(self, freebusy, attendee):
4.78 - remove_from_freebusy(freebusy, attendee, self.uid, self.store)
4.79 + """
4.80 + Remove from 'freebusy' any original recurrence from parent free/busy
4.81 + details for the current object, if the current object is a specific
4.82 + additional recurrence. Otherwise, remove all additional recurrence
4.83 + information corresponding to 'recurrenceids', or if omitted, all
4.84 + recurrences.
4.85 + """
4.86 +
4.87 + if self.recurrenceid:
4.88 + remove_affected_period(freebusy, self.uid, self.recurrenceid)
4.89 + else:
4.90 + # Remove obsolete recurrence periods.
4.91 +
4.92 + remove_additional_periods(freebusy, self.uid, recurrenceids)
4.93 +
4.94 + # Remove original periods affected by additional recurrences.
4.95 +
4.96 + if recurrenceids:
4.97 + for recurrenceid in recurrenceids:
4.98 + remove_affected_period(freebusy, self.uid, recurrenceid)
4.99 +
4.100 + def _update_freebusy(self, freebusy, periods, recurrenceid, transp=None):
4.101
4.102 - def remove_from_freebusy_for_other(self, freebusy, user, other):
4.103 - remove_from_freebusy_for_other(freebusy, user, other, self.uid, self.store)
4.104 + """
4.105 + Update the 'freebusy' collection with the given 'periods', indicating an
4.106 + explicit 'recurrenceid' to affect either a recurrence or the parent
4.107 + event.
4.108 + """
4.109 +
4.110 + update_freebusy(freebusy, periods, transp or self.obj.get_value("TRANSP"),
4.111 + self.uid, recurrenceid)
4.112 +
4.113 + def update_freebusy(self, freebusy, periods, transp=None):
4.114 +
4.115 + """
4.116 + Update the 'freebusy' collection for this event with the given
4.117 + 'periods'.
4.118 + """
4.119 +
4.120 + self._update_freebusy(freebusy, periods, self.recurrenceid, transp)
4.121
4.122 - def update_freebusy(self, freebusy, attendee, periods):
4.123 - update_freebusy(freebusy, attendee, periods, self.obj.get_value("TRANSP"),
4.124 - self.uid, self.store)
4.125 + def update_freebusy_for_participant(self, freebusy, periods, attr, for_organiser=False):
4.126 +
4.127 + """
4.128 + Update the 'freebusy' collection using the given 'periods', subject to
4.129 + the 'attr' provided for the participant, indicating whether this is
4.130 + being generated 'for_organiser' or not.
4.131 + """
4.132
4.133 - def update_freebusy_from_participant(self, user, participant_item):
4.134 + # Organisers employ a special transparency.
4.135 +
4.136 + if for_organiser or attr.get("PARTSTAT") != "DECLINED":
4.137 + self.update_freebusy(freebusy, periods, transp=(for_organiser and "ORG" or None))
4.138 + else:
4.139 + self.remove_from_freebusy(freebusy)
4.140 +
4.141 + # Convenience methods for updating stored free/busy information.
4.142 +
4.143 + def update_freebusy_from_participant(self, user, participant_item, for_organiser):
4.144
4.145 """
4.146 For the given 'user', record the free/busy information for the
4.147 - 'participant_item' (a value plus attributes), using the 'tzid' to define
4.148 - period information.
4.149 + 'participant_item' (a value plus attributes) representing a different
4.150 + identity, thus maintaining a separate record of their free/busy details.
4.151 """
4.152
4.153 participant, participant_attr = participant_item
4.154
4.155 - if participant != user:
4.156 - freebusy = self.store.get_freebusy_for_other(user, participant)
4.157 + if participant == user:
4.158 + return
4.159 +
4.160 + freebusy = self.store.get_freebusy_for_other(user, participant)
4.161 + tzid = self.get_tzid(user)
4.162 + window_end = get_window_end(tzid)
4.163 + periods = self.obj.get_periods_for_freebusy(tzid, window_end)
4.164
4.165 - if participant_attr.get("PARTSTAT") != "DECLINED":
4.166 - update_freebusy_for_other(freebusy, user, participant,
4.167 - self.obj.get_periods_for_freebusy(tzid=None),
4.168 - self.obj.get_value("TRANSP"),
4.169 - self.uid, self.store)
4.170 - else:
4.171 - self.remove_from_freebusy_for_other(freebusy, user, participant)
4.172 + # Record in the free/busy details unless a non-participating attendee.
4.173 +
4.174 + self.update_freebusy_for_participant(freebusy, periods, participant_attr,
4.175 + for_organiser and self.is_not_attendee(participant, self.obj))
4.176 +
4.177 + self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(user, self.uid))
4.178 + self.store.set_freebusy_for_other(user, freebusy, participant)
4.179
4.180 def update_freebusy_from_organiser(self, attendee, organiser_item):
4.181
4.182 @@ -203,17 +269,25 @@
4.183 'organiser_item' (a value plus attributes).
4.184 """
4.185
4.186 - self.update_freebusy_from_participant(attendee, organiser_item)
4.187 + self.update_freebusy_from_participant(attendee, organiser_item, True)
4.188
4.189 def update_freebusy_from_attendees(self, organiser, attendees):
4.190
4.191 "For the 'organiser', record free/busy information from 'attendees'."
4.192
4.193 for attendee_item in attendees.items():
4.194 - self.update_freebusy_from_participant(organiser, attendee_item)
4.195 + self.update_freebusy_from_participant(organiser, attendee_item, False)
4.196 +
4.197 + # Logic, filtering and access to calendar structures and other data.
4.198 +
4.199 + def is_not_attendee(self, identity, obj):
4.200 +
4.201 + "Return whether 'identity' is not an attendee in 'obj'."
4.202 +
4.203 + return identity not in uri_values(obj.get_values("ATTENDEE"))
4.204
4.205 def can_schedule(self, freebusy, periods):
4.206 - return can_schedule(freebusy, periods, self.uid)
4.207 + return can_schedule(freebusy, periods, self.uid, self.recurrenceid)
4.208
4.209 def filter_by_senders(self, mapping):
4.210
4.211 @@ -328,15 +402,32 @@
4.212
4.213 return senders
4.214
4.215 + def _get_object(self, user, uid, recurrenceid):
4.216 +
4.217 + """
4.218 + Return the stored object for the given 'user', 'uid' and 'recurrenceid'.
4.219 + """
4.220 +
4.221 + fragment = self.store.get_event(user, uid, recurrenceid)
4.222 + return fragment and Object(fragment)
4.223 +
4.224 def get_object(self, user):
4.225
4.226 """
4.227 Return the stored object to which the current object refers for the
4.228 - given 'user' and for the given 'objtype'.
4.229 + given 'user'.
4.230 """
4.231
4.232 - fragment = self.store.get_event(user, self.uid)
4.233 - return fragment and Object(fragment)
4.234 + return self._get_object(user, self.uid, self.recurrenceid)
4.235 +
4.236 + def get_parent_object(self, user):
4.237 +
4.238 + """
4.239 + Return the parent object to which the current object refers for the
4.240 + given 'user'.
4.241 + """
4.242 +
4.243 + return self.recurrenceid and self._get_object(user, self.uid, None) or None
4.244
4.245 def have_new_object(self, attendee, obj=None):
4.246
4.247 @@ -413,7 +504,12 @@
4.248
4.249 obj["ATTENDEE"] = attendee_map.items()
4.250
4.251 - self.store.set_event(identity, self.uid, obj.to_node())
4.252 + # Set the complete event if not an additional occurrence.
4.253 +
4.254 + event = obj.to_node()
4.255 + recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID"))
4.256 +
4.257 + self.store.set_event(identity, self.uid, self.recurrenceid, event)
4.258
4.259 return True
4.260
4.261 @@ -432,6 +528,13 @@
4.262 sequence = self.obj.get_value("SEQUENCE") or "0"
4.263 self.obj["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})]
4.264
4.265 + def get_tzid(self, identity):
4.266 +
4.267 + "Return the time regime applicable for the given 'identity'."
4.268 +
4.269 + preferences = Preferences(identity)
4.270 + return preferences.get("TZID") or get_default_timezone()
4.271 +
4.272 # Handler registry.
4.273
4.274 methods = {
5.1 --- a/imiptools/data.py Thu Feb 12 22:34:48 2015 +0100
5.2 +++ b/imiptools/data.py Sat Mar 07 00:11:44 2015 +0100
5.3 @@ -21,8 +21,9 @@
5.4
5.5 from datetime import datetime, timedelta
5.6 from email.mime.text import MIMEText
5.7 -from imiptools.dates import format_datetime, get_datetime, get_freebusy_period, \
5.8 - to_timezone, to_utc_datetime
5.9 +from imiptools.dates import format_datetime, get_datetime, get_duration, \
5.10 + get_freebusy_period, get_period, to_timezone, \
5.11 + to_utc_datetime
5.12 from imiptools.period import period_overlaps
5.13 from pytz import timezone
5.14 from vCalendar import iterwrite, parse, ParseError, to_dict, to_node
5.15 @@ -59,6 +60,13 @@
5.16 def get_utc_datetime(self, name):
5.17 return get_utc_datetime(self.details, name)
5.18
5.19 + def get_item_values(self, name):
5.20 + items = get_item_value_items(self.details, name)
5.21 + return items and [value for value, attr in items]
5.22 +
5.23 + def get_item_value_items(self, name):
5.24 + return get_item_value_items(self.details, name)
5.25 +
5.26 def get_datetime(self, name):
5.27 dt, attr = get_datetime_item(self.details, name)
5.28 return dt
5.29 @@ -66,6 +74,9 @@
5.30 def get_datetime_item(self, name):
5.31 return get_datetime_item(self.details, name)
5.32
5.33 + def get_duration(self, name):
5.34 + return get_duration(self.get_value(name))
5.35 +
5.36 def to_node(self):
5.37 return to_node({self.objtype : [(self.details, self.attr)]})
5.38
5.39 @@ -74,6 +85,9 @@
5.40
5.41 # Direct access to the structure.
5.42
5.43 + def has_key(self, name):
5.44 + return self.details.has_key(name)
5.45 +
5.46 def __getitem__(self, name):
5.47 return self.details[name]
5.48
5.49 @@ -85,11 +99,15 @@
5.50
5.51 # Computed results.
5.52
5.53 - def get_periods(self, tzid, window_size=100):
5.54 - return get_periods(self, tzid, window_size)
5.55 + def has_recurrence(self, tzid, recurrence):
5.56 + recurrences = [start for start, end in get_periods(self, tzid, recurrence, True)]
5.57 + return recurrence in recurrences
5.58
5.59 - def get_periods_for_freebusy(self, tzid, window_size=100):
5.60 - periods = self.get_periods(tzid, window_size)
5.61 + def get_periods(self, tzid, end):
5.62 + return get_periods(self, tzid, end)
5.63 +
5.64 + def get_periods_for_freebusy(self, tzid, end):
5.65 + periods = self.get_periods(tzid, end)
5.66 return get_periods_for_freebusy(self, periods, tzid)
5.67
5.68 # Construction and serialisation.
5.69 @@ -141,7 +159,7 @@
5.70 rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, periods[0][0]))
5.71 rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, periods[-1][1]))
5.72
5.73 - for start, end, uid, transp in periods:
5.74 + for start, end, uid, transp, recurrenceid in periods:
5.75 if transp == "OPAQUE":
5.76 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join([start, end])))
5.77
5.78 @@ -248,13 +266,41 @@
5.79 def get_value(d, name):
5.80 return get_values(d, name, False)
5.81
5.82 +def get_item_value_items(d, name):
5.83 +
5.84 + """
5.85 + Obtain items from 'd' having the given 'name', where a single item yields
5.86 + potentially many values. Return a list of tuples of the form (value,
5.87 + attributes) where the attributes have been given for the property in 'd'.
5.88 + """
5.89 +
5.90 + item = get_item(d, name)
5.91 + if item:
5.92 + values, attr = item
5.93 + if not isinstance(values, list):
5.94 + values = [values]
5.95 + items = []
5.96 + for value in values:
5.97 + items.append((get_datetime(value, attr) or get_period(value, attr), attr))
5.98 + return items
5.99 + else:
5.100 + return None
5.101 +
5.102 def get_utc_datetime(d, name):
5.103 - dt, attr = get_datetime_item(d, name)
5.104 - return to_utc_datetime(dt)
5.105 + t = get_datetime_item(d, name)
5.106 + if not t:
5.107 + return None
5.108 + else:
5.109 + dt, attr = t
5.110 + return to_utc_datetime(dt)
5.111
5.112 def get_datetime_item(d, name):
5.113 - value, attr = get_item(d, name)
5.114 - return get_datetime(value, attr), attr
5.115 + t = get_item(d, name)
5.116 + if not t:
5.117 + return None
5.118 + else:
5.119 + value, attr = t
5.120 + return get_datetime(value, attr), attr
5.121
5.122 def get_addresses(values):
5.123 return [address for name, address in email.utils.getaddresses(values)]
5.124 @@ -307,47 +353,81 @@
5.125 # NOTE: Need to expose the 100 day window for recurring events in the
5.126 # NOTE: configuration.
5.127
5.128 -def get_periods(obj, tzid, window_size=100):
5.129 +def get_window_end(tzid, window_size=100):
5.130 + return to_timezone(datetime.now(), tzid) + timedelta(window_size)
5.131 +
5.132 +def get_periods(obj, tzid, window_end, inclusive=False):
5.133
5.134 """
5.135 Return periods for the given object 'obj', confining materialised periods
5.136 - to the given 'window_size' in days starting from the present moment.
5.137 + to before the given 'window_end' datetime. If 'inclusive' is set to a true
5.138 + value, any period occurring at the 'window_end' will be included.
5.139 """
5.140
5.141 - # NOTE: Need also RDATE and EXDATE support.
5.142 -
5.143 rrule = obj.get_value("RRULE")
5.144
5.145 - if not rrule:
5.146 - return [(obj.get_datetime("DTSTART"), obj.get_datetime("DTEND"))]
5.147 -
5.148 # Use localised datetimes.
5.149
5.150 - dtstart, start_attr = obj.get_datetime_item("DTSTART")
5.151 - dtend, end_attr = obj.get_datetime_item("DTEND")
5.152 + dtstart, dtstart_attr = obj.get_datetime_item("DTSTART")
5.153 +
5.154 + if obj.has_key("DTEND"):
5.155 + dtend, dtend_attr = obj.get_datetime_item("DTEND")
5.156 + duration = dtend - dtstart
5.157 + elif obj.has_key("DURATION"):
5.158 + duration = obj.get_duration("DURATION")
5.159 + dtend = dtstart + duration
5.160 + dtend_attr = dtstart_attr
5.161 + else:
5.162 + dtend, dtend_attr = dtstart, dtstart_attr
5.163
5.164 - tzid = start_attr.get("TZID") or end_attr.get("TZID") or tzid
5.165 + tzid = dtstart_attr.get("TZID") or dtend_attr.get("TZID") or tzid
5.166
5.167 - # NOTE: Need also DURATION support.
5.168 + if not rrule:
5.169 + periods = [(dtstart, dtend)]
5.170 + else:
5.171 + # Recurrence rules create multiple instances to be checked.
5.172 + # Conflicts may only be assessed within a period defined by policy
5.173 + # for the agent, with instances outside that period being considered
5.174 + # unchecked.
5.175
5.176 - duration = dtend - dtstart
5.177 + selector = get_rule(dtstart, rrule)
5.178 + parameters = get_parameters(rrule)
5.179 + periods = []
5.180
5.181 - # Recurrence rules create multiple instances to be checked.
5.182 - # Conflicts may only be assessed within a period defined by policy
5.183 - # for the agent, with instances outside that period being considered
5.184 - # unchecked.
5.185 + for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS"), inclusive):
5.186 + start = to_timezone(datetime(*start), tzid)
5.187 + end = start + duration
5.188 + periods.append((start, end))
5.189 +
5.190 + # Add recurrence dates.
5.191
5.192 - window_end = to_timezone(datetime.now(), tzid) + timedelta(window_size)
5.193 + periods = set(periods)
5.194 + rdates = obj.get_item_values("RDATE")
5.195 +
5.196 + if rdates:
5.197 + for rdate in rdates:
5.198 + if isinstance(rdate, tuple):
5.199 + periods.add(rdate)
5.200 + else:
5.201 + periods.add((rdate, rdate + duration))
5.202
5.203 - selector = get_rule(dtstart, rrule)
5.204 - parameters = get_parameters(rrule)
5.205 - periods = []
5.206 + # Exclude exception dates.
5.207 +
5.208 + exdates = obj.get_item_values("EXDATE")
5.209
5.210 - for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS")):
5.211 - start = to_timezone(datetime(*start), tzid)
5.212 - end = start + duration
5.213 - periods.append((start, end))
5.214 + if exdates:
5.215 + for exdate in exdates:
5.216 + if isinstance(exdate, tuple):
5.217 + period = exdate
5.218 + else:
5.219 + period = (exdate, exdate + duration)
5.220 + if period in periods:
5.221 + periods.remove(period)
5.222
5.223 + # Return a sorted list of the periods.
5.224 +
5.225 + periods = list(periods)
5.226 + periods.sort()
5.227 return periods
5.228
5.229 def get_periods_for_freebusy(obj, periods, tzid):
5.230 @@ -358,7 +438,13 @@
5.231 """
5.232
5.233 start, start_attr = obj.get_datetime_item("DTSTART")
5.234 - end, end_attr = obj.get_datetime_item("DTEND")
5.235 + if obj.has_key("DTEND"):
5.236 + end, end_attr = obj.get_datetime_item("DTEND")
5.237 + elif obj.has_key("DURATION"):
5.238 + duration = obj.get_duration("DURATION")
5.239 + end = start + duration
5.240 + else:
5.241 + end, end_attr = start, start_attr
5.242
5.243 tzid = start_attr.get("TZID") or end_attr.get("TZID") or tzid
5.244
6.1 --- a/imiptools/dates.py Thu Feb 12 22:34:48 2015 +0100
6.2 +++ b/imiptools/dates.py Sat Mar 07 00:11:44 2015 +0100
6.3 @@ -26,15 +26,37 @@
6.4
6.5 # iCalendar date and datetime parsing (from DateSupport in MoinSupport).
6.6
6.7 -date_icalendar_regexp_str = ur'(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})'
6.8 -datetime_icalendar_regexp_str = date_icalendar_regexp_str + \
6.9 +_date_icalendar_regexp_str = ur'(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})'
6.10 +date_icalendar_regexp_str = _date_icalendar_regexp_str + '$'
6.11 +
6.12 +datetime_icalendar_regexp_str = _date_icalendar_regexp_str + \
6.13 ur'(?:' \
6.14 ur'T(?P<hour>[0-2][0-9])(?P<minute>[0-5][0-9])(?P<second>[0-6][0-9])' \
6.15 ur'(?P<utc>Z)?' \
6.16 - ur')?'
6.17 + ur')?$'
6.18 +
6.19 +_duration_time_icalendar_regexp_str = \
6.20 + ur'T' \
6.21 + ur'(?:' \
6.22 + ur'([0-9]+H)(?:([0-9]+M)([0-9]+S)?)?' \
6.23 + ur'|' \
6.24 + ur'([0-9]+M)([0-9]+S)?' \
6.25 + ur'|' \
6.26 + ur'([0-9]+S)' \
6.27 + ur')'
6.28 +
6.29 +duration_icalendar_regexp_str = ur'P' \
6.30 + ur'(?:' \
6.31 + ur'([0-9]+W)' \
6.32 + ur'|' \
6.33 + ur'(?:%s)' \
6.34 + ur'|' \
6.35 + ur'([0-9]+D)(?:%s)?' \
6.36 + ur')$' % (_duration_time_icalendar_regexp_str, _duration_time_icalendar_regexp_str)
6.37
6.38 match_date_icalendar = re.compile(date_icalendar_regexp_str, re.UNICODE).match
6.39 match_datetime_icalendar = re.compile(datetime_icalendar_regexp_str, re.UNICODE).match
6.40 +match_duration_icalendar = re.compile(duration_icalendar_regexp_str, re.UNICODE).match
6.41
6.42 def to_utc_datetime(dt):
6.43
6.44 @@ -169,6 +191,58 @@
6.45
6.46 return None
6.47
6.48 +def get_period(value, attr=None):
6.49 +
6.50 + """
6.51 + Return a tuple of the form (start, end) for the given 'value' in iCalendar
6.52 + format, using the 'attr' mapping (if specified) to control the conversion.
6.53 + """
6.54 +
6.55 + if not value or attr and attr.get("VALUE") != "PERIOD":
6.56 + return None
6.57 +
6.58 + t = value.split("/")
6.59 + if len(t) != 2:
6.60 + return None
6.61 +
6.62 + dtattr = {}
6.63 + if attr:
6.64 + dtattr.update(attr)
6.65 + if dtattr.has_key("VALUE"):
6.66 + del dtattr["VALUE"]
6.67 +
6.68 + start = get_datetime(t[0], dtattr)
6.69 + if t[1].startswith("P"):
6.70 + end = start + get_duration(t[1])
6.71 + else:
6.72 + end = get_datetime(t[1], dtattr)
6.73 +
6.74 + return start, end
6.75 +
6.76 +def get_duration(value):
6.77 +
6.78 + "Return a duration for the given 'value'."
6.79 +
6.80 + if not value:
6.81 + return None
6.82 +
6.83 + m = match_duration_icalendar(value)
6.84 + if m:
6.85 + weeks, days, hours, minutes, seconds = 0, 0, 0, 0, 0
6.86 + for s in m.groups():
6.87 + if not s: continue
6.88 + if s[-1] == "W": weeks += int(s[:-1])
6.89 + elif s[-1] == "D": days += int(s[:-1])
6.90 + elif s[-1] == "H": hours += int(s[:-1])
6.91 + elif s[-1] == "M": minutes += int(s[:-1])
6.92 + elif s[-1] == "S": seconds += int(s[:-1])
6.93 + return timedelta(
6.94 + int(weeks) * 7 + int(days),
6.95 + (int(hours) * 60 + int(minutes)) * 60 + int(seconds)
6.96 + )
6.97 + else:
6.98 + return None
6.99 +
6.100 def get_date(dt):
6.101
6.102 "Return the date of 'dt'."
7.1 --- a/imiptools/filesys.py Thu Feb 12 22:34:48 2015 +0100
7.2 +++ b/imiptools/filesys.py Sat Mar 07 00:11:44 2015 +0100
7.3 @@ -34,6 +34,14 @@
7.4 except OSError:
7.5 pass
7.6
7.7 +def make_path(base, parts):
7.8 + for part in parts:
7.9 + pathname = join(base, part)
7.10 + if not exists(pathname):
7.11 + mkdir(pathname)
7.12 + fix_permissions(pathname, True)
7.13 + base = pathname
7.14 +
7.15 class FileBase:
7.16
7.17 "Basic filesystem operations."
7.18 @@ -43,7 +51,8 @@
7.19 def __init__(self, store_dir):
7.20 self.store_dir = store_dir
7.21 if not exists(self.store_dir):
7.22 - makedirs(self.store_dir, DEFAULT_DIR_PERMISSIONS)
7.23 + makedirs(self.store_dir)
7.24 + fix_permissions(self.store_dir, True)
7.25
7.26 def get_file_object(self, base, *parts):
7.27 pathname = join(base, *parts)
7.28 @@ -66,7 +75,7 @@
7.29 expected = filename
7.30
7.31 if not exists(parent):
7.32 - makedirs(parent, DEFAULT_DIR_PERMISSIONS)
7.33 + make_path(self.store_dir, parts[:-1])
7.34
7.35 return filename
7.36
8.1 --- a/imiptools/handlers/person.py Thu Feb 12 22:34:48 2015 +0100
8.2 +++ b/imiptools/handlers/person.py Sat Mar 07 00:11:44 2015 +0100
8.3 @@ -49,20 +49,29 @@
8.4 if not self.have_new_object(attendee):
8.5 continue
8.6
8.7 - # Store the object and queue any request.
8.8 + # Set the complete event or an additional occurrence.
8.9 +
8.10 + self.store.set_event(attendee, self.uid, self.recurrenceid, self.obj.to_node())
8.11
8.12 - self.store.set_event(attendee, self.uid, self.obj.to_node())
8.13 + # Remove additional recurrences if handling a complete event.
8.14 +
8.15 + if not self.recurrenceid:
8.16 + self.store.remove_recurrences(attendee, self.uid)
8.17 +
8.18 + # Queue any request.
8.19
8.20 if queue:
8.21 - self.store.queue_request(attendee, self.uid)
8.22 + self.store.queue_request(attendee, self.uid, self.recurrenceid)
8.23 elif cancel:
8.24 - self.store.cancel_event(attendee, self.uid)
8.25 + self.store.cancel_event(attendee, self.uid, self.recurrenceid)
8.26
8.27 # No return message will occur to update the free/busy
8.28 # information, so this is done here.
8.29
8.30 freebusy = self.store.get_freebusy(attendee)
8.31 - self.remove_from_freebusy(freebusy, attendee)
8.32 + self.remove_from_freebusy(freebusy)
8.33 +
8.34 + self.store.set_freebusy(attendee, freebusy)
8.35
8.36 if self.publisher:
8.37 self.publisher.set_freebusy(attendee, freebusy)
8.38 @@ -157,10 +166,11 @@
8.39
8.40 def refresh(self):
8.41
8.42 - "Update details of any active event."
8.43 + "Generate details of any active event."
8.44
8.45 - self._record(from_organiser=True, queue=False)
8.46 - return self.wrap("An event update has been received.")
8.47 + # NOTE: Return event details if configured to do so.
8.48 +
8.49 + return self.wrap("A request for updated event details has been received.")
8.50
8.51 def reply(self):
8.52
9.1 --- a/imiptools/handlers/person_outgoing.py Thu Feb 12 22:34:48 2015 +0100
9.2 +++ b/imiptools/handlers/person_outgoing.py Sat Mar 07 00:11:44 2015 +0100
9.3 @@ -21,9 +21,8 @@
9.4 """
9.5
9.6 from imiptools.content import Handler
9.7 -from imiptools.data import uri_dict, uri_item, uri_values
9.8 -from imiptools.dates import get_default_timezone
9.9 -from imiptools.profile import Preferences
9.10 +from imiptools.data import get_window_end, uri_dict, uri_item, uri_values
9.11 +from imiptools.period import remove_affected_period
9.12
9.13 class PersonHandler(Handler):
9.14
9.15 @@ -58,34 +57,54 @@
9.16 # Update the object.
9.17
9.18 if from_organiser:
9.19 - self.store.set_event(identity, self.uid, self.obj.to_node())
9.20 +
9.21 + # Set the complete event or an additional occurrence.
9.22 +
9.23 + self.store.set_event(identity, self.uid, self.recurrenceid, self.obj.to_node())
9.24 +
9.25 + # Remove additional recurrences if handling a complete event.
9.26 +
9.27 + if not self.recurrenceid:
9.28 + self.store.remove_recurrences(identity, self.uid)
9.29 +
9.30 else:
9.31 organiser_item, attendees = self.require_organiser_and_attendees(from_organiser)
9.32 self.merge_attendance(attendees, identity)
9.33
9.34 # Remove any associated request.
9.35
9.36 - self.store.dequeue_request(identity, self.uid)
9.37 + self.store.dequeue_request(identity, self.uid, self.recurrenceid)
9.38
9.39 # Update free/busy information.
9.40
9.41 if update_freebusy:
9.42
9.43 + freebusy = self.store.get_freebusy(identity)
9.44 +
9.45 # Interpretation of periods can depend on the time zone.
9.46
9.47 - preferences = Preferences(identity)
9.48 - tzid = preferences.get("TZID") or get_default_timezone()
9.49 + tzid = self.get_tzid(identity)
9.50 +
9.51 + # Use the stored event in case the reply is incomplete, as is seen
9.52 + # when Claws sends a REPLY for an object originally employing
9.53 + # recurrence information.
9.54 +
9.55 + obj = self.get_object(identity)
9.56
9.57 # If newer than any old version, discard old details from the
9.58 # free/busy record and check for suitability.
9.59
9.60 - periods = self.obj.get_periods_for_freebusy(tzid)
9.61 - freebusy = self.store.get_freebusy(identity)
9.62 + periods = obj.get_periods_for_freebusy(tzid, get_window_end(tzid))
9.63 +
9.64 + self.update_freebusy_for_participant(freebusy, periods, attr,
9.65 + from_organiser and self.is_not_attendee(identity, obj))
9.66
9.67 - if attr.get("PARTSTAT") != "DECLINED":
9.68 - self.update_freebusy(freebusy, identity, periods)
9.69 - else:
9.70 - self.remove_from_freebusy(freebusy, identity)
9.71 + # Remove either original recurrence or additional recurrence
9.72 + # details depending on whether an additional recurrence or a
9.73 + # complete event are being handled, respectively.
9.74 +
9.75 + self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(identity, self.uid))
9.76 + self.store.set_freebusy(identity, freebusy)
9.77
9.78 if self.publisher:
9.79 self.publisher.set_freebusy(identity, freebusy)
9.80 @@ -113,7 +132,7 @@
9.81 given_attendees = set(uri_values(self.obj.get_values("ATTENDEE")))
9.82
9.83 if given_attendees == all_attendees:
9.84 - self.store.cancel_event(identity, self.uid)
9.85 + self.store.cancel_event(identity, self.uid, self.recurrenceid)
9.86
9.87 # Otherwise, remove the given attendees and update the event.
9.88
9.89 @@ -128,17 +147,20 @@
9.90 obj["SEQUENCE"] = self.obj.get_items("SEQUENCE")
9.91 obj["DTSTAMP"] = self.obj.get_items("DTSTAMP")
9.92
9.93 - self.store.set_event(identity, self.uid, obj.to_node())
9.94 + # Set the complete event if not an additional occurrence.
9.95 +
9.96 + self.store.set_event(identity, self.uid, self.recurrenceid, obj.to_node())
9.97
9.98 # Remove any associated request.
9.99
9.100 - self.store.dequeue_request(identity, self.uid)
9.101 + self.store.dequeue_request(identity, self.uid, self.recurrenceid)
9.102
9.103 # Update free/busy information.
9.104
9.105 if update_freebusy:
9.106 freebusy = self.store.get_freebusy(identity)
9.107 - self.remove_from_freebusy(freebusy, identity)
9.108 + self.remove_from_freebusy(freebusy)
9.109 + self.store.set_freebusy(identity, freebusy)
9.110
9.111 if self.publisher:
9.112 self.publisher.set_freebusy(identity, freebusy)
9.113 @@ -165,7 +187,7 @@
9.114 self._record(True, True)
9.115
9.116 def refresh(self):
9.117 - self._record(True, True)
9.118 + pass
9.119
9.120 def reply(self):
9.121 self._record(False, True)
9.122 @@ -219,7 +241,7 @@
9.123 self._record(True)
9.124
9.125 def refresh(self):
9.126 - self._record(True)
9.127 + pass
9.128
9.129 def reply(self):
9.130 self._record(False)
10.1 --- a/imiptools/handlers/resource.py Thu Feb 12 22:34:48 2015 +0100
10.2 +++ b/imiptools/handlers/resource.py Sat Mar 07 00:11:44 2015 +0100
10.3 @@ -20,10 +20,10 @@
10.4 """
10.5
10.6 from imiptools.content import Handler
10.7 -from imiptools.data import get_address, get_uri, to_part
10.8 +from imiptools.data import get_address, get_uri, get_window_end, to_part
10.9 from imiptools.dates import get_default_timezone
10.10 from imiptools.handlers.common import CommonFreebusy
10.11 -from imiptools.profile import Preferences
10.12 +from imiptools.period import remove_affected_period
10.13
10.14 class ResourceHandler(Handler):
10.15
10.16 @@ -60,13 +60,12 @@
10.17
10.18 # Interpretation of periods can depend on the time zone.
10.19
10.20 - preferences = Preferences(attendee)
10.21 - tzid = preferences.get("TZID") or get_default_timezone()
10.22 + tzid = self.get_tzid(attendee)
10.23
10.24 # If newer than any old version, discard old details from the
10.25 # free/busy record and check for suitability.
10.26
10.27 - periods = self.obj.get_periods_for_freebusy(tzid)
10.28 + periods = self.obj.get_periods_for_freebusy(tzid, get_window_end(tzid))
10.29 freebusy = self.store.get_freebusy(attendee)
10.30 scheduled = self.can_schedule(freebusy, periods)
10.31
10.32 @@ -84,15 +83,29 @@
10.33
10.34 self.update_dtstamp()
10.35
10.36 + # Set the complete event or an additional occurrence.
10.37 +
10.38 event = self.obj.to_node()
10.39 - self.store.set_event(attendee, self.uid, event)
10.40 + self.store.set_event(attendee, self.uid, self.recurrenceid, event)
10.41 +
10.42 + # Remove additional recurrences if handling a complete event.
10.43 +
10.44 + if not self.recurrenceid:
10.45 + self.store.remove_recurrences(attendee, self.uid)
10.46
10.47 # Only update free/busy details if the event is scheduled.
10.48
10.49 if scheduled:
10.50 - self.update_freebusy(freebusy, attendee, periods)
10.51 + self.update_freebusy(freebusy, periods)
10.52 else:
10.53 - self.remove_from_freebusy(freebusy, attendee)
10.54 + self.remove_from_freebusy(freebusy)
10.55 +
10.56 + # Remove either original recurrence or additional recurrence
10.57 + # details depending on whether an additional recurrence or a
10.58 + # complete event are being handled, respectively.
10.59 +
10.60 + self.remove_freebusy_for_recurrences(freebusy)
10.61 + self.store.set_freebusy(attendee, freebusy)
10.62
10.63 if self.publisher:
10.64 self.publisher.set_freebusy(attendee, freebusy)
10.65 @@ -101,10 +114,12 @@
10.66
10.67 def _cancel_for_attendee(self, attendee, attendee_attr):
10.68
10.69 - self.store.cancel_event(attendee, self.uid)
10.70 + self.store.cancel_event(attendee, self.uid, self.recurrenceid)
10.71
10.72 freebusy = self.store.get_freebusy(attendee)
10.73 - self.remove_from_freebusy(freebusy, attendee)
10.74 + self.remove_from_freebusy(freebusy)
10.75 +
10.76 + self.store.set_freebusy(attendee, freebusy)
10.77
10.78 if self.publisher:
10.79 self.publisher.set_freebusy(attendee, freebusy)
11.1 --- a/imiptools/period.py Thu Feb 12 22:34:48 2015 +0100
11.2 +++ b/imiptools/period.py Sat Mar 07 00:11:44 2015 +0100
11.3 @@ -25,16 +25,16 @@
11.4
11.5 # Time management with datetime strings in the UTC time zone.
11.6
11.7 -def can_schedule(freebusy, periods, uid):
11.8 +def can_schedule(freebusy, periods, uid, recurrenceid):
11.9
11.10 """
11.11 Return whether the 'freebusy' list can accommodate the given 'periods'
11.12 - employing the specified 'uid'.
11.13 + employing the specified 'uid' and 'recurrenceid'.
11.14 """
11.15
11.16 for conflict in have_conflict(freebusy, periods, True):
11.17 - start, end, found_uid, found_transp = conflict
11.18 - if found_uid != uid:
11.19 + start, end, found_uid, found_transp, found_recurrenceid = conflict
11.20 + if found_uid != uid and found_recurrenceid != recurrenceid:
11.21 return False
11.22
11.23 return True
11.24 @@ -62,17 +62,75 @@
11.25 return False
11.26
11.27 def insert_period(freebusy, period):
11.28 +
11.29 + "Insert into 'freebusy' the given 'period'."
11.30 +
11.31 insort_left(freebusy, period)
11.32
11.33 -def remove_period(freebusy, uid):
11.34 +def remove_period(freebusy, uid, recurrenceid=None):
11.35 +
11.36 + """
11.37 + Remove from 'freebusy' all periods associated with 'uid' and 'recurrenceid'
11.38 + (which if omitted causes the "parent" object's periods to be referenced).
11.39 + """
11.40 +
11.41 + i = 0
11.42 + while i < len(freebusy):
11.43 + t = freebusy[i]
11.44 + if len(t) >= 5 and t[2] == uid and t[4] == recurrenceid:
11.45 + del freebusy[i]
11.46 + else:
11.47 + i += 1
11.48 +
11.49 +def remove_additional_periods(freebusy, uid, recurrenceids=None):
11.50 +
11.51 + """
11.52 + Remove from 'freebusy' all periods associated with 'uid' having a
11.53 + recurrence identifier indicating an additional or modified period.
11.54 +
11.55 + If 'recurrenceids' is specified, remove all periods associated with 'uid'
11.56 + that do not have a recurrence identifier in the given list.
11.57 + """
11.58 +
11.59 i = 0
11.60 while i < len(freebusy):
11.61 t = freebusy[i]
11.62 - if len(t) >= 3 and t[2] == uid:
11.63 + if len(t) >= 5 and t[2] == uid and t[4] and (
11.64 + recurrenceids is None or
11.65 + recurrenceids is not None and t[4] not in recurrenceids
11.66 + ):
11.67 del freebusy[i]
11.68 else:
11.69 i += 1
11.70
11.71 +def remove_affected_period(freebusy, uid, recurrenceid):
11.72 +
11.73 + """
11.74 + Remove from 'freebusy' a period associated with 'uid' that provides an
11.75 + occurrence starting at the given 'recurrenceid', where the recurrence
11.76 + identifier is used to provide an alternative time period whilst also acting
11.77 + as a reference to the originally-defined occurrence.
11.78 + """
11.79 +
11.80 + found = bisect_left(freebusy, (recurrenceid,))
11.81 + while found < len(freebusy):
11.82 + start, end, _uid, transp, _recurrenceid = freebusy[found][:5]
11.83 +
11.84 + # Stop looking if the start no longer matches the recurrence identifier.
11.85 +
11.86 + if start != recurrenceid:
11.87 + return
11.88 +
11.89 + # If the period belongs to the parent object, remove it and return.
11.90 +
11.91 + if not _recurrenceid and uid == _uid:
11.92 + del freebusy[found]
11.93 + break
11.94 +
11.95 + # Otherwise, keep looking for a matching period.
11.96 +
11.97 + found += 1
11.98 +
11.99 def get_overlapping(freebusy, period):
11.100
11.101 """
11.102 @@ -332,7 +390,7 @@
11.103 for point, active in slots:
11.104 for t in active:
11.105 if t and len(t) >= 2:
11.106 - start, end, uid, key = get_freebusy_details(t)
11.107 + start, end, uid, recurrenceid, key = get_freebusy_details(t)
11.108
11.109 try:
11.110 start_slot = points.index(start)
11.111 @@ -348,73 +406,34 @@
11.112
11.113 def get_freebusy_details(t):
11.114
11.115 - "Return a tuple of the form (start, end, uid, key) from 't'."
11.116 + "Return a tuple of the form (start, end, uid, recurrenceid, key) from 't'."
11.117
11.118 # Handle both complete free/busy details...
11.119
11.120 - if len(t) > 2:
11.121 - start, end, uid = t[:3]
11.122 - key = uid
11.123 + if len(t) > 4:
11.124 + start, end, uid, transp, recurrenceid = t[:5]
11.125 + key = uid, recurrenceid
11.126
11.127 # ...and published details without specific event details.
11.128
11.129 else:
11.130 start, end = t[:2]
11.131 uid = None
11.132 + recurrenceid = None
11.133 key = (start, end)
11.134
11.135 - return start, end, uid, key
11.136 -
11.137 -def remove_from_freebusy(freebusy, attendee, uid, store):
11.138 -
11.139 - """
11.140 - For the given 'attendee', remove periods from 'freebusy' that are associated
11.141 - with 'uid' in the 'store'.
11.142 - """
11.143 -
11.144 - remove_period(freebusy, uid)
11.145 - store.set_freebusy(attendee, freebusy)
11.146 + return start, end, uid, recurrenceid, key
11.147
11.148 -def remove_from_freebusy_for_other(freebusy, user, other, uid, store):
11.149 -
11.150 - """
11.151 - For the given 'user', remove for the 'other' party periods from 'freebusy'
11.152 - that are associated with 'uid' in the 'store'.
11.153 - """
11.154 -
11.155 - remove_period(freebusy, uid)
11.156 - store.set_freebusy_for_other(user, freebusy, other)
11.157 -
11.158 -def _update_freebusy(freebusy, periods, transp, uid):
11.159 +def update_freebusy(freebusy, periods, transp, uid, recurrenceid):
11.160
11.161 """
11.162 Update the free/busy details with the given 'periods', 'transp' setting and
11.163 - 'uid'.
11.164 - """
11.165 -
11.166 - remove_period(freebusy, uid)
11.167 -
11.168 - for start, end in periods:
11.169 - insert_period(freebusy, (start, end, uid, transp))
11.170 -
11.171 -def update_freebusy(freebusy, attendee, periods, transp, uid, store):
11.172 -
11.173 - """
11.174 - For the given 'attendee', update the free/busy details with the given
11.175 - 'periods', 'transp' setting and 'uid' in the 'store'.
11.176 + 'uid' plus 'recurrenceid'.
11.177 """
11.178
11.179 - _update_freebusy(freebusy, periods, transp, uid)
11.180 - store.set_freebusy(attendee, freebusy)
11.181 -
11.182 -def update_freebusy_for_other(freebusy, user, other, periods, transp, uid, store):
11.183 + remove_period(freebusy, uid, recurrenceid)
11.184
11.185 - """
11.186 - For the given 'user', update the free/busy details of 'other' with the given
11.187 - 'periods', 'transp' setting and 'uid' in the 'store'.
11.188 - """
11.189 -
11.190 - _update_freebusy(freebusy, periods, transp, uid)
11.191 - store.set_freebusy_for_other(user, freebusy, other)
11.192 + for start, end in periods:
11.193 + insert_period(freebusy, (start, end, uid, transp, recurrenceid))
11.194
11.195 # vim: tabstop=4 expandtab shiftwidth=4
12.1 --- a/tools/make_freebusy.py Thu Feb 12 22:34:48 2015 +0100
12.2 +++ b/tools/make_freebusy.py Sat Mar 07 00:11:44 2015 +0100
12.3 @@ -1,59 +1,100 @@
12.4 #!/usr/bin/env python
12.5
12.6 -from imiptools.data import get_freebusy_period, get_datetime_item, get_value, get_value_map, parse_object
12.7 +from imiptools.data import get_window_end, Object
12.8 from imiptools.dates import format_datetime, get_default_timezone
12.9 from imiptools.profile import Preferences
12.10 from imip_store import FileStore, FilePublisher
12.11 import sys
12.12
12.13 +def get_periods(fb, obj, tzid, window_end, only_organiser):
12.14 +
12.15 + # Update free/busy details with the actual periods associated with the event.
12.16 +
12.17 + for start, end in obj.get_periods_for_freebusy(tzid, window_end):
12.18 + fb.append((start, end,
12.19 + obj.get_value("UID"),
12.20 + only_organiser and "ORG" or obj.get_value("TRANSP") or "OPAQUE",
12.21 + format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) or "",
12.22 + ))
12.23 +
12.24 +# Main program.
12.25 +
12.26 try:
12.27 user = sys.argv[1]
12.28 + store_and_publish = "-s" in sys.argv[2:]
12.29 except IndexError:
12.30 - print >>sys.stderr, "Need a user."
12.31 + print >>sys.stderr, "Need a user, along with the -s option if updating the store."
12.32 sys.exit(1)
12.33
12.34 preferences = Preferences(user)
12.35 tzid = preferences.get("TZID") or get_default_timezone()
12.36
12.37 -s = FileStore()
12.38 -p = FilePublisher()
12.39 +# Get the size of the free/busy window.
12.40 +
12.41 +try:
12.42 + window_size = int(preferences.get("window_size"))
12.43 +except (TypeError, ValueError):
12.44 + window_size = 100
12.45 +window_end = get_window_end(tzid, window_size)
12.46 +
12.47 +store = FileStore()
12.48 +publisher = FilePublisher()
12.49 +
12.50 +# Get all identifiers for events.
12.51
12.52 -events = set(s.get_events(user))
12.53 -cancelled = s.get_cancellations(user) or []
12.54 +uids = store.get_events(user)
12.55 +
12.56 +all_events = set()
12.57 +for uid in uids:
12.58 + all_events.add((uid, None))
12.59 + all_events.update([(uid, recurrenceid) for recurrenceid in store.get_recurrences(user, uid)])
12.60
12.61 -events.difference_update(cancelled)
12.62 +# Filter out cancelled events.
12.63 +
12.64 +cancelled = store.get_cancellations(user) or []
12.65 +all_events.difference_update(cancelled)
12.66 +
12.67 +# Obtain event objects.
12.68
12.69 objs = []
12.70 -for i in events:
12.71 - print >>sys.stderr, i
12.72 - objs.append(parse_object(s.get_event(user, i), "utf-8"))
12.73 +for uid, recurrenceid in all_events:
12.74 + print >>sys.stderr, uid, recurrenceid
12.75 + event = store.get_event(user, uid, recurrenceid)
12.76 + if event:
12.77 + objs.append(Object(event))
12.78 +
12.79 +# Build a free/busy collection for the given user.
12.80
12.81 fb = []
12.82 for obj in objs:
12.83 - if not obj:
12.84 - continue
12.85 - details, details_attr = obj.values()[0]
12.86 + attendees = obj.get_value_map("ATTENDEE")
12.87 + organiser = obj.get_value("ORGANIZER")
12.88
12.89 - participants = {}
12.90 - participants.update(get_value_map(details, "ATTENDEE"))
12.91 - participants.update(get_value_map(details, "ORGANIZER"))
12.92 + for attendee, attendee_attr in attendees.items():
12.93 +
12.94 + # Only consider events where this user actually attends.
12.95
12.96 - for participant, participant_attr in participants.items():
12.97 - if participant == user:
12.98 - if participant_attr.get("PARTSTAT") != "DECLINED":
12.99 - dtstart, dtstart_attr = get_datetime_item(details, "DTSTART")
12.100 - dtend, dtend_attr = get_datetime_item(details, "DTEND")
12.101 - event_tzid = dtstart_attr.get("TZID") or dtend_attr.get("TZID") or tzid
12.102 - dtstart, dtend = get_freebusy_period(dtstart, dtend, event_tzid)
12.103 - fb.append((format_datetime(dtstart),
12.104 - format_datetime(dtend),
12.105 - get_value(details, "UID"),
12.106 - get_value(details, "TRANSP")))
12.107 + if attendee == user:
12.108 + if attendee_attr.get("PARTSTAT", "NEEDS-ACTION") not in ("DECLINED", "DELEGATED", "NEEDS-ACTION"):
12.109 + get_periods(fb, obj, tzid, window_end, False)
12.110 break
12.111
12.112 + # Where not attending, retain the affected periods and mark them as
12.113 + # organising periods.
12.114 +
12.115 + else:
12.116 + if organiser == user:
12.117 + get_periods(fb, obj, tzid, window_end, True)
12.118 +
12.119 fb.sort()
12.120
12.121 -s.set_freebusy(user, fb)
12.122 -p.set_freebusy(user, fb)
12.123 +# Store and publish the free/busy collection.
12.124 +
12.125 +if store_and_publish:
12.126 + store.set_freebusy(user, fb)
12.127 + publisher.set_freebusy(user, fb)
12.128 +else:
12.129 + for item in fb:
12.130 + print "\t".join(item)
12.131
12.132 # vim: tabstop=4 expandtab shiftwidth=4
13.1 --- a/vRecurrence.py Thu Feb 12 22:34:48 2015 +0100
13.2 +++ b/vRecurrence.py Sat Mar 07 00:11:44 2015 +0100
13.3 @@ -3,7 +3,7 @@
13.4 """
13.5 Recurrence rule calculation.
13.6
13.7 -Copyright (C) 2014 Paul Boddie <paul@boddie.org.uk>
13.8 +Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>
13.9
13.10 This program is free software; you can redistribute it and/or modify it under
13.11 the terms of the GNU General Public License as published by the Free Software
13.12 @@ -124,7 +124,7 @@
13.13
13.14 """
13.15 Process the list of 'values' of the form "key=value", returning a list of
13.16 - qualifiers.
13.17 + qualifiers of the form (qualifier name, args).
13.18 """
13.19
13.20 qualifiers = []
13.21 @@ -382,7 +382,19 @@
13.22 # Classes for producing instances from recurrence structures.
13.23
13.24 class Selector:
13.25 +
13.26 + "A generic selector."
13.27 +
13.28 def __init__(self, level, args, qualifier, selecting=None):
13.29 +
13.30 + """
13.31 + Initialise at the given 'level' a selector employing the given 'args'
13.32 + defined in the interpretation of recurrence rule qualifiers, with the
13.33 + 'qualifier' being the name of the rule qualifier, and 'selecting' being
13.34 + an optional selector used to find more specific instances from those
13.35 + found by this selector.
13.36 + """
13.37 +
13.38 self.level = level
13.39 self.pos = positions[level]
13.40 self.args = args
13.41 @@ -393,23 +405,46 @@
13.42 def __repr__(self):
13.43 return "%s(%r, %r, %r, %r)" % (self.__class__.__name__, self.level, self.args, self.qualifier, self.context)
13.44
13.45 - def materialise(self, start, end, count=None, setpos=None):
13.46 + def materialise(self, start, end, count=None, setpos=None, inclusive=False):
13.47 +
13.48 + """
13.49 + Starting at 'start', materialise instances up to but not including any
13.50 + at 'end' or later, returning at most 'count' if specified, and returning
13.51 + only the occurrences indicated by 'setpos' if specified. A list of
13.52 + instances is returned.
13.53 +
13.54 + If 'inclusive' is specified, the selection of instances will include the
13.55 + end of the search period if present in the results.
13.56 + """
13.57 +
13.58 start = to_tuple(start)
13.59 end = to_tuple(end)
13.60 counter = count and [0, count]
13.61 - results = self.materialise_items(self.context, start, end, counter, setpos)
13.62 + results = self.materialise_items(self.context, start, end, counter, setpos, inclusive)
13.63 results.sort()
13.64 return results[:count]
13.65
13.66 - def materialise_item(self, current, last, next, counter, setpos=None):
13.67 + def materialise_item(self, current, earliest, next, counter, setpos=None, inclusive=False):
13.68 +
13.69 + """
13.70 + Given the 'current' instance, the 'earliest' acceptable instance, the
13.71 + 'next' instance, an instance 'counter', and the optional 'setpos'
13.72 + criteria, return a list of result items. Where no selection within the
13.73 + current instance occurs, the current instance will be returned as a
13.74 + result if the same or later than the earliest acceptable instance.
13.75 + """
13.76 +
13.77 if self.selecting:
13.78 - return self.selecting.materialise_items(current, last, next, counter, setpos)
13.79 - elif last <= current:
13.80 + return self.selecting.materialise_items(current, earliest, next, counter, setpos, inclusive)
13.81 + elif earliest <= current:
13.82 return [current]
13.83 else:
13.84 return []
13.85
13.86 def convert_positions(self, setpos):
13.87 +
13.88 + "Convert 'setpos' to 0-based indexes."
13.89 +
13.90 l = []
13.91 for pos in setpos:
13.92 lower = pos < 0 and pos or pos - 1
13.93 @@ -418,21 +453,36 @@
13.94 return l
13.95
13.96 def select_positions(self, results, setpos):
13.97 +
13.98 + "Select in 'results' the 1-based positions given by 'setpos'."
13.99 +
13.100 results.sort()
13.101 l = []
13.102 for lower, upper in self.convert_positions(setpos):
13.103 l += results[lower:upper]
13.104 return l
13.105
13.106 - def filter_by_period(self, results, start, end):
13.107 + def filter_by_period(self, results, start, end, inclusive):
13.108 +
13.109 + """
13.110 + Filter 'results' so that only those at or after 'start' and before 'end'
13.111 + are returned.
13.112 +
13.113 + If 'inclusive' is specified, the selection of instances will include the
13.114 + end of the search period if present in the results.
13.115 + """
13.116 +
13.117 l = []
13.118 for result in results:
13.119 - if start <= result < end:
13.120 + if start <= result and (inclusive and result <= end or result < end):
13.121 l.append(result)
13.122 return l
13.123
13.124 class Pattern(Selector):
13.125 - def materialise_items(self, context, start, end, counter, setpos=None):
13.126 +
13.127 + "A selector of instances according to a repeating pattern."
13.128 +
13.129 + def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False):
13.130 first = scale(self.context[self.pos], self.pos)
13.131
13.132 # Define the step between items.
13.133 @@ -448,10 +498,10 @@
13.134 current = combine(context, first)
13.135 results = []
13.136
13.137 - while current < end and (counter is None or counter[0] < counter[1]):
13.138 + while (inclusive and current <= end or current < end) and (counter is None or counter[0] < counter[1]):
13.139 next = update(current, step)
13.140 current_end = update(current, unit_step)
13.141 - interval_results = self.materialise_item(current, max(current, start), min(current_end, end), counter, setpos)
13.142 + interval_results = self.materialise_item(current, max(current, start), min(current_end, end), counter, setpos, inclusive)
13.143 if counter is not None:
13.144 counter[0] += len(interval_results)
13.145 results += interval_results
13.146 @@ -460,7 +510,10 @@
13.147 return results
13.148
13.149 class WeekDayFilter(Selector):
13.150 - def materialise_items(self, context, start, end, counter, setpos=None):
13.151 +
13.152 + "A selector of instances specified in terms of day numbers."
13.153 +
13.154 + def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False):
13.155 step = scale(1, 2)
13.156 results = []
13.157
13.158 @@ -483,10 +536,10 @@
13.159 current = context
13.160 values = [value for (value, index) in self.args["values"]]
13.161
13.162 - while current < end:
13.163 + while (inclusive and current <= end or current < end):
13.164 next = update(current, step)
13.165 if date(*current).isoweekday() in values:
13.166 - results += self.materialise_item(current, max(current, start), min(next, end), counter)
13.167 + results += self.materialise_item(current, max(current, start), min(next, end), counter, inclusive=inclusive)
13.168 current = next
13.169
13.170 if setpos:
13.171 @@ -510,7 +563,7 @@
13.172 # To support setpos, only current and next bound the search, not
13.173 # the period in addition.
13.174
13.175 - results += self.materialise_item(current, current, next, counter)
13.176 + results += self.materialise_item(current, current, next, counter, inclusive=inclusive)
13.177
13.178 else:
13.179 if index < 0:
13.180 @@ -526,7 +579,7 @@
13.181 # To support setpos, only current and next bound the search, not
13.182 # the period in addition.
13.183
13.184 - results += self.materialise_item(current, current, next, counter)
13.185 + results += self.materialise_item(current, current, next, counter, inclusive=inclusive)
13.186 current = to_tuple(direction(date(*current), timedelta(7)), 3)
13.187
13.188 # Extract selected positions and remove out-of-period instances.
13.189 @@ -534,10 +587,10 @@
13.190 if setpos:
13.191 results = self.select_positions(results, setpos)
13.192
13.193 - return self.filter_by_period(results, start, end)
13.194 + return self.filter_by_period(results, start, end, inclusive)
13.195
13.196 class Enum(Selector):
13.197 - def materialise_items(self, context, start, end, counter, setpos=None):
13.198 + def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False):
13.199 step = scale(1, self.pos)
13.200 results = []
13.201 for value in self.args["values"]:
13.202 @@ -547,17 +600,17 @@
13.203 # To support setpos, only current and next bound the search, not
13.204 # the period in addition.
13.205
13.206 - results += self.materialise_item(current, current, next, counter, setpos)
13.207 + results += self.materialise_item(current, current, next, counter, setpos, inclusive)
13.208
13.209 # Extract selected positions and remove out-of-period instances.
13.210
13.211 if setpos:
13.212 results = self.select_positions(results, setpos)
13.213
13.214 - return self.filter_by_period(results, start, end)
13.215 + return self.filter_by_period(results, start, end, inclusive)
13.216
13.217 class MonthDayFilter(Enum):
13.218 - def materialise_items(self, context, start, end, counter, setpos=None):
13.219 + def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False):
13.220 last_day = monthrange(context[0], context[1])[1]
13.221 step = scale(1, self.pos)
13.222 results = []
13.223 @@ -570,17 +623,17 @@
13.224 # To support setpos, only current and next bound the search, not
13.225 # the period in addition.
13.226
13.227 - results += self.materialise_item(current, current, next, counter)
13.228 + results += self.materialise_item(current, current, next, counter, inclusive=inclusive)
13.229
13.230 # Extract selected positions and remove out-of-period instances.
13.231
13.232 if setpos:
13.233 results = self.select_positions(results, setpos)
13.234
13.235 - return self.filter_by_period(results, start, end)
13.236 + return self.filter_by_period(results, start, end, inclusive)
13.237
13.238 class YearDayFilter(Enum):
13.239 - def materialise_items(self, context, start, end, counter, setpos=None):
13.240 + def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False):
13.241 first_day = date(context[0], 1, 1)
13.242 next_first_day = date(context[0] + 1, 1, 1)
13.243 year_length = (next_first_day - first_day).days
13.244 @@ -595,14 +648,14 @@
13.245 # To support setpos, only current and next bound the search, not
13.246 # the period in addition.
13.247
13.248 - results += self.materialise_item(current, current, next, counter)
13.249 + results += self.materialise_item(current, current, next, counter, inclusive=inclusive)
13.250
13.251 # Extract selected positions and remove out-of-period instances.
13.252
13.253 if setpos:
13.254 results = self.select_positions(results, setpos)
13.255
13.256 - return self.filter_by_period(results, start, end)
13.257 + return self.filter_by_period(results, start, end, inclusive)
13.258
13.259 special_enum_levels = {
13.260 "BYDAY" : WeekDayFilter,
13.261 @@ -613,6 +666,13 @@
13.262 # Public functions.
13.263
13.264 def connect_selectors(selectors):
13.265 +
13.266 + """
13.267 + Make the 'selectors' reference each other in a hierarchy so that
13.268 + materialising the principal selector causes the more specific ones to be
13.269 + employed in the operation.
13.270 + """
13.271 +
13.272 current = selectors[0]
13.273 for selector in selectors[1:]:
13.274 current.selecting = selector
13.275 @@ -637,7 +697,9 @@
13.276 selector object.
13.277 """
13.278
13.279 - qualifiers = get_qualifiers(rule.split(";"))
13.280 + if not isinstance(rule, tuple):
13.281 + rule = rule.split(";")
13.282 + qualifiers = get_qualifiers(rule)
13.283 return get_selector(dt, qualifiers)
13.284
13.285 # vim: tabstop=4 expandtab shiftwidth=4