Code

stupid typo
[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.19 2001-08-07 00:24:42 richard Exp $
20 import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes
22 import roundupdb, htmltemplate, date
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 prop.isLinkType or prop.isMultilinkType:
128                 if type(value) == type([]):
129                     value = [arg.value for arg in value]
130                 else:
131                     value = value.value.split(',')
132                 l = filterspec.get(key, [])
133                 l = l + value
134                 filterspec[key] = l
135             else:
136                 filterspec[key] = value.value
137         return filterspec
139     default_index_sort = ['-activity']
140     default_index_group = ['priority']
141     default_index_filter = []
142     default_index_columns = ['id','activity','title','status','assignedto']
143     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
144     def index(self):
145         ''' put up an index
146         '''
147         self.classname = 'issue'
148         if self.form.has_key(':sort'): sort = self.index_arg(':sort')
149         else: sort = self.default_index_sort
150         if self.form.has_key(':group'): group = self.index_arg(':group')
151         else: group = self.default_index_group
152         if self.form.has_key(':filter'): filter = self.index_arg(':filter')
153         else: filter = self.default_index_filter
154         if self.form.has_key(':columns'): columns = self.index_arg(':columns')
155         else: columns = self.default_index_columns
156         filterspec = self.index_filterspec()
157         if not filterspec:
158             filterspec = self.default_index_filterspec
159         return self.list(columns=columns, filter=filter, group=group,
160             sort=sort, filterspec=filterspec)
162     # XXX deviates from spec - loses the '+' (that's a reserved character
163     # in URLS
164     def list(self, sort=None, group=None, filter=None, columns=None,
165             filterspec=None):
166         ''' call the template index with the args
168             :sort    - sort by prop name, optionally preceeded with '-'
169                      to give descending or nothing for ascending sorting.
170             :group   - group by prop name, optionally preceeded with '-' or
171                      to sort in descending or nothing for ascending order.
172             :filter  - selects which props should be displayed in the filter
173                      section. Default is all.
174             :columns - selects the columns that should be displayed.
175                      Default is all.
177         '''
178         cn = self.classname
179         self.pagehead('Index of %s'%cn)
180         if sort is None: sort = self.index_arg(':sort')
181         if group is None: group = self.index_arg(':group')
182         if filter is None: filter = self.index_arg(':filter')
183         if columns is None: columns = self.index_arg(':columns')
184         if filterspec is None: filterspec = self.index_filterspec()
186         htmltemplate.index(self, self.TEMPLATES, self.db, cn, filterspec,
187             filter, columns, sort, group)
188         self.pagefoot()
190     def shownode(self, message=None):
191         ''' display an item
192         '''
193         cn = self.classname
194         cl = self.db.classes[cn]
196         # possibly perform an edit
197         keys = self.form.keys()
198         num_re = re.compile('^\d+$')
199         if keys:
200             changed = []
201             props = {}
202             try:
203                 keys = self.form.keys()
204                 for key in keys:
205                     if not cl.properties.has_key(key):
206                         continue
207                     proptype = cl.properties[key]
208                     if proptype.isStringType:
209                         value = str(self.form[key].value).strip()
210                     elif proptype.isDateType:
211                         value = date.Date(str(self.form[key].value))
212                     elif proptype.isIntervalType:
213                         value = date.Interval(str(self.form[key].value))
214                     elif proptype.isLinkType:
215                         value = str(self.form[key].value).strip()
216                         # handle key values
217                         link = cl.properties[key].classname
218                         if not num_re.match(value):
219                             try:
220                                 value = self.db.classes[link].lookup(value)
221                             except:
222                                 raise ValueError, 'property "%s": %s not a %s'%(
223                                     key, value, link)
224                     elif proptype.isMultilinkType:
225                         value = self.form[key]
226                         if type(value) != type([]):
227                             value = [i.strip() for i in str(value.value).split(',')]
228                         else:
229                             value = [str(i.value).strip() for i in value]
230                         link = cl.properties[key].classname
231                         l = []
232                         for entry in map(str, value):
233                             if not num_re.match(entry):
234                                 try:
235                                     entry = self.db.classes[link].lookup(entry)
236                                 except:
237                                     raise ValueError, \
238                                         'property "%s": %s not a %s'%(key,
239                                         entry, link)
240                             l.append(entry)
241                         l.sort()
242                         value = l
243                     # if changed, set it
244                     if value != cl.get(self.nodeid, key):
245                         changed.append(key)
246                         props[key] = value
247                 cl.set(self.nodeid, **props)
249                 self._post_editnode(self.nodeid, changed)
250                 # and some nice feedback for the user
251                 message = '%s edited ok'%', '.join(changed)
252             except:
253                 s = StringIO.StringIO()
254                 traceback.print_exc(None, s)
255                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
257         # now the display
258         id = self.nodeid
259         if cl.getkey():
260             id = cl.get(id, cl.getkey())
261         self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
263         nodeid = self.nodeid
265         # use the template to display the item
266         htmltemplate.item(self, self.TEMPLATES, self.db, self.classname, nodeid)
267         self.pagefoot()
268     showissue = shownode
269     showmsg = shownode
271     def showuser(self, message=None):
272         ''' display an item
273         '''
274         if self.user in ('admin', self.db.user.get(self.nodeid, 'username')):
275             self.shownode(message)
276         else:
277             raise Unauthorised
279     def showfile(self):
280         ''' display a file
281         '''
282         nodeid = self.nodeid
283         cl = self.db.file
284         type = cl.get(nodeid, 'type')
285         if type == 'message/rfc822':
286             type = 'text/plain'
287         self.header(headers={'Content-Type': type})
288         self.write(cl.get(nodeid, 'content'))
290     def _createnode(self):
291         ''' create a node based on the contents of the form
292         '''
293         cn = self.classname
294         cl = self.db.classes[cn]
295         props = {}
296         keys = self.form.keys()
297         num_re = re.compile('^\d+$')
298         for key in keys:
299             if not cl.properties.has_key(key):
300                 continue
301             proptype = cl.properties[key]
302             if proptype.isStringType:
303                 value = self.form[key].value.strip()
304             elif proptype.isDateType:
305                 value = date.Date(self.form[key].value.strip())
306             elif proptype.isIntervalType:
307                 value = date.Interval(self.form[key].value.strip())
308             elif proptype.isLinkType:
309                 value = self.form[key].value.strip()
310                 # handle key values
311                 link = cl.properties[key].classname
312                 if not num_re.match(value):
313                     try:
314                         value = self.db.classes[link].lookup(value)
315                     except:
316                         raise ValueError, 'property "%s": %s not a %s'%(
317                             key, value, link)
318             elif proptype.isMultilinkType:
319                 value = self.form[key]
320                 if type(value) != type([]):
321                     value = [i.strip() for i in value.value.split(',')]
322                 else:
323                     value = [i.value.strip() for i in value]
324                 link = cl.properties[key].classname
325                 l = []
326                 for entry in map(str, value):
327                     if not num_re.match(entry):
328                         try:
329                             entry = self.db.classes[link].lookup(entry)
330                         except:
331                             raise ValueError, \
332                                 'property "%s": %s not a %s'%(key,
333                                 entry, link)
334                     l.append(entry)
335                 l.sort()
336                 value = l
337             props[key] = value
338         return cl.create(**props)
340     def _post_editnode(self, nid, changes=None):
341         ''' do the linking and message sending part of the node creation
342         '''
343         cn = self.classname
344         cl = self.db.classes[cn]
345         # link if necessary
346         keys = self.form.keys()
347         for key in keys:
348             if key == ':multilink':
349                 value = self.form[key].value
350                 if type(value) != type([]): value = [value]
351                 for value in value:
352                     designator, property = value.split(':')
353                     link, nodeid = roundupdb.splitDesignator(designator)
354                     link = self.db.classes[link]
355                     value = link.get(nodeid, property)
356                     value.append(nid)
357                     link.set(nodeid, **{property: value})
358             elif key == ':link':
359                 value = self.form[key].value
360                 if type(value) != type([]): value = [value]
361                 for value in value:
362                     designator, property = value.split(':')
363                     link, nodeid = roundupdb.splitDesignator(designator)
364                     link = self.db.classes[link]
365                     link.set(nodeid, **{property: nid})
367         # see if we want to send a message to the nosy list...
368         props = cl.getprops()
369         # don't do the message thing if there's no nosy list, or the editor
370         # of the node is the only person on the nosy list - they're already
371         # aware of the change.
372         nosy = 0
373         if props.has_key('nosy'):
374             nosy = cl.get(nid, 'nosy')
375             uid = self.getuid()
376             if len(nosy) == 1 and uid in nosy:
377                 nosy = 0
378         if (nosy and props.has_key('messages') and
379                 props['messages'].isMultilinkType and
380                 props['messages'].classname == 'msg'):
382             # handle the note
383             note = None
384             if self.form.has_key('__note'):
385                 note = self.form['__note']
386             if note is not None and note.value:
387                 note = note.value
388                 if '\n' in note:
389                     summary = re.split(r'\n\r?', note)[0]
390                 else:
391                     summary = note
392                 m = ['%s\n'%note]
393             else:
394                 summary = 'This %s has been edited through the web.\n'%cn
395                 m = [summary]
397             # generate an edit message - nosyreactor will send it
398             first = 1
399             for name, prop in props.items():
400                 if changes is not None and name not in changes: continue
401                 if first:
402                     m.append('\n-------')
403                     first = 0
404                 value = cl.get(nid, name, None)
405                 if prop.isLinkType:
406                     link = self.db.classes[prop.classname]
407                     key = link.labelprop(default_to_id=1)
408                     if value is not None and key:
409                         value = link.get(value, key)
410                     else:
411                         value = '-'
412                 elif prop.isMultilinkType:
413                     if value is None: value = []
414                     l = []
415                     link = self.db.classes[prop.classname]
416                     key = link.labelprop(default_to_id=1)
417                     for entry in value:
418                         if key:
419                             l.append(link.get(entry, link.getkey()))
420                         else:
421                             l.append(entry)
422                     value = ', '.join(l)
423                 m.append('%s: %s'%(name, value))
425             # now create the message
426             content = '\n'.join(m)
427             message_id = self.db.msg.create(author=self.getuid(),
428                 recipients=[], date=date.Date('.'), summary=summary,
429                 content=content)
430             messages = cl.get(nid, 'messages')
431             messages.append(message_id)
432             props = {'messages': messages}
433             cl.set(nid, **props)
435     def newnode(self, message=None):
436         ''' Add a new node to the database.
437         
438         The form works in two modes: blank form and submission (that is,
439         the submission goes to the same URL). **Eventually this means that
440         the form will have previously entered information in it if
441         submission fails.
443         The new node will be created with the properties specified in the
444         form submission. For multilinks, multiple form entries are handled,
445         as are prop=value,value,value. You can't mix them though.
447         If the new node is to be referenced from somewhere else immediately
448         (ie. the new node is a file that is to be attached to a support
449         issue) then supply one of these arguments in addition to the usual
450         form entries:
451             :link=designator:property
452             :multilink=designator:property
453         ... which means that once the new node is created, the "property"
454         on the node given by "designator" should now reference the new
455         node's id. The node id will be appended to the multilink.
456         '''
457         cn = self.classname
458         cl = self.db.classes[cn]
460         # possibly perform a create
461         keys = self.form.keys()
462         if [i for i in keys if i[0] != ':']:
463             props = {}
464             try:
465                 nid = self._createnode()
466                 self._post_editnode(nid)
467                 # and some nice feedback for the user
468                 message = '%s created ok'%cn
469             except:
470                 s = StringIO.StringIO()
471                 traceback.print_exc(None, s)
472                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
473         self.pagehead('New %s'%self.classname.capitalize(), message)
474         htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
475             self.form)
476         self.pagefoot()
477     newissue = newnode
478     newuser = newnode
480     def newfile(self, message=None):
481         ''' Add a new file to the database.
482         
483         This form works very much the same way as newnode - it just has a
484         file upload.
485         '''
486         cn = self.classname
487         cl = self.db.classes[cn]
489         # possibly perform a create
490         keys = self.form.keys()
491         if [i for i in keys if i[0] != ':']:
492             try:
493                 file = self.form['content']
494                 self._post_editnode(cl.create(content=file.file.read(),
495                     type=mimetypes.guess_type(file.filename)[0],
496                     name=file.filename))
497                 # and some nice feedback for the user
498                 message = '%s created ok'%cn
499             except:
500                 s = StringIO.StringIO()
501                 traceback.print_exc(None, s)
502                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
504         self.pagehead('New %s'%self.classname.capitalize(), message)
505         htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
506             self.form)
507         self.pagefoot()
509     def classes(self, message=None):
510         ''' display a list of all the classes in the database
511         '''
512         if self.user == 'admin':
513             self.pagehead('Table of classes', message)
514             classnames = self.db.classes.keys()
515             classnames.sort()
516             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
517             for cn in classnames:
518                 cl = self.db.getclass(cn)
519                 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
520                 for key, value in cl.properties.items():
521                     if value is None: value = ''
522                     else: value = str(value)
523                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
524                         key, cgi.escape(value)))
525             self.write('</table>')
526             self.pagefoot()
527         else:
528             raise Unauthorised
530     def main(self, dre=re.compile(r'([^\d]+)(\d+)'), nre=re.compile(r'new(\w+)')):
531         path = self.split_path
532         if not path or path[0] in ('', 'index'):
533             self.index()
534         elif len(path) == 1:
535             if path[0] == 'list_classes':
536                 self.classes()
537                 return
538             m = dre.match(path[0])
539             if m:
540                 self.classname = m.group(1)
541                 self.nodeid = m.group(2)
542                 getattr(self, 'show%s'%self.classname)()
543                 return
544             m = nre.match(path[0])
545             if m:
546                 self.classname = m.group(1)
547                 getattr(self, 'new%s'%self.classname)()
548                 return
549             self.classname = path[0]
550             self.list()
551         else:
552             raise 'ValueError', 'Path not understood'
554     def __del__(self):
555         self.db.close()
558 # $Log: not supported by cvs2svn $
559 # Revision 1.18  2001/08/07 00:15:51  richard
560 # Added the copyright/license notice to (nearly) all files at request of
561 # Bizar Software.
563 # Revision 1.17  2001/08/02 06:38:17  richard
564 # Roundupdb now appends "mailing list" information to its messages which
565 # include the e-mail address and web interface address. Templates may
566 # override this in their db classes to include specific information (support
567 # instructions, etc).
569 # Revision 1.16  2001/08/02 05:55:25  richard
570 # Web edit messages aren't sent to the person who did the edit any more. No
571 # message is generated if they are the only person on the nosy list.
573 # Revision 1.15  2001/08/02 00:34:10  richard
574 # bleah syntax error
576 # Revision 1.14  2001/08/02 00:26:16  richard
577 # Changed the order of the information in the message generated by web edits.
579 # Revision 1.13  2001/07/30 08:12:17  richard
580 # Added time logging and file uploading to the templates.
582 # Revision 1.12  2001/07/30 06:26:31  richard
583 # Added some documentation on how the newblah works.
585 # Revision 1.11  2001/07/30 06:17:45  richard
586 # Features:
587 #  . Added ability for cgi newblah forms to indicate that the new node
588 #    should be linked somewhere.
589 # Fixed:
590 #  . Fixed the agument handling for the roundup-admin find command.
591 #  . Fixed handling of summary when no note supplied for newblah. Again.
592 #  . Fixed detection of no form in htmltemplate Field display.
594 # Revision 1.10  2001/07/30 02:37:34  richard
595 # Temporary measure until we have decent schema migration...
597 # Revision 1.9  2001/07/30 01:25:07  richard
598 # Default implementation is now "classic" rather than "extended" as one would
599 # expect.
601 # Revision 1.8  2001/07/29 08:27:40  richard
602 # Fixed handling of passed-in values in form elements (ie. during a
603 # drill-down)
605 # Revision 1.7  2001/07/29 07:01:39  richard
606 # Added vim command to all source so that we don't get no steenkin' tabs :)
608 # Revision 1.6  2001/07/29 04:04:00  richard
609 # Moved some code around allowing for subclassing to change behaviour.
611 # Revision 1.5  2001/07/28 08:16:52  richard
612 # New issue form handles lack of note better now.
614 # Revision 1.4  2001/07/28 00:34:34  richard
615 # Fixed some non-string node ids.
617 # Revision 1.3  2001/07/23 03:56:30  richard
618 # oops, missed a config removal
620 # Revision 1.2  2001/07/22 12:09:32  richard
621 # Final commit of Grande Splite
623 # Revision 1.1  2001/07/22 11:58:35  richard
624 # More Grande Splite
627 # vim: set filetype=python ts=4 sw=4 et si