imip-agent

imip_manager.py

293:0583da64fbda
2015-02-08 Paul Boddie Handle missing end datetimes when editing events.
     1 #!/usr/bin/env python     2      3 """     4 A Web interface to a user's calendar.     5      6 Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>     7      8 This program is free software; you can redistribute it and/or modify it under     9 the terms of the GNU General Public License as published by the Free Software    10 Foundation; either version 3 of the License, or (at your option) any later    11 version.    12     13 This program is distributed in the hope that it will be useful, but WITHOUT    14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS    15 FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more    16 details.    17     18 You should have received a copy of the GNU General Public License along with    19 this program.  If not, see <http://www.gnu.org/licenses/>.    20 """    21     22 # Edit this path to refer to the location of the imiptools libraries, if    23 # necessary.    24     25 LIBRARY_PATH = "/var/lib/imip-agent"    26     27 from datetime import date, datetime, timedelta    28 import babel.dates    29 import cgi, os, sys    30     31 sys.path.append(LIBRARY_PATH)    32     33 from imiptools.content import Handler    34 from imiptools.data import get_address, get_uri, 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    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     # 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    489         if is_organiser:   490             t = self.handle_date_controls("dtstart")   491             if t:   492                 dtstart, attr = t   493                 update = update or self.set_datetime_in_object(dtstart, attr["TZID"], "DTSTART", obj)   494             else:   495                 return False   496    497             # Handle specified end datetimes.   498    499             if args.get("dtend-control", [None])[0] == "enable":   500                 t = self.handle_date_controls("dtend")   501                 if t:   502                     dtend, attr = t   503    504                     # Convert end dates to iCalendar "next day" dates.   505    506                     if not isinstance(dtend, datetime):   507                         dtend += timedelta(1)   508                     update = update or self.set_datetime_in_object(dtend, attr["TZID"], "DTEND", obj)   509                 else:   510                     return False   511    512             # Otherwise, treat the end date as the start date. Datetimes cannot   513             # be duplicated in such a way.   514    515             else:   516                 if isinstance(dtstart, datetime):   517                     return False   518                 dtend = dtstart + timedelta(1)   519                 update = update or self.set_datetime_in_object(dtend, attr["TZID"], "DTEND", obj)   520    521             if dtstart >= dtend:   522                 return False   523    524         # Process any action.   525    526         reply = args.has_key("reply")   527         discard = args.has_key("discard")   528         invite = args.has_key("invite")   529         cancel = args.has_key("cancel")   530         save = args.has_key("save")   531    532         if reply or invite or cancel:   533    534             handler = ManagerHandler(obj, self.user, self.messenger)   535    536             # Process the object and remove it from the list of requests.   537    538             if reply and handler.process_received_request(update) or \   539                (invite or cancel) and handler.process_created_request(invite and "REQUEST" or "CANCEL", update):   540    541                 self.remove_request(uid)   542    543         # Save single user events.   544    545         elif save:   546             self.store.set_event(self.user, uid, obj.to_node())   547             freebusy = self.store.get_freebusy(self.user)   548             self.remove_request(uid)   549    550         # Remove the request and the object.   551    552         elif discard:   553             self.remove_event(uid)   554             self.remove_request(uid)   555    556         else:   557             handled = False   558    559         # Upon handling an action, redirect to the main page.   560    561         if handled:   562             self.redirect(self.env.get_path())   563    564         return handled   565    566     def handle_date_controls(self, name):   567    568         """   569         Handle date control information for fields starting with 'name',   570         returning a (datetime, attr) tuple or None if the fields cannot be used   571         to construct a datetime object.   572         """   573    574         args = self.env.get_args()   575         tzid = self.get_tzid()   576    577         if args.has_key("%s-date" % name):   578             date = args["%s-date" % name][0]   579             hour = args.get("%s-hour" % name, [None])[0]   580             minute = args.get("%s-minute" % name, [None])[0]   581             second = args.get("%s-second" % name, [None])[0]   582             tzid = args.get("%s-tzid" % name, [tzid])[0]   583    584             time = (hour or minute or second) and "T%s%s%s" % (hour, minute, second) or ""   585             value = "%s%s" % (date, time)   586             attr = {"TZID" : tzid}   587             dt = get_datetime(value, attr)   588             if dt:   589                 return dt, attr   590    591         return None   592    593     def set_datetime_in_object(self, dt, tzid, property, obj):   594    595         """   596         Set 'dt' and 'tzid' for the given 'property' in 'obj', returning whether   597         an update has occurred.   598         """   599    600         if dt:   601             old_value = obj.get_value(property)   602             obj[property] = [get_datetime_item(dt, tzid)]   603             return format_datetime(dt) != old_value   604    605         return False   606    607     # Page fragment methods.   608    609     def show_request_controls(self, obj):   610    611         "Show form controls for a request concerning 'obj'."   612    613         page = self.page   614    615         is_organiser = obj.get_value("ORGANIZER") == self.user   616    617         attendees = obj.get_value_map("ATTENDEE")   618         is_attendee = attendees.has_key(self.user)   619         attendee_attr = attendees.get(self.user)   620    621         is_request = obj.get_value("UID") in self._get_requests()   622    623         have_other_attendees = len(attendees) > (is_attendee and 1 or 0)   624    625         # Show appropriate options depending on the role of the user.   626    627         if is_attendee and not is_organiser:   628             page.p("An action is required for this request:")   629    630             page.p()   631             page.input(name="reply", type="submit", value="Reply")   632             page.add(" ")   633             page.input(name="discard", type="submit", value="Discard")   634             page.p.close()   635    636         if is_organiser:   637             if have_other_attendees:   638                 page.p("As organiser, you can perform the following:")   639    640                 page.p()   641                 page.input(name="invite", type="submit", value="Invite")   642                 page.add(" ")   643                 if is_request:   644                     page.input(name="discard", type="submit", value="Discard")   645                 else:   646                     page.input(name="cancel", type="submit", value="Cancel")   647                 page.p.close()   648             else:   649                 page.p()   650                 page.input(name="save", type="submit", value="Save")   651                 page.add(" ")   652                 page.input(name="discard", type="submit", value="Discard")   653                 page.p.close()   654    655     property_items = [   656         ("SUMMARY", "Summary"),   657         ("DTSTART", "Start"),   658         ("DTEND", "End"),   659         ("ORGANIZER", "Organiser"),   660         ("ATTENDEE", "Attendee"),   661         ]   662    663     partstat_items = [   664         ("NEEDS-ACTION", "Not confirmed"),   665         ("ACCEPTED", "Attending"),   666         ("TENTATIVE", "Tentatively attending"),   667         ("DECLINED", "Not attending"),   668         ("DELEGATED", "Delegated"),   669         ]   670    671     def show_object_on_page(self, uid, obj):   672    673         """   674         Show the calendar object with the given 'uid' and representation 'obj'   675         on the current page.   676         """   677    678         page = self.page   679         page.form(method="POST")   680    681         # Obtain the user's timezone.   682    683         tzid = self.get_tzid()   684    685         # Provide controls to change the displayed object.   686    687         args = self.env.get_args()   688    689         t = self.handle_date_controls("dtstart")   690         if t:   691             dtstart, dtstart_attr = t   692         else:   693             dtstart, dtstart_attr = obj.get_datetime_item("DTSTART")   694    695         dtend, dtend_attr = None, {}   696    697         if args.get("dtend-control", [None])[0] == "enable":   698             t = self.handle_date_controls("dtend")   699             if t:   700                 dtend, dtend_attr = t   701         elif not args.has_key("dtend-control"):   702             dtend, dtend_attr = obj.get_datetime_item("DTEND")   703    704         # Change end dates to refer to the actual dates, not the iCalendar   705         # "next day" dates.   706    707         if dtend and not isinstance(dtend, datetime):   708             dtend -= timedelta(1)   709    710         if dtend and (isinstance(dtend, datetime) or dtstart != dtend):   711             page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable", checked="checked")   712             page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable")   713         else:   714             page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable")   715             page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable", checked="checked")   716    717         # Provide a summary of the object.   718    719         page.table(class_="object", cellspacing=5, cellpadding=5)   720         page.thead()   721         page.tr()   722         page.th("Event", class_="mainheading", colspan=2)   723         page.tr.close()   724         page.thead.close()   725         page.tbody()   726    727         is_organiser = obj.get_value("ORGANIZER") == self.user   728    729         for name, label in self.property_items:   730             page.tr()   731    732             # Handle datetimes specially.   733    734             if name in ["DTSTART", "DTEND"]:   735    736                 page.th(label, class_="objectheading %s" % name.lower())   737    738                 if name == "DTSTART":   739                     dt, attr, event_tzid = dtstart, dtstart_attr, dtstart_attr.get("TZID", tzid)   740                 else:   741                     dt, attr, event_tzid = dtend or dtstart, dtend_attr or dtstart_attr, (dtend_attr or dtstart_attr).get("TZID", tzid)   742    743                 strvalue = self.format_datetime(dt, "full")   744                 value = format_datetime(dt)   745    746                 if is_organiser:   747                     page.td(class_="objectvalue %s" % name.lower())   748                     if name == "DTEND":   749                         page.div(class_="disabled")   750                         page.label("Specify end date", for_="dtend-enable", class_="enable")   751                         page.div.close()   752    753                     page.div(class_="enabled")   754                     self._show_date_controls(name.lower(), value, attr, tzid)   755                     if name == "DTEND":   756                         page.label("End on same day", for_="dtend-disable", class_="disable")   757                     page.div.close()   758    759                     page.td.close()   760                 else:   761                     page.td(strvalue)   762    763                 page.tr.close()   764    765             # Handle the summary specially.   766    767             elif name == "SUMMARY":   768                 value = args.get("summary", [obj.get_value(name)])[0]   769    770                 page.th(label, class_="objectheading")   771                 page.td()   772                 if is_organiser:   773                     page.input(name="summary", type="text", value=value, size=80)   774                 else:   775                     page.add(value)   776                 page.td.close()   777                 page.tr.close()   778    779             # Handle potentially many values.   780    781             else:   782                 items = obj.get_items(name)   783                 if not items:   784                     continue   785    786                 page.th(label, class_="objectheading", rowspan=len(items))   787    788                 first = True   789    790                 for value, attr in items:   791                     if not first:   792                         page.tr()   793                     else:   794                         first = False   795    796                     if name in ("ATTENDEE", "ORGANIZER"):   797                         page.td(class_="objectattribute")   798                         page.add(value)   799                         page.add(" ")   800    801                         partstat = attr.get("PARTSTAT")   802                         if value == self.user and (not is_organiser or name == "ORGANIZER"):   803                             self._show_menu("partstat", partstat, self.partstat_items)   804                         else:   805                             page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat")   806                     else:   807                         page.td(class_="objectattribute")   808                         page.add(value)   809    810                     page.td.close()   811                     page.tr.close()   812    813         page.tbody.close()   814         page.table.close()   815    816         dtstart = format_datetime(obj.get_utc_datetime("DTSTART"))   817         dtend = format_datetime(obj.get_utc_datetime("DTEND"))   818    819         # Indicate whether there are conflicting events.   820    821         freebusy = self.store.get_freebusy(self.user)   822    823         if freebusy:   824    825             # Obtain any time zone details from the suggested event.   826    827             _dtstart, attr = obj.get_item("DTSTART")   828             tzid = attr.get("TZID", tzid)   829    830             # Show any conflicts.   831    832             for t in have_conflict(freebusy, [(dtstart, dtend)], True):   833                 start, end, found_uid = t[:3]   834    835                 # Provide details of any conflicting event.   836    837                 if uid != found_uid:   838                     start = self.format_datetime(to_timezone(get_datetime(start), tzid), "full")   839                     end = self.format_datetime(to_timezone(get_datetime(end), tzid), "full")   840                     page.p("Event conflicts with another from %s to %s: " % (start, end))   841    842                     # Show the event summary for the conflicting event.   843    844                     found_obj = self._get_object(found_uid)   845                     if found_obj:   846                         page.a(found_obj.get_value("SUMMARY"), href=self.env.new_url(found_uid))   847    848         self.show_request_controls(obj)   849         page.form.close()   850    851     def show_requests_on_page(self):   852    853         "Show requests for the current user."   854    855         # NOTE: This list could be more informative, but it is envisaged that   856         # NOTE: the requests would be visited directly anyway.   857    858         requests = self._get_requests()   859    860         self.page.div(id="pending-requests")   861    862         if requests:   863             self.page.p("Pending requests:")   864    865             self.page.ul()   866    867             for request in requests:   868                 obj = self._get_object(request)   869                 if obj:   870                     self.page.li()   871                     self.page.a(obj.get_value("SUMMARY"), href="#request-%s" % request)   872                     self.page.li.close()   873    874             self.page.ul.close()   875    876         else:   877             self.page.p("There are no pending requests.")   878    879         self.page.div.close()   880    881     def show_participants_on_page(self):   882    883         "Show participants for scheduling purposes."   884    885         args = self.env.get_args()   886         participants = args.get("participants", [])   887    888         try:   889             for name, value in args.items():   890                 if name.startswith("remove-participant-"):   891                     i = int(name[len("remove-participant-"):])   892                     del participants[i]   893                     break   894         except ValueError:   895             pass   896    897         # Trim empty participants.   898    899         while participants and not participants[-1].strip():   900             participants.pop()   901    902         # Show any specified participants together with controls to remove and   903         # add participants.   904    905         self.page.div(id="participants")   906    907         self.page.p("Participants for scheduling:")   908    909         for i, participant in enumerate(participants):   910             self.page.p()   911             self.page.input(name="participants", type="text", value=participant)   912             self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove")   913             self.page.p.close()   914    915         self.page.p()   916         self.page.input(name="participants", type="text")   917         self.page.input(name="add-participant", type="submit", value="Add")   918         self.page.p.close()   919    920         self.page.div.close()   921    922         return participants   923    924     # Full page output methods.   925    926     def show_object(self, path_info):   927    928         "Show an object request using the given 'path_info' for the current user."   929    930         uid = self._get_uid(path_info)   931         obj = self._get_object(uid)   932    933         if not obj:   934             return False   935    936         handled = self.handle_request(uid, obj)   937    938         if handled:   939             return True   940    941         self.new_page(title="Event")   942         self.show_object_on_page(uid, obj)   943    944         return True   945    946     def show_calendar(self):   947    948         "Show the calendar for the current user."   949    950         handled = self.handle_newevent()   951    952         self.new_page(title="Calendar")   953         page = self.page   954    955         # Form controls are used in various places on the calendar page.   956    957         page.form(method="POST")   958    959         self.show_requests_on_page()   960         participants = self.show_participants_on_page()   961    962         # Show a button for scheduling a new event.   963    964         page.p(class_="controls")   965         page.input(name="newevent", type="submit", value="New event", id="newevent")   966         page.input(name="reset", type="submit", value="Clear selections", id="reset")   967         page.p.close()   968    969         # Show controls for hiding empty days and busy slots.   970         # The positioning of the control, paragraph and table are important here.   971    972         page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D")   973         page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B")   974    975         page.p(class_="controls")   976         page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable")   977         page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable")   978         page.label("Show empty days", for_="showdays", class_="showdays disable")   979         page.label("Hide empty days", for_="showdays", class_="showdays enable")   980         page.p.close()   981    982         freebusy = self.store.get_freebusy(self.user)   983    984         if not freebusy:   985             page.p("No events scheduled.")   986             return   987    988         # Obtain the user's timezone.   989    990         tzid = self.get_tzid()   991    992         # Day view: start at the earliest known day and produce days until the   993         # latest known day, perhaps with expandable sections of empty days.   994    995         # Month view: start at the earliest known month and produce months until   996         # the latest known month, perhaps with expandable sections of empty   997         # months.   998    999         # Details of users to invite to new events could be superimposed on the  1000         # calendar.  1001   1002         # Requests are listed and linked to their tentative positions in the  1003         # calendar. Other participants are also shown.  1004   1005         request_summary = self._get_request_summary()  1006   1007         period_groups = [request_summary, freebusy]  1008         period_group_types = ["request", "freebusy"]  1009         period_group_sources = ["Pending requests", "Your schedule"]  1010   1011         for i, participant in enumerate(participants):  1012             period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant)))  1013             period_group_types.append("freebusy-part%d" % i)  1014             period_group_sources.append(participant)  1015   1016         groups = []  1017         group_columns = []  1018         group_types = period_group_types  1019         group_sources = period_group_sources  1020         all_points = set()  1021   1022         # Obtain time point information for each group of periods.  1023   1024         for periods in period_groups:  1025             periods = convert_periods(periods, tzid)  1026   1027             # Get the time scale with start and end points.  1028   1029             scale = get_scale(periods)  1030   1031             # Get the time slots for the periods.  1032   1033             slots = get_slots(scale)  1034   1035             # Add start of day time points for multi-day periods.  1036   1037             add_day_start_points(slots, tzid)  1038   1039             # Record the slots and all time points employed.  1040   1041             groups.append(slots)  1042             all_points.update([point for point, active in slots])  1043   1044         # Partition the groups into days.  1045   1046         days = {}  1047         partitioned_groups = []  1048         partitioned_group_types = []  1049         partitioned_group_sources = []  1050   1051         for slots, group_type, group_source in zip(groups, group_types, group_sources):  1052   1053             # Propagate time points to all groups of time slots.  1054   1055             add_slots(slots, all_points)  1056   1057             # Count the number of columns employed by the group.  1058   1059             columns = 0  1060   1061             # Partition the time slots by day.  1062   1063             partitioned = {}  1064   1065             for day, day_slots in partition_by_day(slots).items():  1066                 intervals = []  1067                 last = None  1068   1069                 for point, active in day_slots:  1070                     columns = max(columns, len(active))  1071                     if last:  1072                         intervals.append((last, point))  1073                     last = point  1074   1075                 if last:  1076                     intervals.append((last, None))  1077   1078                 if not days.has_key(day):  1079                     days[day] = set()  1080   1081                 # Convert each partition to a mapping from points to active  1082                 # periods.  1083   1084                 partitioned[day] = dict(day_slots)  1085   1086                 # Record the divisions or intervals within each day.  1087   1088                 days[day].update(intervals)  1089   1090             if group_type != "request" or columns:  1091                 group_columns.append(columns)  1092                 partitioned_groups.append(partitioned)  1093                 partitioned_group_types.append(group_type)  1094                 partitioned_group_sources.append(group_source)  1095   1096         # Add empty days.  1097   1098         add_empty_days(days, tzid)  1099   1100         # Show the controls permitting day selection.  1101   1102         self.show_calendar_day_controls(days)  1103   1104         # Show the calendar itself.  1105   1106         page.table(cellspacing=5, cellpadding=5, class_="calendar")  1107         self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns)  1108         self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns)  1109         page.table.close()  1110   1111         # End the form region.  1112   1113         page.form.close()  1114   1115     # More page fragment methods.  1116   1117     def show_calendar_day_controls(self, days):  1118   1119         "Show controls for the given 'days' in the calendar."  1120   1121         page = self.page  1122         slots = self.env.get_args().get("slot", [])  1123   1124         for day in days:  1125             value, identifier = self._day_value_and_identifier(day)  1126             self._slot_selector(value, identifier, slots)  1127   1128         # Generate a dynamic stylesheet to allow day selections to colour  1129         # specific days.  1130         # NOTE: The style details need to be coordinated with the static  1131         # NOTE: stylesheet.  1132   1133         page.style(type="text/css")  1134   1135         for day in days:  1136             daystr = format_datetime(day)  1137             page.add("""\  1138 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s,  1139 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s {  1140     background-color: #5f4;  1141     text-decoration: underline;  1142 }  1143 """ % (daystr, daystr, daystr, daystr))  1144   1145         page.style.close()  1146   1147     def show_calendar_participant_headings(self, group_types, group_sources, group_columns):  1148   1149         """  1150         Show headings for the participants and other scheduling contributors,  1151         defined by 'group_types', 'group_sources' and 'group_columns'.  1152         """  1153   1154         page = self.page  1155   1156         page.colgroup(span=1, id="columns-timeslot")  1157   1158         for group_type, columns in zip(group_types, group_columns):  1159             page.colgroup(span=max(columns, 1), id="columns-%s" % group_type)  1160   1161         page.thead()  1162         page.tr()  1163         page.th("", class_="emptyheading")  1164   1165         for group_type, source, columns in zip(group_types, group_sources, group_columns):  1166             page.th(source,  1167                 class_=(group_type == "request" and "requestheading" or "participantheading"),  1168                 colspan=max(columns, 1))  1169   1170         page.tr.close()  1171         page.thead.close()  1172   1173     def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns):  1174   1175         """  1176         Show calendar days, defined by a collection of 'days', the contributing  1177         period information as 'partitioned_groups' (partitioned by day), the  1178         'partitioned_group_types' indicating the kind of contribution involved,  1179         and the 'group_columns' defining the number of columns in each group.  1180         """  1181   1182         page = self.page  1183   1184         # Determine the number of columns required. Where participants provide  1185         # no columns for events, one still needs to be provided for the  1186         # participant itself.  1187   1188         all_columns = sum([max(columns, 1) for columns in group_columns])  1189   1190         # Determine the days providing time slots.  1191   1192         all_days = days.items()  1193         all_days.sort()  1194   1195         # Produce a heading and time points for each day.  1196   1197         for day, intervals in all_days:  1198             groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups]  1199             is_empty = True  1200   1201             for slots in groups_for_day:  1202                 if not slots:  1203                     continue  1204   1205                 for active in slots.values():  1206                     if active:  1207                         is_empty = False  1208                         break  1209   1210             page.thead(class_="separator%s" % (is_empty and " empty" or ""))  1211             page.tr()  1212             page.th(class_="dayheading container", colspan=all_columns+1)  1213             self._day_heading(day)  1214             page.th.close()  1215             page.tr.close()  1216             page.thead.close()  1217   1218             page.tbody(class_="points%s" % (is_empty and " empty" or ""))  1219             self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns)  1220             page.tbody.close()  1221   1222     def show_calendar_points(self, intervals, groups, group_types, group_columns):  1223   1224         """  1225         Show the time 'intervals' along with period information from the given  1226         'groups', having the indicated 'group_types', each with the number of  1227         columns given by 'group_columns'.  1228         """  1229   1230         page = self.page  1231   1232         # Obtain the user's timezone.  1233   1234         tzid = self.get_tzid()  1235   1236         # Produce a row for each interval.  1237   1238         intervals = list(intervals)  1239         intervals.sort()  1240   1241         for point, endpoint in intervals:  1242             continuation = point == get_start_of_day(point, tzid)  1243   1244             # Some rows contain no period details and are marked as such.  1245   1246             have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None)  1247   1248             css = " ".join(  1249                 ["slot"] +  1250                 (have_active and ["busy"] or ["empty"]) +  1251                 (continuation and ["daystart"] or [])  1252                 )  1253   1254             page.tr(class_=css)  1255             page.th(class_="timeslot")  1256             self._time_point(point, endpoint)  1257             page.th.close()  1258   1259             # Obtain slots for the time point from each group.  1260   1261             for columns, slots, group_type in zip(group_columns, groups, group_types):  1262                 active = slots and slots.get(point)  1263   1264                 # Where no periods exist for the given time interval, generate  1265                 # an empty cell. Where a participant provides no periods at all,  1266                 # the colspan is adjusted to be 1, not 0.  1267   1268                 if not active:  1269                     page.td(class_="empty container", colspan=max(columns, 1))  1270                     self._empty_slot(point, endpoint)  1271                     page.td.close()  1272                     continue  1273   1274                 slots = slots.items()  1275                 slots.sort()  1276                 spans = get_spans(slots)  1277   1278                 empty = 0  1279   1280                 # Show a column for each active period.  1281   1282                 for t in active:  1283                     if t and len(t) >= 2:  1284   1285                         # Flush empty slots preceding this one.  1286   1287                         if empty:  1288                             page.td(class_="empty container", colspan=empty)  1289                             self._empty_slot(point, endpoint)  1290                             page.td.close()  1291                             empty = 0  1292   1293                         start, end, uid, key = get_freebusy_details(t)  1294                         span = spans[key]  1295   1296                         # Produce a table cell only at the start of the period  1297                         # or when continued at the start of a day.  1298   1299                         if point == start or continuation:  1300   1301                             obj = self._get_object(uid)  1302   1303                             has_continued = continuation and point != start  1304                             will_continue = not ends_on_same_day(point, end, tzid)  1305                             is_organiser = obj and obj.get_value("ORGANIZER") == self.user  1306   1307                             css = " ".join(  1308                                 ["event"] +  1309                                 (has_continued and ["continued"] or []) +  1310                                 (will_continue and ["continues"] or []) +  1311                                 (is_organiser and ["organising"] or ["attending"])  1312                                 )  1313   1314                             # Only anchor the first cell of events.  1315   1316                             if point == start:  1317                                 page.td(class_=css, rowspan=span, id="%s-%s" % (group_type, uid))  1318                             else:  1319                                 page.td(class_=css, rowspan=span)  1320   1321                             if not obj:  1322                                 page.span("(Participant is busy)")  1323                             else:  1324                                 summary = obj.get_value("SUMMARY")  1325   1326                                 # Only link to events if they are not being  1327                                 # updated by requests.  1328   1329                                 if uid in self._get_requests() and group_type != "request":  1330                                     page.span(summary)  1331                                 else:  1332                                     href = "%s/%s" % (self.env.get_url().rstrip("/"), uid)  1333                                     page.a(summary, href=href)  1334   1335                             page.td.close()  1336                     else:  1337                         empty += 1  1338   1339                 # Pad with empty columns.  1340   1341                 empty = columns - len(active)  1342   1343                 if empty:  1344                     page.td(class_="empty container", colspan=empty)  1345                     self._empty_slot(point, endpoint)  1346                     page.td.close()  1347   1348             page.tr.close()  1349   1350     def _day_heading(self, day):  1351   1352         """  1353         Generate a heading for 'day' of the following form:  1354   1355         <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label>  1356         """  1357   1358         page = self.page  1359         daystr = format_datetime(day)  1360         value, identifier = self._day_value_and_identifier(day)  1361         page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier)  1362   1363     def _time_point(self, point, endpoint):  1364   1365         """  1366         Generate headings for the 'point' to 'endpoint' period of the following  1367         form:  1368   1369         <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label>  1370         <span class="endpoint">10:00:00 CET</span>  1371         """  1372   1373         page = self.page  1374         tzid = self.get_tzid()  1375         daystr = format_datetime(point.date())  1376         value, identifier = self._slot_value_and_identifier(point, endpoint)  1377         slots = self.env.get_args().get("slot", [])  1378         self._slot_selector(value, identifier, slots)  1379         page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier)  1380         page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint")  1381   1382     def _slot_selector(self, value, identifier, slots):  1383         reset = self.env.get_args().has_key("reset")  1384         page = self.page  1385         if not reset and value in slots:  1386             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked")  1387         else:  1388             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector")  1389   1390     def _empty_slot(self, point, endpoint):  1391         page = self.page  1392         value, identifier = self._slot_value_and_identifier(point, endpoint)  1393         page.label("Select/deselect period", class_="newevent popup", for_=identifier)  1394   1395     def _day_value_and_identifier(self, day):  1396         value = "%s-" % format_datetime(day)  1397         identifier = "day-%s" % value  1398         return value, identifier  1399   1400     def _slot_value_and_identifier(self, point, endpoint):  1401         value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "")  1402         identifier = "slot-%s" % value  1403         return value, identifier  1404   1405     def _show_menu(self, name, default, items):  1406         page = self.page  1407         values = self.env.get_args().get(name, [default])  1408         page.select(name=name)  1409         for v, label in items:  1410             if v in values:  1411                 page.option(label, value=v, selected="selected")  1412             else:  1413                 page.option(label, value=v)  1414         page.select.close()  1415   1416     def _show_date_controls(self, name, default, attr, tzid):  1417   1418         """  1419         Show date controls for a field with the given 'name' and 'default' value  1420         and 'attr', with the given 'tzid' being used if no other time regime  1421         information is provided.  1422         """  1423   1424         page = self.page  1425         args = self.env.get_args()  1426   1427         event_tzid = attr.get("TZID", tzid)  1428         dt = get_datetime(default, attr)  1429   1430         # Show dates for up to one week around the current date.  1431   1432         base = get_date(dt)  1433         items = []  1434         for i in range(-7, 8):  1435             d = base + timedelta(i)  1436             items.append((format_datetime(d), self.format_date(d, "full")))  1437   1438         self._show_menu("%s-date" % name, format_datetime(base), items)  1439   1440         # Show time details.  1441   1442         if isinstance(dt, datetime):  1443             hour = args.get("%s-hour" % name, "%02d" % dt.hour)  1444             minute = args.get("%s-minute" % name, "%02d" % dt.minute)  1445             second = args.get("%s-second" % name, "%02d" % dt.second)  1446             page.add(" ")  1447             page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2)  1448             page.add(":")  1449             page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2)  1450             page.add(":")  1451             page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2)  1452             page.add(" ")  1453             self._show_menu("%s-tzid" % name, event_tzid,  1454                 [(event_tzid, event_tzid)] + (  1455                 event_tzid != tzid and [(tzid, tzid)] or []  1456                 ))  1457   1458     # Incoming HTTP request direction.  1459   1460     def select_action(self):  1461   1462         "Select the desired action and show the result."  1463   1464         path_info = self.env.get_path_info().strip("/")  1465   1466         if not path_info:  1467             self.show_calendar()  1468         elif self.show_object(path_info):  1469             pass  1470         else:  1471             self.no_page()  1472   1473     def __call__(self):  1474   1475         "Interpret a request and show an appropriate response."  1476   1477         if not self.user:  1478             self.no_user()  1479         else:  1480             self.select_action()  1481   1482         # Write the headers and actual content.  1483   1484         print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding  1485         print >>self.out  1486         self.out.write(unicode(self.page).encode(self.encoding))  1487   1488 if __name__ == "__main__":  1489     Manager()()  1490   1491 # vim: tabstop=4 expandtab shiftwidth=4