1cb3113307c71a1dfc8ddd60af2eb571941bbe54
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.26 2001-09-12 08:31:42 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 type = mimetypes.guess_type(file.filename)[0]
399 if not type:
400 type = "application/octet-stream"
401 self._post_editnode(cl.create(content=file.file.read(),
402 type=type, name=file.filename))
403 # and some nice feedback for the user
404 message = '%s created ok'%cn
405 except:
406 s = StringIO.StringIO()
407 traceback.print_exc(None, s)
408 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
410 self.pagehead('New %s'%self.classname.capitalize(), message)
411 htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
412 self.form)
413 self.pagefoot()
415 def classes(self, message=None):
416 ''' display a list of all the classes in the database
417 '''
418 if self.user == 'admin':
419 self.pagehead('Table of classes', message)
420 classnames = self.db.classes.keys()
421 classnames.sort()
422 self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
423 for cn in classnames:
424 cl = self.db.getclass(cn)
425 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
426 for key, value in cl.properties.items():
427 if value is None: value = ''
428 else: value = str(value)
429 self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
430 key, cgi.escape(value)))
431 self.write('</table>')
432 self.pagefoot()
433 else:
434 raise Unauthorised
436 def main(self, dre=re.compile(r'([^\d]+)(\d+)'), nre=re.compile(r'new(\w+)')):
437 path = self.split_path
438 if not path or path[0] in ('', 'index'):
439 self.index()
440 elif len(path) == 1:
441 if path[0] == 'list_classes':
442 self.classes()
443 return
444 m = dre.match(path[0])
445 if m:
446 self.classname = m.group(1)
447 self.nodeid = m.group(2)
448 getattr(self, 'show%s'%self.classname)()
449 return
450 m = nre.match(path[0])
451 if m:
452 self.classname = m.group(1)
453 getattr(self, 'new%s'%self.classname)()
454 return
455 self.classname = path[0]
456 self.list()
457 else:
458 raise 'ValueError', 'Path not understood'
460 def __del__(self):
461 self.db.close()
463 def parsePropsFromForm(cl, form, nodeid=0):
464 '''Pull properties for the given class out of the form.
465 '''
466 props = {}
467 changed = []
468 keys = form.keys()
469 num_re = re.compile('^\d+$')
470 for key in keys:
471 if not cl.properties.has_key(key):
472 continue
473 proptype = cl.properties[key]
474 if isinstance(proptype, hyperdb.String):
475 value = form[key].value.strip()
476 elif isinstance(proptype, hyperdb.Date):
477 value = date.Date(form[key].value.strip())
478 elif isinstance(proptype, hyperdb.Interval):
479 value = date.Interval(form[key].value.strip())
480 elif isinstance(proptype, hyperdb.Link):
481 value = form[key].value.strip()
482 # handle key values
483 link = cl.properties[key].classname
484 if not num_re.match(value):
485 try:
486 value = self.db.classes[link].lookup(value)
487 except:
488 raise ValueError, 'property "%s": %s not a %s'%(
489 key, value, link)
490 elif isinstance(proptype, hyperdb.Multilink):
491 value = form[key]
492 if type(value) != type([]):
493 value = [i.strip() for i in value.value.split(',')]
494 else:
495 value = [i.value.strip() for i in value]
496 link = cl.properties[key].classname
497 l = []
498 for entry in map(str, value):
499 if not num_re.match(entry):
500 try:
501 entry = self.db.classes[link].lookup(entry)
502 except:
503 raise ValueError, \
504 'property "%s": %s not a %s'%(key,
505 entry, link)
506 l.append(entry)
507 l.sort()
508 value = l
509 props[key] = value
510 # if changed, set it
511 if nodeid and value != cl.get(nodeid, key):
512 changed.append(key)
513 props[key] = value
514 return props, changed
516 #
517 # $Log: not supported by cvs2svn $
518 # Revision 1.25 2001/08/29 05:30:49 richard
519 # change messages weren't being saved when there was no-one on the nosy list.
520 #
521 # Revision 1.24 2001/08/29 04:49:39 richard
522 # didn't clean up fully after debugging :(
523 #
524 # Revision 1.23 2001/08/29 04:47:18 richard
525 # Fixed CGI client change messages so they actually include the properties
526 # changed (again).
527 #
528 # Revision 1.22 2001/08/17 00:08:10 richard
529 # reverted back to sending messages always regardless of who is doing the web
530 # edit. change notes weren't being saved. bleah. hackish.
531 #
532 # Revision 1.21 2001/08/15 23:43:18 richard
533 # Fixed some isFooTypes that I missed.
534 # Refactored some code in the CGI code.
535 #
536 # Revision 1.20 2001/08/12 06:32:36 richard
537 # using isinstance(blah, Foo) now instead of isFooType
538 #
539 # Revision 1.19 2001/08/07 00:24:42 richard
540 # stupid typo
541 #
542 # Revision 1.18 2001/08/07 00:15:51 richard
543 # Added the copyright/license notice to (nearly) all files at request of
544 # Bizar Software.
545 #
546 # Revision 1.17 2001/08/02 06:38:17 richard
547 # Roundupdb now appends "mailing list" information to its messages which
548 # include the e-mail address and web interface address. Templates may
549 # override this in their db classes to include specific information (support
550 # instructions, etc).
551 #
552 # Revision 1.16 2001/08/02 05:55:25 richard
553 # Web edit messages aren't sent to the person who did the edit any more. No
554 # message is generated if they are the only person on the nosy list.
555 #
556 # Revision 1.15 2001/08/02 00:34:10 richard
557 # bleah syntax error
558 #
559 # Revision 1.14 2001/08/02 00:26:16 richard
560 # Changed the order of the information in the message generated by web edits.
561 #
562 # Revision 1.13 2001/07/30 08:12:17 richard
563 # Added time logging and file uploading to the templates.
564 #
565 # Revision 1.12 2001/07/30 06:26:31 richard
566 # Added some documentation on how the newblah works.
567 #
568 # Revision 1.11 2001/07/30 06:17:45 richard
569 # Features:
570 # . Added ability for cgi newblah forms to indicate that the new node
571 # should be linked somewhere.
572 # Fixed:
573 # . Fixed the agument handling for the roundup-admin find command.
574 # . Fixed handling of summary when no note supplied for newblah. Again.
575 # . Fixed detection of no form in htmltemplate Field display.
576 #
577 # Revision 1.10 2001/07/30 02:37:34 richard
578 # Temporary measure until we have decent schema migration...
579 #
580 # Revision 1.9 2001/07/30 01:25:07 richard
581 # Default implementation is now "classic" rather than "extended" as one would
582 # expect.
583 #
584 # Revision 1.8 2001/07/29 08:27:40 richard
585 # Fixed handling of passed-in values in form elements (ie. during a
586 # drill-down)
587 #
588 # Revision 1.7 2001/07/29 07:01:39 richard
589 # Added vim command to all source so that we don't get no steenkin' tabs :)
590 #
591 # Revision 1.6 2001/07/29 04:04:00 richard
592 # Moved some code around allowing for subclassing to change behaviour.
593 #
594 # Revision 1.5 2001/07/28 08:16:52 richard
595 # New issue form handles lack of note better now.
596 #
597 # Revision 1.4 2001/07/28 00:34:34 richard
598 # Fixed some non-string node ids.
599 #
600 # Revision 1.3 2001/07/23 03:56:30 richard
601 # oops, missed a config removal
602 #
603 # Revision 1.2 2001/07/22 12:09:32 richard
604 # Final commit of Grande Splite
605 #
606 # Revision 1.1 2001/07/22 11:58:35 richard
607 # More Grande Splite
608 #
609 #
610 # vim: set filetype=python ts=4 sw=4 et si