imip-agent

imip_manager.py

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