Code

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