# HG changeset patch # User Paul Boddie # Date 1425683504 -3600 # Node ID 0b326e3711f45113318815e0b4b7ea04e8aabfb5 # Parent 83cd574a79441ceae71e7ec1c3575ae1c4d91c6c# Parent 53bff024aaf220fc17237cc937c923267ba0793e Merged branches, integrating recurring events. diff -r 83cd574a7944 -r 0b326e3711f4 htdocs/styles.css --- a/htdocs/styles.css Thu Feb 12 22:34:48 2015 +0100 +++ b/htdocs/styles.css Sat Mar 07 00:11:44 2015 +0100 @@ -2,6 +2,7 @@ table.calendar, table.conflicts, +table.recurrences, table.object { border: 2px solid #000; } @@ -32,6 +33,10 @@ white-space: nowrap; } +th.objectheading { + background-color: #fca; +} + th.timeslot { padding-top: 0; vertical-align: top; @@ -93,6 +98,14 @@ font-size: inherit; } +.affected { + font-weight: bold; +} + +.replaced { + text-decoration: line-through; +} + /* Selection of slots/periods for new events. */ input.newevent.selector { diff -r 83cd574a7944 -r 0b326e3711f4 imip_manager.py --- a/imip_manager.py Thu Feb 12 22:34:48 2015 +0100 +++ b/imip_manager.py Sat Mar 07 00:11:44 2015 +0100 @@ -31,7 +31,8 @@ sys.path.append(LIBRARY_PATH) from imiptools.content import Handler -from imiptools.data import get_address, get_uri, make_freebusy, Object, to_part, \ +from imiptools.data import get_address, get_uri, get_window_end, make_freebusy, \ + Object, to_part, \ uri_dict, uri_item, uri_items, uri_values from imiptools.dates import format_datetime, format_time, get_date, get_datetime, \ get_datetime_item, get_default_timezone, \ @@ -41,8 +42,8 @@ from imiptools.period import add_day_start_points, add_empty_days, add_slots, \ convert_periods, get_freebusy_details, \ get_scale, have_conflict, get_slots, get_spans, \ - partition_by_day, remove_from_freebusy, update_freebusy, \ - _update_freebusy + partition_by_day, remove_period, remove_affected_period, \ + update_freebusy from imiptools.profile import Preferences import imip_store import markup @@ -126,7 +127,49 @@ prefs = self.get_preferences() return prefs.get("TZID") or get_default_timezone() -class ManagerHandler(Handler, Common): + def get_window_size(self): + prefs = self.get_preferences() + try: + return int(prefs.get("window_size")) + except (TypeError, ValueError): + return 100 + + def get_window_end(self): + return get_window_end(self.get_tzid(), self.get_window_size()) + + def update_attendees(self, obj, added, removed): + + """ + Update the attendees in 'obj' with the given 'added' and 'removed' + attendee lists. A list is returned containing the attendees whose + attendance should be cancelled. + """ + + to_cancel = [] + + if added or removed: + attendees = uri_items(obj.get_items("ATTENDEE") or []) + + if removed: + remaining = [] + + for attendee, attendee_attr in attendees: + if attendee in removed: + to_cancel.append((attendee, attendee_attr)) + else: + remaining.append((attendee, attendee_attr)) + + attendees = remaining + + if added: + for attendee in added: + attendees.append((attendee, {"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"})) + + obj["ATTENDEE"] = attendees + + return to_cancel + +class ManagerHandler(Common, Handler): """ A content handler for use by the manager, as opposed to operating within the @@ -182,10 +225,10 @@ # newer details (since the outgoing handler updates this user's # free/busy details). - tzid = self.get_tzid() - - _update_freebusy(freebusy, self.obj.get_periods_for_freebusy(tzid), - self.obj.get_value("TRANSP") or "OPAQUE", self.obj.get_value("UID")) + update_freebusy(freebusy, + self.obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()), + self.obj.get_value("TRANSP") or "OPAQUE", + self.uid, self.recurrenceid) user_attr = self.messenger and self.messenger.sender != get_address(self.user) and \ {"SENT-BY" : get_uri(self.messenger.sender)} or {} @@ -250,27 +293,9 @@ if self.messenger and self.messenger.sender != get_address(organiser): organiser_attr["SENT-BY"] = get_uri(self.messenger.sender) - to_cancel = [] - - if added or removed: - attendees = uri_items(self.obj.get_items("ATTENDEE") or []) - - if removed: - remaining = [] - - for attendee, attendee_attr in attendees: - if attendee in removed: - to_cancel.append((attendee, attendee_attr)) - else: - remaining.append((attendee, attendee_attr)) - - attendees = remaining - - if added: - for attendee in added: - attendees.append((attendee, {"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"})) - - self.obj["ATTENDEE"] = attendees + # Update the attendees in the event. + + to_cancel = self.update_attendees(self.obj, added, removed) self.update_dtstamp() self.set_sequence(update) @@ -281,6 +306,7 @@ # is now cancelled. if to_cancel: + remaining = self.obj["ATTENDEE"] self.obj["ATTENDEE"] = to_cancel self.send_message("CANCEL", get_address(organiser), for_organiser=True) @@ -317,17 +343,24 @@ except OSError: self.publisher = None - def _get_uid(self, path_info): - return path_info.lstrip("/").split("/", 1)[0] - - def _get_object(self, uid): - if self.objects.has_key(uid): - return self.objects[uid] - - fragment = uid and self.store.get_event(self.user, uid) or None - obj = self.objects[uid] = fragment and Object(fragment) + def _get_identifiers(self, path_info): + parts = path_info.lstrip("/").split("/") + if len(parts) == 1: + return parts[0], None + else: + return parts[:2] + + def _get_object(self, uid, recurrenceid=None): + if self.objects.has_key((uid, recurrenceid)): + return self.objects[(uid, recurrenceid)] + + fragment = uid and self.store.get_event(self.user, uid, recurrenceid) or None + obj = self.objects[(uid, recurrenceid)] = fragment and Object(fragment) return obj + def _get_recurrences(self, uid): + return self.store.get_recurrences(self.user, uid) + def _get_requests(self): if self.requests is None: cancellations = self.store.get_cancellations(self.user) @@ -337,18 +370,26 @@ def _get_request_summary(self): summary = [] - for uid in self._get_requests(): - obj = self._get_object(uid) + for uid, recurrenceid in self._get_requests(): + obj = self._get_object(uid, recurrenceid) if obj: - for start, end in obj.get_periods_for_freebusy(self.get_tzid()): - summary.append((start, end, uid)) + periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()) + + # Subtract any recurrences from the free/busy details of a parent + # object. + + recurrenceids = self._get_recurrences(uid) + + for start, end in periods: + if recurrenceid or start not in recurrenceids: + summary.append((start, end, uid, obj.get_value("TRANSP"), recurrenceid)) return summary # Preference methods. def get_user_locale(self): if not self.locale: - self.locale = self.get_preferences().get("LANG", "C") + self.locale = self.get_preferences().get("LANG", "en") return self.locale # Prettyprinting of dates and times. @@ -369,21 +410,40 @@ # Data management methods. - def remove_request(self, uid): - return self.store.dequeue_request(self.user, uid) - - def remove_event(self, uid): - return self.store.remove_event(self.user, uid) - - def update_freebusy(self, uid, obj): - tzid = self.get_tzid() + def remove_request(self, uid, recurrenceid=None): + return self.store.dequeue_request(self.user, uid, recurrenceid) + + def remove_event(self, uid, recurrenceid=None): + return self.store.remove_event(self.user, uid, recurrenceid) + + def update_freebusy(self, uid, recurrenceid, obj): + + """ + Update stored free/busy details for the event with the given 'uid' and + 'recurrenceid' having a representation of 'obj'. + """ + + is_only_organiser = self.user not in uri_values(obj.get_values("ATTENDEE")) + freebusy = self.store.get_freebusy(self.user) - update_freebusy(freebusy, self.user, obj.get_periods_for_freebusy(tzid), - obj.get_value("TRANSP"), uid, self.store) - - def remove_from_freebusy(self, uid): + + update_freebusy(freebusy, + obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()), + is_only_organiser and "ORG" or obj.get_value("TRANSP"), + uid, recurrenceid) + + # Subtract any recurrences from the free/busy details of a parent + # object. + + for recurrenceid in self._get_recurrences(uid): + remove_affected_period(freebusy, uid, recurrenceid) + + self.store.set_freebusy(self.user, freebusy) + + def remove_from_freebusy(self, uid, recurrenceid=None): freebusy = self.store.get_freebusy(self.user) - remove_from_freebusy(freebusy, self.user, uid, self.store) + remove_period(freebusy, uid, recurrenceid) + self.store.set_freebusy(self.user, freebusy) # Presentation methods. @@ -412,6 +472,12 @@ self.new_page(title="Redirect") self.page.p("Redirecting to: %s" % url) + def link_to(self, uid, recurrenceid=None): + if recurrenceid: + return self.env.new_url("/".join([uid, recurrenceid])) + else: + return self.env.new_url(uid) + # Request logic methods. def handle_newevent(self): @@ -456,7 +522,10 @@ # Merge adjacent dates and datetimes. - if start == last_end or get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid): + if start == last_end or \ + not isinstance(start, datetime) and \ + get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid): + last = last_start, end continue @@ -464,7 +533,9 @@ # Datetime periods are within single days and are therefore # discarded. - elif get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid): + elif not isinstance(last_start, datetime) and \ + get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid): + continue # Add separate dates and datetimes. @@ -482,48 +553,60 @@ utcnow = get_timestamp() uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) + # Create a calendar object and store it as a request. + + record = [] + rwrite = record.append + # Define a single occurrence if only one coalesced slot exists. - # Otherwise, many occurrences are defined. - - for i, (start, end) in enumerate(coalesced): - this_uid = "%s-%s" % (uid, i) - + + start, end = coalesced[0] + start_value, start_attr = get_datetime_item(start, tzid) + end_value, end_attr = get_datetime_item(end, tzid) + + rwrite(("UID", {}, uid)) + rwrite(("SUMMARY", {}, "New event at %s" % utcnow)) + rwrite(("DTSTAMP", {}, utcnow)) + rwrite(("DTSTART", start_attr, start_value)) + rwrite(("DTEND", end_attr, end_value)) + rwrite(("ORGANIZER", {}, self.user)) + + participants = uri_values(filter(None, participants)) + + for participant in participants: + rwrite(("ATTENDEE", {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}, participant)) + + if self.user not in participants: + rwrite(("ATTENDEE", {"PARTSTAT" : "ACCEPTED"}, self.user)) + + # Define additional occurrences if many slots are defined. + + rdates = [] + + for start, end in coalesced[1:]: start_value, start_attr = get_datetime_item(start, tzid) end_value, end_attr = get_datetime_item(end, tzid) - - # Create a calendar object and store it as a request. - - record = [] - rwrite = record.append - - rwrite(("UID", {}, this_uid)) - rwrite(("SUMMARY", {}, "New event at %s" % utcnow)) - rwrite(("DTSTAMP", {}, utcnow)) - rwrite(("DTSTART", start_attr, start_value)) - rwrite(("DTEND", end_attr, end_value)) - rwrite(("ORGANIZER", {}, self.user)) - - for participant in participants: - if not participant: - continue - participant = get_uri(participant) - rwrite(("ATTENDEE", {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}, participant)) - - obj = ("VEVENT", {}, record) - - self.store.set_event(self.user, this_uid, obj) - self.store.queue_request(self.user, this_uid) + rdates.append("%s/%s" % (start_value, end_value)) + + if rdates: + rwrite(("RDATE", {"VALUE" : "PERIOD", "TZID" : tzid}, rdates)) + + node = ("VEVENT", {}, record) + + self.store.set_event(self.user, uid, None, node=node) + self.store.queue_request(self.user, uid) # Redirect to the object (or the first of the objects), where instead of # attendee controls, there will be organiser controls. - self.redirect(self.env.new_url("%s-0" % uid)) - - def handle_request(self, uid, obj): + self.redirect(self.link_to(uid)) + + def handle_request(self, uid, recurrenceid, obj): """ - Handle actions involving the given 'uid' and 'obj' object, returning an - error if one occurred, or None if the request was successfully handled. + Handle actions involving the given 'uid', 'recurrenceid', and 'obj' as + the object's representation, returning an error if one occurred, or None + if the request was successfully handled. """ # Handle a submitted form. @@ -548,15 +631,13 @@ if args.has_key("summary"): obj["SUMMARY"] = [(args["summary"][0], {})] - organisers = uri_dict(obj.get_value_map("ORGANIZER")) attendees = uri_dict(obj.get_value_map("ATTENDEE")) if args.has_key("partstat"): - for d in attendees, organisers: - if d.has_key(self.user): - d[self.user]["PARTSTAT"] = args["partstat"][0] - if d[self.user].has_key("RSVP"): - del d[self.user]["RSVP"] + if attendees.has_key(self.user): + attendees[self.user]["PARTSTAT"] = args["partstat"][0] + if attendees[self.user].has_key("RSVP"): + del attendees[self.user]["RSVP"] is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user @@ -621,21 +702,22 @@ is_organiser and (invite or cancel) and \ handler.process_created_request(invite and "REQUEST" or "CANCEL", update, removed, added): - self.remove_request(uid) + self.remove_request(uid, recurrenceid) # Save single user events. elif save: - self.store.set_event(self.user, uid, obj.to_node()) - self.update_freebusy(uid, obj) - self.remove_request(uid) + to_cancel = self.update_attendees(obj, added, removed) + self.store.set_event(self.user, uid, recurrenceid, node=obj.to_node()) + self.update_freebusy(uid, recurrenceid, obj=obj) + self.remove_request(uid, recurrenceid) # Remove the request and the object. elif discard: - self.remove_from_freebusy(uid) - self.remove_event(uid) - self.remove_request(uid) + self.remove_from_freebusy(uid, recurrenceid) + self.remove_event(uid, recurrenceid) + self.remove_request(uid, recurrenceid) else: handled = False @@ -707,7 +789,7 @@ attendees = uri_values((obj.get_values("ATTENDEE") or []) + args.get("attendee", [])) is_attendee = self.user in attendees - is_request = obj.get_value("UID") in self._get_requests() + is_request = (obj.get_value("UID"), obj.get_value("RECURRENCE-ID")) in self._get_requests() have_other_attendees = len(attendees) > (is_attendee and 1 or 0) @@ -757,6 +839,7 @@ ("TENTATIVE", "Tentatively attending"), ("DECLINED", "Not attending"), ("DELEGATED", "Delegated"), + (None, "Not indicated"), ] def show_object_on_page(self, uid, obj, error=None): @@ -769,81 +852,32 @@ page = self.page page.form(method="POST") + args = self.env.get_args() + # Obtain the user's timezone. tzid = self.get_tzid() - # Provide controls to change the displayed object. - - args = self.env.get_args() - - # Add or remove new attendees. - # This does not affect the stored object. - - existing_attendees = uri_values(obj.get_values("ATTENDEE") or []) - new_attendees = args.get("added", []) - new_attendee = args.get("attendee", [""])[0] - - if args.has_key("add"): - if new_attendee.strip(): - new_attendee = get_uri(new_attendee.strip()) - if new_attendee not in new_attendees and new_attendee not in existing_attendees: - new_attendees.append(new_attendee) - new_attendee = "" - - if args.has_key("removenew"): - removed_attendee = args["removenew"][0] - if removed_attendee in new_attendees: - new_attendees.remove(removed_attendee) - - # Configure the start and end datetimes. - - dtend_control = args.get("dtend-control", [None])[0] - dttimes_control = args.get("dttimes-control", [None])[0] - with_time = dttimes_control == "enable" - - t = self.handle_date_controls("dtstart", with_time) - if t: - dtstart, dtstart_attr = t + # Obtain basic event information, showing any necessary editing controls. + + is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user + + if is_organiser: + (dtstart, dtstart_attr), (dtend, dtend_attr) = self.show_object_organiser_controls(obj) + new_attendees, new_attendee = self.handle_new_attendees(obj) else: dtstart, dtstart_attr = obj.get_datetime_item("DTSTART") - - if dtend_control == "enable": - t = self.handle_date_controls("dtend", with_time) - if t: - dtend, dtend_attr = t + if obj.has_key("DTEND"): + dtend, dtend_attr = obj.get_datetime_item("DTEND") + elif obj.has_key("DURATION"): + duration = obj.get_duration("DURATION") + dtend = dtstart + duration + dtend_attr = dtstart_attr else: - dtend, dtend_attr = None, {} - elif dtend_control == "disable": - dtend, dtend_attr = None, {} - else: - dtend, dtend_attr = obj.get_datetime_item("DTEND") - - # Change end dates to refer to the actual dates, not the iCalendar - # "next day" dates. - - if dtend and not isinstance(dtend, datetime): - dtend -= timedelta(1) - - # Show the end datetime controls if already active or if an object needs - # them. - - dtend_enabled = dtend_control == "enable" or isinstance(dtend, datetime) or dtstart != dtend - dttimes_enabled = dttimes_control == "enable" or isinstance(dtstart, datetime) or isinstance(dtend, datetime) - - if dtend_enabled: - page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable", checked="checked") - page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable") - else: - page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable") - page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable", checked="checked") - - if dttimes_enabled: - page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable", checked="checked") - page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable") - else: - page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable") - page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable", checked="checked") + dtend, dtend_attr = dtstart, dtstart_attr + + new_attendees = [] + new_attendee = "" # Provide a summary of the object. @@ -855,8 +889,6 @@ page.thead.close() page.tbody() - is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user - for name, label in self.property_items: page.tr() @@ -943,7 +975,7 @@ else: first = False - if name in ("ATTENDEE", "ORGANIZER"): + if name == "ATTENDEE": value = get_uri(value) page.td(class_="objectvalue") @@ -951,12 +983,12 @@ page.add(" ") partstat = attr.get("PARTSTAT") - if value == self.user and (not is_organiser or name == "ORGANIZER"): + if value == self.user: self._show_menu("partstat", partstat, self.partstat_items, "partstat") else: page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat") - if is_organiser and name == "ATTENDEE": + if is_organiser: if value in args.get("remove", []): page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove", checked="checked") else: @@ -1006,26 +1038,123 @@ page.form.close() + def handle_new_attendees(self, obj): + + "Add or remove new attendees. This does not affect the stored object." + + args = self.env.get_args() + + existing_attendees = uri_values(obj.get_values("ATTENDEE") or []) + new_attendees = args.get("added", []) + new_attendee = args.get("attendee", [""])[0] + + if args.has_key("add"): + if new_attendee.strip(): + new_attendee = get_uri(new_attendee.strip()) + if new_attendee not in new_attendees and new_attendee not in existing_attendees: + new_attendees.append(new_attendee) + new_attendee = "" + + if args.has_key("removenew"): + removed_attendee = args["removenew"][0] + if removed_attendee in new_attendees: + new_attendees.remove(removed_attendee) + + return new_attendees, new_attendee + + def show_object_organiser_controls(self, obj): + + "Provide controls to change the displayed object 'obj'." + + page = self.page + args = self.env.get_args() + + # Configure the start and end datetimes. + + dtend_control = args.get("dtend-control", [None])[0] + dttimes_control = args.get("dttimes-control", [None])[0] + with_time = dttimes_control == "enable" + + t = self.handle_date_controls("dtstart", with_time) + if t: + dtstart, dtstart_attr = t + else: + dtstart, dtstart_attr = obj.get_datetime_item("DTSTART") + + if dtend_control == "enable": + t = self.handle_date_controls("dtend", with_time) + if t: + dtend, dtend_attr = t + else: + dtend, dtend_attr = None, {} + elif dtend_control == "disable": + dtend, dtend_attr = None, {} + elif obj.has_key("DTEND"): + dtend, dtend_attr = obj.get_datetime_item("DTEND") + elif obj.has_key("DURATION"): + duration = obj.get_duration("DURATION") + dtend = dtstart + duration + dtend_attr = dtstart_attr + else: + dtend, dtend_attr = dtstart, dtstart_attr + + # Change end dates to refer to the actual dates, not the iCalendar + # "next day" dates. + + if dtend and not isinstance(dtend, datetime): + dtend -= timedelta(1) + + # Show the end datetime controls if already active or if an object needs + # them. + + dtend_enabled = dtend_control == "enable" or isinstance(dtend, datetime) or dtstart != dtend + dttimes_enabled = dttimes_control == "enable" or isinstance(dtstart, datetime) or isinstance(dtend, datetime) + + if dtend_enabled: + page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable", checked="checked") + page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable") + else: + page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable") + page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable", checked="checked") + + if dttimes_enabled: + page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable", checked="checked") + page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable") + else: + page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable") + page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable", checked="checked") + + return (dtstart, dtstart_attr), (dtend, dtend_attr) + def show_recurrences(self, obj): "Show recurrences for the object having the given representation 'obj'." page = self.page - # Obtain the user's timezone. - - tzid = self.get_tzid() - - window_size = 100 - - periods = obj.get_periods(self.get_tzid(), window_size) + # Obtain any parent object if this object is a specific recurrence. + + uid = obj.get_value("UID") + recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) + + if recurrenceid: + obj = self._get_object(uid) + if not obj: + return + + page.p("This event modifies a recurring event.") + + # Obtain the periods associated with the event in the user's time zone. + + periods = obj.get_periods(self.get_tzid(), self.get_window_end()) + recurrenceids = self._get_recurrences(uid) if len(periods) == 1: return - page.p("This event occurs on the following occasions within the next %d days:" % window_size) - - page.table(cellspacing=5, cellpadding=5, class_="conflicts") + page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size()) + + page.table(cellspacing=5, cellpadding=5, class_="recurrences") page.thead() page.tr() page.th("Start") @@ -1035,9 +1164,15 @@ page.tbody() for start, end in periods: + start_utc = format_datetime(to_timezone(start, "UTC")) + css = " ".join([ + recurrenceids and start_utc in recurrenceids and "replaced" or "", + recurrenceid and start_utc == recurrenceid and "affected" or "" + ]) + page.tr() - page.td(self.format_datetime(start, "long")) - page.td(self.format_datetime(end, "long")) + page.td(self.format_datetime(start, "long"), class_=css) + page.td(self.format_datetime(end, "long"), class_=css) page.tr.close() page.tbody.close() @@ -1057,7 +1192,15 @@ tzid = self.get_tzid() dtstart = format_datetime(obj.get_utc_datetime("DTSTART")) - dtend = format_datetime(obj.get_utc_datetime("DTEND")) + if obj.has_key("DTEND"): + dtend = format_datetime(obj.get_utc_datetime("DTEND")) + elif obj.has_key("DURATION"): + duration = obj.get_duration("DURATION") + dtend = format_datetime(obj.get_utc_datetime("DTSTART") + duration) + else: + dtend = dtstart + + periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()) # Indicate whether there are conflicting events. @@ -1072,7 +1215,7 @@ # Show any conflicts. - conflicts = [t for t in have_conflict(freebusy, [(dtstart, dtend)], True) if t[2] != uid] + conflicts = [t for t in have_conflict(freebusy, periods, True) if t[2] != uid] if conflicts: page.p("This event conflicts with others:") @@ -1088,7 +1231,7 @@ page.tbody() for t in conflicts: - start, end, found_uid = t[:3] + start, end, found_uid, transp, found_recurrenceid = t[:5] # Provide details of any conflicting event. @@ -1101,9 +1244,9 @@ page.td() - found_obj = self._get_object(found_uid) + found_obj = self._get_object(found_uid, found_recurrenceid) if found_obj: - page.a(found_obj.get_value("SUMMARY"), href=self.env.new_url(found_uid)) + page.a(found_obj.get_value("SUMMARY"), href=self.link_to(found_uid)) else: page.add("No details available") @@ -1133,11 +1276,11 @@ self.page.ul() - for request in requests: - obj = self._get_object(request) + for uid, recurrenceid in requests: + obj = self._get_object(uid, recurrenceid) if obj: self.page.li() - self.page.a(obj.get_value("SUMMARY"), href="#request-%s" % request) + self.page.a(obj.get_value("SUMMARY"), href="#request-%s-%s" % (uid, recurrenceid or "")) self.page.li.close() self.page.ul.close() @@ -1196,13 +1339,13 @@ "Show an object request using the given 'path_info' for the current user." - uid = self._get_uid(path_info) - obj = self._get_object(uid) + uid, recurrenceid = self._get_identifiers(path_info) + obj = self._get_object(uid, recurrenceid) if not obj: return False - error = self.handle_request(uid, obj) + error = self.handle_request(uid, recurrenceid, obj) if not error: return True @@ -1559,7 +1702,7 @@ page.td.close() empty = 0 - start, end, uid, key = get_freebusy_details(t) + start, end, uid, recurrenceid, key = get_freebusy_details(t) span = spans[key] # Produce a table cell only at the start of the period @@ -1567,7 +1710,7 @@ if point == start or continuation: - obj = self._get_object(uid) + obj = self._get_object(uid, recurrenceid) has_continued = continuation and point != start will_continue = not ends_on_same_day(point, end, tzid) @@ -1581,9 +1724,11 @@ ) # Only anchor the first cell of events. + # NOTE: Need to only anchor the first period for a + # NOTE: recurring event. if point == start: - page.td(class_=css, rowspan=span, id="%s-%s" % (group_type, uid)) + page.td(class_=css, rowspan=span, id="%s-%s-%s" % (group_type, uid, recurrenceid or "")) else: page.td(class_=css, rowspan=span) @@ -1595,11 +1740,10 @@ # Only link to events if they are not being # updated by requests. - if uid in self._get_requests() and group_type != "request": + if (uid, recurrenceid) in self._get_requests() and group_type != "request": page.span(summary) else: - href = "%s/%s" % (self.env.get_url().rstrip("/"), uid) - page.a(summary, href=href) + page.a(summary, href=self.link_to(uid, recurrenceid)) page.td.close() else: @@ -1676,6 +1820,8 @@ values = self.env.get_args().get(name, [default]) page.select(name=name, class_=class_) for v, label in items: + if v is None: + continue if v in values: page.option(label, value=v, selected="selected") else: diff -r 83cd574a7944 -r 0b326e3711f4 imip_store.py --- a/imip_store.py Thu Feb 12 22:34:48 2015 +0100 +++ b/imip_store.py Sat Mar 07 00:11:44 2015 +0100 @@ -24,7 +24,7 @@ from imiptools.data import make_calendar, parse_object, to_stream from imiptools.filesys import fix_permissions, FileBase from os.path import exists, isfile, join -from os import listdir, remove +from os import listdir, remove, rmdir from time import sleep class FileStore(FileBase): @@ -40,6 +40,66 @@ def release_lock(self, user): FileBase.release_lock(self, user) + def _set_defaults(self, t, empty_defaults): + for i, default in empty_defaults: + if i >= len(t): + t += [None] * (i - len(t) + 1) + if not t[i]: + t[i] = default + return t + + def _get_table(self, user, filename, empty_defaults=None): + + """ + From the file for the given 'user' having the given 'filename', return + a list of tuples representing the file's contents. + + The 'empty_defaults' is a list of (index, value) tuples indicating the + default value where a column either does not exist or provides an empty + value. + """ + + self.acquire_lock(user) + try: + f = open(filename, "rb") + try: + l = [] + for line in f.readlines(): + t = line.strip().split("\t") + if empty_defaults: + t = self._set_defaults(t, empty_defaults) + l.append(tuple(t)) + return l + finally: + f.close() + finally: + self.release_lock(user) + + def _set_table(self, user, filename, items, empty_defaults=None): + + """ + For the given 'user', write to the file having the given 'filename' the + 'items'. + + The 'empty_defaults' is a list of (index, value) tuples indicating the + default value where a column either does not exist or provides an empty + value. + """ + + self.acquire_lock(user) + try: + f = open(filename, "wb") + try: + for item in items: + if empty_defaults: + item = self._set_defaults(list(item), empty_defaults) + f.write("\t".join(item) + "\n") + finally: + f.close() + fix_permissions(filename) + finally: + self.release_lock(user) + def _get_object(self, user, filename): """ @@ -88,6 +148,17 @@ return True + def _remove_collection(self, filename): + + "Remove the collection with the given 'filename'." + + try: + rmdir(filename) + except OSError: + return False + + return True + def get_events(self, user): "Return a list of event identifiers." @@ -98,7 +169,20 @@ return [name for name in listdir(filename) if isfile(join(filename, name))] - def get_event(self, user, uid): + def get_event(self, user, uid, recurrenceid=None): + + """ + Get the event for the given 'user' with the given 'uid'. If + the optional 'recurrenceid' is specified, a specific instance or + occurrence of an event is returned. + """ + + if recurrenceid: + return self.get_recurrence(user, uid, recurrenceid) + else: + return self.get_complete_event(user, uid) + + def get_complete_event(self, user, uid): "Get the event for the given 'user' with the given 'uid'." @@ -108,7 +192,20 @@ return self._get_object(user, filename) - def set_event(self, user, uid, node): + def set_event(self, user, uid, recurrenceid, node): + + """ + Set an event for 'user' having the given 'uid' and 'recurrenceid' (which + if the latter is specified, a specific instance or occurrence of an + event is referenced), using the given 'node' description. + """ + + if recurrenceid: + return self.set_recurrence(user, uid, recurrenceid, node) + else: + return self.set_complete_event(user, uid, node) + + def set_complete_event(self, user, uid, node): "Set an event for 'user' having the given 'uid' and 'node'." @@ -118,16 +215,98 @@ return self._set_object(user, filename, node) - def remove_event(self, user, uid): + def remove_event(self, user, uid, recurrenceid=None): + + """ + Remove an event for 'user' having the given 'uid'. If the optional + 'recurrenceid' is specified, a specific instance or occurrence of an + event is removed. + """ + + if recurrenceid: + return self.remove_recurrence(user, uid, recurrenceid) + else: + for recurrenceid in self.get_recurrences(user, uid) or []: + self.remove_recurrence(user, uid, recurrenceid) + return self.remove_complete_event(user, uid) + + def remove_complete_event(self, user, uid): "Remove an event for 'user' having the given 'uid'." + self.remove_recurrences(user, uid) + filename = self.get_object_in_store(user, "objects", uid) if not filename: return False return self._remove_object(filename) + def get_recurrences(self, user, uid): + + """ + Get additional event instances for an event of the given 'user' with the + indicated 'uid'. + """ + + filename = self.get_object_in_store(user, "recurrences", uid) + if not filename or not exists(filename): + return [] + + return [name for name in listdir(filename) if isfile(join(filename, name))] + + def get_recurrence(self, user, uid, recurrenceid): + + """ + For the event of the given 'user' with the given 'uid', return the + specific recurrence indicated by the 'recurrenceid'. + """ + + filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) + if not filename or not exists(filename): + return None + + return self._get_object(user, filename) + + def set_recurrence(self, user, uid, recurrenceid, node): + + "Set an event for 'user' having the given 'uid' and 'node'." + + filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) + if not filename: + return False + + return self._set_object(user, filename, node) + + def remove_recurrence(self, user, uid, recurrenceid): + + """ + Remove a special recurrence from an event stored by 'user' having the + given 'uid' and 'recurrenceid'. + """ + + filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) + if not filename: + return False + + return self._remove_object(filename) + + def remove_recurrences(self, user, uid): + + """ + Remove all recurrences for an event stored by 'user' having the given + 'uid'. + """ + + for recurrenceid in self.get_recurrences(user, uid): + self.remove_recurrence(user, uid, recurrenceid) + + recurrences = self.get_object_in_store(user, "recurrences", uid) + if recurrences: + return self._remove_collection(recurrences) + + return True + def get_freebusy(self, user): "Get free/busy details for the given 'user'." @@ -136,7 +315,7 @@ if not filename or not exists(filename): return [] else: - return self._get_freebusy(user, filename) + return self._get_table(user, filename, [(4, None)]) def get_freebusy_for_other(self, user, other): @@ -146,24 +325,7 @@ if not filename or not exists(filename): return [] else: - return self._get_freebusy(user, filename) - - def _get_freebusy(self, user, filename): - - "For the given 'user', get the free/busy details from 'filename'." - - self.acquire_lock(user) - try: - f = open(filename) - try: - l = [] - for line in f.readlines(): - l.append(tuple(line.strip().split("\t"))) - return l - finally: - f.close() - finally: - self.release_lock(user) + return self._get_table(user, filename, [(4, None)]) def set_freebusy(self, user, freebusy): @@ -173,7 +335,7 @@ if not filename: return False - self._set_freebusy(user, filename, freebusy) + self._set_table(user, filename, freebusy, [(3, "OPAQUE"), (4, "")]) return True def set_freebusy_for_other(self, user, freebusy, other): @@ -184,28 +346,9 @@ if not filename: return False - self._set_freebusy(user, filename, freebusy) + self._set_table(user, filename, freebusy, [(2, ""), (3, "OPAQUE"), (4, "")]) return True - def _set_freebusy(self, user, filename, freebusy): - - """ - For the given 'user', write to the file having the given 'filename' the - 'freebusy' details. - """ - - self.acquire_lock(user) - try: - f = open(filename, "w") - try: - for item in freebusy: - f.write("\t".join([(value or "OPAQUE") for value in item]) + "\n") - finally: - f.close() - fix_permissions(filename) - finally: - self.release_lock(user) - def _get_requests(self, user, queue): "Get requests for the given 'user' from the given 'queue'." @@ -214,15 +357,7 @@ if not filename or not exists(filename): return None - self.acquire_lock(user) - try: - f = open(filename) - try: - return [line.strip() for line in f.readlines()] - finally: - f.close() - finally: - self.release_lock(user) + return self._get_table(user, filename, [(1, None)]) def get_requests(self, user): @@ -252,7 +387,7 @@ f = open(filename, "w") try: for request in requests: - print >>f, request + print >>f, "\t".join([value or "" for value in request]) finally: f.close() fix_permissions(filename) @@ -273,9 +408,12 @@ return self._set_requests(user, cancellations, "cancellations") - def _set_request(self, user, request, queue): + def _set_request(self, user, uid, recurrenceid, queue): - "For the given 'user', set the queued 'request' in the given 'queue'." + """ + For the given 'user', set the queued 'uid' and 'recurrenceid' in the + given 'queue'. + """ filename = self.get_object_in_store(user, queue) if not filename: @@ -285,7 +423,7 @@ try: f = open(filename, "a") try: - print >>f, request + print >>f, "\t".join([uid, recurrenceid or ""]) finally: f.close() fix_permissions(filename) @@ -294,51 +432,63 @@ return True - def set_request(self, user, request): + def set_request(self, user, uid, recurrenceid=None): - "For the given 'user', set the queued 'request'." + "For the given 'user', set the queued 'uid' and 'recurrenceid'." - return self._set_request(user, request, "requests") + return self._set_request(user, uid, recurrenceid, "requests") - def set_cancellation(self, user, cancellation): + def set_cancellation(self, user, uid, recurrenceid=None): + + "For the given 'user', set the queued 'uid' and 'recurrenceid'." - "For the given 'user', set the queued 'cancellation'." + return self._set_request(user, uid, recurrenceid, "cancellations") - return self._set_request(user, cancellation, "cancellations") + def queue_request(self, user, uid, recurrenceid=None): - def queue_request(self, user, uid): - - "Queue a request for 'user' having the given 'uid'." + """ + Queue a request for 'user' having the given 'uid'. If the optional + 'recurrenceid' is specified, the request refers to a specific instance + or occurrence of an event. + """ requests = self.get_requests(user) or [] - if uid not in requests: - return self.set_request(user, uid) + if (uid, recurrenceid) not in requests: + return self.set_request(user, uid, recurrenceid) return False - def dequeue_request(self, user, uid): + def dequeue_request(self, user, uid, recurrenceid=None): - "Dequeue a request for 'user' having the given 'uid'." + """ + Dequeue a request for 'user' having the given 'uid'. If the optional + 'recurrenceid' is specified, the request refers to a specific instance + or occurrence of an event. + """ requests = self.get_requests(user) or [] try: - requests.remove(uid) + requests.remove((uid, recurrenceid)) self.set_requests(user, requests) except ValueError: return False else: return True - def cancel_event(self, user, uid): + def cancel_event(self, user, uid, recurrenceid=None): - "Queue an event for cancellation for 'user' having the given 'uid'." + """ + Queue an event for cancellation for 'user' having the given 'uid'. If + the optional 'recurrenceid' is specified, a specific instance or + occurrence of an event is cancelled. + """ cancellations = self.get_cancellations(user) or [] - if uid not in cancellations: - return self.set_cancellation(user, uid) + if (uid, recurrenceid) not in cancellations: + return self.set_cancellation(user, uid, recurrenceid) return False @@ -364,7 +514,7 @@ rwrite(("UID", {}, user)) rwrite(("DTSTAMP", {}, datetime.utcnow().strftime("%Y%m%dT%H%M%SZ"))) - for start, end, uid, transp in freebusy: + for start, end, uid, transp, recurrenceid in freebusy: if not transp or transp == "OPAQUE": rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join([start, end]))) diff -r 83cd574a7944 -r 0b326e3711f4 imiptools/content.py --- a/imiptools/content.py Thu Feb 12 22:34:48 2015 +0100 +++ b/imiptools/content.py Sat Mar 07 00:11:44 2015 +0100 @@ -24,13 +24,13 @@ from email.mime.text import MIMEText from imiptools.config import MANAGER_PATH, MANAGER_URL from imiptools.data import Object, parse_object, \ - get_address, get_uri, get_value, \ - is_new_object, uri_dict, uri_item -from imiptools.dates import format_datetime, to_timezone + get_address, get_uri, get_value, get_window_end, \ + is_new_object, uri_dict, uri_item, uri_values +from imiptools.dates import format_datetime, get_default_timezone, to_timezone from imiptools.period import can_schedule, insert_period, remove_period, \ - remove_from_freebusy, \ - remove_from_freebusy_for_other, \ - update_freebusy, update_freebusy_for_other + remove_additional_periods, remove_affected_period, \ + update_freebusy +from imiptools.profile import Preferences from socket import gethostname import imip_store @@ -92,8 +92,11 @@ url_base = MANAGER_URL or "http://%s/" % gethostname() return "%s/%s" % (url_base.rstrip("/"), MANAGER_PATH.lstrip("/")) -def get_object_url(uid): - return "%s/%s" % (get_manager_url().rstrip("/"), uid) +def get_object_url(uid, recurrenceid=None): + return "%s/%s%s" % ( + get_manager_url().rstrip("/"), uid, + recurrenceid and "/%s" % recurrenceid or "" + ) class Handler: @@ -115,6 +118,7 @@ self.obj = None self.uid = None + self.recurrenceid = None self.sequence = None self.dtstamp = None @@ -128,6 +132,7 @@ def set_object(self, obj): self.obj = obj self.uid = self.obj.get_value("UID") + self.recurrenceid = format_datetime(self.obj.get_utc_datetime("RECURRENCE-ID")) self.sequence = self.obj.get_value("SEQUENCE") self.dtstamp = self.obj.get_value("DTSTAMP") @@ -140,7 +145,7 @@ if link: texts.append("If your mail program cannot handle this " "message, you may view the details here:\n\n%s" % - get_object_url(self.uid)) + get_object_url(self.uid, self.recurrenceid)) return self.add_result(None, None, MIMEText("\n".join(texts))) @@ -163,38 +168,99 @@ def get_outgoing_methods(self): return self.outgoing_methods - # Access to calendar structures and other data. + # Convenience methods for modifying free/busy collections. + + def remove_from_freebusy(self, freebusy): + + "Remove this event from the given 'freebusy' collection." + + remove_period(freebusy, self.uid, self.recurrenceid) + + def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None): - def remove_from_freebusy(self, freebusy, attendee): - remove_from_freebusy(freebusy, attendee, self.uid, self.store) + """ + Remove from 'freebusy' any original recurrence from parent free/busy + details for the current object, if the current object is a specific + additional recurrence. Otherwise, remove all additional recurrence + information corresponding to 'recurrenceids', or if omitted, all + recurrences. + """ + + if self.recurrenceid: + remove_affected_period(freebusy, self.uid, self.recurrenceid) + else: + # Remove obsolete recurrence periods. + + remove_additional_periods(freebusy, self.uid, recurrenceids) + + # Remove original periods affected by additional recurrences. + + if recurrenceids: + for recurrenceid in recurrenceids: + remove_affected_period(freebusy, self.uid, recurrenceid) + + def _update_freebusy(self, freebusy, periods, recurrenceid, transp=None): - def remove_from_freebusy_for_other(self, freebusy, user, other): - remove_from_freebusy_for_other(freebusy, user, other, self.uid, self.store) + """ + Update the 'freebusy' collection with the given 'periods', indicating an + explicit 'recurrenceid' to affect either a recurrence or the parent + event. + """ + + update_freebusy(freebusy, periods, transp or self.obj.get_value("TRANSP"), + self.uid, recurrenceid) + + def update_freebusy(self, freebusy, periods, transp=None): + + """ + Update the 'freebusy' collection for this event with the given + 'periods'. + """ + + self._update_freebusy(freebusy, periods, self.recurrenceid, transp) - def update_freebusy(self, freebusy, attendee, periods): - update_freebusy(freebusy, attendee, periods, self.obj.get_value("TRANSP"), - self.uid, self.store) + def update_freebusy_for_participant(self, freebusy, periods, attr, for_organiser=False): + + """ + Update the 'freebusy' collection using the given 'periods', subject to + the 'attr' provided for the participant, indicating whether this is + being generated 'for_organiser' or not. + """ - def update_freebusy_from_participant(self, user, participant_item): + # Organisers employ a special transparency. + + if for_organiser or attr.get("PARTSTAT") != "DECLINED": + self.update_freebusy(freebusy, periods, transp=(for_organiser and "ORG" or None)) + else: + self.remove_from_freebusy(freebusy) + + # Convenience methods for updating stored free/busy information. + + def update_freebusy_from_participant(self, user, participant_item, for_organiser): """ For the given 'user', record the free/busy information for the - 'participant_item' (a value plus attributes), using the 'tzid' to define - period information. + 'participant_item' (a value plus attributes) representing a different + identity, thus maintaining a separate record of their free/busy details. """ participant, participant_attr = participant_item - if participant != user: - freebusy = self.store.get_freebusy_for_other(user, participant) + if participant == user: + return + + freebusy = self.store.get_freebusy_for_other(user, participant) + tzid = self.get_tzid(user) + window_end = get_window_end(tzid) + periods = self.obj.get_periods_for_freebusy(tzid, window_end) - if participant_attr.get("PARTSTAT") != "DECLINED": - update_freebusy_for_other(freebusy, user, participant, - self.obj.get_periods_for_freebusy(tzid=None), - self.obj.get_value("TRANSP"), - self.uid, self.store) - else: - self.remove_from_freebusy_for_other(freebusy, user, participant) + # Record in the free/busy details unless a non-participating attendee. + + self.update_freebusy_for_participant(freebusy, periods, participant_attr, + for_organiser and self.is_not_attendee(participant, self.obj)) + + self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(user, self.uid)) + self.store.set_freebusy_for_other(user, freebusy, participant) def update_freebusy_from_organiser(self, attendee, organiser_item): @@ -203,17 +269,25 @@ 'organiser_item' (a value plus attributes). """ - self.update_freebusy_from_participant(attendee, organiser_item) + self.update_freebusy_from_participant(attendee, organiser_item, True) def update_freebusy_from_attendees(self, organiser, attendees): "For the 'organiser', record free/busy information from 'attendees'." for attendee_item in attendees.items(): - self.update_freebusy_from_participant(organiser, attendee_item) + self.update_freebusy_from_participant(organiser, attendee_item, False) + + # Logic, filtering and access to calendar structures and other data. + + def is_not_attendee(self, identity, obj): + + "Return whether 'identity' is not an attendee in 'obj'." + + return identity not in uri_values(obj.get_values("ATTENDEE")) def can_schedule(self, freebusy, periods): - return can_schedule(freebusy, periods, self.uid) + return can_schedule(freebusy, periods, self.uid, self.recurrenceid) def filter_by_senders(self, mapping): @@ -328,15 +402,32 @@ return senders + def _get_object(self, user, uid, recurrenceid): + + """ + Return the stored object for the given 'user', 'uid' and 'recurrenceid'. + """ + + fragment = self.store.get_event(user, uid, recurrenceid) + return fragment and Object(fragment) + def get_object(self, user): """ Return the stored object to which the current object refers for the - given 'user' and for the given 'objtype'. + given 'user'. """ - fragment = self.store.get_event(user, self.uid) - return fragment and Object(fragment) + return self._get_object(user, self.uid, self.recurrenceid) + + def get_parent_object(self, user): + + """ + Return the parent object to which the current object refers for the + given 'user'. + """ + + return self.recurrenceid and self._get_object(user, self.uid, None) or None def have_new_object(self, attendee, obj=None): @@ -413,7 +504,12 @@ obj["ATTENDEE"] = attendee_map.items() - self.store.set_event(identity, self.uid, obj.to_node()) + # Set the complete event if not an additional occurrence. + + event = obj.to_node() + recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) + + self.store.set_event(identity, self.uid, self.recurrenceid, event) return True @@ -432,6 +528,13 @@ sequence = self.obj.get_value("SEQUENCE") or "0" self.obj["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})] + def get_tzid(self, identity): + + "Return the time regime applicable for the given 'identity'." + + preferences = Preferences(identity) + return preferences.get("TZID") or get_default_timezone() + # Handler registry. methods = { diff -r 83cd574a7944 -r 0b326e3711f4 imiptools/data.py --- a/imiptools/data.py Thu Feb 12 22:34:48 2015 +0100 +++ b/imiptools/data.py Sat Mar 07 00:11:44 2015 +0100 @@ -21,8 +21,9 @@ from datetime import datetime, timedelta from email.mime.text import MIMEText -from imiptools.dates import format_datetime, get_datetime, get_freebusy_period, \ - to_timezone, to_utc_datetime +from imiptools.dates import format_datetime, get_datetime, get_duration, \ + get_freebusy_period, get_period, to_timezone, \ + to_utc_datetime from imiptools.period import period_overlaps from pytz import timezone from vCalendar import iterwrite, parse, ParseError, to_dict, to_node @@ -59,6 +60,13 @@ def get_utc_datetime(self, name): return get_utc_datetime(self.details, name) + def get_item_values(self, name): + items = get_item_value_items(self.details, name) + return items and [value for value, attr in items] + + def get_item_value_items(self, name): + return get_item_value_items(self.details, name) + def get_datetime(self, name): dt, attr = get_datetime_item(self.details, name) return dt @@ -66,6 +74,9 @@ def get_datetime_item(self, name): return get_datetime_item(self.details, name) + def get_duration(self, name): + return get_duration(self.get_value(name)) + def to_node(self): return to_node({self.objtype : [(self.details, self.attr)]}) @@ -74,6 +85,9 @@ # Direct access to the structure. + def has_key(self, name): + return self.details.has_key(name) + def __getitem__(self, name): return self.details[name] @@ -85,11 +99,15 @@ # Computed results. - def get_periods(self, tzid, window_size=100): - return get_periods(self, tzid, window_size) + def has_recurrence(self, tzid, recurrence): + recurrences = [start for start, end in get_periods(self, tzid, recurrence, True)] + return recurrence in recurrences - def get_periods_for_freebusy(self, tzid, window_size=100): - periods = self.get_periods(tzid, window_size) + def get_periods(self, tzid, end): + return get_periods(self, tzid, end) + + def get_periods_for_freebusy(self, tzid, end): + periods = self.get_periods(tzid, end) return get_periods_for_freebusy(self, periods, tzid) # Construction and serialisation. @@ -141,7 +159,7 @@ rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, periods[0][0])) rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, periods[-1][1])) - for start, end, uid, transp in periods: + for start, end, uid, transp, recurrenceid in periods: if transp == "OPAQUE": rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join([start, end]))) @@ -248,13 +266,41 @@ def get_value(d, name): return get_values(d, name, False) +def get_item_value_items(d, name): + + """ + Obtain items from 'd' having the given 'name', where a single item yields + potentially many values. Return a list of tuples of the form (value, + attributes) where the attributes have been given for the property in 'd'. + """ + + item = get_item(d, name) + if item: + values, attr = item + if not isinstance(values, list): + values = [values] + items = [] + for value in values: + items.append((get_datetime(value, attr) or get_period(value, attr), attr)) + return items + else: + return None + def get_utc_datetime(d, name): - dt, attr = get_datetime_item(d, name) - return to_utc_datetime(dt) + t = get_datetime_item(d, name) + if not t: + return None + else: + dt, attr = t + return to_utc_datetime(dt) def get_datetime_item(d, name): - value, attr = get_item(d, name) - return get_datetime(value, attr), attr + t = get_item(d, name) + if not t: + return None + else: + value, attr = t + return get_datetime(value, attr), attr def get_addresses(values): return [address for name, address in email.utils.getaddresses(values)] @@ -307,47 +353,81 @@ # NOTE: Need to expose the 100 day window for recurring events in the # NOTE: configuration. -def get_periods(obj, tzid, window_size=100): +def get_window_end(tzid, window_size=100): + return to_timezone(datetime.now(), tzid) + timedelta(window_size) + +def get_periods(obj, tzid, window_end, inclusive=False): """ Return periods for the given object 'obj', confining materialised periods - to the given 'window_size' in days starting from the present moment. + to before the given 'window_end' datetime. If 'inclusive' is set to a true + value, any period occurring at the 'window_end' will be included. """ - # NOTE: Need also RDATE and EXDATE support. - rrule = obj.get_value("RRULE") - if not rrule: - return [(obj.get_datetime("DTSTART"), obj.get_datetime("DTEND"))] - # Use localised datetimes. - dtstart, start_attr = obj.get_datetime_item("DTSTART") - dtend, end_attr = obj.get_datetime_item("DTEND") + dtstart, dtstart_attr = obj.get_datetime_item("DTSTART") + + if obj.has_key("DTEND"): + dtend, dtend_attr = obj.get_datetime_item("DTEND") + duration = dtend - dtstart + elif obj.has_key("DURATION"): + duration = obj.get_duration("DURATION") + dtend = dtstart + duration + dtend_attr = dtstart_attr + else: + dtend, dtend_attr = dtstart, dtstart_attr - tzid = start_attr.get("TZID") or end_attr.get("TZID") or tzid + tzid = dtstart_attr.get("TZID") or dtend_attr.get("TZID") or tzid - # NOTE: Need also DURATION support. + if not rrule: + periods = [(dtstart, dtend)] + else: + # Recurrence rules create multiple instances to be checked. + # Conflicts may only be assessed within a period defined by policy + # for the agent, with instances outside that period being considered + # unchecked. - duration = dtend - dtstart + selector = get_rule(dtstart, rrule) + parameters = get_parameters(rrule) + periods = [] - # Recurrence rules create multiple instances to be checked. - # Conflicts may only be assessed within a period defined by policy - # for the agent, with instances outside that period being considered - # unchecked. + for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS"), inclusive): + start = to_timezone(datetime(*start), tzid) + end = start + duration + periods.append((start, end)) + + # Add recurrence dates. - window_end = to_timezone(datetime.now(), tzid) + timedelta(window_size) + periods = set(periods) + rdates = obj.get_item_values("RDATE") + + if rdates: + for rdate in rdates: + if isinstance(rdate, tuple): + periods.add(rdate) + else: + periods.add((rdate, rdate + duration)) - selector = get_rule(dtstart, rrule) - parameters = get_parameters(rrule) - periods = [] + # Exclude exception dates. + + exdates = obj.get_item_values("EXDATE") - for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS")): - start = to_timezone(datetime(*start), tzid) - end = start + duration - periods.append((start, end)) + if exdates: + for exdate in exdates: + if isinstance(exdate, tuple): + period = exdate + else: + period = (exdate, exdate + duration) + if period in periods: + periods.remove(period) + # Return a sorted list of the periods. + + periods = list(periods) + periods.sort() return periods def get_periods_for_freebusy(obj, periods, tzid): @@ -358,7 +438,13 @@ """ start, start_attr = obj.get_datetime_item("DTSTART") - end, end_attr = obj.get_datetime_item("DTEND") + if obj.has_key("DTEND"): + end, end_attr = obj.get_datetime_item("DTEND") + elif obj.has_key("DURATION"): + duration = obj.get_duration("DURATION") + end = start + duration + else: + end, end_attr = start, start_attr tzid = start_attr.get("TZID") or end_attr.get("TZID") or tzid diff -r 83cd574a7944 -r 0b326e3711f4 imiptools/dates.py --- a/imiptools/dates.py Thu Feb 12 22:34:48 2015 +0100 +++ b/imiptools/dates.py Sat Mar 07 00:11:44 2015 +0100 @@ -26,15 +26,37 @@ # iCalendar date and datetime parsing (from DateSupport in MoinSupport). -date_icalendar_regexp_str = ur'(?P[0-9]{4})(?P[0-9]{2})(?P[0-9]{2})' -datetime_icalendar_regexp_str = date_icalendar_regexp_str + \ +_date_icalendar_regexp_str = ur'(?P[0-9]{4})(?P[0-9]{2})(?P[0-9]{2})' +date_icalendar_regexp_str = _date_icalendar_regexp_str + '$' + +datetime_icalendar_regexp_str = _date_icalendar_regexp_str + \ ur'(?:' \ ur'T(?P[0-2][0-9])(?P[0-5][0-9])(?P[0-6][0-9])' \ ur'(?PZ)?' \ - ur')?' + ur')?$' + +_duration_time_icalendar_regexp_str = \ + ur'T' \ + ur'(?:' \ + ur'([0-9]+H)(?:([0-9]+M)([0-9]+S)?)?' \ + ur'|' \ + ur'([0-9]+M)([0-9]+S)?' \ + ur'|' \ + ur'([0-9]+S)' \ + ur')' + +duration_icalendar_regexp_str = ur'P' \ + ur'(?:' \ + ur'([0-9]+W)' \ + ur'|' \ + ur'(?:%s)' \ + ur'|' \ + ur'([0-9]+D)(?:%s)?' \ + ur')$' % (_duration_time_icalendar_regexp_str, _duration_time_icalendar_regexp_str) match_date_icalendar = re.compile(date_icalendar_regexp_str, re.UNICODE).match match_datetime_icalendar = re.compile(datetime_icalendar_regexp_str, re.UNICODE).match +match_duration_icalendar = re.compile(duration_icalendar_regexp_str, re.UNICODE).match def to_utc_datetime(dt): @@ -169,6 +191,58 @@ return None +def get_period(value, attr=None): + + """ + Return a tuple of the form (start, end) for the given 'value' in iCalendar + format, using the 'attr' mapping (if specified) to control the conversion. + """ + + if not value or attr and attr.get("VALUE") != "PERIOD": + return None + + t = value.split("/") + if len(t) != 2: + return None + + dtattr = {} + if attr: + dtattr.update(attr) + if dtattr.has_key("VALUE"): + del dtattr["VALUE"] + + start = get_datetime(t[0], dtattr) + if t[1].startswith("P"): + end = start + get_duration(t[1]) + else: + end = get_datetime(t[1], dtattr) + + return start, end + +def get_duration(value): + + "Return a duration for the given 'value'." + + if not value: + return None + + m = match_duration_icalendar(value) + if m: + weeks, days, hours, minutes, seconds = 0, 0, 0, 0, 0 + for s in m.groups(): + if not s: continue + if s[-1] == "W": weeks += int(s[:-1]) + elif s[-1] == "D": days += int(s[:-1]) + elif s[-1] == "H": hours += int(s[:-1]) + elif s[-1] == "M": minutes += int(s[:-1]) + elif s[-1] == "S": seconds += int(s[:-1]) + return timedelta( + int(weeks) * 7 + int(days), + (int(hours) * 60 + int(minutes)) * 60 + int(seconds) + ) + else: + return None + def get_date(dt): "Return the date of 'dt'." diff -r 83cd574a7944 -r 0b326e3711f4 imiptools/filesys.py --- a/imiptools/filesys.py Thu Feb 12 22:34:48 2015 +0100 +++ b/imiptools/filesys.py Sat Mar 07 00:11:44 2015 +0100 @@ -34,6 +34,14 @@ except OSError: pass +def make_path(base, parts): + for part in parts: + pathname = join(base, part) + if not exists(pathname): + mkdir(pathname) + fix_permissions(pathname, True) + base = pathname + class FileBase: "Basic filesystem operations." @@ -43,7 +51,8 @@ def __init__(self, store_dir): self.store_dir = store_dir if not exists(self.store_dir): - makedirs(self.store_dir, DEFAULT_DIR_PERMISSIONS) + makedirs(self.store_dir) + fix_permissions(self.store_dir, True) def get_file_object(self, base, *parts): pathname = join(base, *parts) @@ -66,7 +75,7 @@ expected = filename if not exists(parent): - makedirs(parent, DEFAULT_DIR_PERMISSIONS) + make_path(self.store_dir, parts[:-1]) return filename diff -r 83cd574a7944 -r 0b326e3711f4 imiptools/handlers/person.py --- a/imiptools/handlers/person.py Thu Feb 12 22:34:48 2015 +0100 +++ b/imiptools/handlers/person.py Sat Mar 07 00:11:44 2015 +0100 @@ -49,20 +49,29 @@ if not self.have_new_object(attendee): continue - # Store the object and queue any request. + # Set the complete event or an additional occurrence. + + self.store.set_event(attendee, self.uid, self.recurrenceid, self.obj.to_node()) - self.store.set_event(attendee, self.uid, self.obj.to_node()) + # Remove additional recurrences if handling a complete event. + + if not self.recurrenceid: + self.store.remove_recurrences(attendee, self.uid) + + # Queue any request. if queue: - self.store.queue_request(attendee, self.uid) + self.store.queue_request(attendee, self.uid, self.recurrenceid) elif cancel: - self.store.cancel_event(attendee, self.uid) + self.store.cancel_event(attendee, self.uid, self.recurrenceid) # No return message will occur to update the free/busy # information, so this is done here. freebusy = self.store.get_freebusy(attendee) - self.remove_from_freebusy(freebusy, attendee) + self.remove_from_freebusy(freebusy) + + self.store.set_freebusy(attendee, freebusy) if self.publisher: self.publisher.set_freebusy(attendee, freebusy) @@ -157,10 +166,11 @@ def refresh(self): - "Update details of any active event." + "Generate details of any active event." - self._record(from_organiser=True, queue=False) - return self.wrap("An event update has been received.") + # NOTE: Return event details if configured to do so. + + return self.wrap("A request for updated event details has been received.") def reply(self): diff -r 83cd574a7944 -r 0b326e3711f4 imiptools/handlers/person_outgoing.py --- a/imiptools/handlers/person_outgoing.py Thu Feb 12 22:34:48 2015 +0100 +++ b/imiptools/handlers/person_outgoing.py Sat Mar 07 00:11:44 2015 +0100 @@ -21,9 +21,8 @@ """ from imiptools.content import Handler -from imiptools.data import uri_dict, uri_item, uri_values -from imiptools.dates import get_default_timezone -from imiptools.profile import Preferences +from imiptools.data import get_window_end, uri_dict, uri_item, uri_values +from imiptools.period import remove_affected_period class PersonHandler(Handler): @@ -58,34 +57,54 @@ # Update the object. if from_organiser: - self.store.set_event(identity, self.uid, self.obj.to_node()) + + # Set the complete event or an additional occurrence. + + self.store.set_event(identity, self.uid, self.recurrenceid, self.obj.to_node()) + + # Remove additional recurrences if handling a complete event. + + if not self.recurrenceid: + self.store.remove_recurrences(identity, self.uid) + else: organiser_item, attendees = self.require_organiser_and_attendees(from_organiser) self.merge_attendance(attendees, identity) # Remove any associated request. - self.store.dequeue_request(identity, self.uid) + self.store.dequeue_request(identity, self.uid, self.recurrenceid) # Update free/busy information. if update_freebusy: + freebusy = self.store.get_freebusy(identity) + # Interpretation of periods can depend on the time zone. - preferences = Preferences(identity) - tzid = preferences.get("TZID") or get_default_timezone() + tzid = self.get_tzid(identity) + + # Use the stored event in case the reply is incomplete, as is seen + # when Claws sends a REPLY for an object originally employing + # recurrence information. + + obj = self.get_object(identity) # If newer than any old version, discard old details from the # free/busy record and check for suitability. - periods = self.obj.get_periods_for_freebusy(tzid) - freebusy = self.store.get_freebusy(identity) + periods = obj.get_periods_for_freebusy(tzid, get_window_end(tzid)) + + self.update_freebusy_for_participant(freebusy, periods, attr, + from_organiser and self.is_not_attendee(identity, obj)) - if attr.get("PARTSTAT") != "DECLINED": - self.update_freebusy(freebusy, identity, periods) - else: - self.remove_from_freebusy(freebusy, identity) + # Remove either original recurrence or additional recurrence + # details depending on whether an additional recurrence or a + # complete event are being handled, respectively. + + self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(identity, self.uid)) + self.store.set_freebusy(identity, freebusy) if self.publisher: self.publisher.set_freebusy(identity, freebusy) @@ -113,7 +132,7 @@ given_attendees = set(uri_values(self.obj.get_values("ATTENDEE"))) if given_attendees == all_attendees: - self.store.cancel_event(identity, self.uid) + self.store.cancel_event(identity, self.uid, self.recurrenceid) # Otherwise, remove the given attendees and update the event. @@ -128,17 +147,20 @@ obj["SEQUENCE"] = self.obj.get_items("SEQUENCE") obj["DTSTAMP"] = self.obj.get_items("DTSTAMP") - self.store.set_event(identity, self.uid, obj.to_node()) + # Set the complete event if not an additional occurrence. + + self.store.set_event(identity, self.uid, self.recurrenceid, obj.to_node()) # Remove any associated request. - self.store.dequeue_request(identity, self.uid) + self.store.dequeue_request(identity, self.uid, self.recurrenceid) # Update free/busy information. if update_freebusy: freebusy = self.store.get_freebusy(identity) - self.remove_from_freebusy(freebusy, identity) + self.remove_from_freebusy(freebusy) + self.store.set_freebusy(identity, freebusy) if self.publisher: self.publisher.set_freebusy(identity, freebusy) @@ -165,7 +187,7 @@ self._record(True, True) def refresh(self): - self._record(True, True) + pass def reply(self): self._record(False, True) @@ -219,7 +241,7 @@ self._record(True) def refresh(self): - self._record(True) + pass def reply(self): self._record(False) diff -r 83cd574a7944 -r 0b326e3711f4 imiptools/handlers/resource.py --- a/imiptools/handlers/resource.py Thu Feb 12 22:34:48 2015 +0100 +++ b/imiptools/handlers/resource.py Sat Mar 07 00:11:44 2015 +0100 @@ -20,10 +20,10 @@ """ from imiptools.content import Handler -from imiptools.data import get_address, get_uri, to_part +from imiptools.data import get_address, get_uri, get_window_end, to_part from imiptools.dates import get_default_timezone from imiptools.handlers.common import CommonFreebusy -from imiptools.profile import Preferences +from imiptools.period import remove_affected_period class ResourceHandler(Handler): @@ -60,13 +60,12 @@ # Interpretation of periods can depend on the time zone. - preferences = Preferences(attendee) - tzid = preferences.get("TZID") or get_default_timezone() + tzid = self.get_tzid(attendee) # If newer than any old version, discard old details from the # free/busy record and check for suitability. - periods = self.obj.get_periods_for_freebusy(tzid) + periods = self.obj.get_periods_for_freebusy(tzid, get_window_end(tzid)) freebusy = self.store.get_freebusy(attendee) scheduled = self.can_schedule(freebusy, periods) @@ -84,15 +83,29 @@ self.update_dtstamp() + # Set the complete event or an additional occurrence. + event = self.obj.to_node() - self.store.set_event(attendee, self.uid, event) + self.store.set_event(attendee, self.uid, self.recurrenceid, event) + + # Remove additional recurrences if handling a complete event. + + if not self.recurrenceid: + self.store.remove_recurrences(attendee, self.uid) # Only update free/busy details if the event is scheduled. if scheduled: - self.update_freebusy(freebusy, attendee, periods) + self.update_freebusy(freebusy, periods) else: - self.remove_from_freebusy(freebusy, attendee) + self.remove_from_freebusy(freebusy) + + # Remove either original recurrence or additional recurrence + # details depending on whether an additional recurrence or a + # complete event are being handled, respectively. + + self.remove_freebusy_for_recurrences(freebusy) + self.store.set_freebusy(attendee, freebusy) if self.publisher: self.publisher.set_freebusy(attendee, freebusy) @@ -101,10 +114,12 @@ def _cancel_for_attendee(self, attendee, attendee_attr): - self.store.cancel_event(attendee, self.uid) + self.store.cancel_event(attendee, self.uid, self.recurrenceid) freebusy = self.store.get_freebusy(attendee) - self.remove_from_freebusy(freebusy, attendee) + self.remove_from_freebusy(freebusy) + + self.store.set_freebusy(attendee, freebusy) if self.publisher: self.publisher.set_freebusy(attendee, freebusy) diff -r 83cd574a7944 -r 0b326e3711f4 imiptools/period.py --- a/imiptools/period.py Thu Feb 12 22:34:48 2015 +0100 +++ b/imiptools/period.py Sat Mar 07 00:11:44 2015 +0100 @@ -25,16 +25,16 @@ # Time management with datetime strings in the UTC time zone. -def can_schedule(freebusy, periods, uid): +def can_schedule(freebusy, periods, uid, recurrenceid): """ Return whether the 'freebusy' list can accommodate the given 'periods' - employing the specified 'uid'. + employing the specified 'uid' and 'recurrenceid'. """ for conflict in have_conflict(freebusy, periods, True): - start, end, found_uid, found_transp = conflict - if found_uid != uid: + start, end, found_uid, found_transp, found_recurrenceid = conflict + if found_uid != uid and found_recurrenceid != recurrenceid: return False return True @@ -62,17 +62,75 @@ return False def insert_period(freebusy, period): + + "Insert into 'freebusy' the given 'period'." + insort_left(freebusy, period) -def remove_period(freebusy, uid): +def remove_period(freebusy, uid, recurrenceid=None): + + """ + Remove from 'freebusy' all periods associated with 'uid' and 'recurrenceid' + (which if omitted causes the "parent" object's periods to be referenced). + """ + + i = 0 + while i < len(freebusy): + t = freebusy[i] + if len(t) >= 5 and t[2] == uid and t[4] == recurrenceid: + del freebusy[i] + else: + i += 1 + +def remove_additional_periods(freebusy, uid, recurrenceids=None): + + """ + Remove from 'freebusy' all periods associated with 'uid' having a + recurrence identifier indicating an additional or modified period. + + If 'recurrenceids' is specified, remove all periods associated with 'uid' + that do not have a recurrence identifier in the given list. + """ + i = 0 while i < len(freebusy): t = freebusy[i] - if len(t) >= 3 and t[2] == uid: + if len(t) >= 5 and t[2] == uid and t[4] and ( + recurrenceids is None or + recurrenceids is not None and t[4] not in recurrenceids + ): del freebusy[i] else: i += 1 +def remove_affected_period(freebusy, uid, recurrenceid): + + """ + Remove from 'freebusy' a period associated with 'uid' that provides an + occurrence starting at the given 'recurrenceid', where the recurrence + identifier is used to provide an alternative time period whilst also acting + as a reference to the originally-defined occurrence. + """ + + found = bisect_left(freebusy, (recurrenceid,)) + while found < len(freebusy): + start, end, _uid, transp, _recurrenceid = freebusy[found][:5] + + # Stop looking if the start no longer matches the recurrence identifier. + + if start != recurrenceid: + return + + # If the period belongs to the parent object, remove it and return. + + if not _recurrenceid and uid == _uid: + del freebusy[found] + break + + # Otherwise, keep looking for a matching period. + + found += 1 + def get_overlapping(freebusy, period): """ @@ -332,7 +390,7 @@ for point, active in slots: for t in active: if t and len(t) >= 2: - start, end, uid, key = get_freebusy_details(t) + start, end, uid, recurrenceid, key = get_freebusy_details(t) try: start_slot = points.index(start) @@ -348,73 +406,34 @@ def get_freebusy_details(t): - "Return a tuple of the form (start, end, uid, key) from 't'." + "Return a tuple of the form (start, end, uid, recurrenceid, key) from 't'." # Handle both complete free/busy details... - if len(t) > 2: - start, end, uid = t[:3] - key = uid + if len(t) > 4: + start, end, uid, transp, recurrenceid = t[:5] + key = uid, recurrenceid # ...and published details without specific event details. else: start, end = t[:2] uid = None + recurrenceid = None key = (start, end) - return start, end, uid, key - -def remove_from_freebusy(freebusy, attendee, uid, store): - - """ - For the given 'attendee', remove periods from 'freebusy' that are associated - with 'uid' in the 'store'. - """ - - remove_period(freebusy, uid) - store.set_freebusy(attendee, freebusy) + return start, end, uid, recurrenceid, key -def remove_from_freebusy_for_other(freebusy, user, other, uid, store): - - """ - For the given 'user', remove for the 'other' party periods from 'freebusy' - that are associated with 'uid' in the 'store'. - """ - - remove_period(freebusy, uid) - store.set_freebusy_for_other(user, freebusy, other) - -def _update_freebusy(freebusy, periods, transp, uid): +def update_freebusy(freebusy, periods, transp, uid, recurrenceid): """ Update the free/busy details with the given 'periods', 'transp' setting and - 'uid'. - """ - - remove_period(freebusy, uid) - - for start, end in periods: - insert_period(freebusy, (start, end, uid, transp)) - -def update_freebusy(freebusy, attendee, periods, transp, uid, store): - - """ - For the given 'attendee', update the free/busy details with the given - 'periods', 'transp' setting and 'uid' in the 'store'. + 'uid' plus 'recurrenceid'. """ - _update_freebusy(freebusy, periods, transp, uid) - store.set_freebusy(attendee, freebusy) - -def update_freebusy_for_other(freebusy, user, other, periods, transp, uid, store): + remove_period(freebusy, uid, recurrenceid) - """ - For the given 'user', update the free/busy details of 'other' with the given - 'periods', 'transp' setting and 'uid' in the 'store'. - """ - - _update_freebusy(freebusy, periods, transp, uid) - store.set_freebusy_for_other(user, freebusy, other) + for start, end in periods: + insert_period(freebusy, (start, end, uid, transp, recurrenceid)) # vim: tabstop=4 expandtab shiftwidth=4 diff -r 83cd574a7944 -r 0b326e3711f4 tools/make_freebusy.py --- a/tools/make_freebusy.py Thu Feb 12 22:34:48 2015 +0100 +++ b/tools/make_freebusy.py Sat Mar 07 00:11:44 2015 +0100 @@ -1,59 +1,100 @@ #!/usr/bin/env python -from imiptools.data import get_freebusy_period, get_datetime_item, get_value, get_value_map, parse_object +from imiptools.data import get_window_end, Object from imiptools.dates import format_datetime, get_default_timezone from imiptools.profile import Preferences from imip_store import FileStore, FilePublisher import sys +def get_periods(fb, obj, tzid, window_end, only_organiser): + + # Update free/busy details with the actual periods associated with the event. + + for start, end in obj.get_periods_for_freebusy(tzid, window_end): + fb.append((start, end, + obj.get_value("UID"), + only_organiser and "ORG" or obj.get_value("TRANSP") or "OPAQUE", + format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) or "", + )) + +# Main program. + try: user = sys.argv[1] + store_and_publish = "-s" in sys.argv[2:] except IndexError: - print >>sys.stderr, "Need a user." + print >>sys.stderr, "Need a user, along with the -s option if updating the store." sys.exit(1) preferences = Preferences(user) tzid = preferences.get("TZID") or get_default_timezone() -s = FileStore() -p = FilePublisher() +# Get the size of the free/busy window. + +try: + window_size = int(preferences.get("window_size")) +except (TypeError, ValueError): + window_size = 100 +window_end = get_window_end(tzid, window_size) + +store = FileStore() +publisher = FilePublisher() + +# Get all identifiers for events. -events = set(s.get_events(user)) -cancelled = s.get_cancellations(user) or [] +uids = store.get_events(user) + +all_events = set() +for uid in uids: + all_events.add((uid, None)) + all_events.update([(uid, recurrenceid) for recurrenceid in store.get_recurrences(user, uid)]) -events.difference_update(cancelled) +# Filter out cancelled events. + +cancelled = store.get_cancellations(user) or [] +all_events.difference_update(cancelled) + +# Obtain event objects. objs = [] -for i in events: - print >>sys.stderr, i - objs.append(parse_object(s.get_event(user, i), "utf-8")) +for uid, recurrenceid in all_events: + print >>sys.stderr, uid, recurrenceid + event = store.get_event(user, uid, recurrenceid) + if event: + objs.append(Object(event)) + +# Build a free/busy collection for the given user. fb = [] for obj in objs: - if not obj: - continue - details, details_attr = obj.values()[0] + attendees = obj.get_value_map("ATTENDEE") + organiser = obj.get_value("ORGANIZER") - participants = {} - participants.update(get_value_map(details, "ATTENDEE")) - participants.update(get_value_map(details, "ORGANIZER")) + for attendee, attendee_attr in attendees.items(): + + # Only consider events where this user actually attends. - for participant, participant_attr in participants.items(): - if participant == user: - if participant_attr.get("PARTSTAT") != "DECLINED": - dtstart, dtstart_attr = get_datetime_item(details, "DTSTART") - dtend, dtend_attr = get_datetime_item(details, "DTEND") - event_tzid = dtstart_attr.get("TZID") or dtend_attr.get("TZID") or tzid - dtstart, dtend = get_freebusy_period(dtstart, dtend, event_tzid) - fb.append((format_datetime(dtstart), - format_datetime(dtend), - get_value(details, "UID"), - get_value(details, "TRANSP"))) + if attendee == user: + if attendee_attr.get("PARTSTAT", "NEEDS-ACTION") not in ("DECLINED", "DELEGATED", "NEEDS-ACTION"): + get_periods(fb, obj, tzid, window_end, False) break + # Where not attending, retain the affected periods and mark them as + # organising periods. + + else: + if organiser == user: + get_periods(fb, obj, tzid, window_end, True) + fb.sort() -s.set_freebusy(user, fb) -p.set_freebusy(user, fb) +# Store and publish the free/busy collection. + +if store_and_publish: + store.set_freebusy(user, fb) + publisher.set_freebusy(user, fb) +else: + for item in fb: + print "\t".join(item) # vim: tabstop=4 expandtab shiftwidth=4 diff -r 83cd574a7944 -r 0b326e3711f4 vRecurrence.py --- a/vRecurrence.py Thu Feb 12 22:34:48 2015 +0100 +++ b/vRecurrence.py Sat Mar 07 00:11:44 2015 +0100 @@ -3,7 +3,7 @@ """ Recurrence rule calculation. -Copyright (C) 2014 Paul Boddie +Copyright (C) 2014, 2015 Paul Boddie This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software @@ -124,7 +124,7 @@ """ Process the list of 'values' of the form "key=value", returning a list of - qualifiers. + qualifiers of the form (qualifier name, args). """ qualifiers = [] @@ -382,7 +382,19 @@ # Classes for producing instances from recurrence structures. class Selector: + + "A generic selector." + def __init__(self, level, args, qualifier, selecting=None): + + """ + Initialise at the given 'level' a selector employing the given 'args' + defined in the interpretation of recurrence rule qualifiers, with the + 'qualifier' being the name of the rule qualifier, and 'selecting' being + an optional selector used to find more specific instances from those + found by this selector. + """ + self.level = level self.pos = positions[level] self.args = args @@ -393,23 +405,46 @@ def __repr__(self): return "%s(%r, %r, %r, %r)" % (self.__class__.__name__, self.level, self.args, self.qualifier, self.context) - def materialise(self, start, end, count=None, setpos=None): + def materialise(self, start, end, count=None, setpos=None, inclusive=False): + + """ + Starting at 'start', materialise instances up to but not including any + at 'end' or later, returning at most 'count' if specified, and returning + only the occurrences indicated by 'setpos' if specified. A list of + instances is returned. + + If 'inclusive' is specified, the selection of instances will include the + end of the search period if present in the results. + """ + start = to_tuple(start) end = to_tuple(end) counter = count and [0, count] - results = self.materialise_items(self.context, start, end, counter, setpos) + results = self.materialise_items(self.context, start, end, counter, setpos, inclusive) results.sort() return results[:count] - def materialise_item(self, current, last, next, counter, setpos=None): + def materialise_item(self, current, earliest, next, counter, setpos=None, inclusive=False): + + """ + Given the 'current' instance, the 'earliest' acceptable instance, the + 'next' instance, an instance 'counter', and the optional 'setpos' + criteria, return a list of result items. Where no selection within the + current instance occurs, the current instance will be returned as a + result if the same or later than the earliest acceptable instance. + """ + if self.selecting: - return self.selecting.materialise_items(current, last, next, counter, setpos) - elif last <= current: + return self.selecting.materialise_items(current, earliest, next, counter, setpos, inclusive) + elif earliest <= current: return [current] else: return [] def convert_positions(self, setpos): + + "Convert 'setpos' to 0-based indexes." + l = [] for pos in setpos: lower = pos < 0 and pos or pos - 1 @@ -418,21 +453,36 @@ return l def select_positions(self, results, setpos): + + "Select in 'results' the 1-based positions given by 'setpos'." + results.sort() l = [] for lower, upper in self.convert_positions(setpos): l += results[lower:upper] return l - def filter_by_period(self, results, start, end): + def filter_by_period(self, results, start, end, inclusive): + + """ + Filter 'results' so that only those at or after 'start' and before 'end' + are returned. + + If 'inclusive' is specified, the selection of instances will include the + end of the search period if present in the results. + """ + l = [] for result in results: - if start <= result < end: + if start <= result and (inclusive and result <= end or result < end): l.append(result) return l class Pattern(Selector): - def materialise_items(self, context, start, end, counter, setpos=None): + + "A selector of instances according to a repeating pattern." + + def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False): first = scale(self.context[self.pos], self.pos) # Define the step between items. @@ -448,10 +498,10 @@ current = combine(context, first) results = [] - while current < end and (counter is None or counter[0] < counter[1]): + while (inclusive and current <= end or current < end) and (counter is None or counter[0] < counter[1]): next = update(current, step) current_end = update(current, unit_step) - interval_results = self.materialise_item(current, max(current, start), min(current_end, end), counter, setpos) + interval_results = self.materialise_item(current, max(current, start), min(current_end, end), counter, setpos, inclusive) if counter is not None: counter[0] += len(interval_results) results += interval_results @@ -460,7 +510,10 @@ return results class WeekDayFilter(Selector): - def materialise_items(self, context, start, end, counter, setpos=None): + + "A selector of instances specified in terms of day numbers." + + def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False): step = scale(1, 2) results = [] @@ -483,10 +536,10 @@ current = context values = [value for (value, index) in self.args["values"]] - while current < end: + while (inclusive and current <= end or current < end): next = update(current, step) if date(*current).isoweekday() in values: - results += self.materialise_item(current, max(current, start), min(next, end), counter) + results += self.materialise_item(current, max(current, start), min(next, end), counter, inclusive=inclusive) current = next if setpos: @@ -510,7 +563,7 @@ # To support setpos, only current and next bound the search, not # the period in addition. - results += self.materialise_item(current, current, next, counter) + results += self.materialise_item(current, current, next, counter, inclusive=inclusive) else: if index < 0: @@ -526,7 +579,7 @@ # To support setpos, only current and next bound the search, not # the period in addition. - results += self.materialise_item(current, current, next, counter) + results += self.materialise_item(current, current, next, counter, inclusive=inclusive) current = to_tuple(direction(date(*current), timedelta(7)), 3) # Extract selected positions and remove out-of-period instances. @@ -534,10 +587,10 @@ if setpos: results = self.select_positions(results, setpos) - return self.filter_by_period(results, start, end) + return self.filter_by_period(results, start, end, inclusive) class Enum(Selector): - def materialise_items(self, context, start, end, counter, setpos=None): + def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False): step = scale(1, self.pos) results = [] for value in self.args["values"]: @@ -547,17 +600,17 @@ # To support setpos, only current and next bound the search, not # the period in addition. - results += self.materialise_item(current, current, next, counter, setpos) + results += self.materialise_item(current, current, next, counter, setpos, inclusive) # Extract selected positions and remove out-of-period instances. if setpos: results = self.select_positions(results, setpos) - return self.filter_by_period(results, start, end) + return self.filter_by_period(results, start, end, inclusive) class MonthDayFilter(Enum): - def materialise_items(self, context, start, end, counter, setpos=None): + def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False): last_day = monthrange(context[0], context[1])[1] step = scale(1, self.pos) results = [] @@ -570,17 +623,17 @@ # To support setpos, only current and next bound the search, not # the period in addition. - results += self.materialise_item(current, current, next, counter) + results += self.materialise_item(current, current, next, counter, inclusive=inclusive) # Extract selected positions and remove out-of-period instances. if setpos: results = self.select_positions(results, setpos) - return self.filter_by_period(results, start, end) + return self.filter_by_period(results, start, end, inclusive) class YearDayFilter(Enum): - def materialise_items(self, context, start, end, counter, setpos=None): + def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False): first_day = date(context[0], 1, 1) next_first_day = date(context[0] + 1, 1, 1) year_length = (next_first_day - first_day).days @@ -595,14 +648,14 @@ # To support setpos, only current and next bound the search, not # the period in addition. - results += self.materialise_item(current, current, next, counter) + results += self.materialise_item(current, current, next, counter, inclusive=inclusive) # Extract selected positions and remove out-of-period instances. if setpos: results = self.select_positions(results, setpos) - return self.filter_by_period(results, start, end) + return self.filter_by_period(results, start, end, inclusive) special_enum_levels = { "BYDAY" : WeekDayFilter, @@ -613,6 +666,13 @@ # Public functions. def connect_selectors(selectors): + + """ + Make the 'selectors' reference each other in a hierarchy so that + materialising the principal selector causes the more specific ones to be + employed in the operation. + """ + current = selectors[0] for selector in selectors[1:]: current.selecting = selector @@ -637,7 +697,9 @@ selector object. """ - qualifiers = get_qualifiers(rule.split(";")) + if not isinstance(rule, tuple): + rule = rule.split(";") + qualifiers = get_qualifiers(rule) return get_selector(dt, qualifiers) # vim: tabstop=4 expandtab shiftwidth=4