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.
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.
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
519 #
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.
524 #
525 # Revision 1.20 2001/08/12 06:32:36 richard
526 # using isinstance(blah, Foo) now instead of isFooType
527 #
528 # Revision 1.19 2001/08/07 00:24:42 richard
529 # stupid typo
530 #
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.
534 #
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).
540 #
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.
544 #
545 # Revision 1.15 2001/08/02 00:34:10 richard
546 # bleah syntax error
547 #
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.
550 #
551 # Revision 1.13 2001/07/30 08:12:17 richard
552 # Added time logging and file uploading to the templates.
553 #
554 # Revision 1.12 2001/07/30 06:26:31 richard
555 # Added some documentation on how the newblah works.
556 #
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.
565 #
566 # Revision 1.10 2001/07/30 02:37:34 richard
567 # Temporary measure until we have decent schema migration...
568 #
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.
572 #
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)
576 #
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 :)
579 #
580 # Revision 1.6 2001/07/29 04:04:00 richard
581 # Moved some code around allowing for subclassing to change behaviour.
582 #
583 # Revision 1.5 2001/07/28 08:16:52 richard
584 # New issue form handles lack of note better now.
585 #
586 # Revision 1.4 2001/07/28 00:34:34 richard
587 # Fixed some non-string node ids.
588 #
589 # Revision 1.3 2001/07/23 03:56:30 richard
590 # oops, missed a config removal
591 #
592 # Revision 1.2 2001/07/22 12:09:32 richard
593 # Final commit of Grande Splite
594 #
595 # Revision 1.1 2001/07/22 11:58:35 richard
596 # More Grande Splite
597 #
598 #
599 # vim: set filetype=python ts=4 sw=4 et si