# HG changeset patch # User Paul Boddie # Date 1427155862 -3600 # Node ID d6466d09eb7e0400897861d8405ecb704e1cd4ba # Parent c28146a67ca280447afd2a4f5082281e396822c1 Introduced some support for editing recurrence periods in events, employing common methods to handle datetime controls. Updated the stylesheet to use checkboxes instead of radio buttons to configure period end and time details, so that a collection of controls may be used for a collection of recurrence periods with the controls having the same field name. diff -r c28146a67ca2 -r d6466d09eb7e htdocs/styles.css --- a/htdocs/styles.css Tue Mar 24 01:10:28 2015 +0100 +++ b/htdocs/styles.css Tue Mar 24 01:11:02 2015 +0100 @@ -2,7 +2,7 @@ table.calendar, table.conflicts, -table.recurrences, +table.recurrence, table.object { border: 2px solid #000; } @@ -170,13 +170,11 @@ /* Hiding/showing end datetimes and start/end times. */ -input#dttimes-disable, input#dttimes-enable, -input#dtend-disable, input#dtend-enable, -input#dttimes-disable:checked ~ .object td.objectvalue .time.enabled, +input#dttimes-enable:not(:checked) ~ .object td.objectvalue .time.enabled, input#dttimes-enable:checked ~ .object td.objectvalue .time.disabled, -input#dtend-disable:checked ~ .object td.objectvalue.dtend .dt.enabled, +input#dtend-enable:not(:checked) ~ .object td.objectvalue.dtend .dt.enabled, input#dtend-enable:checked ~ .object td.objectvalue.dtend .dt.disabled, /* Hiding/showing remove/uninvite labels. */ diff -r c28146a67ca2 -r d6466d09eb7e imip_manager.py --- a/imip_manager.py Tue Mar 24 01:10:28 2015 +0100 +++ b/imip_manager.py Tue Mar 24 01:11:02 2015 +0100 @@ -34,10 +34,11 @@ 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, \ +from imiptools.dates import format_datetime, format_time, to_date, get_datetime, \ get_datetime_item, get_default_timezone, \ - get_end_of_day, get_start_of_day, get_start_of_next_day, \ - get_timestamp, ends_on_same_day, to_timezone + get_end_of_day, get_period_item, get_start_of_day, \ + get_start_of_next_day, get_timestamp, ends_on_same_day, \ + to_timezone from imiptools.handlers import Handler from imiptools.mail import Messenger from imiptools.period import add_day_start_points, add_empty_days, add_slots, \ @@ -347,6 +348,12 @@ except OSError: self.publisher = None + def _suffixed_name(self, name, index=None): + return index is not None and "%s-%d" % (name, index) or name + + def _simple_suffixed_name(self, name, suffix, index=None): + return index is not None and "%s-%s" % (name, suffix) or name + def _get_identifiers(self, path_info): parts = path_info.lstrip("/").split("/") if len(parts) == 1: @@ -668,42 +675,12 @@ update = False if is_organiser: - dtend_enabled = args.get("dtend-control", [None])[0] == "enable" - dttimes_enabled = args.get("dttimes-control", [None])[0] == "enable" - - t = self.handle_date_controls("dtstart", dttimes_enabled) - if t: - dtstart, attr = t - update = self.set_datetime_in_object(dtstart, attr.get("TZID"), "DTSTART", obj) or update - else: - return ["dtstart"] - - # Handle specified end datetimes. - - if dtend_enabled: - t = self.handle_date_controls("dtend", dttimes_enabled) - if t: - dtend, attr = t - - # Convert end dates to iCalendar "next day" dates. - - if not isinstance(dtend, datetime): - dtend += timedelta(1) - update = self.set_datetime_in_object(dtend, attr.get("TZID"), "DTEND", obj) or update - else: - return ["dtend"] - - # Otherwise, treat the end date as the start date. Datetimes are - # handled by making the event occupy the rest of the day. - - else: - dtend = dtstart + timedelta(1) - if isinstance(dtstart, datetime): - dtend = get_start_of_day(dtend, attr["TZID"]) - update = self.set_datetime_in_object(dtend, attr.get("TZID"), "DTEND", obj) or update - - if dtstart >= dtend: - return ["dtstart", "dtend"] + periods, errors = self.handle_all_period_controls() + if errors: + return errors + elif periods: + self.set_period_in_object(obj, periods[0]) + self.set_periods_in_object(obj, periods[1:]) # Obtain any participants to be added or removed. @@ -751,38 +728,196 @@ return None - def handle_date_controls(self, name, with_time=True): + def handle_all_period_controls(self): """ - Handle date control information for fields starting with 'name', - returning a (datetime, attr) tuple or None if the fields cannot be used - to construct a datetime object. + Handle datetime controls for a particular period, where 'index' may be + used to indicate a recurring period, or the main start and end datetimes + are handled. """ args = self.env.get_args() - if args.has_key("%s-date" % name): - date = args["%s-date" % name][0] - - if with_time: - hour = args.get("%s-hour" % name, [None])[0] - minute = args.get("%s-minute" % name, [None])[0] - second = args.get("%s-second" % name, [None])[0] - tzid = args.get("%s-tzid" % name, [self.get_tzid()])[0] - - time = (hour or minute or second) and "T%s%s%s" % (hour, minute, second) or "" - value = "%s%s" % (date, time) - attr = {"TZID" : tzid, "VALUE" : "DATE-TIME"} - dt = get_datetime(value, attr) + periods = [] + + # Get the main period details. + + dtend_enabled = args.get("dtend-control", [None])[0] == "enable" + dttimes_enabled = args.get("dttimes-control", [None])[0] == "enable" + start_values = self.get_date_control_values("dtstart") + end_values = self.get_date_control_values("dtend") + + period, errors = self.handle_period_controls(start_values, end_values, dtend_enabled, dttimes_enabled) + + if errors: + return None, errors + + periods.append(period) + + # Get the recurring period details. + + all_dtend_enabled = map(lambda x: x == "enable", args.get("dtend-control-recur", [])) + all_dttimes_enabled = map(lambda x: x == "enable", args.get("dttimes-control-recur", [])) + all_start_values = self.get_date_control_values("dtstart-recur", multiple=True) + all_end_values = self.get_date_control_values("dtend-recur", multiple=True) + + for start_values, end_values, dtend_enabled, dttimes_enabled in \ + map(None, all_start_values, all_end_values, all_dtend_enabled, all_dttimes_enabled): + + period, errors = self.handle_period_controls(start_values, end_values, dtend_enabled, dttimes_enabled) + + if errors: + return None, errors + + periods.append(period) + + return periods, None + + def handle_period_controls(self, start_values, end_values, dtend_enabled, dttimes_enabled): + + """ + Handle datetime controls for a particular period, described by the given + 'start_values' and 'end_values', with 'dtend_enabled' and + 'dttimes_enabled' affecting the usage of the provided values. + """ + + t = self.handle_date_control_values(start_values, dttimes_enabled) + if t: + dtstart, dtstart_attr = t + else: + return None, ["dtstart"] + + # Handle specified end datetimes. + + if dtend_enabled: + t = self.handle_date_control_values(end_values, dttimes_enabled) + if t: + dtend, dtend_attr = t + + # Convert end dates to iCalendar "next day" dates. + + if not isinstance(dtend, datetime): + dtend += timedelta(1) else: - attr = {"VALUE" : "DATE"} - dt = get_datetime(date) - - if dt: - return dt, attr + return None, ["dtend"] + + # Otherwise, treat the end date as the start date. Datetimes are + # handled by making the event occupy the rest of the day. + + else: + dtend = dtstart + timedelta(1) + dtend_attr = dtstart_attr + + if isinstance(dtstart, datetime): + dtend = get_start_of_day(dtend, attr["TZID"]) + + if dtstart >= dtend: + return None, ["dtstart", "dtend"] + + return ((dtstart, dtstart_attr), (dtend, dtend_attr)), None + + def handle_date_control_values(self, values, with_time=True): + + """ + Handle date control information for the given 'values', returning a + (datetime, attr) tuple, or None if the fields cannot be used to + construct a datetime object. + """ + + if not values or not values["date"]: + return None + elif with_time: + value = "%s%s" % (values["date"], values["time"]) + attr = {"TZID" : values["tzid"], "VALUE" : "DATE-TIME"} + dt = get_datetime(value, attr) + else: + attr = {"VALUE" : "DATE"} + dt = get_datetime(values["date"]) + + if dt: + return dt, attr return None + def get_date_control_values(self, name, multiple=False): + + """ + Return a dictionary containing date, time and tzid entries for fields + starting with 'name'. + """ + + args = self.env.get_args() + + dates = args.get("%s-date" % name, []) + hours = args.get("%s-hour" % name, []) + minutes = args.get("%s-minute" % name, []) + seconds = args.get("%s-second" % name, []) + tzids = args.get("%s-tzid" % name, []) + + # Handle absent values by employing None values. + + field_values = map(None, dates, hours, minutes, seconds, tzids) + if not field_values and not multiple: + field_values = [(None, None, None, None, None)] + + all_values = [] + + for date, hour, minute, second, tzid in field_values: + + # Construct a usable dictionary of values. + + time = (hour or minute or second) and \ + "T%s%s%s" % ( + (hour or "").rjust(2, "0")[:2], + (minute or "").rjust(2, "0")[:2], + (second or "").rjust(2, "0")[:2] + ) or "" + + value = { + "date" : date, + "time" : time, + "tzid" : tzid or self.get_tzid() + } + + # Return a single value or append to a collection of all values. + + if not multiple: + return value + else: + all_values.append(value) + + return all_values + + def set_period_in_object(self, obj, period): + + "Set in the given 'obj' the given 'period' as the main start and end." + + (dtstart, dtstart_attr), (dtend, dtend_attr) = period + + return self.set_datetime_in_object(dtstart, dtstart_attr.get("TZID"), "DTSTART", obj) or \ + self.set_datetime_in_object(dtend, dtend_attr.get("TZID"), "DTEND", obj) + + def set_periods_in_object(self, obj, periods): + + "Set in the given 'obj' the given 'periods'." + + update = False + + old_values = obj.get_values("RDATE") + new_rdates = [] + + del obj["RDATE"] + + for period in periods: + (dtstart, dtstart_attr), (dtend, dtend_attr) = period + tzid = dtstart_attr.get("TZID") or dtend_attr.get("TZID") + new_rdates.append(get_period_item(dtstart, dtend, tzid)) + + obj["RDATE"] = new_rdates + + # NOTE: To do: calculate the update status. + return update + def set_datetime_in_object(self, dt, tzid, property, obj): """ @@ -860,12 +995,14 @@ (dtstart, dtstart_attr), (dtend, dtend_attr) = self.get_event_period(obj) - t = self.handle_date_controls("dtstart", with_time) + d = self.get_date_control_values("dtstart") + t = self.handle_date_control_values(d, with_time) if t: dtstart, dtstart_attr = t if dtend_control == "enable": - t = self.handle_date_controls("dtend", with_time) + d = self.get_date_control_values("dtend") + t = self.handle_date_control_values(d, with_time) if t: dtend, dtend_attr = t else: @@ -1113,11 +1250,62 @@ page.form.close() - def show_datetime_controls(self, obj, dt, attr, is_start_datetime): + def show_object_datetime_controls(self, start, end, index=None): + + """ + Show datetime-related controls if already active or if an object needs + them for the given 'start' to 'end' period. The given 'index' is used to + parameterise individual controls for dynamic manipulation. + """ + + page = self.page + args = self.env.get_args() + sn = self._suffixed_name + ssn = self._simple_suffixed_name + + # Add a dynamic stylesheet to permit the controls to modify the display. + # NOTE: The style details need to be coordinated with the static + # NOTE: stylesheet. + + if index is not None: + page.style(type="text/css") + + # Unlike the rules for object properties, these affect recurrence + # properties. + + page.add("""\ +input#dttimes-enable-%(index)d, +input#dtend-enable-%(index)d, +input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled, +input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled, +input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled, +input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled { + display: none; +}""" % {"index" : index}) + + page.style.close() + + dtend_control = args.get(ssn("dtend-control", "recur", index), [None])[0] + dttimes_control = args.get(ssn("dttimes-control", "recur", index), [None])[0] + + dtend_enabled = dtend_control == "enable" or isinstance(end, datetime) or start != end + dttimes_enabled = dttimes_control == "enable" or isinstance(start, datetime) or isinstance(end, datetime) + + if dtend_enabled: + page.input(name=ssn("dtend-control", "recur", index), type="checkbox", value="enable", id=sn("dtend-enable", index), checked="checked") + else: + page.input(name=ssn("dtend-control", "recur", index), type="checkbox", value="enable", id=sn("dtend-enable", index)) + + if dttimes_enabled: + page.input(name=ssn("dttimes-control", "recur", index), type="checkbox", value="enable", id=sn("dttimes-enable", index), checked="checked") + else: + page.input(name=ssn("dttimes-control", "recur", index), type="checkbox", value="enable", id=sn("dttimes-enable", index)) + + def show_datetime_controls(self, obj, dt, attr, show_start): """ Show datetime details from the given 'obj' for the datetime 'dt' and - attributes 'attr', showing start details if 'is_start_datetime' is set + attributes 'attr', showing start details if 'show_start' is set to a true value. Details will appear as controls for organisers and labels for attendees. """ @@ -1128,14 +1316,14 @@ # Show controls for editing as organiser. if is_organiser: - page.td(class_="objectvalue dt%s" % (is_start_datetime and "start" or "end")) - - if is_start_datetime: + page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) + + if show_start: page.div(class_="dt enabled") self._show_date_controls("dtstart", dt, attr.get("TZID")) page.br() page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") - page.label("Specify dates only", for_="dttimes-disable", class_="time enabled disable") + page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable") page.div.close() else: @@ -1145,7 +1333,7 @@ page.div(class_="dt enabled") self._show_date_controls("dtend", dt, attr.get("TZID")) page.br() - page.label("End on same day", for_="dtend-disable", class_="disable") + page.label("End on same day", for_="dtend-enable", class_="disable") page.div.close() page.td.close() @@ -1155,41 +1343,68 @@ else: page.td(self.format_datetime(dt, "full")) - def show_object_datetime_controls(self, start, end): + def show_recurrence_controls(self, obj, index, start, end, origin, recurrenceid, recurrenceids, show_start): """ - Show datetime-related controls if already active or if an object needs - them for the given 'start' to 'end' period. + Show datetime details from the given 'obj' for the recurrence having the + given 'index', with the recurrence period described by the datetimes + 'start' and 'end', indicating the 'origin' of the period from the event + details, employing any 'recurrenceid' and 'recurrenceids' for the object + to configure the displayed information. + + If 'show_start' is set to a true value, the start details will be shown; + otherwise, the end details will be shown. """ page = self.page - args = self.env.get_args() - - dtend_control = args.get("dtend-control", [None])[0] - dttimes_control = args.get("dttimes-control", [None])[0] - - dtend_enabled = dtend_control == "enable" or isinstance(end, datetime) or start != end - dttimes_enabled = dttimes_control == "enable" or isinstance(start, datetime) or isinstance(end, 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") + sn = self._suffixed_name + ssn = self._simple_suffixed_name + + is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user + + start_utc = format_datetime(to_timezone(start, "UTC")) + replaced = recurrenceids and start_utc in recurrenceids and "replaced" or "" + css = " ".join([ + replaced, + recurrenceid and start_utc == recurrenceid and "affected" or "" + ]) + + # Show controls for editing as organiser. + + if is_organiser and not replaced and origin != "RRULE": + page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) + + if show_start: + page.div(class_="dt enabled") + self._show_date_controls(ssn("dtstart", "recur", index), start, None) + page.br() + page.label("Specify times", for_=sn("dttimes-enable", index), class_="time disabled enable") + page.label("Specify dates only", for_=sn("dttimes-enable", index), class_="time enabled disable") + page.div.close() + + else: + page.div(class_="dt disabled") + page.label("Specify end date", for_=sn("dtend-enable", index), class_="enable") + page.div.close() + page.div(class_="dt enabled") + self._show_date_controls(ssn("dtend", "recur", index), end, None) + page.br() + page.label("End on same day", for_=sn("dtend-enable", index), class_="disable") + page.div.close() + + page.td.close() + + # Show label as attendee. + 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") + page.td(self.format_datetime(show_start and start or end, "long"), class_=css) def show_recurrences(self, obj): "Show recurrences for the object having the given representation 'obj'." page = self.page + is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user # Obtain any parent object if this object is a specific recurrence. @@ -1205,37 +1420,71 @@ # Obtain the periods associated with the event in the user's time zone. - periods = obj.get_periods(self.get_tzid(), self.get_window_end()) + periods = obj.get_periods(self.get_tzid(), self.get_window_end(), origin=True) recurrenceids = self._get_recurrences(uid) if len(periods) == 1: return - 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") - page.th("End") - page.tr.close() - page.thead.close() - 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 "" - ]) - + if is_organiser: + page.p("This event recurs on the following occasions within the next %d days:" % self.get_window_size()) + else: + page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size()) + + # Determine whether any periods are explicitly created or are part of a + # rule. + + explicit_periods = filter(lambda t: t[2] != "RRULE", periods) + + # Show each recurrence in a separate table if editable. + + if is_organiser and explicit_periods: + for index, (start, end, origin) in enumerate(periods[1:]): + + # Isolate the controls from neighbouring tables. + + page.div() + + self.show_object_datetime_controls(start, end, index) + + # NOTE: Need to customise the TH classes according to errors and + # NOTE: index information. + + page.table(cellspacing=5, cellpadding=5, class_="recurrence") + page.caption("Occurrence") + page.tbody() + page.tr() + page.th("Start", class_="objectheading start") + self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, True) + page.tr.close() + page.tr() + page.th("End", class_="objectheading end") + self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, False) + page.tr.close() + page.tbody.close() + page.table.close() + + page.div.close() + + # Otherwise, use a compact single table. + + else: + page.table(cellspacing=5, cellpadding=5, class_="recurrence") + page.caption("Occurrences") + page.thead() page.tr() - page.td(self.format_datetime(start, "long"), class_=css) - page.td(self.format_datetime(end, "long"), class_=css) + page.th("Start", class_="objectheading start") + page.th("End", class_="objectheading end") page.tr.close() - - page.tbody.close() - page.table.close() + page.thead.close() + page.tbody() + for index, (start, end, origin) in enumerate(periods): + page.tr() + self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, True) + self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, False) + page.tr.close() + page.tbody.close() + page.table.close() def show_conflicting_events(self, uid, obj): @@ -1774,8 +2023,8 @@ ]) # Only anchor the first cell of events. - # NOTE: Need to only anchor the first period for a - # NOTE: recurring event. + # Need to only anchor the first period for a recurring + # event. html_id = "%s-%s-%s" % (group_type, uid, recurrenceid or "") @@ -1907,7 +2156,7 @@ """ Show date controls for a field with the given 'name' and 'default' value - and 'attr'. + and 'tzid'. """ page = self.page @@ -1917,7 +2166,7 @@ # Show dates for up to one week around the current date. - base = get_date(default) + base = to_date(default) items = [] for i in range(-7, 8): d = base + timedelta(i)