imip-agent

imip_manager.py

239:96006c733107
2015-02-03 Paul Boddie Added support for whole-day selection, creating events with date value types. Made get_datetime more flexible about parsing dates when VALUE is undefined. Added a get_datetime_item function to produce iCalendar attributes and values.
     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, make_freebusy, parse_object, \    35                            Object, to_part    36 from imiptools.dates import format_datetime, get_datetime, get_datetime_item, \    37                             get_start_of_day, get_start_of_next_day, get_timestamp, \    38                             ends_on_same_day, to_timezone    39 from imiptools.mail import Messenger    40 from imiptools.period import add_day_start_points, add_slots, convert_periods, \    41                              get_freebusy_details, \    42                              get_scale, have_conflict, get_slots, get_spans, \    43                              partition_by_day    44 from imiptools.profile import Preferences    45 import imip_store    46 import markup    47     48 getenv = os.environ.get    49 setenv = os.environ.__setitem__    50     51 class CGIEnvironment:    52     53     "A CGI-compatible environment."    54     55     def __init__(self, charset=None):    56         self.charset = charset    57         self.args = None    58         self.method = None    59         self.path = None    60         self.path_info = None    61         self.user = None    62     63     def get_args(self):    64         if self.args is None:    65             if self.get_method() != "POST":    66                 setenv("QUERY_STRING", "")    67             args = cgi.parse(keep_blank_values=True)    68     69             if not self.charset:    70                 self.args = args    71             else:    72                 self.args = {}    73                 for key, values in args.items():    74                     self.args[key] = [unicode(value, self.charset) for value in values]    75     76         return self.args    77     78     def get_method(self):    79         if self.method is None:    80             self.method = getenv("REQUEST_METHOD") or "GET"    81         return self.method    82     83     def get_path(self):    84         if self.path is None:    85             self.path = getenv("SCRIPT_NAME") or ""    86         return self.path    87     88     def get_path_info(self):    89         if self.path_info is None:    90             self.path_info = getenv("PATH_INFO") or ""    91         return self.path_info    92     93     def get_user(self):    94         if self.user is None:    95             self.user = getenv("REMOTE_USER") or ""    96         return self.user    97     98     def get_output(self):    99         return sys.stdout   100    101     def get_url(self):   102         path = self.get_path()   103         path_info = self.get_path_info()   104         return "%s%s" % (path.rstrip("/"), path_info)   105    106     def new_url(self, path_info):   107         path = self.get_path()   108         return "%s/%s" % (path.rstrip("/"), path_info.lstrip("/"))   109    110 class ManagerHandler(Handler):   111    112     """   113     A content handler for use by the manager, as opposed to operating within the   114     mail processing pipeline.   115     """   116    117     def __init__(self, obj, user, messenger):   118         Handler.__init__(self, messenger=messenger)   119         self.set_object(obj)   120         self.user = user   121    122         self.organiser = self.obj.get_value("ORGANIZER")   123         self.attendees = self.obj.get_values("ATTENDEE")   124    125     # Communication methods.   126    127     def send_message(self, method, sender):   128    129         """   130         Create a full calendar object employing the given 'method', and send it   131         to the appropriate recipients, also sending a copy to the 'sender'.   132         """   133    134         parts = [self.obj.to_part(method)]   135    136         if self.user == self.organiser:   137             recipients = map(get_address, self.attendees)   138         else:   139             recipients = [get_address(self.organiser)]   140    141         # Bundle free/busy information if appropriate.   142    143         preferences = Preferences(self.user)   144    145         if preferences.get("freebusy_sharing") == "share" and \   146            preferences.get("freebusy_bundling") == "always":   147    148             # Invent a unique identifier.   149    150             utcnow = get_timestamp()   151             uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user))   152    153             freebusy = self.store.get_freebusy(self.user)   154             parts.append(to_part("PUBLISH", [make_freebusy(freebusy, uid, self.user)]))   155    156         message = self.messenger.make_outgoing_message(parts, recipients, outgoing_bcc=sender)   157         self.messenger.sendmail(recipients, message.as_string(), outgoing_bcc=sender)   158    159     # Action methods.   160    161     def process_received_request(self, accept, update=False):   162    163         """   164         Process the current request for the given 'user', accepting any request   165         when 'accept' is true, declining requests otherwise. Return whether any   166         action was taken.   167    168         If 'update' is given, the sequence number will be incremented in order   169         to override any previous response.   170         """   171    172         # When accepting or declining, do so only on behalf of this user,   173         # preserving any other attributes set as an attendee.   174    175         for attendee, attendee_attr in self.obj.get_items("ATTENDEE"):   176    177             if attendee == self.user:   178                 attendee_attr["PARTSTAT"] = accept and "ACCEPTED" or "DECLINED"   179                 if self.messenger and self.messenger.sender != get_address(attendee):   180                     attendee_attr["SENT-BY"] = get_uri(self.messenger.sender)   181                 self.obj["ATTENDEE"] = [(attendee, attendee_attr)]   182                 if update:   183                     sequence = self.obj.get_value("SEQUENCE") or "0"   184                     self.obj["SEQUENCE"] = [(str(int(sequence) + 1), {})]   185                 self.update_dtstamp()   186    187                 self.send_message("REPLY", get_address(attendee))   188    189                 return True   190    191         return False   192    193     def process_created_request(self, update=False):   194    195         """   196         Process the current request for the given 'user', sending a created   197         request to attendees. Return whether any action was taken.   198    199         If 'update' is given, the sequence number will be incremented in order   200         to override any previous message.   201         """   202    203         organiser, organiser_attr = self.obj.get_item("ORGANIZER")   204    205         if self.messenger and self.messenger.sender != get_address(organiser):   206             organiser_attr["SENT-BY"] = get_uri(self.messenger.sender)   207         if update:   208             sequence = self.obj.get_value("SEQUENCE") or "0"   209             self.obj["SEQUENCE"] = [(str(int(sequence) + 1), {})]   210         self.update_dtstamp()   211    212         self.send_message("REQUEST", get_address(self.organiser))   213    214         return True   215    216 class Manager:   217    218     "A simple manager application."   219    220     def __init__(self, messenger=None):   221         self.messenger = messenger or Messenger()   222    223         self.encoding = "utf-8"   224         self.env = CGIEnvironment(self.encoding)   225    226         user = self.env.get_user()   227         self.user = user and get_uri(user) or None   228         self.preferences = None   229         self.locale = None   230         self.requests = None   231    232         self.out = self.env.get_output()   233         self.page = markup.page()   234    235         self.store = imip_store.FileStore()   236         self.objects = {}   237    238         try:   239             self.publisher = imip_store.FilePublisher()   240         except OSError:   241             self.publisher = None   242    243     def _get_uid(self, path_info):   244         return path_info.lstrip("/").split("/", 1)[0]   245    246     def _get_object(self, uid):   247         if self.objects.has_key(uid):   248             return self.objects[uid]   249    250         f = uid and self.store.get_event(self.user, uid) or None   251    252         if not f:   253             return None   254    255         fragment = parse_object(f, "utf-8")   256         obj = self.objects[uid] = fragment and Object(fragment)   257    258         return obj   259    260     def _get_requests(self):   261         if self.requests is None:   262             self.requests = self.store.get_requests(self.user)   263         return self.requests   264    265     def _get_request_summary(self):   266         summary = []   267         for uid in self._get_requests():   268             obj = self._get_object(uid)   269             if obj:   270                 summary.append((   271                     obj.get_value("DTSTART"),   272                     obj.get_value("DTEND"),   273                     uid   274                     ))   275         return summary   276    277     # Preference methods.   278    279     def get_user_locale(self):   280         if not self.locale:   281             self.locale = self.get_preferences().get("LANG", "C")   282         return self.locale   283    284     def get_preferences(self):   285         if not self.preferences:   286             self.preferences = Preferences(self.user)   287         return self.preferences   288    289     # Prettyprinting of dates and times.   290    291     def format_date(self, dt, format):   292         return self._format_datetime(babel.dates.format_date, dt, format)   293    294     def format_time(self, dt, format):   295         return self._format_datetime(babel.dates.format_time, dt, format)   296    297     def format_datetime(self, dt, format):   298         return self._format_datetime(   299             isinstance(dt, datetime) and babel.dates.format_datetime or babel.dates.format_date,   300             dt, format)   301    302     def format_end_datetime(self, dt, format):   303         if isinstance(dt, date) and not isinstance(dt, datetime):   304             dt = dt - timedelta(1)   305         return self.format_datetime(dt, format)   306    307     def _format_datetime(self, fn, dt, format):   308         return fn(dt, format=format, locale=self.get_user_locale())   309    310     # Data management methods.   311    312     def remove_request(self, uid):   313         return self.store.dequeue_request(self.user, uid)   314    315     def remove_event(self, uid):   316         return self.store.remove_event(self.user, uid)   317    318     # Presentation methods.   319    320     def new_page(self, title):   321         self.page.init(title=title, charset=self.encoding, css=self.env.new_url("styles.css"))   322    323     def status(self, code, message):   324         self.header("Status", "%s %s" % (code, message))   325    326     def header(self, header, value):   327         print >>self.out, "%s: %s" % (header, value)   328    329     def no_user(self):   330         self.status(403, "Forbidden")   331         self.new_page(title="Forbidden")   332         self.page.p("You are not logged in and thus cannot access scheduling requests.")   333    334     def no_page(self):   335         self.status(404, "Not Found")   336         self.new_page(title="Not Found")   337         self.page.p("No page is provided at the given address.")   338    339     def redirect(self, url):   340         self.status(302, "Redirect")   341         self.header("Location", url)   342         self.new_page(title="Redirect")   343         self.page.p("Redirecting to: %s" % url)   344    345     # Request logic and page fragment methods.   346    347     def handle_newevent(self):   348    349         """   350         Handle any new event operation, creating a new event and redirecting to   351         the event page for further activity.   352         """   353    354         # Handle a submitted form.   355    356         args = self.env.get_args()   357    358         if not args.has_key("newevent"):   359             return   360    361         # Create a new event using the available information.   362    363         slots = args.get("slot", [])   364         participants = args.get("participants", [])   365    366         if not slots:   367             return   368    369         # Coalesce the selected slots.   370    371         slots.sort()   372         coalesced = []   373         last = None   374    375         for slot in slots:   376             start, end = slot.split("-")   377             if last:   378                 if start == last[1]:   379                     last = last[0], end   380                     continue   381                 else:   382                     coalesced.append(last)   383             last = start, end   384    385         if last:   386             coalesced.append(last)   387    388         # Obtain the user's timezone.   389    390         prefs = self.get_preferences()   391         tzid = prefs.get("TZID", "UTC")   392    393         # Invent a unique identifier.   394    395         utcnow = get_timestamp()   396         uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user))   397    398         # Define a single occurrence if only one coalesced slot exists.   399         # Otherwise, many occurrences are defined.   400    401         for i, (start, end) in enumerate(coalesced):   402             this_uid = "%s-%s" % (uid, i)   403    404             start = get_datetime(start, {"TZID" : tzid})   405             end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid)   406    407             start_value, start_attr = get_datetime_item(start)   408             end_value, end_attr = get_datetime_item(end)   409    410             # Create a calendar object and store it as a request.   411    412             record = []   413             rwrite = record.append   414    415             rwrite(("UID", {}, this_uid))   416             rwrite(("SUMMARY", {}, "New event at %s" % utcnow))   417             rwrite(("DTSTAMP", {}, utcnow))   418             rwrite(("DTSTART", start_attr, start_value))   419             rwrite(("DTEND", end_attr, end_value))   420             rwrite(("ORGANIZER", {}, self.user))   421    422             for participant in participants:   423                 if not participant:   424                     continue   425                 participant = get_uri(participant)   426                 if participant != self.user:   427                     rwrite(("ATTENDEE", {}, participant))   428    429             obj = ("VEVENT", {}, record)   430    431             self.store.set_event(self.user, this_uid, obj)   432             self.store.queue_request(self.user, this_uid)   433    434         # Redirect to the object (or the first of the objects), where instead of   435         # attendee controls, there will be organiser controls.   436    437         self.redirect(self.env.new_url("%s-0" % uid))   438    439     def handle_request(self, uid, obj, queued):   440    441         """   442         Handle actions involving the given 'uid' and 'obj' object, where   443         'queued' indicates that the object has not yet been handled.   444         """   445    446         # Handle a submitted form.   447    448         args = self.env.get_args()   449         handled = True   450    451         # Update the object.   452    453         if args.has_key("summary"):   454             obj["SUMMARY"] = [(args["summary"][0], {})]   455    456         # Process any action.   457    458         accept = args.has_key("accept")   459         decline = args.has_key("decline")   460         invite = args.has_key("invite")   461         update = not queued and args.has_key("update")   462    463         if accept or decline or invite:   464    465             handler = ManagerHandler(obj, self.user, self.messenger)   466    467             # Process the object and remove it from the list of requests.   468    469             if (accept or decline) and handler.process_received_request(accept, update) or \   470                 invite and handler.process_created_request(update):   471    472                 self.remove_request(uid)   473    474         elif args.has_key("discard"):   475    476             # Remove the request and the object.   477    478             self.remove_event(uid)   479             self.remove_request(uid)   480    481         else:   482             handled = False   483    484         # Upon handling an action, redirect to the main page.   485    486         if handled:   487             self.redirect(self.env.get_path())   488    489         return handled   490    491     def show_request_controls(self, obj, needs_action):   492    493         """   494         Show form controls for a request concerning 'obj', indicating whether   495         action is needed if 'needs_action' is specified as a true value.   496         """   497    498         page = self.page   499    500         is_organiser = obj.get_value("ORGANIZER") == self.user   501    502         if not is_organiser:   503             attendees = obj.get_value_map("ATTENDEE")   504             attendee_attr = attendees.get(self.user)   505    506             if attendee_attr:   507                 partstat = attendee_attr.get("PARTSTAT")   508                 if partstat == "ACCEPTED":   509                     page.p("This request has been accepted.")   510                 elif partstat == "DECLINED":   511                     page.p("This request has been declined.")   512                 else:   513                     page.p("This request has not yet been dealt with.")   514    515         if needs_action:   516             page.p("An action is required for this request:")   517         else:   518             page.p("This request can be updated as follows:")   519    520         page.p()   521    522         # Show appropriate options depending on the role of the user.   523    524         if is_organiser:   525             page.input(name="invite", type="submit", value="Invite")   526         else:   527             page.input(name="accept", type="submit", value="Accept")   528             page.add(" ")   529             page.input(name="decline", type="submit", value="Decline")   530    531         page.add(" ")   532         page.input(name="discard", type="submit", value="Discard")   533    534         # Updated objects need to have details updated upon sending.   535    536         if not needs_action:   537             page.input(name="update", type="hidden", value="true")   538    539         page.p.close()   540    541     object_labels = {   542         "SUMMARY"   : "Summary",   543         "DTSTART"   : "Start",   544         "DTEND"     : "End",   545         "ORGANIZER" : "Organiser",   546         "ATTENDEE"  : "Attendee",   547         }   548    549     def show_object_on_page(self, uid, obj, needs_action):   550    551         """   552         Show the calendar object with the given 'uid' and representation 'obj'   553         on the current page.   554         """   555    556         page = self.page   557         page.form(method="POST")   558    559         # Obtain the user's timezone.   560    561         prefs = self.get_preferences()   562         tzid = prefs.get("TZID", "UTC")   563    564         # Provide a summary of the object.   565    566         page.table(class_="object", cellspacing=5, cellpadding=5)   567         page.thead()   568         page.tr()   569         page.th("Event", class_="mainheading", colspan=2)   570         page.tr.close()   571         page.thead.close()   572         page.tbody()   573    574         for name in ["SUMMARY", "DTSTART", "DTEND", "ORGANIZER", "ATTENDEE"]:   575             page.tr()   576    577             label = self.object_labels.get(name, name)   578    579             # Handle datetimes specially.   580    581             if name in ["DTSTART", "DTEND"]:   582                 value, attr = obj.get_item(name)   583                 tzid = attr.get("TZID", tzid)   584                 value = (   585                     name == "DTSTART" and self.format_datetime or self.format_end_datetime   586                     )(to_timezone(get_datetime(value), tzid), "full")   587                 page.th(label, class_="objectheading")   588                 page.td(value)   589                 page.tr.close()   590    591             # Handle the summary specially.   592    593             elif name == "SUMMARY":   594                 value = obj.get_value(name)   595                 page.th(label, class_="objectheading")   596                 page.td()   597                 page.input(name="summary", type="text", value=value, size=80)   598                 page.td.close()   599                 page.tr.close()   600    601             # Handle potentially many values.   602    603             else:   604                 items = obj.get_items(name)   605                 if not items:   606                     continue   607    608                 page.th(label, class_="objectheading", rowspan=len(items))   609    610                 first = True   611    612                 for value, attr in items:   613                     if not first:   614                         page.tr()   615                     else:   616                         first = False   617    618                     page.td()   619                     page.add(value)   620    621                     if name == "ATTENDEE":   622                         partstat = attr.get("PARTSTAT")   623                         if partstat:   624                             page.add(" (%s)" % partstat)   625    626                     page.td.close()   627                     page.tr.close()   628    629         page.tbody.close()   630         page.table.close()   631    632         dtstart = format_datetime(obj.get_utc_datetime("DTSTART"))   633         dtend = format_datetime(obj.get_utc_datetime("DTEND"))   634    635         # Indicate whether there are conflicting events.   636    637         freebusy = self.store.get_freebusy(self.user)   638    639         if freebusy:   640    641             # Obtain any time zone details from the suggested event.   642    643             _dtstart, attr = obj.get_item("DTSTART")   644             tzid = attr.get("TZID", tzid)   645    646             # Show any conflicts.   647    648             for t in have_conflict(freebusy, [(dtstart, dtend)], True):   649                 start, end, found_uid = t[:3]   650    651                 # Provide details of any conflicting event.   652    653                 if uid != found_uid:   654                     start = self.format_datetime(to_timezone(get_datetime(start), tzid), "full")   655                     end = self.format_datetime(to_timezone(get_datetime(end), tzid), "full")   656                     page.p("Event conflicts with another from %s to %s: " % (start, end))   657    658                     # Show the event summary for the conflicting event.   659    660                     found_obj = self._get_object(found_uid)   661                     if found_obj:   662                         page.a(found_obj.get_value("SUMMARY"), href=self.env.new_url(found_uid))   663    664         self.show_request_controls(obj, needs_action)   665         page.form.close()   666    667     def show_requests_on_page(self):   668    669         "Show requests for the current user."   670    671         # NOTE: This list could be more informative, but it is envisaged that   672         # NOTE: the requests would be visited directly anyway.   673    674         requests = self._get_requests()   675    676         self.page.div(id="pending-requests")   677    678         if requests:   679             self.page.p("Pending requests:")   680    681             self.page.ul()   682    683             for request in requests:   684                 obj = self._get_object(request)   685                 if obj:   686                     self.page.li()   687                     self.page.a(obj.get_value("SUMMARY"), href="#request-%s" % request)   688                     self.page.li.close()   689    690             self.page.ul.close()   691    692         else:   693             self.page.p("There are no pending requests.")   694    695         self.page.div.close()   696    697     def show_participants_on_page(self):   698    699         "Show participants for scheduling purposes."   700    701         args = self.env.get_args()   702         participants = args.get("participants", [])   703    704         try:   705             for name, value in args.items():   706                 if name.startswith("remove-participant-"):   707                     i = int(name[len("remove-participant-"):])   708                     del participants[i]   709                     break   710         except ValueError:   711             pass   712    713         # Trim empty participants.   714    715         while participants and not participants[-1].strip():   716             participants.pop()   717    718         # Show any specified participants together with controls to remove and   719         # add participants.   720    721         self.page.div(id="participants")   722    723         self.page.p("Participants for scheduling:")   724    725         for i, participant in enumerate(participants):   726             self.page.p()   727             self.page.input(name="participants", type="text", value=participant)   728             self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove")   729             self.page.p.close()   730    731         self.page.p()   732         self.page.input(name="participants", type="text")   733         self.page.input(name="add-participant", type="submit", value="Add")   734         self.page.p.close()   735    736         self.page.div.close()   737    738         return participants   739    740     # Full page output methods.   741    742     def show_object(self, path_info):   743    744         "Show an object request using the given 'path_info' for the current user."   745    746         uid = self._get_uid(path_info)   747         obj = self._get_object(uid)   748    749         if not obj:   750             return False   751    752         is_request = uid in self._get_requests()   753         handled = self.handle_request(uid, obj, is_request)   754    755         if handled:   756             return True   757    758         self.new_page(title="Event")   759         self.show_object_on_page(uid, obj, is_request and not handled)   760    761         return True   762    763     def show_calendar(self):   764    765         "Show the calendar for the current user."   766    767         handled = self.handle_newevent()   768    769         self.new_page(title="Calendar")   770         page = self.page   771    772         # Form controls are used in various places on the calendar page.   773    774         page.form(method="POST")   775    776         self.show_requests_on_page()   777         participants = self.show_participants_on_page()   778    779         # Show a button for scheduling a new event.   780    781         page.p(class_="controls")   782         page.input(name="newevent", type="submit", value="New event", id="newevent")   783         page.p.close()   784    785         # Show controls for hiding empty and busy slots.   786         # The positioning of the control, paragraph and table are important here.   787    788         page.input(name="hideslots", type="checkbox", value="hide", id="hideslots")   789         page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy")   790    791         page.p(class_="controls")   792         page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable")   793         page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable")   794         page.label("Hide unused time periods", for_="hideslots", class_="hideslots enable")   795         page.label("Show unused time periods", for_="hideslots", class_="hideslots disable")   796         page.p.close()   797    798         freebusy = self.store.get_freebusy(self.user)   799    800         if not freebusy:   801             page.p("No events scheduled.")   802             return   803    804         # Obtain the user's timezone.   805    806         prefs = self.get_preferences()   807         tzid = prefs.get("TZID", "UTC")   808    809         # Day view: start at the earliest known day and produce days until the   810         # latest known day, perhaps with expandable sections of empty days.   811    812         # Month view: start at the earliest known month and produce months until   813         # the latest known month, perhaps with expandable sections of empty   814         # months.   815    816         # Details of users to invite to new events could be superimposed on the   817         # calendar.   818    819         # Requests are listed and linked to their tentative positions in the   820         # calendar. Other participants are also shown.   821    822         request_summary = self._get_request_summary()   823    824         period_groups = [request_summary, freebusy]   825         period_group_types = ["request", "freebusy"]   826         period_group_sources = ["Pending requests", "Your schedule"]   827    828         for i, participant in enumerate(participants):   829             period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant)))   830             period_group_types.append("freebusy-part%d" % i)   831             period_group_sources.append(participant)   832    833         groups = []   834         group_columns = []   835         group_types = period_group_types   836         group_sources = period_group_sources   837         all_points = set()   838    839         # Obtain time point information for each group of periods.   840    841         for periods in period_groups:   842             periods = convert_periods(periods, tzid)   843    844             # Get the time scale with start and end points.   845    846             scale = get_scale(periods)   847    848             # Get the time slots for the periods.   849    850             slots = get_slots(scale)   851    852             # Add start of day time points for multi-day periods.   853    854             add_day_start_points(slots)   855    856             # Record the slots and all time points employed.   857    858             groups.append(slots)   859             all_points.update([point for point, active in slots])   860    861         # Partition the groups into days.   862    863         days = {}   864         partitioned_groups = []   865         partitioned_group_types = []   866         partitioned_group_sources = []   867    868         for slots, group_type, group_source in zip(groups, group_types, group_sources):   869    870             # Propagate time points to all groups of time slots.   871    872             add_slots(slots, all_points)   873    874             # Count the number of columns employed by the group.   875    876             columns = 0   877    878             # Partition the time slots by day.   879    880             partitioned = {}   881    882             for day, day_slots in partition_by_day(slots).items():   883                 intervals = []   884                 last = None   885    886                 for point, active in day_slots:   887                     columns = max(columns, len(active))   888                     if last:   889                         intervals.append((last, point))   890                     last = point   891    892                 if last:   893                     intervals.append((last, None))   894    895                 if not days.has_key(day):   896                     days[day] = set()   897    898                 # Convert each partition to a mapping from points to active   899                 # periods.   900    901                 partitioned[day] = dict(day_slots)   902    903                 # Record the divisions or intervals within each day.   904    905                 days[day].update(intervals)   906    907             if group_type != "request" or columns:   908                 group_columns.append(columns)   909                 partitioned_groups.append(partitioned)   910                 partitioned_group_types.append(group_type)   911                 partitioned_group_sources.append(group_source)   912    913         page.table(cellspacing=5, cellpadding=5, class_="calendar")   914         self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns)   915         self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns)   916         page.table.close()   917    918         # End the form region.   919    920         page.form.close()   921    922     def show_calendar_participant_headings(self, group_types, group_sources, group_columns):   923    924         """   925         Show headings for the participants and other scheduling contributors,   926         defined by 'group_types', 'group_sources' and 'group_columns'.   927         """   928    929         page = self.page   930    931         page.colgroup(span=1, id="columns-timeslot")   932    933         for group_type, columns in zip(group_types, group_columns):   934             page.colgroup(span=max(columns, 1), id="columns-%s" % group_type)   935    936         page.thead()   937         page.tr()   938         page.th("", class_="emptyheading")   939    940         for group_type, source, columns in zip(group_types, group_sources, group_columns):   941             page.th(source,   942                 class_=(group_type == "request" and "requestheading" or "participantheading"),   943                 colspan=max(columns, 1))   944    945         page.tr.close()   946         page.thead.close()   947    948     def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns):   949    950         """   951         Show calendar days, defined by a collection of 'days', the contributing   952         period information as 'partitioned_groups' (partitioned by day), the   953         'partitioned_group_types' indicating the kind of contribution involved,   954         and the 'group_columns' defining the number of columns in each group.   955         """   956    957         page = self.page   958    959         # Determine the number of columns required. Where participants provide   960         # no columns for events, one still needs to be provided for the   961         # participant itself.   962    963         all_columns = sum([max(columns, 1) for columns in group_columns])   964    965         # Determine the days providing time slots.   966    967         all_days = days.items()   968         all_days.sort()   969    970         # Produce a heading and time points for each day.   971    972         for day, intervals in all_days:   973             page.thead()   974             page.tr()   975             page.th(class_="dayheading", colspan=all_columns+1)   976             self._day_heading(day)   977             page.th.close()   978             page.tr.close()   979             page.thead.close()   980    981             groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups]   982    983             page.tbody()   984             self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns)   985             page.tbody.close()   986    987     def show_calendar_points(self, intervals, groups, group_types, group_columns):   988    989         """   990         Show the time 'intervals' along with period information from the given   991         'groups', having the indicated 'group_types', each with the number of   992         columns given by 'group_columns'.   993         """   994    995         page = self.page   996    997         # Produce a row for each interval.   998    999         intervals = list(intervals)  1000         intervals.sort()  1001   1002         for point, endpoint in intervals:  1003             continuation = point == get_start_of_day(point)  1004   1005             # Some rows contain no period details and are marked as such.  1006   1007             have_active = reduce(lambda x, y: x or y, [slots.get(point) for slots in groups], None)  1008   1009             css = " ".join(  1010                 ["slot"] +  1011                 (have_active and ["busy"] or ["empty"]) +  1012                 (continuation and ["daystart"] or [])  1013                 )  1014   1015             page.tr(class_=css)  1016             page.th(class_="timeslot")  1017             self._time_point(point, endpoint)  1018             page.th.close()  1019   1020             # Obtain slots for the time point from each group.  1021   1022             for columns, slots, group_type in zip(group_columns, groups, group_types):  1023                 active = slots and slots.get(point)  1024   1025                 # Where no periods exist for the given time interval, generate  1026                 # an empty cell. Where a participant provides no periods at all,  1027                 # the colspan is adjusted to be 1, not 0.  1028   1029                 if not active:  1030                     page.td(class_="empty container", colspan=max(columns, 1))  1031                     self._empty_slot(point, endpoint)  1032                     page.td.close()  1033                     continue  1034   1035                 slots = slots.items()  1036                 slots.sort()  1037                 spans = get_spans(slots)  1038   1039                 # Show a column for each active period.  1040   1041                 for t in active:  1042                     if t and len(t) >= 2:  1043                         start, end, uid, key = get_freebusy_details(t)  1044                         span = spans[key]  1045   1046                         # Produce a table cell only at the start of the period  1047                         # or when continued at the start of a day.  1048   1049                         if point == start or continuation:  1050   1051                             has_continued = continuation and point != start  1052                             will_continue = not ends_on_same_day(point, end)  1053                             css = " ".join(  1054                                 ["event"] +  1055                                 (has_continued and ["continued"] or []) +  1056                                 (will_continue and ["continues"] or [])  1057                                 )  1058   1059                             # Only anchor the first cell of events.  1060   1061                             if point == start:  1062                                 page.td(class_=css, rowspan=span, id="%s-%s" % (group_type, uid))  1063                             else:  1064                                 page.td(class_=css, rowspan=span)  1065   1066                             obj = self._get_object(uid)  1067   1068                             if not obj:  1069                                 page.span("")  1070                             else:  1071                                 summary = obj.get_value("SUMMARY")  1072   1073                                 # Only link to events if they are not being  1074                                 # updated by requests.  1075   1076                                 if uid in self._get_requests() and group_type != "request":  1077                                     page.span(summary)  1078                                 else:  1079                                     href = "%s/%s" % (self.env.get_url().rstrip("/"), uid)  1080                                     page.a(summary, href=href)  1081   1082                             page.td.close()  1083                     else:  1084                         page.td(class_="empty container")  1085                         self._empty_slot(point, endpoint)  1086                         page.td.close()  1087   1088                 # Pad with empty columns.  1089   1090                 i = columns - len(active)  1091                 while i > 0:  1092                     i -= 1  1093                     page.td(class_="empty container")  1094                     self._empty_slot(point, endpoint)  1095                     page.td.close()  1096   1097             page.tr.close()  1098   1099     def _day_heading(self, day):  1100         page = self.page  1101         value, identifier = self._day_value_and_identifier(day)  1102         slots = self.env.get_args().get("slot", [])  1103         self._slot_selector(value, identifier, slots)  1104         page.label(self.format_date(day, "full"), class_="day", for_=identifier)  1105   1106     def _time_point(self, point, endpoint):  1107         page = self.page  1108         value, identifier = self._slot_value_and_identifier(point, endpoint)  1109         slots = self.env.get_args().get("slot", [])  1110         self._slot_selector(value, identifier, slots)  1111         page.label(self.format_time(point, "long"), class_="timepoint", for_=identifier)  1112   1113     def _slot_selector(self, value, identifier, slots):  1114         page = self.page  1115         if value in slots:  1116             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent", checked="checked")  1117         else:  1118             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent")  1119   1120     def _empty_slot(self, point, endpoint):  1121         page = self.page  1122         value, identifier = self._slot_value_and_identifier(point, endpoint)  1123         page.label("Select/deselect period", class_="newevent popup", for_=identifier)  1124   1125     def _day_value_and_identifier(self, day):  1126         value = "%s-" % format_datetime(day)  1127         identifier = "day-%s" % value  1128         return value, identifier  1129   1130     def _slot_value_and_identifier(self, point, endpoint):  1131         value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "")  1132         identifier = "slot-%s" % value  1133         return value, identifier  1134   1135     def select_action(self):  1136   1137         "Select the desired action and show the result."  1138   1139         path_info = self.env.get_path_info().strip("/")  1140   1141         if not path_info:  1142             self.show_calendar()  1143         elif self.show_object(path_info):  1144             pass  1145         else:  1146             self.no_page()  1147   1148     def __call__(self):  1149   1150         "Interpret a request and show an appropriate response."  1151   1152         if not self.user:  1153             self.no_user()  1154         else:  1155             self.select_action()  1156   1157         # Write the headers and actual content.  1158   1159         print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding  1160         print >>self.out  1161         self.out.write(unicode(self.page).encode(self.encoding))  1162   1163 if __name__ == "__main__":  1164     Manager()()  1165   1166 # vim: tabstop=4 expandtab shiftwidth=4