imip-agent

imip_manager.py

186:b4e941d0b547
2015-01-28 Paul Boddie Added table structure: colgroups for participants, thead/tbody for days.
     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 import babel.dates    28 import cgi, os, sys    29     30 sys.path.append(LIBRARY_PATH)    31     32 from imiptools.content import Handler, get_address, \    33                               get_item, get_uri, get_utc_datetime, get_value, \    34                               get_value_map, get_values, parse_object, to_part    35 from imiptools.dates import format_datetime, get_datetime, get_start_of_day, \    36                             to_timezone    37 from imiptools.mail import Messenger    38 from imiptools.period import add_day_start_points, add_slots, convert_periods, \    39                              get_freebusy_details, \    40                              get_scale, have_conflict, get_slots, get_spans, \    41                              partition_by_day    42 from imiptools.profile import Preferences    43 from vCalendar import to_node    44 import markup    45 import imip_store    46     47 getenv = os.environ.get    48 setenv = os.environ.__setitem__    49     50 class CGIEnvironment:    51     52     "A CGI-compatible environment."    53     54     def __init__(self):    55         self.args = None    56         self.method = None    57         self.path = None    58         self.path_info = None    59         self.user = None    60     61     def get_args(self):    62         if self.args is None:    63             if self.get_method() != "POST":    64                 setenv("QUERY_STRING", "")    65             self.args = cgi.parse(keep_blank_values=True)    66         return self.args    67     68     def get_method(self):    69         if self.method is None:    70             self.method = getenv("REQUEST_METHOD") or "GET"    71         return self.method    72     73     def get_path(self):    74         if self.path is None:    75             self.path = getenv("SCRIPT_NAME") or ""    76         return self.path    77     78     def get_path_info(self):    79         if self.path_info is None:    80             self.path_info = getenv("PATH_INFO") or ""    81         return self.path_info    82     83     def get_user(self):    84         if self.user is None:    85             self.user = getenv("REMOTE_USER") or ""    86         return self.user    87     88     def get_output(self):    89         return sys.stdout    90     91     def get_url(self):    92         path = self.get_path()    93         path_info = self.get_path_info()    94         return "%s%s" % (path.rstrip("/"), path_info)    95     96     def new_url(self, path_info):    97         path = self.get_path()    98         return "%s/%s" % (path.rstrip("/"), path_info.lstrip("/"))    99    100 class ManagerHandler(Handler):   101    102     """   103     A content handler for use by the manager, as opposed to operating within the   104     mail processing pipeline.   105     """   106    107     def __init__(self, obj, user, messenger):   108         details, details_attr = obj.values()[0]   109         Handler.__init__(self, details)   110         self.obj = obj   111         self.user = user   112         self.messenger = messenger   113    114         self.organisers = map(get_address, self.get_values("ORGANIZER"))   115    116     # Communication methods.   117    118     def send_message(self, sender):   119    120         """   121         Create a full calendar object and send it to the organisers, sending a   122         copy to the 'sender'.   123         """   124    125         node = to_node(self.obj)   126         part = to_part("REPLY", [node])   127         message = self.messenger.make_outgoing_message([part], self.organisers, outgoing_bcc=sender)   128         self.messenger.sendmail(self.organisers, message.as_string(), outgoing_bcc=sender)   129    130     # Action methods.   131    132     def process_request(self, accept, update=False):   133    134         """   135         Process the current request for the given 'user', accepting any request   136         when 'accept' is true, declining requests otherwise. Return whether any   137         action was taken.   138    139         If 'update' is given, the sequence number will be incremented in order   140         to override any previous response.   141         """   142    143         # When accepting or declining, do so only on behalf of this user,   144         # preserving any other attributes set as an attendee.   145    146         for attendee, attendee_attr in self.get_items("ATTENDEE"):   147    148             if attendee == self.user:   149                 freebusy = self.store.get_freebusy(attendee)   150    151                 attendee_attr["PARTSTAT"] = accept and "ACCEPTED" or "DECLINED"   152                 if self.messenger and self.messenger.sender != get_address(attendee):   153                     attendee_attr["SENT-BY"] = get_uri(self.messenger.sender)   154                 self.details["ATTENDEE"] = [(attendee, attendee_attr)]   155                 if update:   156                     sequence = self.get_value("SEQUENCE") or "0"   157                     self.details["SEQUENCE"] = [(str(int(sequence) + 1), {})]   158                 self.update_dtstamp()   159    160                 self.send_message(get_address(attendee))   161    162                 return True   163    164         return False   165    166 class Manager:   167    168     "A simple manager application."   169    170     def __init__(self, messenger=None):   171         self.messenger = messenger or Messenger()   172    173         self.env = CGIEnvironment()   174         user = self.env.get_user()   175         self.user = user and get_uri(user) or None   176         self.preferences = None   177         self.locale = None   178         self.requests = None   179    180         self.out = self.env.get_output()   181         self.page = markup.page()   182         self.encoding = "utf-8"   183    184         self.store = imip_store.FileStore()   185         self.objects = {}   186    187         try:   188             self.publisher = imip_store.FilePublisher()   189         except OSError:   190             self.publisher = None   191    192     def _get_uid(self, path_info):   193         return path_info.lstrip("/").split("/", 1)[0]   194    195     def _get_object(self, uid):   196         if self.objects.has_key(uid):   197             return self.objects[uid]   198    199         f = uid and self.store.get_event(self.user, uid) or None   200    201         if not f:   202             return None   203    204         self.objects[uid] = obj = parse_object(f, "utf-8")   205    206         if not obj:   207             return None   208    209         return obj   210    211     def _get_details(self, obj):   212         details, details_attr = obj.values()[0]   213         return details   214    215     def _get_requests(self):   216         if self.requests is None:   217             self.requests = self.store.get_requests(self.user)   218         return self.requests   219    220     def _get_request_summary(self):   221         summary = []   222         for uid in self._get_requests():   223             obj = self._get_object(uid)   224             if obj:   225                 details = self._get_details(obj)   226                 summary.append((   227                     get_value(details, "DTSTART"),   228                     get_value(details, "DTEND"),   229                     uid   230                     ))   231         return summary   232    233     # Preference methods.   234    235     def get_user_locale(self):   236         if not self.locale:   237             self.locale = self.get_preferences().get("LANG", "C")   238         return self.locale   239    240     def get_preferences(self):   241         if not self.preferences:   242             self.preferences = Preferences(self.user)   243         return self.preferences   244    245     # Prettyprinting of dates and times.   246    247     def format_date(self, dt, format):   248         return self._format_datetime(babel.dates.format_date, dt, format)   249    250     def format_time(self, dt, format):   251         return self._format_datetime(babel.dates.format_time, dt, format)   252    253     def format_datetime(self, dt, format):   254         return self._format_datetime(babel.dates.format_datetime, dt, format)   255    256     def _format_datetime(self, fn, dt, format):   257         return fn(dt, format=format, locale=self.get_user_locale())   258    259     # Data management methods.   260    261     def remove_request(self, uid):   262         return self.store.dequeue_request(self.user, uid)   263    264     # Presentation methods.   265    266     def new_page(self, title):   267         self.page.init(title=title, charset=self.encoding)   268    269     def status(self, code, message):   270         self.header("Status", "%s %s" % (code, message))   271    272     def header(self, header, value):   273         print >>self.out, "%s: %s" % (header, value)   274    275     def no_user(self):   276         self.status(403, "Forbidden")   277         self.new_page(title="Forbidden")   278         self.page.p("You are not logged in and thus cannot access scheduling requests.")   279    280     def no_page(self):   281         self.status(404, "Not Found")   282         self.new_page(title="Not Found")   283         self.page.p("No page is provided at the given address.")   284    285     def redirect(self, url):   286         self.status(302, "Redirect")   287         self.header("Location", url)   288         self.new_page(title="Redirect")   289         self.page.p("Redirecting to: %s" % url)   290    291     # Request logic and page fragment methods.   292    293     def handle_request(self, uid, request, queued):   294    295         """   296         Handle actions involving the given 'uid' and 'request' object, where   297         'queued' indicates that the object has not yet been handled.   298         """   299    300         # Handle a submitted form.   301    302         args = self.env.get_args()   303         handled = True   304    305         accept = args.has_key("accept")   306         decline = args.has_key("decline")   307         update = not queued and args.has_key("update")   308    309         if accept or decline:   310    311             handler = ManagerHandler(request, self.user, self.messenger)   312    313             if handler.process_request(accept, update):   314    315                 # Remove the request from the list.   316    317                 self.remove_request(uid)   318    319         elif args.has_key("ignore"):   320    321             # Remove the request from the list.   322    323             self.remove_request(uid)   324    325         else:   326             handled = False   327    328         if handled:   329             self.redirect(self.env.get_path())   330    331         return handled   332    333     def show_request_form(self, obj, needs_action):   334    335         """   336         Show a form for a request concerning 'obj', indicating whether action is   337         needed if 'needs_action' is specified as a true value.   338         """   339    340         details = self._get_details(obj)   341    342         attendees = get_value_map(details, "ATTENDEE")   343         attendee_attr = attendees.get(self.user)   344    345         if attendee_attr:   346             partstat = attendee_attr.get("PARTSTAT")   347             if partstat == "ACCEPTED":   348                 self.page.p("This request has been accepted.")   349             elif partstat == "DECLINED":   350                 self.page.p("This request has been declined.")   351             else:   352                 self.page.p("This request has been ignored.")   353    354         if needs_action:   355             self.page.p("An action is required for this request:")   356         else:   357             self.page.p("This request can be updated as follows:")   358    359         self.page.form(method="POST")   360         self.page.p()   361         self.page.input(name="accept", type="submit", value="Accept")   362         self.page.add(" ")   363         self.page.input(name="decline", type="submit", value="Decline")   364         self.page.add(" ")   365         self.page.input(name="ignore", type="submit", value="Ignore")   366         if not needs_action:   367             self.page.input(name="update", type="hidden", value="true")   368         self.page.p.close()   369         self.page.form.close()   370    371     def show_object_on_page(self, uid, obj):   372    373         """   374         Show the calendar object with the given 'uid' and representation 'obj'   375         on the current page.   376         """   377    378         # Obtain the user's timezone.   379    380         prefs = self.get_preferences()   381         tzid = prefs.get("TZID", "UTC")   382    383         # Provide a summary of the object.   384    385         details = self._get_details(obj)   386    387         self.page.dl()   388    389         for name in ["SUMMARY", "DTSTART", "DTEND", "ORGANIZER", "ATTENDEE"]:   390             if name in ["DTSTART", "DTEND"]:   391                 value, attr = get_item(details, name)   392                 tzid = attr.get("TZID", tzid)   393                 value = self.format_datetime(to_timezone(get_datetime(value), tzid), "full")   394                 self.page.dt(name)   395                 self.page.dd(value)   396             else:   397                 for value in get_values(details, name):   398                     self.page.dt(name)   399                     self.page.dd(value)   400    401         self.page.dl.close()   402    403         dtstart = format_datetime(get_utc_datetime(details, "DTSTART"))   404         dtend = format_datetime(get_utc_datetime(details, "DTEND"))   405    406         # Indicate whether there are conflicting events.   407    408         freebusy = self.store.get_freebusy(self.user)   409    410         if freebusy:   411    412             # Obtain any time zone details from the suggested event.   413    414             _dtstart, attr = get_item(details, "DTSTART")   415             tzid = attr.get("TZID", tzid)   416    417             # Show any conflicts.   418    419             for t in have_conflict(freebusy, [(dtstart, dtend)], True):   420                 start, end, found_uid = t[:3]   421    422                 # Provide details of any conflicting event.   423    424                 if uid != found_uid:   425                     start = self.format_datetime(to_timezone(get_datetime(start), tzid), "full")   426                     end = self.format_datetime(to_timezone(get_datetime(end), tzid), "full")   427                     self.page.p("Event conflicts with another from %s to %s: " % (start, end))   428    429                     # Show the event summary for the conflicting event.   430    431                     found_obj = self._get_object(found_uid)   432                     if found_obj:   433                         found_details = self._get_details(found_obj)   434                         self.page.a(get_value(found_details, "SUMMARY"), href=self.env.new_url(found_uid))   435    436     def show_requests_on_page(self):   437    438         "Show requests for the current user."   439    440         # NOTE: This list could be more informative, but it is envisaged that   441         # NOTE: the requests would be visited directly anyway.   442    443         requests = self._get_requests()   444    445         self.page.div(id="pending-requests")   446    447         if requests:   448             self.page.p("Pending requests:")   449    450             self.page.ul()   451    452             for request in requests:   453                 obj = self._get_object(request)   454                 if obj:   455                     details = self._get_details(obj)   456                     self.page.li()   457                     self.page.a(get_value(details, "SUMMARY"), href="#request-%s" % request)   458                     self.page.li.close()   459    460             self.page.ul.close()   461    462         else:   463             self.page.p("There are no pending requests.")   464    465         self.page.div.close()   466    467     def show_participants_on_page(self):   468    469         "Show participants for scheduling purposes."   470    471         args = self.env.get_args()   472         participants = args.get("participants", [])   473    474         try:   475             for name, value in args.items():   476                 if name.startswith("remove-participant-"):   477                     i = int(name[len("remove-participant-"):])   478                     del participants[i]   479                     break   480         except ValueError:   481             pass   482    483         # Trim empty participants.   484    485         while participants and not participants[-1].strip():   486             participants.pop()   487    488         # Show any specified participants together with controls to remove and   489         # add participants.   490    491         self.page.div(id="participants")   492    493         self.page.form(method="POST")   494    495         self.page.p("Participants for scheduling:")   496    497         for i, participant in enumerate(participants):   498             self.page.p()   499             self.page.input(name="participants", type="text", value=participant)   500             self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove")   501             self.page.p.close()   502    503         self.page.p()   504         self.page.input(name="participants", type="text")   505         self.page.input(name="add-participant", type="submit", value="Add")   506         self.page.p.close()   507    508         self.page.form.close()   509    510         self.page.div.close()   511    512         return participants   513    514     # Full page output methods.   515    516     def show_object(self, path_info):   517    518         "Show an object request using the given 'path_info' for the current user."   519    520         uid = self._get_uid(path_info)   521         obj = self._get_object(uid)   522    523         if not obj:   524             return False   525    526         is_request = uid in self._get_requests()   527         handled = self.handle_request(uid, obj, is_request)   528    529         if handled:   530             return True   531    532         self.new_page(title="Event")   533    534         self.show_object_on_page(uid, obj)   535    536         self.show_request_form(obj, is_request and not handled)   537    538         return True   539    540     def show_calendar(self):   541    542         "Show the calendar for the current user."   543    544         self.new_page(title="Calendar")   545         page = self.page   546    547         self.show_requests_on_page()   548         participants = self.show_participants_on_page()   549    550         freebusy = self.store.get_freebusy(self.user)   551    552         if not freebusy:   553             page.p("No events scheduled.")   554             return   555    556         # Obtain the user's timezone.   557    558         prefs = self.get_preferences()   559         tzid = prefs.get("TZID", "UTC")   560    561         # Day view: start at the earliest known day and produce days until the   562         # latest known day, perhaps with expandable sections of empty days.   563    564         # Month view: start at the earliest known month and produce months until   565         # the latest known month, perhaps with expandable sections of empty   566         # months.   567    568         # Details of users to invite to new events could be superimposed on the   569         # calendar.   570    571         # Requests are listed and linked to their tentative positions in the   572         # calendar. Other participants are also shown.   573    574         request_summary = self._get_request_summary()   575    576         period_groups = [request_summary, freebusy]   577         period_group_types = ["request", "freebusy"]   578         period_group_sources = ["Pending requests", "Your schedule"]   579    580         for participant in participants:   581             period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant)))   582             period_group_types.append("freebusy")   583             period_group_sources.append(participant)   584    585         groups = []   586         group_columns = []   587         group_types = period_group_types   588         group_sources = period_group_sources   589         all_points = set()   590    591         # Obtain time point information for each group of periods.   592    593         for periods in period_groups:   594             periods = convert_periods(periods, tzid)   595    596             # Get the time scale with start and end points.   597    598             scale = get_scale(periods)   599    600             # Get the time slots for the periods.   601    602             slots = get_slots(scale)   603    604             # Add start of day time points for multi-day periods.   605    606             add_day_start_points(slots)   607    608             # Record the slots and all time points employed.   609    610             groups.append(slots)   611             all_points.update([point for point, slot in slots])   612    613         # Partition the groups into days.   614    615         days = {}   616         partitioned_groups = []   617         partitioned_group_types = []   618         partitioned_group_sources = []   619    620         for slots, group_type, group_source in zip(groups, group_types, group_sources):   621    622             # Propagate time points to all groups of time slots.   623    624             add_slots(slots, all_points)   625    626             # Count the number of columns employed by the group.   627    628             columns = 0   629    630             # Partition the time slots by day.   631    632             partitioned = {}   633    634             for day, day_slots in partition_by_day(slots).items():   635                 columns = max(columns, max(map(lambda i: len(i[1]), day_slots)))   636    637                 if not days.has_key(day):   638                     days[day] = set()   639    640                 # Convert each partition to a mapping from points to active   641                 # periods.   642    643                 day_slots = dict(day_slots)   644                 partitioned[day] = day_slots   645                 days[day].update(day_slots.keys())   646    647             if partitioned:   648                 group_columns.append(columns)   649                 partitioned_groups.append(partitioned)   650                 partitioned_group_types.append(group_type)   651                 partitioned_group_sources.append(group_source)   652    653         page.table(border=1, cellspacing=0, cellpadding=5)   654         self.show_calendar_participant_headings(partitioned_group_sources, group_columns)   655         self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns)   656         page.table.close()   657    658     def show_calendar_participant_headings(self, group_sources, group_columns):   659    660         """   661         Show headings for the participants and other scheduling contributors,   662         defined by 'group_sources' and 'group_columns'.   663         """   664    665         page = self.page   666    667         page.colgroup(span=1) # for datetime information   668    669         for columns in group_columns:   670             page.colgroup(span=columns)   671    672         page.thead()   673         page.tr()   674         page.th("", class_="emptyheading")   675    676         for source, columns in zip(group_sources, group_columns):   677             page.th(source, class_="participantheading", colspan=columns)   678    679         page.tr.close()   680         page.thead.close()   681    682     def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns):   683    684         """   685         Show calendar days, defined by a collection of 'days', the contributing   686         period information as 'partitioned_groups' (partitioned by day), the   687         'partitioned_group_types' indicating the kind of contribution involved,   688         and the 'group_columns' defining the number of columns in each group.   689         """   690    691         page = self.page   692    693         # Determine the number of columns required, the days providing time   694         # slots.   695    696         all_columns = sum(group_columns)   697         all_days = days.items()   698         all_days.sort()   699    700         # Produce a heading and time points for each day.   701    702         for day, points in all_days:   703             page.thead()   704             page.tr()   705             page.th(class_="dayheading", colspan=all_columns+1)   706             page.add(self.format_date(day, "full"))   707             page.th.close()   708             page.tr.close()   709             page.thead.close()   710    711             groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups]   712    713             page.tbody()   714             self.show_calendar_points(points, groups_for_day, partitioned_group_types, group_columns)   715             page.tbody.close()   716    717     def show_calendar_points(self, points, groups, group_types, group_columns):   718    719         """   720         Show the time 'points' along with period information from the given   721         'groups', having the indicated 'group_types', each with the number of   722         columns given by 'group_columns'.   723         """   724    725         page = self.page   726    727         # Produce a row for each time point.   728    729         points = list(points)   730         points.sort()   731    732         for point in points:   733             continuation = point == get_start_of_day(point)   734    735             page.tr()   736             page.th(class_="timeslot")   737             page.add(self.format_time(point, "long"))   738             page.th.close()   739    740             # Obtain slots for the time point from each group.   741    742             for columns, slots, group_type in zip(group_columns, groups, group_types):   743                 active = slots and slots.get(point)   744    745                 if not active:   746                     page.td(class_="empty", colspan=columns)   747                     page.td.close()   748                     continue   749    750                 slots = slots.items()   751                 slots.sort()   752                 spans = get_spans(slots)   753    754                 # Show a column for each active period.   755    756                 for t in active:   757                     if t and len(t) >= 2:   758                         start, end, uid, key = get_freebusy_details(t)   759                         span = spans[key]   760    761                         # Produce a table cell only at the start of the period   762                         # or when continued at the start of a day.   763    764                         if point == start or continuation:   765    766                             page.td(class_="event", rowspan=span)   767    768                             obj = self._get_object(uid)   769    770                             if not obj:   771                                 page.span("")   772                             else:   773                                 details = self._get_details(obj)   774                                 summary = get_value(details, "SUMMARY")   775    776                                 # Only link to events if they are not being   777                                 # updated by requests.   778    779                                 if uid in self._get_requests() and group_type != "request":   780                                     page.span(summary, id="%s-%s" % (group_type, uid))   781                                 else:   782                                     href = "%s/%s" % (self.env.get_url().rstrip("/"), uid)   783    784                                     # Only anchor the first cell of events.   785    786                                     if point == start:   787                                         page.a(summary, href=href, id="%s-%s" % (group_type, uid))   788                                     else:   789                                         page.a(summary, href=href)   790    791                             page.td.close()   792                     else:   793                         page.td(class_="empty")   794                         page.td.close()   795    796                 # Pad with empty columns.   797    798                 i = columns - len(active)   799                 while i > 0:   800                     i -= 1   801                     page.td(class_="empty")   802                     page.td.close()   803    804             page.tr.close()   805    806     def select_action(self):   807    808         "Select the desired action and show the result."   809    810         path_info = self.env.get_path_info().strip("/")   811    812         if not path_info:   813             self.show_calendar()   814         elif self.show_object(path_info):   815             pass   816         else:   817             self.no_page()   818    819     def __call__(self):   820    821         "Interpret a request and show an appropriate response."   822    823         if not self.user:   824             self.no_user()   825         else:   826             self.select_action()   827    828         # Write the headers and actual content.   829    830         print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding   831         print >>self.out   832         self.out.write(unicode(self.page).encode(self.encoding))   833    834 if __name__ == "__main__":   835     Manager()()   836    837 # vim: tabstop=4 expandtab shiftwidth=4