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