Code

Fixed some isFooTypes that I missed.
[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.21 2001-08-15 23:43:18 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)
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         # see if we want to send a message to the nosy list...
280         props = cl.getprops()
281         # don't do the message thing if there's no nosy list, or the editor
282         # of the node is the only person on the nosy list - they're already
283         # aware of the change.
284         nosy = 0
285         if props.has_key('nosy'):
286             nosy = cl.get(nid, 'nosy')
287             uid = self.getuid()
288             if len(nosy) == 1 and uid in nosy:
289                 nosy = 0
290         if (nosy and props.has_key('messages') and
291                 isinstance(props['messages'], hyperdb.Multilink) and
292                 props['messages'].classname == 'msg'):
294             # handle the note
295             note = None
296             if self.form.has_key('__note'):
297                 note = self.form['__note']
298             if note is not None and note.value:
299                 note = note.value
300                 if '\n' in note:
301                     summary = re.split(r'\n\r?', note)[0]
302                 else:
303                     summary = note
304                 m = ['%s\n'%note]
305             else:
306                 summary = 'This %s has been edited through the web.\n'%cn
307                 m = [summary]
309             # generate an edit message - nosyreactor will send it
310             first = 1
311             for name, prop in props.items():
312                 if changes is not None and name not in changes: continue
313                 if first:
314                     m.append('\n-------')
315                     first = 0
316                 value = cl.get(nid, name, None)
317                 if isinstance(prop, hyperdb.Link):
318                     link = self.db.classes[prop.classname]
319                     key = link.labelprop(default_to_id=1)
320                     if value is not None and key:
321                         value = link.get(value, key)
322                     else:
323                         value = '-'
324                 elif isinstance(prop, hyperdb.Multilink):
325                     if value is None: value = []
326                     l = []
327                     link = self.db.classes[prop.classname]
328                     key = link.labelprop(default_to_id=1)
329                     for entry in value:
330                         if key:
331                             l.append(link.get(entry, link.getkey()))
332                         else:
333                             l.append(entry)
334                     value = ', '.join(l)
335                 m.append('%s: %s'%(name, value))
337             # now create the message
338             content = '\n'.join(m)
339             message_id = self.db.msg.create(author=self.getuid(),
340                 recipients=[], date=date.Date('.'), summary=summary,
341                 content=content)
342             messages = cl.get(nid, 'messages')
343             messages.append(message_id)
344             props = {'messages': messages}
345             cl.set(nid, **props)
347     def newnode(self, message=None):
348         ''' Add a new node to the database.
349         
350         The form works in two modes: blank form and submission (that is,
351         the submission goes to the same URL). **Eventually this means that
352         the form will have previously entered information in it if
353         submission fails.
355         The new node will be created with the properties specified in the
356         form submission. For multilinks, multiple form entries are handled,
357         as are prop=value,value,value. You can't mix them though.
359         If the new node is to be referenced from somewhere else immediately
360         (ie. the new node is a file that is to be attached to a support
361         issue) then supply one of these arguments in addition to the usual
362         form entries:
363             :link=designator:property
364             :multilink=designator:property
365         ... which means that once the new node is created, the "property"
366         on the node given by "designator" should now reference the new
367         node's id. The node id will be appended to the multilink.
368         '''
369         cn = self.classname
370         cl = self.db.classes[cn]
372         # possibly perform a create
373         keys = self.form.keys()
374         if [i for i in keys if i[0] != ':']:
375             props = {}
376             try:
377                 nid = self._createnode()
378                 self._post_editnode(nid)
379                 # and some nice feedback for the user
380                 message = '%s created ok'%cn
381             except:
382                 s = StringIO.StringIO()
383                 traceback.print_exc(None, s)
384                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
385         self.pagehead('New %s'%self.classname.capitalize(), message)
386         htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
387             self.form)
388         self.pagefoot()
389     newissue = newnode
390     newuser = newnode
392     def newfile(self, message=None):
393         ''' Add a new file to the database.
394         
395         This form works very much the same way as newnode - it just has a
396         file upload.
397         '''
398         cn = self.classname
399         cl = self.db.classes[cn]
401         # possibly perform a create
402         keys = self.form.keys()
403         if [i for i in keys if i[0] != ':']:
404             try:
405                 file = self.form['content']
406                 self._post_editnode(cl.create(content=file.file.read(),
407                     type=mimetypes.guess_type(file.filename)[0],
408                     name=file.filename))
409                 # and some nice feedback for the user
410                 message = '%s created ok'%cn
411             except:
412                 s = StringIO.StringIO()
413                 traceback.print_exc(None, s)
414                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
416         self.pagehead('New %s'%self.classname.capitalize(), message)
417         htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
418             self.form)
419         self.pagefoot()
421     def classes(self, message=None):
422         ''' display a list of all the classes in the database
423         '''
424         if self.user == 'admin':
425             self.pagehead('Table of classes', message)
426             classnames = self.db.classes.keys()
427             classnames.sort()
428             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
429             for cn in classnames:
430                 cl = self.db.getclass(cn)
431                 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
432                 for key, value in cl.properties.items():
433                     if value is None: value = ''
434                     else: value = str(value)
435                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
436                         key, cgi.escape(value)))
437             self.write('</table>')
438             self.pagefoot()
439         else:
440             raise Unauthorised
442     def main(self, dre=re.compile(r'([^\d]+)(\d+)'), nre=re.compile(r'new(\w+)')):
443         path = self.split_path
444         if not path or path[0] in ('', 'index'):
445             self.index()
446         elif len(path) == 1:
447             if path[0] == 'list_classes':
448                 self.classes()
449                 return
450             m = dre.match(path[0])
451             if m:
452                 self.classname = m.group(1)
453                 self.nodeid = m.group(2)
454                 getattr(self, 'show%s'%self.classname)()
455                 return
456             m = nre.match(path[0])
457             if m:
458                 self.classname = m.group(1)
459                 getattr(self, 'new%s'%self.classname)()
460                 return
461             self.classname = path[0]
462             self.list()
463         else:
464             raise 'ValueError', 'Path not understood'
466     def __del__(self):
467         self.db.close()
469 def parsePropsFromForm(cl, form, note_changed=0):
470     '''Pull properties for the given class out of the form.
471     '''
472     props = {}
473     changed = []
474     keys = form.keys()
475     num_re = re.compile('^\d+$')
476     for key in keys:
477         if not cl.properties.has_key(key):
478             continue
479         proptype = cl.properties[key]
480         if isinstance(proptype, hyperdb.String):
481             value = form[key].value.strip()
482         elif isinstance(proptype, hyperdb.Date):
483             value = date.Date(form[key].value.strip())
484         elif isinstance(proptype, hyperdb.Interval):
485             value = date.Interval(form[key].value.strip())
486         elif isinstance(proptype, hyperdb.Link):
487             value = form[key].value.strip()
488             # handle key values
489             link = cl.properties[key].classname
490             if not num_re.match(value):
491                 try:
492                     value = self.db.classes[link].lookup(value)
493                 except:
494                     raise ValueError, 'property "%s": %s not a %s'%(
495                         key, value, link)
496         elif isinstance(proptype, hyperdb.Multilink):
497             value = form[key]
498             if type(value) != type([]):
499                 value = [i.strip() for i in value.value.split(',')]
500             else:
501                 value = [i.value.strip() for i in value]
502             link = cl.properties[key].classname
503             l = []
504             for entry in map(str, value):
505                 if not num_re.match(entry):
506                     try:
507                         entry = self.db.classes[link].lookup(entry)
508                     except:
509                         raise ValueError, \
510                             'property "%s": %s not a %s'%(key,
511                             entry, link)
512                 l.append(entry)
513             l.sort()
514             value = l
515         props[key] = value
516         # if changed, set it
517         if note_changed and value != cl.get(self.nodeid, key):
518             changed.append(key)
519             props[key] = value
520     return props, changed
523 # $Log: not supported by cvs2svn $
524 # Revision 1.20  2001/08/12 06:32:36  richard
525 # using isinstance(blah, Foo) now instead of isFooType
527 # Revision 1.19  2001/08/07 00:24:42  richard
528 # stupid typo
530 # Revision 1.18  2001/08/07 00:15:51  richard
531 # Added the copyright/license notice to (nearly) all files at request of
532 # Bizar Software.
534 # Revision 1.17  2001/08/02 06:38:17  richard
535 # Roundupdb now appends "mailing list" information to its messages which
536 # include the e-mail address and web interface address. Templates may
537 # override this in their db classes to include specific information (support
538 # instructions, etc).
540 # Revision 1.16  2001/08/02 05:55:25  richard
541 # Web edit messages aren't sent to the person who did the edit any more. No
542 # message is generated if they are the only person on the nosy list.
544 # Revision 1.15  2001/08/02 00:34:10  richard
545 # bleah syntax error
547 # Revision 1.14  2001/08/02 00:26:16  richard
548 # Changed the order of the information in the message generated by web edits.
550 # Revision 1.13  2001/07/30 08:12:17  richard
551 # Added time logging and file uploading to the templates.
553 # Revision 1.12  2001/07/30 06:26:31  richard
554 # Added some documentation on how the newblah works.
556 # Revision 1.11  2001/07/30 06:17:45  richard
557 # Features:
558 #  . Added ability for cgi newblah forms to indicate that the new node
559 #    should be linked somewhere.
560 # Fixed:
561 #  . Fixed the agument handling for the roundup-admin find command.
562 #  . Fixed handling of summary when no note supplied for newblah. Again.
563 #  . Fixed detection of no form in htmltemplate Field display.
565 # Revision 1.10  2001/07/30 02:37:34  richard
566 # Temporary measure until we have decent schema migration...
568 # Revision 1.9  2001/07/30 01:25:07  richard
569 # Default implementation is now "classic" rather than "extended" as one would
570 # expect.
572 # Revision 1.8  2001/07/29 08:27:40  richard
573 # Fixed handling of passed-in values in form elements (ie. during a
574 # drill-down)
576 # Revision 1.7  2001/07/29 07:01:39  richard
577 # Added vim command to all source so that we don't get no steenkin' tabs :)
579 # Revision 1.6  2001/07/29 04:04:00  richard
580 # Moved some code around allowing for subclassing to change behaviour.
582 # Revision 1.5  2001/07/28 08:16:52  richard
583 # New issue form handles lack of note better now.
585 # Revision 1.4  2001/07/28 00:34:34  richard
586 # Fixed some non-string node ids.
588 # Revision 1.3  2001/07/23 03:56:30  richard
589 # oops, missed a config removal
591 # Revision 1.2  2001/07/22 12:09:32  richard
592 # Final commit of Grande Splite
594 # Revision 1.1  2001/07/22 11:58:35  richard
595 # More Grande Splite
598 # vim: set filetype=python ts=4 sw=4 et si