imip-agent

imip_manager.py

389:0a81ad586e8e
2015-03-06 Paul Boddie Renamed the item values methods and functions, extending them to return periods as well as datetimes. Introduced period support for RDATE and EXDATE properties when obtaining event period details. recurring-events
     1 #!/usr/bin/env python     2      3 """     4 A Web interface to a user's calendar.     5      6 Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>     7      8 This program is free software; you can redistribute it and/or modify it under     9 the terms of the GNU General Public License as published by the Free Software    10 Foundation; either version 3 of the License, or (at your option) any later    11 version.    12     13 This program is distributed in the hope that it will be useful, but WITHOUT    14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS    15 FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more    16 details.    17     18 You should have received a copy of the GNU General Public License along with    19 this program.  If not, see <http://www.gnu.org/licenses/>.    20 """    21     22 # Edit this path to refer to the location of the imiptools libraries, if    23 # necessary.    24     25 LIBRARY_PATH = "/var/lib/imip-agent"    26     27 from datetime import date, datetime, timedelta    28 import babel.dates    29 import cgi, os, sys    30     31 sys.path.append(LIBRARY_PATH)    32     33 from imiptools.content import Handler    34 from imiptools.data import get_address, get_uri, get_window_end, make_freebusy, \    35                            Object, to_part, \    36                            uri_dict, uri_item, uri_items, uri_values    37 from imiptools.dates import format_datetime, format_time, get_date, get_datetime, \    38                             get_datetime_item, get_default_timezone, \    39                             get_end_of_day, get_start_of_day, get_start_of_next_day, \    40                             get_timestamp, ends_on_same_day, to_timezone    41 from imiptools.mail import Messenger    42 from imiptools.period import add_day_start_points, add_empty_days, add_slots, \    43                              convert_periods, get_freebusy_details, \    44                              get_scale, have_conflict, get_slots, get_spans, \    45                              partition_by_day, remove_period, remove_affected_period, \    46                              update_freebusy    47 from imiptools.profile import Preferences    48 import imip_store    49 import markup    50     51 getenv = os.environ.get    52 setenv = os.environ.__setitem__    53     54 class CGIEnvironment:    55     56     "A CGI-compatible environment."    57     58     def __init__(self, charset=None):    59         self.charset = charset    60         self.args = None    61         self.method = None    62         self.path = None    63         self.path_info = None    64         self.user = None    65     66     def get_args(self):    67         if self.args is None:    68             if self.get_method() != "POST":    69                 setenv("QUERY_STRING", "")    70             args = cgi.parse(keep_blank_values=True)    71     72             if not self.charset:    73                 self.args = args    74             else:    75                 self.args = {}    76                 for key, values in args.items():    77                     self.args[key] = [unicode(value, self.charset) for value in values]    78     79         return self.args    80     81     def get_method(self):    82         if self.method is None:    83             self.method = getenv("REQUEST_METHOD") or "GET"    84         return self.method    85     86     def get_path(self):    87         if self.path is None:    88             self.path = getenv("SCRIPT_NAME") or ""    89         return self.path    90     91     def get_path_info(self):    92         if self.path_info is None:    93             self.path_info = getenv("PATH_INFO") or ""    94         return self.path_info    95     96     def get_user(self):    97         if self.user is None:    98             self.user = getenv("REMOTE_USER") or ""    99         return self.user   100    101     def get_output(self):   102         return sys.stdout   103    104     def get_url(self):   105         path = self.get_path()   106         path_info = self.get_path_info()   107         return "%s%s" % (path.rstrip("/"), path_info)   108    109     def new_url(self, path_info):   110         path = self.get_path()   111         return "%s/%s" % (path.rstrip("/"), path_info.lstrip("/"))   112    113 class Common:   114    115     "Common handler and manager methods."   116    117     def __init__(self, user):   118         self.user = user   119         self.preferences = None   120    121     def get_preferences(self):   122         if not self.preferences:   123             self.preferences = Preferences(self.user)   124         return self.preferences   125    126     def get_tzid(self):   127         prefs = self.get_preferences()   128         return prefs.get("TZID") or get_default_timezone()   129    130     def get_window_size(self):   131         prefs = self.get_preferences()   132         try:   133             return int(prefs.get("window_size"))   134         except (TypeError, ValueError):   135             return 100   136    137     def get_window_end(self):   138         return get_window_end(self.get_tzid(), self.get_window_size())   139    140     def update_attendees(self, obj, added, removed):   141    142         """   143         Update the attendees in 'obj' with the given 'added' and 'removed'   144         attendee lists. A list is returned containing the attendees whose   145         attendance should be cancelled.   146         """   147    148         to_cancel = []   149    150         if added or removed:   151             attendees = uri_items(obj.get_items("ATTENDEE") or [])   152    153             if removed:   154                 remaining = []   155    156                 for attendee, attendee_attr in attendees:   157                     if attendee in removed:   158                         to_cancel.append((attendee, attendee_attr))   159                     else:   160                         remaining.append((attendee, attendee_attr))   161    162                 attendees = remaining   163    164             if added:   165                 for attendee in added:   166                     attendees.append((attendee, {"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"}))   167    168             obj["ATTENDEE"] = attendees   169    170         return to_cancel   171    172 class ManagerHandler(Common, Handler):   173    174     """   175     A content handler for use by the manager, as opposed to operating within the   176     mail processing pipeline.   177     """   178    179     def __init__(self, obj, user, messenger):   180         Handler.__init__(self, messenger=messenger)   181         Common.__init__(self, user)   182    183         self.set_object(obj)   184    185     # Communication methods.   186    187     def send_message(self, method, sender, for_organiser):   188    189         """   190         Create a full calendar object employing the given 'method', and send it   191         to the appropriate recipients, also sending a copy to the 'sender'. The   192         'for_organiser' value indicates whether the organiser is sending this   193         message.   194         """   195    196         parts = [self.obj.to_part(method)]   197    198         # As organiser, send an invitation to attendees, excluding oneself if   199         # also attending. The updated event will be saved by the outgoing   200         # handler.   201    202         organiser = get_uri(self.obj.get_value("ORGANIZER"))   203         attendees = uri_values(self.obj.get_values("ATTENDEE"))   204    205         if for_organiser:   206             recipients = [get_address(attendee) for attendee in attendees if attendee != self.user]   207         else:   208             recipients = [get_address(organiser)]   209    210         # Bundle free/busy information if appropriate.   211    212         preferences = Preferences(self.user)   213    214         if preferences.get("freebusy_sharing") == "share" and \   215            preferences.get("freebusy_bundling") == "always":   216    217             # Invent a unique identifier.   218    219             utcnow = get_timestamp()   220             uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user))   221    222             freebusy = self.store.get_freebusy(self.user)   223    224             # Replace the non-updated free/busy details for this event with   225             # newer details (since the outgoing handler updates this user's   226             # free/busy details).   227    228             update_freebusy(freebusy,   229                 self.obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()),   230                 self.obj.get_value("TRANSP") or "OPAQUE",   231                 self.uid, self.recurrenceid)   232    233             user_attr = self.messenger and self.messenger.sender != get_address(self.user) and \   234                 {"SENT-BY" : get_uri(self.messenger.sender)} or {}   235    236             parts.append(to_part("PUBLISH", [   237                 make_freebusy(freebusy, uid, self.user, user_attr)   238                 ]))   239    240         message = self.messenger.make_outgoing_message(parts, recipients, outgoing_bcc=sender)   241         self.messenger.sendmail(recipients, message.as_string(), outgoing_bcc=sender)   242    243     # Action methods.   244    245     def process_received_request(self, update=False):   246    247         """   248         Process the current request for the given 'user'. Return whether any   249         action was taken.   250    251         If 'update' is given, the sequence number will be incremented in order   252         to override any previous response.   253         """   254    255         # Reply only on behalf of this user.   256    257         for attendee, attendee_attr in uri_items(self.obj.get_items("ATTENDEE")):   258    259             if attendee == self.user:   260                 if attendee_attr.has_key("RSVP"):   261                     del attendee_attr["RSVP"]   262                 if self.messenger and self.messenger.sender != get_address(attendee):   263                     attendee_attr["SENT-BY"] = get_uri(self.messenger.sender)   264                 self.obj["ATTENDEE"] = [(attendee, attendee_attr)]   265    266                 self.update_dtstamp()   267                 self.set_sequence(update)   268    269                 self.send_message("REPLY", get_address(attendee), for_organiser=False)   270    271                 return True   272    273         return False   274    275     def process_created_request(self, method, update=False, removed=None, added=None):   276    277         """   278         Process the current request for the given 'user', sending a created   279         request of the given 'method' to attendees. Return whether any action   280         was taken.   281    282         If 'update' is given, the sequence number will be incremented in order   283         to override any previous message.   284    285         If 'removed' is specified, a list of participants to be removed is   286         provided.   287    288         If 'added' is specified, a list of participants to be added is provided.   289         """   290    291         organiser, organiser_attr = uri_item(self.obj.get_item("ORGANIZER"))   292    293         if self.messenger and self.messenger.sender != get_address(organiser):   294             organiser_attr["SENT-BY"] = get_uri(self.messenger.sender)   295    296         # Update the attendees in the event.   297    298         to_cancel = self.update_attendees(self.obj, added, removed)   299    300         self.update_dtstamp()   301         self.set_sequence(update)   302    303         self.send_message(method, get_address(organiser), for_organiser=True)   304    305         # When cancelling, replace the attendees with those for whom the event   306         # is now cancelled.   307    308         if to_cancel:   309             remaining = self.obj["ATTENDEE"]   310             self.obj["ATTENDEE"] = to_cancel   311             self.send_message("CANCEL", get_address(organiser), for_organiser=True)   312    313             # Just in case more work is done with this event, the attendees are   314             # now restored.   315    316             self.obj["ATTENDEE"] = remaining   317    318         return True   319    320 class Manager(Common):   321    322     "A simple manager application."   323    324     def __init__(self, messenger=None):   325         self.messenger = messenger or Messenger()   326         self.encoding = "utf-8"   327         self.env = CGIEnvironment(self.encoding)   328    329         user = self.env.get_user()   330         Common.__init__(self, user and get_uri(user) or None)   331    332         self.locale = None   333         self.requests = None   334    335         self.out = self.env.get_output()   336         self.page = markup.page()   337    338         self.store = imip_store.FileStore()   339         self.objects = {}   340    341         try:   342             self.publisher = imip_store.FilePublisher()   343         except OSError:   344             self.publisher = None   345    346     def _get_identifiers(self, path_info):   347         parts = path_info.lstrip("/").split("/")   348         if len(parts) == 1:   349             return parts[0], None   350         else:   351             return parts[:2]   352    353     def _get_object(self, uid, recurrenceid=None):   354         if self.objects.has_key((uid, recurrenceid)):   355             return self.objects[(uid, recurrenceid)]   356    357         fragment = uid and self.store.get_event(self.user, uid, recurrenceid) or None   358         obj = self.objects[(uid, recurrenceid)] = fragment and Object(fragment)   359         return obj   360    361     def _get_recurrences(self, uid):   362         return self.store.get_recurrences(self.user, uid)   363    364     def _get_requests(self):   365         if self.requests is None:   366             cancellations = self.store.get_cancellations(self.user)   367             requests = set(self.store.get_requests(self.user))   368             self.requests = requests.difference(cancellations)   369         return self.requests   370    371     def _get_request_summary(self):   372         summary = []   373         for uid, recurrenceid in self._get_requests():   374             obj = self._get_object(uid, recurrenceid)   375             if obj:   376                 periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end())   377    378                 # Subtract any recurrences from the free/busy details of a parent   379                 # object.   380    381                 recurrenceids = self._get_recurrences(uid)   382    383                 for start, end in periods:   384                     if recurrenceid or start not in recurrenceids:   385                         summary.append((start, end, uid, obj.get_value("TRANSP"), recurrenceid))   386         return summary   387    388     # Preference methods.   389    390     def get_user_locale(self):   391         if not self.locale:   392             self.locale = self.get_preferences().get("LANG", "en")   393         return self.locale   394    395     # Prettyprinting of dates and times.   396    397     def format_date(self, dt, format):   398         return self._format_datetime(babel.dates.format_date, dt, format)   399    400     def format_time(self, dt, format):   401         return self._format_datetime(babel.dates.format_time, dt, format)   402    403     def format_datetime(self, dt, format):   404         return self._format_datetime(   405             isinstance(dt, datetime) and babel.dates.format_datetime or babel.dates.format_date,   406             dt, format)   407    408     def _format_datetime(self, fn, dt, format):   409         return fn(dt, format=format, locale=self.get_user_locale())   410    411     # Data management methods.   412    413     def remove_request(self, uid, recurrenceid=None):   414         return self.store.dequeue_request(self.user, uid, recurrenceid)   415    416     def remove_event(self, uid, recurrenceid=None):   417         return self.store.remove_event(self.user, uid, recurrenceid)   418    419     def update_freebusy(self, uid, recurrenceid, obj):   420    421         """   422         Update stored free/busy details for the event with the given 'uid' and   423         'recurrenceid' having a representation of 'obj'.   424         """   425    426         is_only_organiser = self.user not in uri_values(obj.get_values("ATTENDEE"))   427    428         freebusy = self.store.get_freebusy(self.user)   429    430         update_freebusy(freebusy,   431             obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()),   432             is_only_organiser and "ORG" or obj.get_value("TRANSP"),   433             uid, recurrenceid)   434    435         # Subtract any recurrences from the free/busy details of a parent   436         # object.   437    438         for recurrenceid in self._get_recurrences(uid):   439             remove_affected_period(freebusy, uid, recurrenceid)   440    441         self.store.set_freebusy(self.user, freebusy)   442    443     def remove_from_freebusy(self, uid, recurrenceid=None):   444         freebusy = self.store.get_freebusy(self.user)   445         remove_period(freebusy, uid, recurrenceid)   446         self.store.set_freebusy(self.user, freebusy)   447    448     # Presentation methods.   449    450     def new_page(self, title):   451         self.page.init(title=title, charset=self.encoding, css=self.env.new_url("styles.css"))   452    453     def status(self, code, message):   454         self.header("Status", "%s %s" % (code, message))   455    456     def header(self, header, value):   457         print >>self.out, "%s: %s" % (header, value)   458    459     def no_user(self):   460         self.status(403, "Forbidden")   461         self.new_page(title="Forbidden")   462         self.page.p("You are not logged in and thus cannot access scheduling requests.")   463    464     def no_page(self):   465         self.status(404, "Not Found")   466         self.new_page(title="Not Found")   467         self.page.p("No page is provided at the given address.")   468    469     def redirect(self, url):   470         self.status(302, "Redirect")   471         self.header("Location", url)   472         self.new_page(title="Redirect")   473         self.page.p("Redirecting to: %s" % url)   474    475     def link_to(self, uid, recurrenceid=None):   476         if recurrenceid:   477             return self.env.new_url("/".join([uid, recurrenceid]))   478         else:   479             return self.env.new_url(uid)   480    481     # Request logic methods.   482    483     def handle_newevent(self):   484    485         """   486         Handle any new event operation, creating a new event and redirecting to   487         the event page for further activity.   488         """   489    490         # Handle a submitted form.   491    492         args = self.env.get_args()   493    494         if not args.has_key("newevent"):   495             return   496    497         # Create a new event using the available information.   498    499         slots = args.get("slot", [])   500         participants = args.get("participants", [])   501    502         if not slots:   503             return   504    505         # Obtain the user's timezone.   506    507         tzid = self.get_tzid()   508    509         # Coalesce the selected slots.   510    511         slots.sort()   512         coalesced = []   513         last = None   514    515         for slot in slots:   516             start, end = slot.split("-")   517             start = get_datetime(start, {"TZID" : tzid})   518             end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid)   519    520             if last:   521                 last_start, last_end = last   522    523                 # Merge adjacent dates and datetimes.   524    525                 if start == last_end or get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid):   526                     last = last_start, end   527                     continue   528    529                 # Handle datetimes within dates.   530                 # Datetime periods are within single days and are therefore   531                 # discarded.   532    533                 elif get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid):   534                     continue   535    536                 # Add separate dates and datetimes.   537    538                 else:   539                     coalesced.append(last)   540    541             last = start, end   542    543         if last:   544             coalesced.append(last)   545    546         # Invent a unique identifier.   547    548         utcnow = get_timestamp()   549         uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user))   550    551         # Define a single occurrence if only one coalesced slot exists.   552         # Otherwise, many occurrences are defined.   553    554         for i, (start, end) in enumerate(coalesced):   555             this_uid = "%s-%s" % (uid, i)   556    557             start_value, start_attr = get_datetime_item(start, tzid)   558             end_value, end_attr = get_datetime_item(end, tzid)   559    560             # Create a calendar object and store it as a request.   561    562             record = []   563             rwrite = record.append   564    565             rwrite(("UID", {}, this_uid))   566             rwrite(("SUMMARY", {}, "New event at %s" % utcnow))   567             rwrite(("DTSTAMP", {}, utcnow))   568             rwrite(("DTSTART", start_attr, start_value))   569             rwrite(("DTEND", end_attr, end_value))   570             rwrite(("ORGANIZER", {}, self.user))   571    572             participants = uri_values(filter(None, participants))   573    574             for participant in participants:   575                 rwrite(("ATTENDEE", {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}, participant))   576    577             if self.user not in participants:   578                 rwrite(("ATTENDEE", {"PARTSTAT" : "ACCEPTED"}, self.user))   579    580             node = ("VEVENT", {}, record)   581    582             self.store.set_event(self.user, this_uid, None, node=node)   583             self.store.queue_request(self.user, this_uid)   584    585         # Redirect to the object (or the first of the objects), where instead of   586         # attendee controls, there will be organiser controls.   587    588         self.redirect(self.link_to("%s-0" % uid))   589    590     def handle_request(self, uid, recurrenceid, obj):   591    592         """   593         Handle actions involving the given 'uid', 'recurrenceid', and 'obj' as   594         the object's representation, returning an error if one occurred, or None   595         if the request was successfully handled.   596         """   597    598         # Handle a submitted form.   599    600         args = self.env.get_args()   601    602         # Get the possible actions.   603    604         reply = args.has_key("reply")   605         discard = args.has_key("discard")   606         invite = args.has_key("invite")   607         cancel = args.has_key("cancel")   608         save = args.has_key("save")   609    610         have_action = reply or discard or invite or cancel or save   611    612         if not have_action:   613             return ["action"]   614    615         # Update the object.   616    617         if args.has_key("summary"):   618             obj["SUMMARY"] = [(args["summary"][0], {})]   619    620         attendees = uri_dict(obj.get_value_map("ATTENDEE"))   621    622         if args.has_key("partstat"):   623             if attendees.has_key(self.user):   624                 attendees[self.user]["PARTSTAT"] = args["partstat"][0]   625                 if attendees[self.user].has_key("RSVP"):   626                     del attendees[self.user]["RSVP"]   627    628         is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user   629    630         # Obtain the user's timezone and process datetime values.   631    632         update = False   633    634         if is_organiser:   635             dtend_enabled = args.get("dtend-control", [None])[0] == "enable"   636             dttimes_enabled = args.get("dttimes-control", [None])[0] == "enable"   637    638             t = self.handle_date_controls("dtstart", dttimes_enabled)   639             if t:   640                 dtstart, attr = t   641                 update = self.set_datetime_in_object(dtstart, attr.get("TZID"), "DTSTART", obj) or update   642             else:   643                 return ["dtstart"]   644    645             # Handle specified end datetimes.   646    647             if dtend_enabled:   648                 t = self.handle_date_controls("dtend", dttimes_enabled)   649                 if t:   650                     dtend, attr = t   651    652                     # Convert end dates to iCalendar "next day" dates.   653    654                     if not isinstance(dtend, datetime):   655                         dtend += timedelta(1)   656                     update = self.set_datetime_in_object(dtend, attr.get("TZID"), "DTEND", obj) or update   657                 else:   658                     return ["dtend"]   659    660             # Otherwise, treat the end date as the start date. Datetimes are   661             # handled by making the event occupy the rest of the day.   662    663             else:   664                 dtend = dtstart + timedelta(1)   665                 if isinstance(dtstart, datetime):   666                     dtend = get_start_of_day(dtend, attr["TZID"])   667                 update = self.set_datetime_in_object(dtend, attr.get("TZID"), "DTEND", obj) or update   668    669             if dtstart >= dtend:   670                 return ["dtstart", "dtend"]   671    672         # Obtain any participants to be added or removed.   673    674         removed = args.get("remove")   675         added = args.get("added")   676    677         # Process any action.   678    679         handled = True   680    681         if reply or invite or cancel:   682    683             handler = ManagerHandler(obj, self.user, self.messenger)   684    685             # Process the object and remove it from the list of requests.   686    687             if reply and handler.process_received_request(update) or \   688                is_organiser and (invite or cancel) and \   689                handler.process_created_request(invite and "REQUEST" or "CANCEL", update, removed, added):   690    691                 self.remove_request(uid, recurrenceid)   692    693         # Save single user events.   694    695         elif save:   696             to_cancel = self.update_attendees(obj, added, removed)   697             self.store.set_event(self.user, uid, recurrenceid, node=obj.to_node())   698             self.update_freebusy(uid, recurrenceid, obj=obj)   699             self.remove_request(uid, recurrenceid)   700    701         # Remove the request and the object.   702    703         elif discard:   704             self.remove_from_freebusy(uid, recurrenceid)   705             self.remove_event(uid, recurrenceid)   706             self.remove_request(uid, recurrenceid)   707    708         else:   709             handled = False   710    711         # Upon handling an action, redirect to the main page.   712    713         if handled:   714             self.redirect(self.env.get_path())   715    716         return None   717    718     def handle_date_controls(self, name, with_time=True):   719    720         """   721         Handle date control information for fields starting with 'name',   722         returning a (datetime, attr) tuple or None if the fields cannot be used   723         to construct a datetime object.   724         """   725    726         args = self.env.get_args()   727    728         if args.has_key("%s-date" % name):   729             date = args["%s-date" % name][0]   730    731             if with_time:   732                 hour = args.get("%s-hour" % name, [None])[0]   733                 minute = args.get("%s-minute" % name, [None])[0]   734                 second = args.get("%s-second" % name, [None])[0]   735                 tzid = args.get("%s-tzid" % name, [self.get_tzid()])[0]   736    737                 time = (hour or minute or second) and "T%s%s%s" % (hour, minute, second) or ""   738                 value = "%s%s" % (date, time)   739                 attr = {"TZID" : tzid, "VALUE" : "DATE-TIME"}   740                 dt = get_datetime(value, attr)   741             else:   742                 attr = {"VALUE" : "DATE"}   743                 dt = get_datetime(date)   744    745             if dt:   746                 return dt, attr   747    748         return None   749    750     def set_datetime_in_object(self, dt, tzid, property, obj):   751    752         """   753         Set 'dt' and 'tzid' for the given 'property' in 'obj', returning whether   754         an update has occurred.   755         """   756    757         if dt:   758             old_value = obj.get_value(property)   759             obj[property] = [get_datetime_item(dt, tzid)]   760             return format_datetime(dt) != old_value   761    762         return False   763    764     # Page fragment methods.   765    766     def show_request_controls(self, obj):   767    768         "Show form controls for a request concerning 'obj'."   769    770         page = self.page   771         args = self.env.get_args()   772    773         is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user   774    775         attendees = uri_values((obj.get_values("ATTENDEE") or []) + args.get("attendee", []))   776         is_attendee = self.user in attendees   777    778         is_request = (obj.get_value("UID"), obj.get_value("RECURRENCE-ID")) in self._get_requests()   779    780         have_other_attendees = len(attendees) > (is_attendee and 1 or 0)   781    782         # Show appropriate options depending on the role of the user.   783    784         if is_attendee and not is_organiser:   785             page.p("An action is required for this request:")   786    787             page.p()   788             page.input(name="reply", type="submit", value="Reply")   789             page.add(" ")   790             page.input(name="discard", type="submit", value="Discard")   791             page.p.close()   792    793         if is_organiser:   794             if have_other_attendees:   795                 page.p("As organiser, you can perform the following:")   796    797                 page.p()   798                 page.input(name="invite", type="submit", value="Invite")   799                 page.add(" ")   800                 if is_request:   801                     page.input(name="discard", type="submit", value="Discard")   802                 else:   803                     page.input(name="cancel", type="submit", value="Cancel")   804                 page.p.close()   805             else:   806                 page.p("As attendee, you can perform the following:")   807    808                 page.p()   809                 page.input(name="save", type="submit", value="Save")   810                 page.add(" ")   811                 page.input(name="discard", type="submit", value="Discard")   812                 page.p.close()   813    814     property_items = [   815         ("SUMMARY", "Summary"),   816         ("DTSTART", "Start"),   817         ("DTEND", "End"),   818         ("ORGANIZER", "Organiser"),   819         ("ATTENDEE", "Attendee"),   820         ]   821    822     partstat_items = [   823         ("NEEDS-ACTION", "Not confirmed"),   824         ("ACCEPTED", "Attending"),   825         ("TENTATIVE", "Tentatively attending"),   826         ("DECLINED", "Not attending"),   827         ("DELEGATED", "Delegated"),   828         (None, "Not indicated"),   829         ]   830    831     def show_object_on_page(self, uid, obj, error=None):   832    833         """   834         Show the calendar object with the given 'uid' and representation 'obj'   835         on the current page. If 'error' is given, show a suitable message.   836         """   837    838         page = self.page   839         page.form(method="POST")   840    841         args = self.env.get_args()   842    843         # Obtain the user's timezone.   844    845         tzid = self.get_tzid()   846    847         # Obtain basic event information, showing any necessary editing controls.   848    849         is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user   850    851         if is_organiser:   852             (dtstart, dtstart_attr), (dtend, dtend_attr) = self.show_object_organiser_controls(obj)   853             new_attendees, new_attendee = self.handle_new_attendees(obj)   854         else:   855             dtstart, dtstart_attr = obj.get_datetime_item("DTSTART")   856             dtend, dtend_attr = obj.get_datetime_item("DTEND")   857             new_attendees = []   858             new_attendee = ""   859    860         # Provide a summary of the object.   861    862         page.table(class_="object", cellspacing=5, cellpadding=5)   863         page.thead()   864         page.tr()   865         page.th("Event", class_="mainheading", colspan=2)   866         page.tr.close()   867         page.thead.close()   868         page.tbody()   869    870         for name, label in self.property_items:   871             page.tr()   872    873             # Handle datetimes specially.   874    875             if name in ["DTSTART", "DTEND"]:   876                 field = name.lower()   877    878                 page.th(label, class_="objectheading %s%s" % (field, error and field in error and " error" or ""))   879    880                 # Obtain the datetime.   881    882                 if name == "DTSTART":   883                     dt, attr, event_tzid = dtstart, dtstart_attr, dtstart_attr.get("TZID", tzid)   884    885                 # Where no end datetime exists, use the start datetime as the   886                 # basis of any potential datetime specified if dt-control is   887                 # set.   888    889                 else:   890                     dt, attr, event_tzid = dtend or dtstart, dtend_attr or dtstart_attr, (dtend_attr or dtstart_attr).get("TZID", tzid)   891    892                 # Show controls for editing as organiser.   893    894                 if is_organiser:   895                     value = format_datetime(dt)   896    897                     page.td(class_="objectvalue %s" % field)   898                     if name == "DTEND":   899                         page.div(class_="dt disabled")   900                         page.label("Specify end date", for_="dtend-enable", class_="enable")   901                         page.div.close()   902    903                     page.div(class_="dt enabled")   904                     self._show_date_controls(field, value, attr, tzid)   905                     if name == "DTSTART":   906                         page.label("Specify times", for_="dttimes-enable", class_="time disabled enable")   907                         page.label("Specify dates only", for_="dttimes-disable", class_="time enabled disable")   908                     elif name == "DTEND":   909                         page.label("End on same day", for_="dtend-disable", class_="disable")   910                     page.div.close()   911    912                     page.td.close()   913    914                 # Show a label as attendee.   915    916                 else:   917                     page.td(self.format_datetime(dt, "full"))   918    919                 page.tr.close()   920    921             # Handle the summary specially.   922    923             elif name == "SUMMARY":   924                 value = args.get("summary", [obj.get_value(name)])[0]   925    926                 page.th(label, class_="objectheading")   927                 page.td()   928                 if is_organiser:   929                     page.input(name="summary", type="text", value=value, size=80)   930                 else:   931                     page.add(value)   932                 page.td.close()   933                 page.tr.close()   934    935             # Handle potentially many values.   936    937             else:   938                 items = obj.get_items(name) or []   939                 rowspan = len(items)   940    941                 if name == "ATTENDEE":   942                     rowspan += len(new_attendees) + 1   943                 elif not items:   944                     continue   945    946                 page.th(label, class_="objectheading", rowspan=rowspan)   947    948                 first = True   949    950                 for i, (value, attr) in enumerate(items):   951                     if not first:   952                         page.tr()   953                     else:   954                         first = False   955    956                     if name == "ATTENDEE":   957                         value = get_uri(value)   958    959                         page.td(class_="objectvalue")   960                         page.add(value)   961                         page.add(" ")   962    963                         partstat = attr.get("PARTSTAT")   964                         if value == self.user:   965                             self._show_menu("partstat", partstat, self.partstat_items, "partstat")   966                         else:   967                             page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat")   968    969                         if is_organiser:   970                             if value in args.get("remove", []):   971                                 page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove", checked="checked")   972                             else:   973                                 page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove")   974                             page.label("Remove", for_="remove-%d" % i, class_="remove")   975                             page.label("Uninvited", for_="remove-%d" % i, class_="removed")   976    977                     else:   978                         page.td(class_="objectvalue")   979                         page.add(value)   980    981                     page.td.close()   982                     page.tr.close()   983    984                 # Allow more attendees to be specified.   985    986                 if is_organiser and name == "ATTENDEE":   987                     for i, attendee in enumerate(new_attendees):   988                         if not first:   989                             page.tr()   990                         else:   991                             first = False   992    993                         page.td()   994                         page.input(name="added", type="value", value=attendee)   995                         page.input(name="removenew", type="submit", value=attendee, id="removenew-%d" % i, class_="remove")   996                         page.label("Remove", for_="removenew-%d" % i, class_="remove")   997                         page.td.close()   998                         page.tr.close()   999   1000                     if not first:  1001                         page.tr()  1002   1003                     page.td()  1004                     page.input(name="attendee", type="value", value=new_attendee)  1005                     page.input(name="add", type="submit", value="add", id="add-%d" % i, class_="add")  1006                     page.label("Add", for_="add-%d" % i, class_="add")  1007                     page.td.close()  1008                     page.tr.close()  1009   1010         page.tbody.close()  1011         page.table.close()  1012   1013         self.show_recurrences(obj)  1014         self.show_conflicting_events(uid, obj)  1015         self.show_request_controls(obj)  1016   1017         page.form.close()  1018   1019     def handle_new_attendees(self, obj):  1020   1021         "Add or remove new attendees. This does not affect the stored object."  1022   1023         args = self.env.get_args()  1024   1025         existing_attendees = uri_values(obj.get_values("ATTENDEE") or [])  1026         new_attendees = args.get("added", [])  1027         new_attendee = args.get("attendee", [""])[0]  1028   1029         if args.has_key("add"):  1030             if new_attendee.strip():  1031                 new_attendee = get_uri(new_attendee.strip())  1032                 if new_attendee not in new_attendees and new_attendee not in existing_attendees:  1033                     new_attendees.append(new_attendee)  1034                 new_attendee = ""  1035   1036         if args.has_key("removenew"):  1037             removed_attendee = args["removenew"][0]  1038             if removed_attendee in new_attendees:  1039                 new_attendees.remove(removed_attendee)  1040   1041         return new_attendees, new_attendee  1042   1043     def show_object_organiser_controls(self, obj):  1044   1045         "Provide controls to change the displayed object 'obj'."  1046   1047         page = self.page  1048         args = self.env.get_args()  1049   1050         # Configure the start and end datetimes.  1051   1052         dtend_control = args.get("dtend-control", [None])[0]  1053         dttimes_control = args.get("dttimes-control", [None])[0]  1054         with_time = dttimes_control == "enable"  1055   1056         t = self.handle_date_controls("dtstart", with_time)  1057         if t:  1058             dtstart, dtstart_attr = t  1059         else:  1060             dtstart, dtstart_attr = obj.get_datetime_item("DTSTART")  1061   1062         if dtend_control == "enable":  1063             t = self.handle_date_controls("dtend", with_time)  1064             if t:  1065                 dtend, dtend_attr = t  1066             else:  1067                 dtend, dtend_attr = None, {}  1068         elif dtend_control == "disable":  1069             dtend, dtend_attr = None, {}  1070         else:  1071             dtend, dtend_attr = obj.get_datetime_item("DTEND")  1072   1073         # Change end dates to refer to the actual dates, not the iCalendar  1074         # "next day" dates.  1075   1076         if dtend and not isinstance(dtend, datetime):  1077             dtend -= timedelta(1)  1078   1079         # Show the end datetime controls if already active or if an object needs  1080         # them.  1081   1082         dtend_enabled = dtend_control == "enable" or isinstance(dtend, datetime) or dtstart != dtend  1083         dttimes_enabled = dttimes_control == "enable" or isinstance(dtstart, datetime) or isinstance(dtend, datetime)  1084   1085         if dtend_enabled:  1086             page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable", checked="checked")  1087             page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable")  1088         else:  1089             page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable")  1090             page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable", checked="checked")  1091   1092         if dttimes_enabled:  1093             page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable", checked="checked")  1094             page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable")  1095         else:  1096             page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable")  1097             page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable", checked="checked")  1098   1099         return (dtstart, dtstart_attr), (dtend, dtend_attr)  1100   1101     def show_recurrences(self, obj):  1102   1103         "Show recurrences for the object having the given representation 'obj'."  1104   1105         page = self.page  1106   1107         # Obtain any parent object if this object is a specific recurrence.  1108   1109         uid = obj.get_value("UID")  1110         recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID"))  1111   1112         if recurrenceid:  1113             obj = self._get_object(uid)  1114             if not obj:  1115                 return  1116   1117             page.p("This event modifies a recurring event.")  1118   1119         # Obtain the periods associated with the event in the user's time zone.  1120   1121         periods = obj.get_periods(self.get_tzid(), self.get_window_end())  1122         recurrenceids = self._get_recurrences(uid)  1123   1124         if len(periods) == 1:  1125             return  1126   1127         page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size())  1128   1129         page.table(cellspacing=5, cellpadding=5, class_="recurrences")  1130         page.thead()  1131         page.tr()  1132         page.th("Start")  1133         page.th("End")  1134         page.tr.close()  1135         page.thead.close()  1136         page.tbody()  1137   1138         for start, end in periods:  1139             start_utc = format_datetime(to_timezone(start, "UTC"))  1140             css = " ".join([  1141                 recurrenceids and start_utc in recurrenceids and "replaced" or "",  1142                 recurrenceid and start_utc == recurrenceid and "affected" or ""  1143                 ])  1144   1145             page.tr()  1146             page.td(self.format_datetime(start, "long"), class_=css)  1147             page.td(self.format_datetime(end, "long"), class_=css)  1148             page.tr.close()  1149   1150         page.tbody.close()  1151         page.table.close()  1152   1153     def show_conflicting_events(self, uid, obj):  1154   1155         """  1156         Show conflicting events for the object having the given 'uid' and  1157         representation 'obj'.  1158         """  1159   1160         page = self.page  1161   1162         # Obtain the user's timezone.  1163   1164         tzid = self.get_tzid()  1165   1166         dtstart = format_datetime(obj.get_utc_datetime("DTSTART"))  1167         dtend = format_datetime(obj.get_utc_datetime("DTEND"))  1168   1169         periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end())  1170   1171         # Indicate whether there are conflicting events.  1172   1173         freebusy = self.store.get_freebusy(self.user)  1174   1175         if freebusy:  1176   1177             # Obtain any time zone details from the suggested event.  1178   1179             _dtstart, attr = obj.get_item("DTSTART")  1180             tzid = attr.get("TZID", tzid)  1181   1182             # Show any conflicts.  1183   1184             conflicts = [t for t in have_conflict(freebusy, periods, True) if t[2] != uid]  1185   1186             if conflicts:  1187                 page.p("This event conflicts with others:")  1188   1189                 page.table(cellspacing=5, cellpadding=5, class_="conflicts")  1190                 page.thead()  1191                 page.tr()  1192                 page.th("Event")  1193                 page.th("Start")  1194                 page.th("End")  1195                 page.tr.close()  1196                 page.thead.close()  1197                 page.tbody()  1198   1199                 for t in conflicts:  1200                     start, end, found_uid, transp, found_recurrenceid = t[:5]  1201   1202                     # Provide details of any conflicting event.  1203   1204                     start = self.format_datetime(to_timezone(get_datetime(start), tzid), "long")  1205                     end = self.format_datetime(to_timezone(get_datetime(end), tzid), "long")  1206   1207                     page.tr()  1208   1209                     # Show the event summary for the conflicting event.  1210   1211                     page.td()  1212   1213                     found_obj = self._get_object(found_uid, found_recurrenceid)  1214                     if found_obj:  1215                         page.a(found_obj.get_value("SUMMARY"), href=self.link_to(found_uid))  1216                     else:  1217                         page.add("No details available")  1218   1219                     page.td.close()  1220   1221                     page.td(start)  1222                     page.td(end)  1223   1224                     page.tr.close()  1225   1226                 page.tbody.close()  1227                 page.table.close()  1228   1229     def show_requests_on_page(self):  1230   1231         "Show requests for the current user."  1232   1233         # NOTE: This list could be more informative, but it is envisaged that  1234         # NOTE: the requests would be visited directly anyway.  1235   1236         requests = self._get_requests()  1237   1238         self.page.div(id="pending-requests")  1239   1240         if requests:  1241             self.page.p("Pending requests:")  1242   1243             self.page.ul()  1244   1245             for uid, recurrenceid in requests:  1246                 obj = self._get_object(uid, recurrenceid)  1247                 if obj:  1248                     self.page.li()  1249                     self.page.a(obj.get_value("SUMMARY"), href="#request-%s-%s" % (uid, recurrenceid or ""))  1250                     self.page.li.close()  1251   1252             self.page.ul.close()  1253   1254         else:  1255             self.page.p("There are no pending requests.")  1256   1257         self.page.div.close()  1258   1259     def show_participants_on_page(self):  1260   1261         "Show participants for scheduling purposes."  1262   1263         args = self.env.get_args()  1264         participants = args.get("participants", [])  1265   1266         try:  1267             for name, value in args.items():  1268                 if name.startswith("remove-participant-"):  1269                     i = int(name[len("remove-participant-"):])  1270                     del participants[i]  1271                     break  1272         except ValueError:  1273             pass  1274   1275         # Trim empty participants.  1276   1277         while participants and not participants[-1].strip():  1278             participants.pop()  1279   1280         # Show any specified participants together with controls to remove and  1281         # add participants.  1282   1283         self.page.div(id="participants")  1284   1285         self.page.p("Participants for scheduling:")  1286   1287         for i, participant in enumerate(participants):  1288             self.page.p()  1289             self.page.input(name="participants", type="text", value=participant)  1290             self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove")  1291             self.page.p.close()  1292   1293         self.page.p()  1294         self.page.input(name="participants", type="text")  1295         self.page.input(name="add-participant", type="submit", value="Add")  1296         self.page.p.close()  1297   1298         self.page.div.close()  1299   1300         return participants  1301   1302     # Full page output methods.  1303   1304     def show_object(self, path_info):  1305   1306         "Show an object request using the given 'path_info' for the current user."  1307   1308         uid, recurrenceid = self._get_identifiers(path_info)  1309         obj = self._get_object(uid, recurrenceid)  1310   1311         if not obj:  1312             return False  1313   1314         error = self.handle_request(uid, recurrenceid, obj)  1315   1316         if not error:  1317             return True  1318   1319         self.new_page(title="Event")  1320         self.show_object_on_page(uid, obj, error)  1321   1322         return True  1323   1324     def show_calendar(self):  1325   1326         "Show the calendar for the current user."  1327   1328         handled = self.handle_newevent()  1329   1330         self.new_page(title="Calendar")  1331         page = self.page  1332   1333         # Form controls are used in various places on the calendar page.  1334   1335         page.form(method="POST")  1336   1337         self.show_requests_on_page()  1338         participants = self.show_participants_on_page()  1339   1340         # Show a button for scheduling a new event.  1341   1342         page.p(class_="controls")  1343         page.input(name="newevent", type="submit", value="New event", id="newevent", accesskey="N")  1344         page.input(name="reset", type="submit", value="Clear selections", id="reset")  1345         page.p.close()  1346   1347         # Show controls for hiding empty days and busy slots.  1348         # The positioning of the control, paragraph and table are important here.  1349   1350         page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D")  1351         page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B")  1352   1353         page.p(class_="controls")  1354         page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable")  1355         page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable")  1356         page.label("Show empty days", for_="showdays", class_="showdays disable")  1357         page.label("Hide empty days", for_="showdays", class_="showdays enable")  1358         page.p.close()  1359   1360         freebusy = self.store.get_freebusy(self.user)  1361   1362         if not freebusy:  1363             page.p("No events scheduled.")  1364             return  1365   1366         # Obtain the user's timezone.  1367   1368         tzid = self.get_tzid()  1369   1370         # Day view: start at the earliest known day and produce days until the  1371         # latest known day, perhaps with expandable sections of empty days.  1372   1373         # Month view: start at the earliest known month and produce months until  1374         # the latest known month, perhaps with expandable sections of empty  1375         # months.  1376   1377         # Details of users to invite to new events could be superimposed on the  1378         # calendar.  1379   1380         # Requests are listed and linked to their tentative positions in the  1381         # calendar. Other participants are also shown.  1382   1383         request_summary = self._get_request_summary()  1384   1385         period_groups = [request_summary, freebusy]  1386         period_group_types = ["request", "freebusy"]  1387         period_group_sources = ["Pending requests", "Your schedule"]  1388   1389         for i, participant in enumerate(participants):  1390             period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant)))  1391             period_group_types.append("freebusy-part%d" % i)  1392             period_group_sources.append(participant)  1393   1394         groups = []  1395         group_columns = []  1396         group_types = period_group_types  1397         group_sources = period_group_sources  1398         all_points = set()  1399   1400         # Obtain time point information for each group of periods.  1401   1402         for periods in period_groups:  1403             periods = convert_periods(periods, tzid)  1404   1405             # Get the time scale with start and end points.  1406   1407             scale = get_scale(periods)  1408   1409             # Get the time slots for the periods.  1410   1411             slots = get_slots(scale)  1412   1413             # Add start of day time points for multi-day periods.  1414   1415             add_day_start_points(slots, tzid)  1416   1417             # Record the slots and all time points employed.  1418   1419             groups.append(slots)  1420             all_points.update([point for point, active in slots])  1421   1422         # Partition the groups into days.  1423   1424         days = {}  1425         partitioned_groups = []  1426         partitioned_group_types = []  1427         partitioned_group_sources = []  1428   1429         for slots, group_type, group_source in zip(groups, group_types, group_sources):  1430   1431             # Propagate time points to all groups of time slots.  1432   1433             add_slots(slots, all_points)  1434   1435             # Count the number of columns employed by the group.  1436   1437             columns = 0  1438   1439             # Partition the time slots by day.  1440   1441             partitioned = {}  1442   1443             for day, day_slots in partition_by_day(slots).items():  1444                 intervals = []  1445                 last = None  1446   1447                 for point, active in day_slots:  1448                     columns = max(columns, len(active))  1449                     if last:  1450                         intervals.append((last, point))  1451                     last = point  1452   1453                 if last:  1454                     intervals.append((last, None))  1455   1456                 if not days.has_key(day):  1457                     days[day] = set()  1458   1459                 # Convert each partition to a mapping from points to active  1460                 # periods.  1461   1462                 partitioned[day] = dict(day_slots)  1463   1464                 # Record the divisions or intervals within each day.  1465   1466                 days[day].update(intervals)  1467   1468             if group_type != "request" or columns:  1469                 group_columns.append(columns)  1470                 partitioned_groups.append(partitioned)  1471                 partitioned_group_types.append(group_type)  1472                 partitioned_group_sources.append(group_source)  1473   1474         # Add empty days.  1475   1476         add_empty_days(days, tzid)  1477   1478         # Show the controls permitting day selection.  1479   1480         self.show_calendar_day_controls(days)  1481   1482         # Show the calendar itself.  1483   1484         page.table(cellspacing=5, cellpadding=5, class_="calendar")  1485         self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns)  1486         self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns)  1487         page.table.close()  1488   1489         # End the form region.  1490   1491         page.form.close()  1492   1493     # More page fragment methods.  1494   1495     def show_calendar_day_controls(self, days):  1496   1497         "Show controls for the given 'days' in the calendar."  1498   1499         page = self.page  1500         slots = self.env.get_args().get("slot", [])  1501   1502         for day in days:  1503             value, identifier = self._day_value_and_identifier(day)  1504             self._slot_selector(value, identifier, slots)  1505   1506         # Generate a dynamic stylesheet to allow day selections to colour  1507         # specific days.  1508         # NOTE: The style details need to be coordinated with the static  1509         # NOTE: stylesheet.  1510   1511         page.style(type="text/css")  1512   1513         for day in days:  1514             daystr = format_datetime(day)  1515             page.add("""\  1516 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s,  1517 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s {  1518     background-color: #5f4;  1519     text-decoration: underline;  1520 }  1521 """ % (daystr, daystr, daystr, daystr))  1522   1523         page.style.close()  1524   1525     def show_calendar_participant_headings(self, group_types, group_sources, group_columns):  1526   1527         """  1528         Show headings for the participants and other scheduling contributors,  1529         defined by 'group_types', 'group_sources' and 'group_columns'.  1530         """  1531   1532         page = self.page  1533   1534         page.colgroup(span=1, id="columns-timeslot")  1535   1536         for group_type, columns in zip(group_types, group_columns):  1537             page.colgroup(span=max(columns, 1), id="columns-%s" % group_type)  1538   1539         page.thead()  1540         page.tr()  1541         page.th("", class_="emptyheading")  1542   1543         for group_type, source, columns in zip(group_types, group_sources, group_columns):  1544             page.th(source,  1545                 class_=(group_type == "request" and "requestheading" or "participantheading"),  1546                 colspan=max(columns, 1))  1547   1548         page.tr.close()  1549         page.thead.close()  1550   1551     def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns):  1552   1553         """  1554         Show calendar days, defined by a collection of 'days', the contributing  1555         period information as 'partitioned_groups' (partitioned by day), the  1556         'partitioned_group_types' indicating the kind of contribution involved,  1557         and the 'group_columns' defining the number of columns in each group.  1558         """  1559   1560         page = self.page  1561   1562         # Determine the number of columns required. Where participants provide  1563         # no columns for events, one still needs to be provided for the  1564         # participant itself.  1565   1566         all_columns = sum([max(columns, 1) for columns in group_columns])  1567   1568         # Determine the days providing time slots.  1569   1570         all_days = days.items()  1571         all_days.sort()  1572   1573         # Produce a heading and time points for each day.  1574   1575         for day, intervals in all_days:  1576             groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups]  1577             is_empty = True  1578   1579             for slots in groups_for_day:  1580                 if not slots:  1581                     continue  1582   1583                 for active in slots.values():  1584                     if active:  1585                         is_empty = False  1586                         break  1587   1588             page.thead(class_="separator%s" % (is_empty and " empty" or ""))  1589             page.tr()  1590             page.th(class_="dayheading container", colspan=all_columns+1)  1591             self._day_heading(day)  1592             page.th.close()  1593             page.tr.close()  1594             page.thead.close()  1595   1596             page.tbody(class_="points%s" % (is_empty and " empty" or ""))  1597             self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns)  1598             page.tbody.close()  1599   1600     def show_calendar_points(self, intervals, groups, group_types, group_columns):  1601   1602         """  1603         Show the time 'intervals' along with period information from the given  1604         'groups', having the indicated 'group_types', each with the number of  1605         columns given by 'group_columns'.  1606         """  1607   1608         page = self.page  1609   1610         # Obtain the user's timezone.  1611   1612         tzid = self.get_tzid()  1613   1614         # Produce a row for each interval.  1615   1616         intervals = list(intervals)  1617         intervals.sort()  1618   1619         for point, endpoint in intervals:  1620             continuation = point == get_start_of_day(point, tzid)  1621   1622             # Some rows contain no period details and are marked as such.  1623   1624             have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None)  1625   1626             css = " ".join(  1627                 ["slot"] +  1628                 (have_active and ["busy"] or ["empty"]) +  1629                 (continuation and ["daystart"] or [])  1630                 )  1631   1632             page.tr(class_=css)  1633             page.th(class_="timeslot")  1634             self._time_point(point, endpoint)  1635             page.th.close()  1636   1637             # Obtain slots for the time point from each group.  1638   1639             for columns, slots, group_type in zip(group_columns, groups, group_types):  1640                 active = slots and slots.get(point)  1641   1642                 # Where no periods exist for the given time interval, generate  1643                 # an empty cell. Where a participant provides no periods at all,  1644                 # the colspan is adjusted to be 1, not 0.  1645   1646                 if not active:  1647                     page.td(class_="empty container", colspan=max(columns, 1))  1648                     self._empty_slot(point, endpoint)  1649                     page.td.close()  1650                     continue  1651   1652                 slots = slots.items()  1653                 slots.sort()  1654                 spans = get_spans(slots)  1655   1656                 empty = 0  1657   1658                 # Show a column for each active period.  1659   1660                 for t in active:  1661                     if t and len(t) >= 2:  1662   1663                         # Flush empty slots preceding this one.  1664   1665                         if empty:  1666                             page.td(class_="empty container", colspan=empty)  1667                             self._empty_slot(point, endpoint)  1668                             page.td.close()  1669                             empty = 0  1670   1671                         start, end, uid, recurrenceid, key = get_freebusy_details(t)  1672                         span = spans[key]  1673   1674                         # Produce a table cell only at the start of the period  1675                         # or when continued at the start of a day.  1676   1677                         if point == start or continuation:  1678   1679                             obj = self._get_object(uid, recurrenceid)  1680   1681                             has_continued = continuation and point != start  1682                             will_continue = not ends_on_same_day(point, end, tzid)  1683                             is_organiser = obj and get_uri(obj.get_value("ORGANIZER")) == self.user  1684   1685                             css = " ".join(  1686                                 ["event"] +  1687                                 (has_continued and ["continued"] or []) +  1688                                 (will_continue and ["continues"] or []) +  1689                                 (is_organiser and ["organising"] or ["attending"])  1690                                 )  1691   1692                             # Only anchor the first cell of events.  1693                             # NOTE: Need to only anchor the first period for a  1694                             # NOTE: recurring event.  1695   1696                             if point == start:  1697                                 page.td(class_=css, rowspan=span, id="%s-%s-%s" % (group_type, uid, recurrenceid or ""))  1698                             else:  1699                                 page.td(class_=css, rowspan=span)  1700   1701                             if not obj:  1702                                 page.span("(Participant is busy)")  1703                             else:  1704                                 summary = obj.get_value("SUMMARY")  1705   1706                                 # Only link to events if they are not being  1707                                 # updated by requests.  1708   1709                                 if (uid, recurrenceid) in self._get_requests() and group_type != "request":  1710                                     page.span(summary)  1711                                 else:  1712                                     page.a(summary, href=self.link_to(uid, recurrenceid))  1713   1714                             page.td.close()  1715                     else:  1716                         empty += 1  1717   1718                 # Pad with empty columns.  1719   1720                 empty = columns - len(active)  1721   1722                 if empty:  1723                     page.td(class_="empty container", colspan=empty)  1724                     self._empty_slot(point, endpoint)  1725                     page.td.close()  1726   1727             page.tr.close()  1728   1729     def _day_heading(self, day):  1730   1731         """  1732         Generate a heading for 'day' of the following form:  1733   1734         <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label>  1735         """  1736   1737         page = self.page  1738         daystr = format_datetime(day)  1739         value, identifier = self._day_value_and_identifier(day)  1740         page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier)  1741   1742     def _time_point(self, point, endpoint):  1743   1744         """  1745         Generate headings for the 'point' to 'endpoint' period of the following  1746         form:  1747   1748         <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label>  1749         <span class="endpoint">10:00:00 CET</span>  1750         """  1751   1752         page = self.page  1753         tzid = self.get_tzid()  1754         daystr = format_datetime(point.date())  1755         value, identifier = self._slot_value_and_identifier(point, endpoint)  1756         slots = self.env.get_args().get("slot", [])  1757         self._slot_selector(value, identifier, slots)  1758         page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier)  1759         page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint")  1760   1761     def _slot_selector(self, value, identifier, slots):  1762         reset = self.env.get_args().has_key("reset")  1763         page = self.page  1764         if not reset and value in slots:  1765             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked")  1766         else:  1767             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector")  1768   1769     def _empty_slot(self, point, endpoint):  1770         page = self.page  1771         value, identifier = self._slot_value_and_identifier(point, endpoint)  1772         page.label("Select/deselect period", class_="newevent popup", for_=identifier)  1773   1774     def _day_value_and_identifier(self, day):  1775         value = "%s-" % format_datetime(day)  1776         identifier = "day-%s" % value  1777         return value, identifier  1778   1779     def _slot_value_and_identifier(self, point, endpoint):  1780         value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "")  1781         identifier = "slot-%s" % value  1782         return value, identifier  1783   1784     def _show_menu(self, name, default, items, class_=""):  1785         page = self.page  1786         values = self.env.get_args().get(name, [default])  1787         page.select(name=name, class_=class_)  1788         for v, label in items:  1789             if v is None:  1790                 continue  1791             if v in values:  1792                 page.option(label, value=v, selected="selected")  1793             else:  1794                 page.option(label, value=v)  1795         page.select.close()  1796   1797     def _show_date_controls(self, name, default, attr, tzid):  1798   1799         """  1800         Show date controls for a field with the given 'name' and 'default' value  1801         and 'attr', with the given 'tzid' being used if no other time regime  1802         information is provided.  1803         """  1804   1805         page = self.page  1806         args = self.env.get_args()  1807   1808         event_tzid = attr.get("TZID", tzid)  1809         dt = get_datetime(default, attr)  1810   1811         # Show dates for up to one week around the current date.  1812   1813         base = get_date(dt)  1814         items = []  1815         for i in range(-7, 8):  1816             d = base + timedelta(i)  1817             items.append((format_datetime(d), self.format_date(d, "full")))  1818   1819         self._show_menu("%s-date" % name, format_datetime(base), items)  1820   1821         # Show time details.  1822   1823         dt_time = isinstance(dt, datetime) and dt or None  1824         hour = args.get("%s-hour" % name, "%02d" % (dt_time and dt_time.hour or 0))  1825         minute = args.get("%s-minute" % name, "%02d" % (dt_time and dt_time.minute or 0))  1826         second = args.get("%s-second" % name, "%02d" % (dt_time and dt_time.second or 0))  1827   1828         page.span(class_="time enabled")  1829         page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2)  1830         page.add(":")  1831         page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2)  1832         page.add(":")  1833         page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2)  1834         page.add(" ")  1835         self._show_menu("%s-tzid" % name, event_tzid,  1836             [(event_tzid, event_tzid)] + (  1837             event_tzid != tzid and [(tzid, tzid)] or []  1838             ))  1839         page.span.close()  1840   1841     # Incoming HTTP request direction.  1842   1843     def select_action(self):  1844   1845         "Select the desired action and show the result."  1846   1847         path_info = self.env.get_path_info().strip("/")  1848   1849         if not path_info:  1850             self.show_calendar()  1851         elif self.show_object(path_info):  1852             pass  1853         else:  1854             self.no_page()  1855   1856     def __call__(self):  1857   1858         "Interpret a request and show an appropriate response."  1859   1860         if not self.user:  1861             self.no_user()  1862         else:  1863             self.select_action()  1864   1865         # Write the headers and actual content.  1866   1867         print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding  1868         print >>self.out  1869         self.out.write(unicode(self.page).encode(self.encoding))  1870   1871 if __name__ == "__main__":  1872     Manager()()  1873   1874 # vim: tabstop=4 expandtab shiftwidth=4