imip-agent

imip_manager.py

381:f5fe45d84ecc
2015-03-05 Paul Boddie Changed complete event handling to always remove special/additional recurrences and to remove free/busy information for existing additional recurrences in anticipation of updated additional recurrence information. 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_="conflicts")  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 = recurrenceids and start_utc in recurrenceids and "replaced" or ""  1141   1142             page.tr()  1143             page.td(self.format_datetime(start, "long"), class_=css)  1144             page.td(self.format_datetime(end, "long"), class_=css)  1145             page.tr.close()  1146   1147         page.tbody.close()  1148         page.table.close()  1149   1150     def show_conflicting_events(self, uid, obj):  1151   1152         """  1153         Show conflicting events for the object having the given 'uid' and  1154         representation 'obj'.  1155         """  1156   1157         page = self.page  1158   1159         # Obtain the user's timezone.  1160   1161         tzid = self.get_tzid()  1162   1163         dtstart = format_datetime(obj.get_utc_datetime("DTSTART"))  1164         dtend = format_datetime(obj.get_utc_datetime("DTEND"))  1165   1166         # Indicate whether there are conflicting events.  1167   1168         freebusy = self.store.get_freebusy(self.user)  1169   1170         if freebusy:  1171   1172             # Obtain any time zone details from the suggested event.  1173   1174             _dtstart, attr = obj.get_item("DTSTART")  1175             tzid = attr.get("TZID", tzid)  1176   1177             # Show any conflicts.  1178   1179             conflicts = [t for t in have_conflict(freebusy, [(dtstart, dtend)], True) if t[2] != uid]  1180   1181             if conflicts:  1182                 page.p("This event conflicts with others:")  1183   1184                 page.table(cellspacing=5, cellpadding=5, class_="conflicts")  1185                 page.thead()  1186                 page.tr()  1187                 page.th("Event")  1188                 page.th("Start")  1189                 page.th("End")  1190                 page.tr.close()  1191                 page.thead.close()  1192                 page.tbody()  1193   1194                 for t in conflicts:  1195                     start, end, found_uid, transp, found_recurrenceid = t[:5]  1196   1197                     # Provide details of any conflicting event.  1198   1199                     start = self.format_datetime(to_timezone(get_datetime(start), tzid), "long")  1200                     end = self.format_datetime(to_timezone(get_datetime(end), tzid), "long")  1201   1202                     page.tr()  1203   1204                     # Show the event summary for the conflicting event.  1205   1206                     page.td()  1207   1208                     found_obj = self._get_object(found_uid, found_recurrenceid)  1209                     if found_obj:  1210                         page.a(found_obj.get_value("SUMMARY"), href=self.link_to(found_uid))  1211                     else:  1212                         page.add("No details available")  1213   1214                     page.td.close()  1215   1216                     page.td(start)  1217                     page.td(end)  1218   1219                     page.tr.close()  1220   1221                 page.tbody.close()  1222                 page.table.close()  1223   1224     def show_requests_on_page(self):  1225   1226         "Show requests for the current user."  1227   1228         # NOTE: This list could be more informative, but it is envisaged that  1229         # NOTE: the requests would be visited directly anyway.  1230   1231         requests = self._get_requests()  1232   1233         self.page.div(id="pending-requests")  1234   1235         if requests:  1236             self.page.p("Pending requests:")  1237   1238             self.page.ul()  1239   1240             for uid, recurrenceid in requests:  1241                 obj = self._get_object(uid, recurrenceid)  1242                 if obj:  1243                     self.page.li()  1244                     self.page.a(obj.get_value("SUMMARY"), href="#request-%s-%s" % (uid, recurrenceid or ""))  1245                     self.page.li.close()  1246   1247             self.page.ul.close()  1248   1249         else:  1250             self.page.p("There are no pending requests.")  1251   1252         self.page.div.close()  1253   1254     def show_participants_on_page(self):  1255   1256         "Show participants for scheduling purposes."  1257   1258         args = self.env.get_args()  1259         participants = args.get("participants", [])  1260   1261         try:  1262             for name, value in args.items():  1263                 if name.startswith("remove-participant-"):  1264                     i = int(name[len("remove-participant-"):])  1265                     del participants[i]  1266                     break  1267         except ValueError:  1268             pass  1269   1270         # Trim empty participants.  1271   1272         while participants and not participants[-1].strip():  1273             participants.pop()  1274   1275         # Show any specified participants together with controls to remove and  1276         # add participants.  1277   1278         self.page.div(id="participants")  1279   1280         self.page.p("Participants for scheduling:")  1281   1282         for i, participant in enumerate(participants):  1283             self.page.p()  1284             self.page.input(name="participants", type="text", value=participant)  1285             self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove")  1286             self.page.p.close()  1287   1288         self.page.p()  1289         self.page.input(name="participants", type="text")  1290         self.page.input(name="add-participant", type="submit", value="Add")  1291         self.page.p.close()  1292   1293         self.page.div.close()  1294   1295         return participants  1296   1297     # Full page output methods.  1298   1299     def show_object(self, path_info):  1300   1301         "Show an object request using the given 'path_info' for the current user."  1302   1303         uid, recurrenceid = self._get_identifiers(path_info)  1304         obj = self._get_object(uid, recurrenceid)  1305   1306         if not obj:  1307             return False  1308   1309         error = self.handle_request(uid, recurrenceid, obj)  1310   1311         if not error:  1312             return True  1313   1314         self.new_page(title="Event")  1315         self.show_object_on_page(uid, obj, error)  1316   1317         return True  1318   1319     def show_calendar(self):  1320   1321         "Show the calendar for the current user."  1322   1323         handled = self.handle_newevent()  1324   1325         self.new_page(title="Calendar")  1326         page = self.page  1327   1328         # Form controls are used in various places on the calendar page.  1329   1330         page.form(method="POST")  1331   1332         self.show_requests_on_page()  1333         participants = self.show_participants_on_page()  1334   1335         # Show a button for scheduling a new event.  1336   1337         page.p(class_="controls")  1338         page.input(name="newevent", type="submit", value="New event", id="newevent", accesskey="N")  1339         page.input(name="reset", type="submit", value="Clear selections", id="reset")  1340         page.p.close()  1341   1342         # Show controls for hiding empty days and busy slots.  1343         # The positioning of the control, paragraph and table are important here.  1344   1345         page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D")  1346         page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B")  1347   1348         page.p(class_="controls")  1349         page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable")  1350         page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable")  1351         page.label("Show empty days", for_="showdays", class_="showdays disable")  1352         page.label("Hide empty days", for_="showdays", class_="showdays enable")  1353         page.p.close()  1354   1355         freebusy = self.store.get_freebusy(self.user)  1356   1357         if not freebusy:  1358             page.p("No events scheduled.")  1359             return  1360   1361         # Obtain the user's timezone.  1362   1363         tzid = self.get_tzid()  1364   1365         # Day view: start at the earliest known day and produce days until the  1366         # latest known day, perhaps with expandable sections of empty days.  1367   1368         # Month view: start at the earliest known month and produce months until  1369         # the latest known month, perhaps with expandable sections of empty  1370         # months.  1371   1372         # Details of users to invite to new events could be superimposed on the  1373         # calendar.  1374   1375         # Requests are listed and linked to their tentative positions in the  1376         # calendar. Other participants are also shown.  1377   1378         request_summary = self._get_request_summary()  1379   1380         period_groups = [request_summary, freebusy]  1381         period_group_types = ["request", "freebusy"]  1382         period_group_sources = ["Pending requests", "Your schedule"]  1383   1384         for i, participant in enumerate(participants):  1385             period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant)))  1386             period_group_types.append("freebusy-part%d" % i)  1387             period_group_sources.append(participant)  1388   1389         groups = []  1390         group_columns = []  1391         group_types = period_group_types  1392         group_sources = period_group_sources  1393         all_points = set()  1394   1395         # Obtain time point information for each group of periods.  1396   1397         for periods in period_groups:  1398             periods = convert_periods(periods, tzid)  1399   1400             # Get the time scale with start and end points.  1401   1402             scale = get_scale(periods)  1403   1404             # Get the time slots for the periods.  1405   1406             slots = get_slots(scale)  1407   1408             # Add start of day time points for multi-day periods.  1409   1410             add_day_start_points(slots, tzid)  1411   1412             # Record the slots and all time points employed.  1413   1414             groups.append(slots)  1415             all_points.update([point for point, active in slots])  1416   1417         # Partition the groups into days.  1418   1419         days = {}  1420         partitioned_groups = []  1421         partitioned_group_types = []  1422         partitioned_group_sources = []  1423   1424         for slots, group_type, group_source in zip(groups, group_types, group_sources):  1425   1426             # Propagate time points to all groups of time slots.  1427   1428             add_slots(slots, all_points)  1429   1430             # Count the number of columns employed by the group.  1431   1432             columns = 0  1433   1434             # Partition the time slots by day.  1435   1436             partitioned = {}  1437   1438             for day, day_slots in partition_by_day(slots).items():  1439                 intervals = []  1440                 last = None  1441   1442                 for point, active in day_slots:  1443                     columns = max(columns, len(active))  1444                     if last:  1445                         intervals.append((last, point))  1446                     last = point  1447   1448                 if last:  1449                     intervals.append((last, None))  1450   1451                 if not days.has_key(day):  1452                     days[day] = set()  1453   1454                 # Convert each partition to a mapping from points to active  1455                 # periods.  1456   1457                 partitioned[day] = dict(day_slots)  1458   1459                 # Record the divisions or intervals within each day.  1460   1461                 days[day].update(intervals)  1462   1463             if group_type != "request" or columns:  1464                 group_columns.append(columns)  1465                 partitioned_groups.append(partitioned)  1466                 partitioned_group_types.append(group_type)  1467                 partitioned_group_sources.append(group_source)  1468   1469         # Add empty days.  1470   1471         add_empty_days(days, tzid)  1472   1473         # Show the controls permitting day selection.  1474   1475         self.show_calendar_day_controls(days)  1476   1477         # Show the calendar itself.  1478   1479         page.table(cellspacing=5, cellpadding=5, class_="calendar")  1480         self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns)  1481         self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns)  1482         page.table.close()  1483   1484         # End the form region.  1485   1486         page.form.close()  1487   1488     # More page fragment methods.  1489   1490     def show_calendar_day_controls(self, days):  1491   1492         "Show controls for the given 'days' in the calendar."  1493   1494         page = self.page  1495         slots = self.env.get_args().get("slot", [])  1496   1497         for day in days:  1498             value, identifier = self._day_value_and_identifier(day)  1499             self._slot_selector(value, identifier, slots)  1500   1501         # Generate a dynamic stylesheet to allow day selections to colour  1502         # specific days.  1503         # NOTE: The style details need to be coordinated with the static  1504         # NOTE: stylesheet.  1505   1506         page.style(type="text/css")  1507   1508         for day in days:  1509             daystr = format_datetime(day)  1510             page.add("""\  1511 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s,  1512 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s {  1513     background-color: #5f4;  1514     text-decoration: underline;  1515 }  1516 """ % (daystr, daystr, daystr, daystr))  1517   1518         page.style.close()  1519   1520     def show_calendar_participant_headings(self, group_types, group_sources, group_columns):  1521   1522         """  1523         Show headings for the participants and other scheduling contributors,  1524         defined by 'group_types', 'group_sources' and 'group_columns'.  1525         """  1526   1527         page = self.page  1528   1529         page.colgroup(span=1, id="columns-timeslot")  1530   1531         for group_type, columns in zip(group_types, group_columns):  1532             page.colgroup(span=max(columns, 1), id="columns-%s" % group_type)  1533   1534         page.thead()  1535         page.tr()  1536         page.th("", class_="emptyheading")  1537   1538         for group_type, source, columns in zip(group_types, group_sources, group_columns):  1539             page.th(source,  1540                 class_=(group_type == "request" and "requestheading" or "participantheading"),  1541                 colspan=max(columns, 1))  1542   1543         page.tr.close()  1544         page.thead.close()  1545   1546     def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns):  1547   1548         """  1549         Show calendar days, defined by a collection of 'days', the contributing  1550         period information as 'partitioned_groups' (partitioned by day), the  1551         'partitioned_group_types' indicating the kind of contribution involved,  1552         and the 'group_columns' defining the number of columns in each group.  1553         """  1554   1555         page = self.page  1556   1557         # Determine the number of columns required. Where participants provide  1558         # no columns for events, one still needs to be provided for the  1559         # participant itself.  1560   1561         all_columns = sum([max(columns, 1) for columns in group_columns])  1562   1563         # Determine the days providing time slots.  1564   1565         all_days = days.items()  1566         all_days.sort()  1567   1568         # Produce a heading and time points for each day.  1569   1570         for day, intervals in all_days:  1571             groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups]  1572             is_empty = True  1573   1574             for slots in groups_for_day:  1575                 if not slots:  1576                     continue  1577   1578                 for active in slots.values():  1579                     if active:  1580                         is_empty = False  1581                         break  1582   1583             page.thead(class_="separator%s" % (is_empty and " empty" or ""))  1584             page.tr()  1585             page.th(class_="dayheading container", colspan=all_columns+1)  1586             self._day_heading(day)  1587             page.th.close()  1588             page.tr.close()  1589             page.thead.close()  1590   1591             page.tbody(class_="points%s" % (is_empty and " empty" or ""))  1592             self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns)  1593             page.tbody.close()  1594   1595     def show_calendar_points(self, intervals, groups, group_types, group_columns):  1596   1597         """  1598         Show the time 'intervals' along with period information from the given  1599         'groups', having the indicated 'group_types', each with the number of  1600         columns given by 'group_columns'.  1601         """  1602   1603         page = self.page  1604   1605         # Obtain the user's timezone.  1606   1607         tzid = self.get_tzid()  1608   1609         # Produce a row for each interval.  1610   1611         intervals = list(intervals)  1612         intervals.sort()  1613   1614         for point, endpoint in intervals:  1615             continuation = point == get_start_of_day(point, tzid)  1616   1617             # Some rows contain no period details and are marked as such.  1618   1619             have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None)  1620   1621             css = " ".join(  1622                 ["slot"] +  1623                 (have_active and ["busy"] or ["empty"]) +  1624                 (continuation and ["daystart"] or [])  1625                 )  1626   1627             page.tr(class_=css)  1628             page.th(class_="timeslot")  1629             self._time_point(point, endpoint)  1630             page.th.close()  1631   1632             # Obtain slots for the time point from each group.  1633   1634             for columns, slots, group_type in zip(group_columns, groups, group_types):  1635                 active = slots and slots.get(point)  1636   1637                 # Where no periods exist for the given time interval, generate  1638                 # an empty cell. Where a participant provides no periods at all,  1639                 # the colspan is adjusted to be 1, not 0.  1640   1641                 if not active:  1642                     page.td(class_="empty container", colspan=max(columns, 1))  1643                     self._empty_slot(point, endpoint)  1644                     page.td.close()  1645                     continue  1646   1647                 slots = slots.items()  1648                 slots.sort()  1649                 spans = get_spans(slots)  1650   1651                 empty = 0  1652   1653                 # Show a column for each active period.  1654   1655                 for t in active:  1656                     if t and len(t) >= 2:  1657   1658                         # Flush empty slots preceding this one.  1659   1660                         if empty:  1661                             page.td(class_="empty container", colspan=empty)  1662                             self._empty_slot(point, endpoint)  1663                             page.td.close()  1664                             empty = 0  1665   1666                         start, end, uid, recurrenceid, key = get_freebusy_details(t)  1667                         span = spans[key]  1668   1669                         # Produce a table cell only at the start of the period  1670                         # or when continued at the start of a day.  1671   1672                         if point == start or continuation:  1673   1674                             obj = self._get_object(uid, recurrenceid)  1675   1676                             has_continued = continuation and point != start  1677                             will_continue = not ends_on_same_day(point, end, tzid)  1678                             is_organiser = obj and get_uri(obj.get_value("ORGANIZER")) == self.user  1679   1680                             css = " ".join(  1681                                 ["event"] +  1682                                 (has_continued and ["continued"] or []) +  1683                                 (will_continue and ["continues"] or []) +  1684                                 (is_organiser and ["organising"] or ["attending"])  1685                                 )  1686   1687                             # Only anchor the first cell of events.  1688                             # NOTE: Need to only anchor the first period for a  1689                             # NOTE: recurring event.  1690   1691                             if point == start:  1692                                 page.td(class_=css, rowspan=span, id="%s-%s-%s" % (group_type, uid, recurrenceid or ""))  1693                             else:  1694                                 page.td(class_=css, rowspan=span)  1695   1696                             if not obj:  1697                                 page.span("(Participant is busy)")  1698                             else:  1699                                 summary = obj.get_value("SUMMARY")  1700   1701                                 # Only link to events if they are not being  1702                                 # updated by requests.  1703   1704                                 if (uid, recurrenceid) in self._get_requests() and group_type != "request":  1705                                     page.span(summary)  1706                                 else:  1707                                     page.a(summary, href=self.link_to(uid, recurrenceid))  1708   1709                             page.td.close()  1710                     else:  1711                         empty += 1  1712   1713                 # Pad with empty columns.  1714   1715                 empty = columns - len(active)  1716   1717                 if empty:  1718                     page.td(class_="empty container", colspan=empty)  1719                     self._empty_slot(point, endpoint)  1720                     page.td.close()  1721   1722             page.tr.close()  1723   1724     def _day_heading(self, day):  1725   1726         """  1727         Generate a heading for 'day' of the following form:  1728   1729         <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label>  1730         """  1731   1732         page = self.page  1733         daystr = format_datetime(day)  1734         value, identifier = self._day_value_and_identifier(day)  1735         page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier)  1736   1737     def _time_point(self, point, endpoint):  1738   1739         """  1740         Generate headings for the 'point' to 'endpoint' period of the following  1741         form:  1742   1743         <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label>  1744         <span class="endpoint">10:00:00 CET</span>  1745         """  1746   1747         page = self.page  1748         tzid = self.get_tzid()  1749         daystr = format_datetime(point.date())  1750         value, identifier = self._slot_value_and_identifier(point, endpoint)  1751         slots = self.env.get_args().get("slot", [])  1752         self._slot_selector(value, identifier, slots)  1753         page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier)  1754         page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint")  1755   1756     def _slot_selector(self, value, identifier, slots):  1757         reset = self.env.get_args().has_key("reset")  1758         page = self.page  1759         if not reset and value in slots:  1760             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked")  1761         else:  1762             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector")  1763   1764     def _empty_slot(self, point, endpoint):  1765         page = self.page  1766         value, identifier = self._slot_value_and_identifier(point, endpoint)  1767         page.label("Select/deselect period", class_="newevent popup", for_=identifier)  1768   1769     def _day_value_and_identifier(self, day):  1770         value = "%s-" % format_datetime(day)  1771         identifier = "day-%s" % value  1772         return value, identifier  1773   1774     def _slot_value_and_identifier(self, point, endpoint):  1775         value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "")  1776         identifier = "slot-%s" % value  1777         return value, identifier  1778   1779     def _show_menu(self, name, default, items, class_=""):  1780         page = self.page  1781         values = self.env.get_args().get(name, [default])  1782         page.select(name=name, class_=class_)  1783         for v, label in items:  1784             if v is None:  1785                 continue  1786             if v in values:  1787                 page.option(label, value=v, selected="selected")  1788             else:  1789                 page.option(label, value=v)  1790         page.select.close()  1791   1792     def _show_date_controls(self, name, default, attr, tzid):  1793   1794         """  1795         Show date controls for a field with the given 'name' and 'default' value  1796         and 'attr', with the given 'tzid' being used if no other time regime  1797         information is provided.  1798         """  1799   1800         page = self.page  1801         args = self.env.get_args()  1802   1803         event_tzid = attr.get("TZID", tzid)  1804         dt = get_datetime(default, attr)  1805   1806         # Show dates for up to one week around the current date.  1807   1808         base = get_date(dt)  1809         items = []  1810         for i in range(-7, 8):  1811             d = base + timedelta(i)  1812             items.append((format_datetime(d), self.format_date(d, "full")))  1813   1814         self._show_menu("%s-date" % name, format_datetime(base), items)  1815   1816         # Show time details.  1817   1818         dt_time = isinstance(dt, datetime) and dt or None  1819         hour = args.get("%s-hour" % name, "%02d" % (dt_time and dt_time.hour or 0))  1820         minute = args.get("%s-minute" % name, "%02d" % (dt_time and dt_time.minute or 0))  1821         second = args.get("%s-second" % name, "%02d" % (dt_time and dt_time.second or 0))  1822   1823         page.span(class_="time enabled")  1824         page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2)  1825         page.add(":")  1826         page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2)  1827         page.add(":")  1828         page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2)  1829         page.add(" ")  1830         self._show_menu("%s-tzid" % name, event_tzid,  1831             [(event_tzid, event_tzid)] + (  1832             event_tzid != tzid and [(tzid, tzid)] or []  1833             ))  1834         page.span.close()  1835   1836     # Incoming HTTP request direction.  1837   1838     def select_action(self):  1839   1840         "Select the desired action and show the result."  1841   1842         path_info = self.env.get_path_info().strip("/")  1843   1844         if not path_info:  1845             self.show_calendar()  1846         elif self.show_object(path_info):  1847             pass  1848         else:  1849             self.no_page()  1850   1851     def __call__(self):  1852   1853         "Interpret a request and show an appropriate response."  1854   1855         if not self.user:  1856             self.no_user()  1857         else:  1858             self.select_action()  1859   1860         # Write the headers and actual content.  1861   1862         print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding  1863         print >>self.out  1864         self.out.write(unicode(self.page).encode(self.encoding))  1865   1866 if __name__ == "__main__":  1867     Manager()()  1868   1869 # vim: tabstop=4 expandtab shiftwidth=4