Code

Added some documentation on how the newblah works.
[roundup.git] / roundup / cgi_client.py
1 # $Id: cgi_client.py,v 1.12 2001-07-30 06:26:31 richard Exp $
3 import os, cgi, pprint, StringIO, urlparse, re, traceback
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                 # if this item has messages, generate an edit message
230                 # TODO: don't send the edit message to the person who
231                 # performed the edit
232                 if (cl.getprops().has_key('messages') and
233                         cl.getprops()['messages'].isMultilinkType and
234                         cl.getprops()['messages'].classname == 'msg'):
235                     nid = self.nodeid
236                     m = []
237                     for name, prop in cl.getprops().items():
238                         # TODO: the None default is only here because we
239                         # don't have schema migration :(
240                         if prop.isMultilinkType:
241                             value = cl.get(nid, name, [])
242                         else:
243                             value = cl.get(nid, name, None)
244                         if prop.isLinkType:
245                             link = self.db.classes[prop.classname]
246                             key = link.getkey()
247                             if value is not None and key:
248                                 value = link.get(value, key)
249                             else:
250                                 value = '-'
251                         elif prop.isMultilinkType:
252                             l = []
253                             link = self.db.classes[prop.classname]
254                             for entry in value:
255                                 key = link.getkey()
256                                 if key:
257                                     l.append(link.get(entry, link.getkey()))
258                                 else:
259                                     l.append(entry)
260                             value = ', '.join(l)
261                         if name in changed:
262                             chg = '*'
263                         else:
264                             chg = ' '
265                         m.append('%s %s: %s'%(chg, name, value))
267                     # handle the note
268                     if self.form.has_key('__note'):
269                         note = self.form['__note'].value
270                         if '\n' in note:
271                             summary = re.split(r'\n\r?', note)[0]
272                         else:
273                             summary = note
274                         m.insert(0, '%s\n\n'%note)
275                     else:
276                         if len(changed) > 1:
277                             plural = 's were'
278                         else:
279                             plural = ' was'
280                         summary = 'This %s has been edited through the web '\
281                             'and the %s value%s changed.'%(cn,
282                             ', '.join(changed), plural)
283                         m.insert(0, '%s\n\n'%summary)
285                     # now create the message
286                     content = '\n'.join(m)
287                     message_id = self.db.msg.create(author='1', recipients=[],
288                         date=date.Date('.'), summary=summary, content=content)
289                     messages = cl.get(nid, 'messages')
290                     messages.append(message_id)
291                     props = {'messages': messages}
292                     cl.set(nid, **props)
294                 # and some nice feedback for the user
295                 message = '%s edited ok'%', '.join(changed)
296             except:
297                 s = StringIO.StringIO()
298                 traceback.print_exc(None, s)
299                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
301         # now the display
302         id = self.nodeid
303         if cl.getkey():
304             id = cl.get(id, cl.getkey())
305         self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
307         nodeid = self.nodeid
309         # use the template to display the item
310         htmltemplate.item(self, self.TEMPLATES, self.db, self.classname, nodeid)
311         self.pagefoot()
312     showissue = shownode
313     showmsg = shownode
315     def newnode(self, message=None):
316         ''' Add a new node to the database.
317         
318         The form works in two modes: blank form and submission (that is,
319         the submission goes to the same URL). **Eventually this means that
320         the form will have previously entered information in it if
321         submission fails.
323         The new node will be created with the properties specified in the
324         form submission. For multilinks, multiple form entries are handled,
325         as are prop=value,value,value. You can't mix them though.
327         If the new node is to be referenced from somewhere else immediately
328         (ie. the new node is a file that is to be attached to a support
329         issue) then supply one of these arguments in addition to the usual
330         form entries:
331             :link=designator:property
332             :multilink=designator:property
333         ... which means that once the new node is created, the "property"
334         on the node given by "designator" should now reference the new
335         node's id. The node id will be appended to the multilink.
336         '''
337         cn = self.classname
338         cl = self.db.classes[cn]
340         # possibly perform a create
341         keys = self.form.keys()
342         num_re = re.compile('^\d+$')
343         if [i for i in keys if i[0] != ':']:
344             props = {}
345             try:
346                 keys = self.form.keys()
347                 for key in keys:
348                     if not cl.properties.has_key(key):
349                         continue
350                     proptype = cl.properties[key]
351                     if proptype.isStringType:
352                         value = self.form[key].value.strip()
353                     elif proptype.isDateType:
354                         value = date.Date(self.form[key].value.strip())
355                     elif proptype.isIntervalType:
356                         value = date.Interval(self.form[key].value.strip())
357                     elif proptype.isLinkType:
358                         value = self.form[key].value.strip()
359                         # handle key values
360                         link = cl.properties[key].classname
361                         if not num_re.match(value):
362                             try:
363                                 value = self.db.classes[link].lookup(value)
364                             except:
365                                 raise ValueError, 'property "%s": %s not a %s'%(
366                                     key, value, link)
367                     elif proptype.isMultilinkType:
368                         value = self.form[key]
369                         if type(value) != type([]):
370                             value = [i.strip() for i in value.value.split(',')]
371                         else:
372                             value = [i.value.strip() for i in value]
373                         link = cl.properties[key].classname
374                         l = []
375                         for entry in map(str, value):
376                             if not num_re.match(entry):
377                                 try:
378                                     entry = self.db.classes[link].lookup(entry)
379                                 except:
380                                     raise ValueError, \
381                                         'property "%s": %s not a %s'%(key,
382                                         entry, link)
383                             l.append(entry)
384                         l.sort()
385                         value = l
386                     props[key] = value
387                 nid = cl.create(**props)
389                 # link if necessary
390                 for key in keys:
391                     print key,
392                     if key == ':multilink':
393                         value = self.form[key].value
394                         if type(value) != type([]): value = [value]
395                         for value in value:
396                             designator, property = value.split(':')
397                             print 'miltilinking to ', designator, property
398                             link, nodeid = roundupdb.splitDesignator(designator)
399                             link = self.db.classes[link]
400                             value = link.get(nodeid, property)
401                             value.append(nid)
402                             link.set(nodeid, **{property: value})
403                     elif key == ':link':
404                         value = self.form[key].value
405                         if type(value) != type([]): value = [value]
406                         for value in value:
407                             designator, property = value.split(':')
408                             print 'linking to ', designator, property
409                             link, nodeid = roundupdb.splitDesignator(designator)
410                             link = self.db.classes[link]
411                             link.set(nodeid, **{property: nid})
412                     else:
413                         print 'ignoring'
415                 # if this item has messages, 
416                 if (cl.getprops().has_key('messages') and
417                         cl.getprops()['messages'].isMultilinkType and
418                         cl.getprops()['messages'].classname == 'msg'):
419                     # generate an edit message - nosyreactor will send it
420                     m = []
421                     for name, prop in cl.getprops().items():
422                         value = cl.get(nid, name)
423                         if prop.isLinkType:
424                             link = self.db.classes[prop.classname]
425                             key = link.getkey()
426                             if value is not None and key:
427                                 value = link.get(value, key)
428                             else:
429                                 value = '-'
430                         elif prop.isMultilinkType:
431                             l = []
432                             link = self.db.classes[prop.classname]
433                             for entry in value:
434                                 key = link.getkey()
435                                 if key:
436                                     l.append(link.get(entry, link.getkey()))
437                                 else:
438                                     l.append(entry)
439                             value = ', '.join(l)
440                         m.append('%s: %s'%(name, value))
442                     # handle the note
443                     note = None
444                     if self.form.has_key('__note'):
445                         note = self.form['__note']
446                     if note and note.value:
447                         note = note.value
448                         if '\n' in note:
449                             summary = re.split(r'\n\r?', note)[0]
450                         else:
451                             summary = note
452                         m.append('\n%s\n'%note)
453                     else:
454                         summary = 'This %s has been created through the web.'%cn
455                         m.append('\n%s\s'%summary)
457                     # now create the message
458                     content = '\n'.join(m)
459                     message_id = self.db.msg.create(author='1', recipients=[],
460                         date=date.Date('.'), summary=summary, content=content)
461                     messages = cl.get(nid, 'messages')
462                     messages.append(message_id)
463                     props = {'messages': messages}
464                     cl.set(nid, **props)
466                 # and some nice feedback for the user
467                 message = '%s created ok'%cn
468             except:
469                 s = StringIO.StringIO()
470                 traceback.print_exc(None, s)
471                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
472         self.pagehead('New %s'%self.classname.capitalize(), message)
473         htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
474             self.form)
475         self.pagefoot()
476     newissue = newnode
477     newuser = newnode
479     def showuser(self, message=None):
480         ''' display an item
481         '''
482         if self.user in ('admin', self.db.user.get(self.nodeid, 'username')):
483             self.shownode(message)
484         else:
485             raise Unauthorised
487     def showfile(self):
488         ''' display a file
489         '''
490         nodeid = self.nodeid
491         cl = self.db.file
492         type = cl.get(nodeid, 'type')
493         if type == 'message/rfc822':
494             type = 'text/plain'
495         self.header(headers={'Content-Type': type})
496         self.write(cl.get(nodeid, 'content'))
498     def classes(self, message=None):
499         ''' display a list of all the classes in the database
500         '''
501         if self.user == 'admin':
502             self.pagehead('Table of classes', message)
503             classnames = self.db.classes.keys()
504             classnames.sort()
505             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
506             for cn in classnames:
507                 cl = self.db.getclass(cn)
508                 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
509                 for key, value in cl.properties.items():
510                     if value is None: value = ''
511                     else: value = str(value)
512                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
513                         key, cgi.escape(value)))
514             self.write('</table>')
515             self.pagefoot()
516         else:
517             raise Unauthorised
519     def main(self, dre=re.compile(r'([^\d]+)(\d+)'), nre=re.compile(r'new(\w+)')):
520         path = self.split_path
521         if not path or path[0] in ('', 'index'):
522             self.index()
523         elif len(path) == 1:
524             if path[0] == 'list_classes':
525                 self.classes()
526                 return
527             m = dre.match(path[0])
528             if m:
529                 self.classname = m.group(1)
530                 self.nodeid = m.group(2)
531                 getattr(self, 'show%s'%self.classname)()
532                 return
533             m = nre.match(path[0])
534             if m:
535                 self.classname = m.group(1)
536                 getattr(self, 'new%s'%self.classname)()
537                 return
538             self.classname = path[0]
539             self.list()
540         else:
541             raise 'ValueError', 'Path not understood'
543     def __del__(self):
544         self.db.close()
547 # $Log: not supported by cvs2svn $
548 # Revision 1.11  2001/07/30 06:17:45  richard
549 # Features:
550 #  . Added ability for cgi newblah forms to indicate that the new node
551 #    should be linked somewhere.
552 # Fixed:
553 #  . Fixed the agument handling for the roundup-admin find command.
554 #  . Fixed handling of summary when no note supplied for newblah. Again.
555 #  . Fixed detection of no form in htmltemplate Field display.
557 # Revision 1.10  2001/07/30 02:37:34  richard
558 # Temporary measure until we have decent schema migration...
560 # Revision 1.9  2001/07/30 01:25:07  richard
561 # Default implementation is now "classic" rather than "extended" as one would
562 # expect.
564 # Revision 1.8  2001/07/29 08:27:40  richard
565 # Fixed handling of passed-in values in form elements (ie. during a
566 # drill-down)
568 # Revision 1.7  2001/07/29 07:01:39  richard
569 # Added vim command to all source so that we don't get no steenkin' tabs :)
571 # Revision 1.6  2001/07/29 04:04:00  richard
572 # Moved some code around allowing for subclassing to change behaviour.
574 # Revision 1.5  2001/07/28 08:16:52  richard
575 # New issue form handles lack of note better now.
577 # Revision 1.4  2001/07/28 00:34:34  richard
578 # Fixed some non-string node ids.
580 # Revision 1.3  2001/07/23 03:56:30  richard
581 # oops, missed a config removal
583 # Revision 1.2  2001/07/22 12:09:32  richard
584 # Final commit of Grande Splite
586 # Revision 1.1  2001/07/22 11:58:35  richard
587 # More Grande Splite
590 # vim: set filetype=python ts=4 sw=4 et si