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