Code

change messages weren't being saved when there was no-one on the nosy list.
[roundup.git] / roundup / cgi_client.py
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17
18 # $Id: cgi_client.py,v 1.25 2001-08-29 05:30:49 richard Exp $
20 import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes
22 import roundupdb, htmltemplate, date, hyperdb
24 class Unauthorised(ValueError):
25     pass
27 class Client:
28     def __init__(self, out, db, env, user):
29         self.out = out
30         self.db = db
31         self.env = env
32         self.user = user
33         self.path = env['PATH_INFO']
34         self.split_path = self.path.split('/')
36         self.headers_done = 0
37         self.form = cgi.FieldStorage(environ=env)
38         self.headers_done = 0
39         self.debug = 0
41     def getuid(self):
42         return self.db.user.lookup(self.user)
44     def header(self, headers={'Content-Type':'text/html'}):
45         if not headers.has_key('Content-Type'):
46             headers['Content-Type'] = 'text/html'
47         for entry in headers.items():
48             self.out.write('%s: %s\n'%entry)
49         self.out.write('\n')
50         self.headers_done = 1
52     def pagehead(self, title, message=None):
53         url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
54         machine = self.env['SERVER_NAME']
55         port = self.env['SERVER_PORT']
56         if port != '80': machine = machine + ':' + port
57         base = urlparse.urlunparse(('http', machine, url, None, None, None))
58         if message is not None:
59             message = '<div class="system-msg">%s</div>'%message
60         else:
61             message = ''
62         style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
63         userid = self.db.user.lookup(self.user)
64         self.write('''<html><head>
65 <title>%s</title>
66 <style type="text/css">%s</style>
67 </head>
68 <body bgcolor=#ffffff>
69 %s
70 <table width=100%% border=0 cellspacing=0 cellpadding=2>
71 <tr class="location-bar"><td><big><strong>%s</strong></big>
72 (login: <a href="user%s">%s</a>)</td></tr>
73 </table>
74 '''%(title, style, message, title, userid, self.user))
76     def pagefoot(self):
77         if self.debug:
78             self.write('<hr><small><dl>')
79             self.write('<dt><b>Path</b></dt>')
80             self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
81             keys = self.form.keys()
82             keys.sort()
83             if keys:
84                 self.write('<dt><b>Form entries</b></dt>')
85                 for k in self.form.keys():
86                     v = str(self.form[k].value)
87                     self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
88             keys = self.env.keys()
89             keys.sort()
90             self.write('<dt><b>CGI environment</b></dt>')
91             for k in keys:
92                 v = self.env[k]
93                 self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
94             self.write('</dl></small>')
95         self.write('</body></html>')
97     def write(self, content):
98         if not self.headers_done:
99             self.header()
100         self.out.write(content)
102     def index_arg(self, arg):
103         ''' handle the args to index - they might be a list from the form
104             (ie. submitted from a form) or they might be a command-separated
105             single string (ie. manually constructed GET args)
106         '''
107         if self.form.has_key(arg):
108             arg =  self.form[arg]
109             if type(arg) == type([]):
110                 return [arg.value for arg in arg]
111             return arg.value.split(',')
112         return []
114     def index_filterspec(self):
115         ''' pull the index filter spec from the form
117         Links and multilinks want to be lists - the rest are straight
118         strings.
119         '''
120         props = self.db.classes[self.classname].getprops()
121         # all the form args not starting with ':' are filters
122         filterspec = {}
123         for key in self.form.keys():
124             if key[0] == ':': continue
125             prop = props[key]
126             value = self.form[key]
127             if (isinstance(prop, hyperdb.Link) or
128                     isinstance(prop, hyperdb.Multilink)):
129                 if type(value) == type([]):
130                     value = [arg.value for arg in value]
131                 else:
132                     value = value.value.split(',')
133                 l = filterspec.get(key, [])
134                 l = l + value
135                 filterspec[key] = l
136             else:
137                 filterspec[key] = value.value
138         return filterspec
140     default_index_sort = ['-activity']
141     default_index_group = ['priority']
142     default_index_filter = []
143     default_index_columns = ['id','activity','title','status','assignedto']
144     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
145     def index(self):
146         ''' put up an index
147         '''
148         self.classname = 'issue'
149         if self.form.has_key(':sort'): sort = self.index_arg(':sort')
150         else: sort = self.default_index_sort
151         if self.form.has_key(':group'): group = self.index_arg(':group')
152         else: group = self.default_index_group
153         if self.form.has_key(':filter'): filter = self.index_arg(':filter')
154         else: filter = self.default_index_filter
155         if self.form.has_key(':columns'): columns = self.index_arg(':columns')
156         else: columns = self.default_index_columns
157         filterspec = self.index_filterspec()
158         if not filterspec:
159             filterspec = self.default_index_filterspec
160         return self.list(columns=columns, filter=filter, group=group,
161             sort=sort, filterspec=filterspec)
163     # XXX deviates from spec - loses the '+' (that's a reserved character
164     # in URLS
165     def list(self, sort=None, group=None, filter=None, columns=None,
166             filterspec=None):
167         ''' call the template index with the args
169             :sort    - sort by prop name, optionally preceeded with '-'
170                      to give descending or nothing for ascending sorting.
171             :group   - group by prop name, optionally preceeded with '-' or
172                      to sort in descending or nothing for ascending order.
173             :filter  - selects which props should be displayed in the filter
174                      section. Default is all.
175             :columns - selects the columns that should be displayed.
176                      Default is all.
178         '''
179         cn = self.classname
180         self.pagehead('Index of %s'%cn)
181         if sort is None: sort = self.index_arg(':sort')
182         if group is None: group = self.index_arg(':group')
183         if filter is None: filter = self.index_arg(':filter')
184         if columns is None: columns = self.index_arg(':columns')
185         if filterspec is None: filterspec = self.index_filterspec()
187         htmltemplate.index(self, self.TEMPLATES, self.db, cn, filterspec,
188             filter, columns, sort, group)
189         self.pagefoot()
191     def shownode(self, message=None):
192         ''' display an item
193         '''
194         cn = self.classname
195         cl = self.db.classes[cn]
197         # possibly perform an edit
198         keys = self.form.keys()
199         num_re = re.compile('^\d+$')
200         if keys:
201             try:
202                 props, changed = parsePropsFromForm(cl, self.form, self.nodeid)
203                 cl.set(self.nodeid, **props)
204                 self._post_editnode(self.nodeid, changed)
205                 # and some nice feedback for the user
206                 message = '%s edited ok'%', '.join(changed)
207             except:
208                 s = StringIO.StringIO()
209                 traceback.print_exc(None, s)
210                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
212         # now the display
213         id = self.nodeid
214         if cl.getkey():
215             id = cl.get(id, cl.getkey())
216         self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
218         nodeid = self.nodeid
220         # use the template to display the item
221         htmltemplate.item(self, self.TEMPLATES, self.db, self.classname, nodeid)
222         self.pagefoot()
223     showissue = shownode
224     showmsg = shownode
226     def showuser(self, message=None):
227         ''' display an item
228         '''
229         if self.user in ('admin', self.db.user.get(self.nodeid, 'username')):
230             self.shownode(message)
231         else:
232             raise Unauthorised
234     def showfile(self):
235         ''' display a file
236         '''
237         nodeid = self.nodeid
238         cl = self.db.file
239         type = cl.get(nodeid, 'type')
240         if type == 'message/rfc822':
241             type = 'text/plain'
242         self.header(headers={'Content-Type': type})
243         self.write(cl.get(nodeid, 'content'))
245     def _createnode(self):
246         ''' create a node based on the contents of the form
247         '''
248         cl = self.db.classes[self.classname]
249         props, dummy = parsePropsFromForm(cl, self.form)
250         return cl.create(**props)
252     def _post_editnode(self, nid, changes=None):
253         ''' do the linking and message sending part of the node creation
254         '''
255         cn = self.classname
256         cl = self.db.classes[cn]
257         # link if necessary
258         keys = self.form.keys()
259         for key in keys:
260             if key == ':multilink':
261                 value = self.form[key].value
262                 if type(value) != type([]): value = [value]
263                 for value in value:
264                     designator, property = value.split(':')
265                     link, nodeid = roundupdb.splitDesignator(designator)
266                     link = self.db.classes[link]
267                     value = link.get(nodeid, property)
268                     value.append(nid)
269                     link.set(nodeid, **{property: value})
270             elif key == ':link':
271                 value = self.form[key].value
272                 if type(value) != type([]): value = [value]
273                 for value in value:
274                     designator, property = value.split(':')
275                     link, nodeid = roundupdb.splitDesignator(designator)
276                     link = self.db.classes[link]
277                     link.set(nodeid, **{property: nid})
279         # generate an edit message
280         # don't bother if there's no messages or nosy list 
281         props = cl.getprops()
282         note = None
283         if self.form.has_key('__note'):
284             note = self.form['__note']
285             note = note.value
286         send = len(cl.get(nid, 'nosy', [])) or note
287         if (send and props.has_key('messages') and
288                 isinstance(props['messages'], hyperdb.Multilink) and
289                 props['messages'].classname == 'msg'):
291             # handle the note
292             if note:
293                 if '\n' in note:
294                     summary = re.split(r'\n\r?', note)[0]
295                 else:
296                     summary = note
297                 m = ['%s\n'%note]
298             else:
299                 summary = 'This %s has been edited through the web.\n'%cn
300                 m = [summary]
302             first = 1
303             for name, prop in props.items():
304                 if changes is not None and name not in changes: continue
305                 if first:
306                     m.append('\n-------')
307                     first = 0
308                 value = cl.get(nid, name, None)
309                 if isinstance(prop, hyperdb.Link):
310                     link = self.db.classes[prop.classname]
311                     key = link.labelprop(default_to_id=1)
312                     if value is not None and key:
313                         value = link.get(value, key)
314                     else:
315                         value = '-'
316                 elif isinstance(prop, hyperdb.Multilink):
317                     if value is None: value = []
318                     l = []
319                     link = self.db.classes[prop.classname]
320                     key = link.labelprop(default_to_id=1)
321                     for entry in value:
322                         if key:
323                             l.append(link.get(entry, link.getkey()))
324                         else:
325                             l.append(entry)
326                     value = ', '.join(l)
327                 m.append('%s: %s'%(name, value))
329             # now create the message
330             content = '\n'.join(m)
331             message_id = self.db.msg.create(author=self.getuid(),
332                 recipients=[], date=date.Date('.'), summary=summary,
333                 content=content)
334             messages = cl.get(nid, 'messages')
335             messages.append(message_id)
336             props = {'messages': messages}
337             cl.set(nid, **props)
339     def newnode(self, message=None):
340         ''' Add a new node to the database.
341         
342         The form works in two modes: blank form and submission (that is,
343         the submission goes to the same URL). **Eventually this means that
344         the form will have previously entered information in it if
345         submission fails.
347         The new node will be created with the properties specified in the
348         form submission. For multilinks, multiple form entries are handled,
349         as are prop=value,value,value. You can't mix them though.
351         If the new node is to be referenced from somewhere else immediately
352         (ie. the new node is a file that is to be attached to a support
353         issue) then supply one of these arguments in addition to the usual
354         form entries:
355             :link=designator:property
356             :multilink=designator:property
357         ... which means that once the new node is created, the "property"
358         on the node given by "designator" should now reference the new
359         node's id. The node id will be appended to the multilink.
360         '''
361         cn = self.classname
362         cl = self.db.classes[cn]
364         # possibly perform a create
365         keys = self.form.keys()
366         if [i for i in keys if i[0] != ':']:
367             props = {}
368             try:
369                 nid = self._createnode()
370                 self._post_editnode(nid)
371                 # and some nice feedback for the user
372                 message = '%s created ok'%cn
373             except:
374                 s = StringIO.StringIO()
375                 traceback.print_exc(None, s)
376                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
377         self.pagehead('New %s'%self.classname.capitalize(), message)
378         htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
379             self.form)
380         self.pagefoot()
381     newissue = newnode
382     newuser = newnode
384     def newfile(self, message=None):
385         ''' Add a new file to the database.
386         
387         This form works very much the same way as newnode - it just has a
388         file upload.
389         '''
390         cn = self.classname
391         cl = self.db.classes[cn]
393         # possibly perform a create
394         keys = self.form.keys()
395         if [i for i in keys if i[0] != ':']:
396             try:
397                 file = self.form['content']
398                 self._post_editnode(cl.create(content=file.file.read(),
399                     type=mimetypes.guess_type(file.filename)[0],
400                     name=file.filename))
401                 # and some nice feedback for the user
402                 message = '%s created ok'%cn
403             except:
404                 s = StringIO.StringIO()
405                 traceback.print_exc(None, s)
406                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
408         self.pagehead('New %s'%self.classname.capitalize(), message)
409         htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
410             self.form)
411         self.pagefoot()
413     def classes(self, message=None):
414         ''' display a list of all the classes in the database
415         '''
416         if self.user == 'admin':
417             self.pagehead('Table of classes', message)
418             classnames = self.db.classes.keys()
419             classnames.sort()
420             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
421             for cn in classnames:
422                 cl = self.db.getclass(cn)
423                 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
424                 for key, value in cl.properties.items():
425                     if value is None: value = ''
426                     else: value = str(value)
427                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
428                         key, cgi.escape(value)))
429             self.write('</table>')
430             self.pagefoot()
431         else:
432             raise Unauthorised
434     def main(self, dre=re.compile(r'([^\d]+)(\d+)'), nre=re.compile(r'new(\w+)')):
435         path = self.split_path
436         if not path or path[0] in ('', 'index'):
437             self.index()
438         elif len(path) == 1:
439             if path[0] == 'list_classes':
440                 self.classes()
441                 return
442             m = dre.match(path[0])
443             if m:
444                 self.classname = m.group(1)
445                 self.nodeid = m.group(2)
446                 getattr(self, 'show%s'%self.classname)()
447                 return
448             m = nre.match(path[0])
449             if m:
450                 self.classname = m.group(1)
451                 getattr(self, 'new%s'%self.classname)()
452                 return
453             self.classname = path[0]
454             self.list()
455         else:
456             raise 'ValueError', 'Path not understood'
458     def __del__(self):
459         self.db.close()
461 def parsePropsFromForm(cl, form, nodeid=0):
462     '''Pull properties for the given class out of the form.
463     '''
464     props = {}
465     changed = []
466     keys = form.keys()
467     num_re = re.compile('^\d+$')
468     for key in keys:
469         if not cl.properties.has_key(key):
470             continue
471         proptype = cl.properties[key]
472         if isinstance(proptype, hyperdb.String):
473             value = form[key].value.strip()
474         elif isinstance(proptype, hyperdb.Date):
475             value = date.Date(form[key].value.strip())
476         elif isinstance(proptype, hyperdb.Interval):
477             value = date.Interval(form[key].value.strip())
478         elif isinstance(proptype, hyperdb.Link):
479             value = form[key].value.strip()
480             # handle key values
481             link = cl.properties[key].classname
482             if not num_re.match(value):
483                 try:
484                     value = self.db.classes[link].lookup(value)
485                 except:
486                     raise ValueError, 'property "%s": %s not a %s'%(
487                         key, value, link)
488         elif isinstance(proptype, hyperdb.Multilink):
489             value = form[key]
490             if type(value) != type([]):
491                 value = [i.strip() for i in value.value.split(',')]
492             else:
493                 value = [i.value.strip() for i in value]
494             link = cl.properties[key].classname
495             l = []
496             for entry in map(str, value):
497                 if not num_re.match(entry):
498                     try:
499                         entry = self.db.classes[link].lookup(entry)
500                     except:
501                         raise ValueError, \
502                             'property "%s": %s not a %s'%(key,
503                             entry, link)
504                 l.append(entry)
505             l.sort()
506             value = l
507         props[key] = value
508         # if changed, set it
509         if nodeid and value != cl.get(nodeid, key):
510             changed.append(key)
511             props[key] = value
512     return props, changed
515 # $Log: not supported by cvs2svn $
516 # Revision 1.24  2001/08/29 04:49:39  richard
517 # didn't clean up fully after debugging :(
519 # Revision 1.23  2001/08/29 04:47:18  richard
520 # Fixed CGI client change messages so they actually include the properties
521 # changed (again).
523 # Revision 1.22  2001/08/17 00:08:10  richard
524 # reverted back to sending messages always regardless of who is doing the web
525 # edit. change notes weren't being saved. bleah. hackish.
527 # Revision 1.21  2001/08/15 23:43:18  richard
528 # Fixed some isFooTypes that I missed.
529 # Refactored some code in the CGI code.
531 # Revision 1.20  2001/08/12 06:32:36  richard
532 # using isinstance(blah, Foo) now instead of isFooType
534 # Revision 1.19  2001/08/07 00:24:42  richard
535 # stupid typo
537 # Revision 1.18  2001/08/07 00:15:51  richard
538 # Added the copyright/license notice to (nearly) all files at request of
539 # Bizar Software.
541 # Revision 1.17  2001/08/02 06:38:17  richard
542 # Roundupdb now appends "mailing list" information to its messages which
543 # include the e-mail address and web interface address. Templates may
544 # override this in their db classes to include specific information (support
545 # instructions, etc).
547 # Revision 1.16  2001/08/02 05:55:25  richard
548 # Web edit messages aren't sent to the person who did the edit any more. No
549 # message is generated if they are the only person on the nosy list.
551 # Revision 1.15  2001/08/02 00:34:10  richard
552 # bleah syntax error
554 # Revision 1.14  2001/08/02 00:26:16  richard
555 # Changed the order of the information in the message generated by web edits.
557 # Revision 1.13  2001/07/30 08:12:17  richard
558 # Added time logging and file uploading to the templates.
560 # Revision 1.12  2001/07/30 06:26:31  richard
561 # Added some documentation on how the newblah works.
563 # Revision 1.11  2001/07/30 06:17:45  richard
564 # Features:
565 #  . Added ability for cgi newblah forms to indicate that the new node
566 #    should be linked somewhere.
567 # Fixed:
568 #  . Fixed the agument handling for the roundup-admin find command.
569 #  . Fixed handling of summary when no note supplied for newblah. Again.
570 #  . Fixed detection of no form in htmltemplate Field display.
572 # Revision 1.10  2001/07/30 02:37:34  richard
573 # Temporary measure until we have decent schema migration...
575 # Revision 1.9  2001/07/30 01:25:07  richard
576 # Default implementation is now "classic" rather than "extended" as one would
577 # expect.
579 # Revision 1.8  2001/07/29 08:27:40  richard
580 # Fixed handling of passed-in values in form elements (ie. during a
581 # drill-down)
583 # Revision 1.7  2001/07/29 07:01:39  richard
584 # Added vim command to all source so that we don't get no steenkin' tabs :)
586 # Revision 1.6  2001/07/29 04:04:00  richard
587 # Moved some code around allowing for subclassing to change behaviour.
589 # Revision 1.5  2001/07/28 08:16:52  richard
590 # New issue form handles lack of note better now.
592 # Revision 1.4  2001/07/28 00:34:34  richard
593 # Fixed some non-string node ids.
595 # Revision 1.3  2001/07/23 03:56:30  richard
596 # oops, missed a config removal
598 # Revision 1.2  2001/07/22 12:09:32  richard
599 # Final commit of Grande Splite
601 # Revision 1.1  2001/07/22 11:58:35  richard
602 # More Grande Splite
605 # vim: set filetype=python ts=4 sw=4 et si