Code

1cb3113307c71a1dfc8ddd60af2eb571941bbe54
[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.26 2001-09-12 08:31:42 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                 type = mimetypes.guess_type(file.filename)[0]
399                 if not type:
400                     type = "application/octet-stream"
401                 self._post_editnode(cl.create(content=file.file.read(),
402                     type=type, name=file.filename))
403                 # and some nice feedback for the user
404                 message = '%s created ok'%cn
405             except:
406                 s = StringIO.StringIO()
407                 traceback.print_exc(None, s)
408                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
410         self.pagehead('New %s'%self.classname.capitalize(), message)
411         htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
412             self.form)
413         self.pagefoot()
415     def classes(self, message=None):
416         ''' display a list of all the classes in the database
417         '''
418         if self.user == 'admin':
419             self.pagehead('Table of classes', message)
420             classnames = self.db.classes.keys()
421             classnames.sort()
422             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
423             for cn in classnames:
424                 cl = self.db.getclass(cn)
425                 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
426                 for key, value in cl.properties.items():
427                     if value is None: value = ''
428                     else: value = str(value)
429                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
430                         key, cgi.escape(value)))
431             self.write('</table>')
432             self.pagefoot()
433         else:
434             raise Unauthorised
436     def main(self, dre=re.compile(r'([^\d]+)(\d+)'), nre=re.compile(r'new(\w+)')):
437         path = self.split_path
438         if not path or path[0] in ('', 'index'):
439             self.index()
440         elif len(path) == 1:
441             if path[0] == 'list_classes':
442                 self.classes()
443                 return
444             m = dre.match(path[0])
445             if m:
446                 self.classname = m.group(1)
447                 self.nodeid = m.group(2)
448                 getattr(self, 'show%s'%self.classname)()
449                 return
450             m = nre.match(path[0])
451             if m:
452                 self.classname = m.group(1)
453                 getattr(self, 'new%s'%self.classname)()
454                 return
455             self.classname = path[0]
456             self.list()
457         else:
458             raise 'ValueError', 'Path not understood'
460     def __del__(self):
461         self.db.close()
463 def parsePropsFromForm(cl, form, nodeid=0):
464     '''Pull properties for the given class out of the form.
465     '''
466     props = {}
467     changed = []
468     keys = form.keys()
469     num_re = re.compile('^\d+$')
470     for key in keys:
471         if not cl.properties.has_key(key):
472             continue
473         proptype = cl.properties[key]
474         if isinstance(proptype, hyperdb.String):
475             value = form[key].value.strip()
476         elif isinstance(proptype, hyperdb.Date):
477             value = date.Date(form[key].value.strip())
478         elif isinstance(proptype, hyperdb.Interval):
479             value = date.Interval(form[key].value.strip())
480         elif isinstance(proptype, hyperdb.Link):
481             value = form[key].value.strip()
482             # handle key values
483             link = cl.properties[key].classname
484             if not num_re.match(value):
485                 try:
486                     value = self.db.classes[link].lookup(value)
487                 except:
488                     raise ValueError, 'property "%s": %s not a %s'%(
489                         key, value, link)
490         elif isinstance(proptype, hyperdb.Multilink):
491             value = form[key]
492             if type(value) != type([]):
493                 value = [i.strip() for i in value.value.split(',')]
494             else:
495                 value = [i.value.strip() for i in value]
496             link = cl.properties[key].classname
497             l = []
498             for entry in map(str, value):
499                 if not num_re.match(entry):
500                     try:
501                         entry = self.db.classes[link].lookup(entry)
502                     except:
503                         raise ValueError, \
504                             'property "%s": %s not a %s'%(key,
505                             entry, link)
506                 l.append(entry)
507             l.sort()
508             value = l
509         props[key] = value
510         # if changed, set it
511         if nodeid and value != cl.get(nodeid, key):
512             changed.append(key)
513             props[key] = value
514     return props, changed
517 # $Log: not supported by cvs2svn $
518 # Revision 1.25  2001/08/29 05:30:49  richard
519 # change messages weren't being saved when there was no-one on the nosy list.
521 # Revision 1.24  2001/08/29 04:49:39  richard
522 # didn't clean up fully after debugging :(
524 # Revision 1.23  2001/08/29 04:47:18  richard
525 # Fixed CGI client change messages so they actually include the properties
526 # changed (again).
528 # Revision 1.22  2001/08/17 00:08:10  richard
529 # reverted back to sending messages always regardless of who is doing the web
530 # edit. change notes weren't being saved. bleah. hackish.
532 # Revision 1.21  2001/08/15 23:43:18  richard
533 # Fixed some isFooTypes that I missed.
534 # Refactored some code in the CGI code.
536 # Revision 1.20  2001/08/12 06:32:36  richard
537 # using isinstance(blah, Foo) now instead of isFooType
539 # Revision 1.19  2001/08/07 00:24:42  richard
540 # stupid typo
542 # Revision 1.18  2001/08/07 00:15:51  richard
543 # Added the copyright/license notice to (nearly) all files at request of
544 # Bizar Software.
546 # Revision 1.17  2001/08/02 06:38:17  richard
547 # Roundupdb now appends "mailing list" information to its messages which
548 # include the e-mail address and web interface address. Templates may
549 # override this in their db classes to include specific information (support
550 # instructions, etc).
552 # Revision 1.16  2001/08/02 05:55:25  richard
553 # Web edit messages aren't sent to the person who did the edit any more. No
554 # message is generated if they are the only person on the nosy list.
556 # Revision 1.15  2001/08/02 00:34:10  richard
557 # bleah syntax error
559 # Revision 1.14  2001/08/02 00:26:16  richard
560 # Changed the order of the information in the message generated by web edits.
562 # Revision 1.13  2001/07/30 08:12:17  richard
563 # Added time logging and file uploading to the templates.
565 # Revision 1.12  2001/07/30 06:26:31  richard
566 # Added some documentation on how the newblah works.
568 # Revision 1.11  2001/07/30 06:17:45  richard
569 # Features:
570 #  . Added ability for cgi newblah forms to indicate that the new node
571 #    should be linked somewhere.
572 # Fixed:
573 #  . Fixed the agument handling for the roundup-admin find command.
574 #  . Fixed handling of summary when no note supplied for newblah. Again.
575 #  . Fixed detection of no form in htmltemplate Field display.
577 # Revision 1.10  2001/07/30 02:37:34  richard
578 # Temporary measure until we have decent schema migration...
580 # Revision 1.9  2001/07/30 01:25:07  richard
581 # Default implementation is now "classic" rather than "extended" as one would
582 # expect.
584 # Revision 1.8  2001/07/29 08:27:40  richard
585 # Fixed handling of passed-in values in form elements (ie. during a
586 # drill-down)
588 # Revision 1.7  2001/07/29 07:01:39  richard
589 # Added vim command to all source so that we don't get no steenkin' tabs :)
591 # Revision 1.6  2001/07/29 04:04:00  richard
592 # Moved some code around allowing for subclassing to change behaviour.
594 # Revision 1.5  2001/07/28 08:16:52  richard
595 # New issue form handles lack of note better now.
597 # Revision 1.4  2001/07/28 00:34:34  richard
598 # Fixed some non-string node ids.
600 # Revision 1.3  2001/07/23 03:56:30  richard
601 # oops, missed a config removal
603 # Revision 1.2  2001/07/22 12:09:32  richard
604 # Final commit of Grande Splite
606 # Revision 1.1  2001/07/22 11:58:35  richard
607 # More Grande Splite
610 # vim: set filetype=python ts=4 sw=4 et si