imip-agent

imip_manager.py

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