Code

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