Code

Even better (more useful) headings
[roundup.git] / roundup / cgi_client.py
1 # $Id: cgi_client.py,v 1.15 2001-08-02 00:34:10 richard Exp $
3 import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes
5 import roundupdb, htmltemplate, date
7 class Unauthorised(ValueError):
8     pass
10 class Client:
11     def __init__(self, out, db, env, user):
12         self.out = out
13         self.db = db
14         self.env = env
15         self.user = user
16         self.path = env['PATH_INFO']
17         self.split_path = self.path.split('/')
19         self.headers_done = 0
20         self.form = cgi.FieldStorage(environ=env)
21         self.headers_done = 0
22         self.debug = 0
24     def header(self, headers={'Content-Type':'text/html'}):
25         if not headers.has_key('Content-Type'):
26             headers['Content-Type'] = 'text/html'
27         for entry in headers.items():
28             self.out.write('%s: %s\n'%entry)
29         self.out.write('\n')
30         self.headers_done = 1
32     def pagehead(self, title, message=None):
33         url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
34         machine = self.env['SERVER_NAME']
35         port = self.env['SERVER_PORT']
36         if port != '80': machine = machine + ':' + port
37         base = urlparse.urlunparse(('http', machine, url, None, None, None))
38         if message is not None:
39             message = '<div class="system-msg">%s</div>'%message
40         else:
41             message = ''
42         style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
43         userid = self.db.user.lookup(self.user)
44         self.write('''<html><head>
45 <title>%s</title>
46 <style type="text/css">%s</style>
47 </head>
48 <body bgcolor=#ffffff>
49 %s
50 <table width=100%% border=0 cellspacing=0 cellpadding=2>
51 <tr class="location-bar"><td><big><strong>%s</strong></big>
52 (login: <a href="user%s">%s</a>)</td></tr>
53 </table>
54 '''%(title, style, message, title, userid, self.user))
56     def pagefoot(self):
57         if self.debug:
58             self.write('<hr><small><dl>')
59             self.write('<dt><b>Path</b></dt>')
60             self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
61             keys = self.form.keys()
62             keys.sort()
63             if keys:
64                 self.write('<dt><b>Form entries</b></dt>')
65                 for k in self.form.keys():
66                     v = str(self.form[k].value)
67                     self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
68             keys = self.env.keys()
69             keys.sort()
70             self.write('<dt><b>CGI environment</b></dt>')
71             for k in keys:
72                 v = self.env[k]
73                 self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
74             self.write('</dl></small>')
75         self.write('</body></html>')
77     def write(self, content):
78         if not self.headers_done:
79             self.header()
80         self.out.write(content)
82     def index_arg(self, arg):
83         ''' handle the args to index - they might be a list from the form
84             (ie. submitted from a form) or they might be a command-separated
85             single string (ie. manually constructed GET args)
86         '''
87         if self.form.has_key(arg):
88             arg =  self.form[arg]
89             if type(arg) == type([]):
90                 return [arg.value for arg in arg]
91             return arg.value.split(',')
92         return []
94     def index_filterspec(self):
95         ''' pull the index filter spec from the form
97         Links and multilinks want to be lists - the rest are straight
98         strings.
99         '''
100         props = self.db.classes[self.classname].getprops()
101         # all the form args not starting with ':' are filters
102         filterspec = {}
103         for key in self.form.keys():
104             if key[0] == ':': continue
105             prop = props[key]
106             value = self.form[key]
107             if prop.isLinkType or prop.isMultilinkType:
108                 if type(value) == type([]):
109                     value = [arg.value for arg in value]
110                 else:
111                     value = value.value.split(',')
112                 l = filterspec.get(key, [])
113                 l = l + value
114                 filterspec[key] = l
115             else:
116                 filterspec[key] = value.value
117         return filterspec
119     default_index_sort = ['-activity']
120     default_index_group = ['priority']
121     default_index_filter = []
122     default_index_columns = ['id','activity','title','status','assignedto']
123     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
124     def index(self):
125         ''' put up an index
126         '''
127         self.classname = 'issue'
128         if self.form.has_key(':sort'): sort = self.index_arg(':sort')
129         else: sort = self.default_index_sort
130         if self.form.has_key(':group'): group = self.index_arg(':group')
131         else: group = self.default_index_group
132         if self.form.has_key(':filter'): filter = self.index_arg(':filter')
133         else: filter = self.default_index_filter
134         if self.form.has_key(':columns'): columns = self.index_arg(':columns')
135         else: columns = self.default_index_columns
136         filterspec = self.index_filterspec()
137         if not filterspec:
138             filterspec = self.default_index_filterspec
139         return self.list(columns=columns, filter=filter, group=group,
140             sort=sort, filterspec=filterspec)
142     # XXX deviates from spec - loses the '+' (that's a reserved character
143     # in URLS
144     def list(self, sort=None, group=None, filter=None, columns=None,
145             filterspec=None):
146         ''' call the template index with the args
148             :sort    - sort by prop name, optionally preceeded with '-'
149                      to give descending or nothing for ascending sorting.
150             :group   - group by prop name, optionally preceeded with '-' or
151                      to sort in descending or nothing for ascending order.
152             :filter  - selects which props should be displayed in the filter
153                      section. Default is all.
154             :columns - selects the columns that should be displayed.
155                      Default is all.
157         '''
158         cn = self.classname
159         self.pagehead('Index of %s'%cn)
160         if sort is None: sort = self.index_arg(':sort')
161         if group is None: group = self.index_arg(':group')
162         if filter is None: filter = self.index_arg(':filter')
163         if columns is None: columns = self.index_arg(':columns')
164         if filterspec is None: filterspec = self.index_filterspec()
166         htmltemplate.index(self, self.TEMPLATES, self.db, cn, filterspec,
167             filter, columns, sort, group)
168         self.pagefoot()
170     def shownode(self, message=None):
171         ''' display an item
172         '''
173         cn = self.classname
174         cl = self.db.classes[cn]
176         # possibly perform an edit
177         keys = self.form.keys()
178         num_re = re.compile('^\d+$')
179         if keys:
180             changed = []
181             props = {}
182             try:
183                 keys = self.form.keys()
184                 for key in keys:
185                     if not cl.properties.has_key(key):
186                         continue
187                     proptype = cl.properties[key]
188                     if proptype.isStringType:
189                         value = str(self.form[key].value).strip()
190                     elif proptype.isDateType:
191                         value = date.Date(str(self.form[key].value))
192                     elif proptype.isIntervalType:
193                         value = date.Interval(str(self.form[key].value))
194                     elif proptype.isLinkType:
195                         value = str(self.form[key].value).strip()
196                         # handle key values
197                         link = cl.properties[key].classname
198                         if not num_re.match(value):
199                             try:
200                                 value = self.db.classes[link].lookup(value)
201                             except:
202                                 raise ValueError, 'property "%s": %s not a %s'%(
203                                     key, value, link)
204                     elif proptype.isMultilinkType:
205                         value = self.form[key]
206                         if type(value) != type([]):
207                             value = [i.strip() for i in str(value.value).split(',')]
208                         else:
209                             value = [str(i.value).strip() for i in value]
210                         link = cl.properties[key].classname
211                         l = []
212                         for entry in map(str, value):
213                             if not num_re.match(entry):
214                                 try:
215                                     entry = self.db.classes[link].lookup(entry)
216                                 except:
217                                     raise ValueError, \
218                                         'property "%s": %s not a %s'%(key,
219                                         entry, link)
220                             l.append(entry)
221                         l.sort()
222                         value = l
223                     # if changed, set it
224                     if value != cl.get(self.nodeid, key):
225                         changed.append(key)
226                         props[key] = value
227                 cl.set(self.nodeid, **props)
229                 self._post_editnode(self.nodeid)
230                 # and some nice feedback for the user
231                 message = '%s edited ok'%', '.join(changed)
232             except:
233                 s = StringIO.StringIO()
234                 traceback.print_exc(None, s)
235                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
237         # now the display
238         id = self.nodeid
239         if cl.getkey():
240             id = cl.get(id, cl.getkey())
241         self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
243         nodeid = self.nodeid
245         # use the template to display the item
246         htmltemplate.item(self, self.TEMPLATES, self.db, self.classname, nodeid)
247         self.pagefoot()
248     showissue = shownode
249     showmsg = shownode
251     def showuser(self, message=None):
252         ''' display an item
253         '''
254         if self.user in ('admin', self.db.user.get(self.nodeid, 'username')):
255             self.shownode(message)
256         else:
257             raise Unauthorised
259     def showfile(self):
260         ''' display a file
261         '''
262         nodeid = self.nodeid
263         cl = self.db.file
264         type = cl.get(nodeid, 'type')
265         if type == 'message/rfc822':
266             type = 'text/plain'
267         self.header(headers={'Content-Type': type})
268         self.write(cl.get(nodeid, 'content'))
270     def _createnode(self):
271         ''' create a node based on the contents of the form
272         '''
273         cn = self.classname
274         cl = self.db.classes[cn]
275         props = {}
276         keys = self.form.keys()
277         num_re = re.compile('^\d+$')
278         for key in keys:
279             if not cl.properties.has_key(key):
280                 continue
281             proptype = cl.properties[key]
282             if proptype.isStringType:
283                 value = self.form[key].value.strip()
284             elif proptype.isDateType:
285                 value = date.Date(self.form[key].value.strip())
286             elif proptype.isIntervalType:
287                 value = date.Interval(self.form[key].value.strip())
288             elif proptype.isLinkType:
289                 value = self.form[key].value.strip()
290                 # handle key values
291                 link = cl.properties[key].classname
292                 if not num_re.match(value):
293                     try:
294                         value = self.db.classes[link].lookup(value)
295                     except:
296                         raise ValueError, 'property "%s": %s not a %s'%(
297                             key, value, link)
298             elif proptype.isMultilinkType:
299                 value = self.form[key]
300                 if type(value) != type([]):
301                     value = [i.strip() for i in value.value.split(',')]
302                 else:
303                     value = [i.value.strip() for i in value]
304                 link = cl.properties[key].classname
305                 l = []
306                 for entry in map(str, value):
307                     if not num_re.match(entry):
308                         try:
309                             entry = self.db.classes[link].lookup(entry)
310                         except:
311                             raise ValueError, \
312                                 'property "%s": %s not a %s'%(key,
313                                 entry, link)
314                     l.append(entry)
315                 l.sort()
316                 value = l
317             props[key] = value
318         return cl.create(**props)
320     def _post_editnode(self, nid):
321         ''' do the linking and message sending part of the node creation
322         '''
323         cn = self.classname
324         cl = self.db.classes[cn]
325         # link if necessary
326         keys = self.form.keys()
327         for key in keys:
328             if key == ':multilink':
329                 value = self.form[key].value
330                 if type(value) != type([]): value = [value]
331                 for value in value:
332                     designator, property = value.split(':')
333                     link, nodeid = roundupdb.splitDesignator(designator)
334                     link = self.db.classes[link]
335                     value = link.get(nodeid, property)
336                     value.append(nid)
337                     link.set(nodeid, **{property: value})
338             elif key == ':link':
339                 value = self.form[key].value
340                 if type(value) != type([]): value = [value]
341                 for value in value:
342                     designator, property = value.split(':')
343                     link, nodeid = roundupdb.splitDesignator(designator)
344                     link = self.db.classes[link]
345                     link.set(nodeid, **{property: nid})
347         # if this item has messages, 
348         if (cl.getprops().has_key('messages') and
349                 cl.getprops()['messages'].isMultilinkType and
350                 cl.getprops()['messages'].classname == 'msg'):
351             # handle the note
352             note = None
353             if self.form.has_key('__note'):
354                 note = self.form['__note']
355             if note is not None and note.value:
356                 note = note.value
357                 if '\n' in note:
358                     summary = re.split(r'\n\r?', note)[0]
359                 else:
360                     summary = note
361                 m = ['%s\n'%note]
362             else:
363                 summary = 'This %s has been created through the web.\n'%cn
364                 m = [summary]
365             m.append('\n-------\n')
367             # generate an edit message - nosyreactor will send it
368             for name, prop in cl.getprops().items():
369                 value = cl.get(nid, name)
370                 if prop.isLinkType:
371                     link = self.db.classes[prop.classname]
372                     key = link.getkey()
373                     if value is not None and key:
374                         value = link.get(value, key)
375                     else:
376                         value = '-'
377                 elif prop.isMultilinkType:
378                     l = []
379                     link = self.db.classes[prop.classname]
380                     for entry in value:
381                         key = link.getkey()
382                         if key:
383                             l.append(link.get(entry, link.getkey()))
384                         else:
385                             l.append(entry)
386                     value = ', '.join(l)
387                 m.append('%s: %s'%(name, value))
389             # now create the message
390             content = '\n'.join(m)
391             message_id = self.db.msg.create(author='1', recipients=[],
392                 date=date.Date('.'), summary=summary, content=content)
393             messages = cl.get(nid, 'messages')
394             messages.append(message_id)
395             props = {'messages': messages}
396             cl.set(nid, **props)
398     def newnode(self, message=None):
399         ''' Add a new node to the database.
400         
401         The form works in two modes: blank form and submission (that is,
402         the submission goes to the same URL). **Eventually this means that
403         the form will have previously entered information in it if
404         submission fails.
406         The new node will be created with the properties specified in the
407         form submission. For multilinks, multiple form entries are handled,
408         as are prop=value,value,value. You can't mix them though.
410         If the new node is to be referenced from somewhere else immediately
411         (ie. the new node is a file that is to be attached to a support
412         issue) then supply one of these arguments in addition to the usual
413         form entries:
414             :link=designator:property
415             :multilink=designator:property
416         ... which means that once the new node is created, the "property"
417         on the node given by "designator" should now reference the new
418         node's id. The node id will be appended to the multilink.
419         '''
420         cn = self.classname
421         cl = self.db.classes[cn]
423         # possibly perform a create
424         keys = self.form.keys()
425         if [i for i in keys if i[0] != ':']:
426             props = {}
427             try:
428                 nid = self._createnode()
429                 self._post_editnode(nid)
430                 # and some nice feedback for the user
431                 message = '%s created ok'%cn
432             except:
433                 s = StringIO.StringIO()
434                 traceback.print_exc(None, s)
435                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
436         self.pagehead('New %s'%self.classname.capitalize(), message)
437         htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
438             self.form)
439         self.pagefoot()
440     newissue = newnode
441     newuser = newnode
443     def newfile(self, message=None):
444         ''' Add a new file to the database.
445         
446         This form works very much the same way as newnode - it just has a
447         file upload.
448         '''
449         cn = self.classname
450         cl = self.db.classes[cn]
452         # possibly perform a create
453         keys = self.form.keys()
454         if [i for i in keys if i[0] != ':']:
455             try:
456                 file = self.form['content']
457                 self._post_editnode(cl.create(content=file.file.read(),
458                     type=mimetypes.guess_type(file.filename)[0],
459                     name=file.filename))
460                 # and some nice feedback for the user
461                 message = '%s created ok'%cn
462             except:
463                 s = StringIO.StringIO()
464                 traceback.print_exc(None, s)
465                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
467         self.pagehead('New %s'%self.classname.capitalize(), message)
468         htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
469             self.form)
470         self.pagefoot()
472     def classes(self, message=None):
473         ''' display a list of all the classes in the database
474         '''
475         if self.user == 'admin':
476             self.pagehead('Table of classes', message)
477             classnames = self.db.classes.keys()
478             classnames.sort()
479             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
480             for cn in classnames:
481                 cl = self.db.getclass(cn)
482                 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
483                 for key, value in cl.properties.items():
484                     if value is None: value = ''
485                     else: value = str(value)
486                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
487                         key, cgi.escape(value)))
488             self.write('</table>')
489             self.pagefoot()
490         else:
491             raise Unauthorised
493     def main(self, dre=re.compile(r'([^\d]+)(\d+)'), nre=re.compile(r'new(\w+)')):
494         path = self.split_path
495         if not path or path[0] in ('', 'index'):
496             self.index()
497         elif len(path) == 1:
498             if path[0] == 'list_classes':
499                 self.classes()
500                 return
501             m = dre.match(path[0])
502             if m:
503                 self.classname = m.group(1)
504                 self.nodeid = m.group(2)
505                 getattr(self, 'show%s'%self.classname)()
506                 return
507             m = nre.match(path[0])
508             if m:
509                 self.classname = m.group(1)
510                 getattr(self, 'new%s'%self.classname)()
511                 return
512             self.classname = path[0]
513             self.list()
514         else:
515             raise 'ValueError', 'Path not understood'
517     def __del__(self):
518         self.db.close()
521 # $Log: not supported by cvs2svn $
522 # Revision 1.14  2001/08/02 00:26:16  richard
523 # Changed the order of the information in the message generated by web edits.
525 # Revision 1.13  2001/07/30 08:12:17  richard
526 # Added time logging and file uploading to the templates.
528 # Revision 1.12  2001/07/30 06:26:31  richard
529 # Added some documentation on how the newblah works.
531 # Revision 1.11  2001/07/30 06:17:45  richard
532 # Features:
533 #  . Added ability for cgi newblah forms to indicate that the new node
534 #    should be linked somewhere.
535 # Fixed:
536 #  . Fixed the agument handling for the roundup-admin find command.
537 #  . Fixed handling of summary when no note supplied for newblah. Again.
538 #  . Fixed detection of no form in htmltemplate Field display.
540 # Revision 1.10  2001/07/30 02:37:34  richard
541 # Temporary measure until we have decent schema migration...
543 # Revision 1.9  2001/07/30 01:25:07  richard
544 # Default implementation is now "classic" rather than "extended" as one would
545 # expect.
547 # Revision 1.8  2001/07/29 08:27:40  richard
548 # Fixed handling of passed-in values in form elements (ie. during a
549 # drill-down)
551 # Revision 1.7  2001/07/29 07:01:39  richard
552 # Added vim command to all source so that we don't get no steenkin' tabs :)
554 # Revision 1.6  2001/07/29 04:04:00  richard
555 # Moved some code around allowing for subclassing to change behaviour.
557 # Revision 1.5  2001/07/28 08:16:52  richard
558 # New issue form handles lack of note better now.
560 # Revision 1.4  2001/07/28 00:34:34  richard
561 # Fixed some non-string node ids.
563 # Revision 1.3  2001/07/23 03:56:30  richard
564 # oops, missed a config removal
566 # Revision 1.2  2001/07/22 12:09:32  richard
567 # Final commit of Grande Splite
569 # Revision 1.1  2001/07/22 11:58:35  richard
570 # More Grande Splite
573 # vim: set filetype=python ts=4 sw=4 et si