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 THE 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.18 2001-08-07 00:15:51 richard Exp $
20 import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes
22 import roundupdb, htmltemplate, date
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 prop.isLinkType or prop.isMultilinkType:
128 if type(value) == type([]):
129 value = [arg.value for arg in value]
130 else:
131 value = value.value.split(',')
132 l = filterspec.get(key, [])
133 l = l + value
134 filterspec[key] = l
135 else:
136 filterspec[key] = value.value
137 return filterspec
139 default_index_sort = ['-activity']
140 default_index_group = ['priority']
141 default_index_filter = []
142 default_index_columns = ['id','activity','title','status','assignedto']
143 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
144 def index(self):
145 ''' put up an index
146 '''
147 self.classname = 'issue'
148 if self.form.has_key(':sort'): sort = self.index_arg(':sort')
149 else: sort = self.default_index_sort
150 if self.form.has_key(':group'): group = self.index_arg(':group')
151 else: group = self.default_index_group
152 if self.form.has_key(':filter'): filter = self.index_arg(':filter')
153 else: filter = self.default_index_filter
154 if self.form.has_key(':columns'): columns = self.index_arg(':columns')
155 else: columns = self.default_index_columns
156 filterspec = self.index_filterspec()
157 if not filterspec:
158 filterspec = self.default_index_filterspec
159 return self.list(columns=columns, filter=filter, group=group,
160 sort=sort, filterspec=filterspec)
162 # XXX deviates from spec - loses the '+' (that's a reserved character
163 # in URLS
164 def list(self, sort=None, group=None, filter=None, columns=None,
165 filterspec=None):
166 ''' call the template index with the args
168 :sort - sort by prop name, optionally preceeded with '-'
169 to give descending or nothing for ascending sorting.
170 :group - group by prop name, optionally preceeded with '-' or
171 to sort in descending or nothing for ascending order.
172 :filter - selects which props should be displayed in the filter
173 section. Default is all.
174 :columns - selects the columns that should be displayed.
175 Default is all.
177 '''
178 cn = self.classname
179 self.pagehead('Index of %s'%cn)
180 if sort is None: sort = self.index_arg(':sort')
181 if group is None: group = self.index_arg(':group')
182 if filter is None: filter = self.index_arg(':filter')
183 if columns is None: columns = self.index_arg(':columns')
184 if filterspec is None: filterspec = self.index_filterspec()
186 htmltemplate.index(self, self.TEMPLATES, self.db, cn, filterspec,
187 filter, columns, sort, group)
188 self.pagefoot()
190 def shownode(self, message=None):
191 ''' display an item
192 '''
193 cn = self.classname
194 cl = self.db.classes[cn]
196 # possibly perform an edit
197 keys = self.form.keys()
198 num_re = re.compile('^\d+$')
199 if keys:
200 changed = []
201 props = {}
202 try:
203 keys = self.form.keys()
204 for key in keys:
205 if not cl.properties.has_key(key):
206 continue
207 proptype = cl.properties[key]
208 if proptype.isStringType:
209 value = str(self.form[key].value).strip()
210 elif proptype.isDateType:
211 value = date.Date(str(self.form[key].value))
212 elif proptype.isIntervalType:
213 value = date.Interval(str(self.form[key].value))
214 elif proptype.isLinkType:
215 value = str(self.form[key].value).strip()
216 # handle key values
217 link = cl.properties[key].classname
218 if not num_re.match(value):
219 try:
220 value = self.db.classes[link].lookup(value)
221 except:
222 raise ValueError, 'property "%s": %s not a %s'%(
223 key, value, link)
224 elif proptype.isMultilinkType:
225 value = self.form[key]
226 if type(value) != type([]):
227 value = [i.strip() for i in str(value.value).split(',')]
228 else:
229 value = [str(i.value).strip() for i in value]
230 link = cl.properties[key].classname
231 l = []
232 for entry in map(str, value):
233 if not num_re.match(entry):
234 try:
235 entry = self.db.classes[link].lookup(entry)
236 except:
237 raise ValueError, \
238 'property "%s": %s not a %s'%(key,
239 entry, link)
240 l.append(entry)
241 l.sort()
242 value = l
243 # if changed, set it
244 if value != cl.get(self.nodeid, key):
245 changed.append(key)
246 props[key] = value
247 cl.set(self.nodeid, **props)
249 self._post_editnode(self.nodeid, changed)
250 # and some nice feedback for the user
251 message = '%s edited ok'%', '.join(changed)
252 except:
253 s = StringIO.StringIO()
254 traceback.print_exc(None, s)
255 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
257 # now the display
258 id = self.nodeid
259 if cl.getkey():
260 id = cl.get(id, cl.getkey())
261 self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
263 nodeid = self.nodeid
265 # use the template to display the item
266 htmltemplate.item(self, self.TEMPLATES, self.db, self.classname, nodeid)
267 self.pagefoot()
268 showissue = shownode
269 showmsg = shownode
271 def showuser(self, message=None):
272 ''' display an item
273 '''
274 if self.user in ('admin', self.db.user.get(self.nodeid, 'username')):
275 self.shownode(message)
276 else:
277 raise Unauthorised
279 def showfile(self):
280 ''' display a file
281 '''
282 nodeid = self.nodeid
283 cl = self.db.file
284 type = cl.get(nodeid, 'type')
285 if type == 'message/rfc822':
286 type = 'text/plain'
287 self.header(headers={'Content-Type': type})
288 self.write(cl.get(nodeid, 'content'))
290 def _createnode(self):
291 ''' create a node based on the contents of the form
292 '''
293 cn = self.classname
294 cl = self.db.classes[cn]
295 props = {}
296 keys = self.form.keys()
297 num_re = re.compile('^\d+$')
298 for key in keys:
299 if not cl.properties.has_key(key):
300 continue
301 proptype = cl.properties[key]
302 if proptype.isStringType:
303 value = self.form[key].value.strip()
304 elif proptype.isDateType:
305 value = date.Date(self.form[key].value.strip())
306 elif proptype.isIntervalType:
307 value = date.Interval(self.form[key].value.strip())
308 elif proptype.isLinkType:
309 value = self.form[key].value.strip()
310 # handle key values
311 link = cl.properties[key].classname
312 if not num_re.match(value):
313 try:
314 value = self.db.classes[link].lookup(value)
315 except:
316 raise ValueError, 'property "%s": %s not a %s'%(
317 key, value, link)
318 elif proptype.isMultilinkType:
319 value = self.form[key]
320 if type(value) != type([]):
321 value = [i.strip() for i in value.value.split(',')]
322 else:
323 value = [i.value.strip() for i in value]
324 link = cl.properties[key].classname
325 l = []
326 for entry in map(str, value):
327 if not num_re.match(entry):
328 try:
329 entry = self.db.classes[link].lookup(entry)
330 except:
331 raise ValueError, \
332 'property "%s": %s not a %s'%(key,
333 entry, link)
334 l.append(entry)
335 l.sort()
336 value = l
337 props[key] = value
338 return cl.create(**props)
340 def _post_editnode(self, nid, changes=None):
341 ''' do the linking and message sending part of the node creation
342 '''
343 cn = self.classname
344 cl = self.db.classes[cn]
345 # link if necessary
346 keys = self.form.keys()
347 for key in keys:
348 if key == ':multilink':
349 value = self.form[key].value
350 if type(value) != type([]): value = [value]
351 for value in value:
352 designator, property = value.split(':')
353 link, nodeid = roundupdb.splitDesignator(designator)
354 link = self.db.classes[link]
355 value = link.get(nodeid, property)
356 value.append(nid)
357 link.set(nodeid, **{property: value})
358 elif key == ':link':
359 value = self.form[key].value
360 if type(value) != type([]): value = [value]
361 for value in value:
362 designator, property = value.split(':')
363 link, nodeid = roundupdb.splitDesignator(designator)
364 link = self.db.classes[link]
365 link.set(nodeid, **{property: nid})
367 # see if we want to send a message to the nosy list...
368 props = cl.getprops()
369 # don't do the message thing if there's no nosy list, or the editor
370 # of the node is the only person on the nosy list - they're already
371 # aware of the change.
372 nosy = 0
373 if props.has_key('nosy'):
374 nosy = cl.get(nid, 'nosy')
375 uid = self.getuid()
376 if len(nosy) == 1 and uid in nosy:
377 nosy = 0
378 if (nosy and props.has_key('messages') and
379 props['messages'].isMultilinkType and
380 props['messages'].classname == 'msg'):
382 # handle the note
383 note = None
384 if self.form.has_key('__note'):
385 note = self.form['__note']
386 if note is not None and note.value:
387 note = note.value
388 if '\n' in note:
389 summary = re.split(r'\n\r?', note)[0]
390 else:
391 summary = note
392 m = ['%s\n'%note]
393 else:
394 summary = 'This %s has been edited through the web.\n'%cn
395 m = [summary]
397 # generate an edit message - nosyreactor will send it
398 first = 1
399 for name, prop in props.items():
400 if changes is not None and name not in changes: continue
401 if first:
402 m.append('\n-------')
403 first = 0
404 value = cl.get(nid, name, None)
405 if prop.isLinkType:
406 link = self.db.classes[prop.classname]
407 key = link.labelprop(default_to_id=1)
408 if value is not None and key:
409 value = link.get(value, key)
410 else:
411 value = '-'
412 elif prop.isMultilinkType:
413 if value is None: value = []
414 l = []
415 link = self.db.classes[prop.classname]
416 key = link.labelprop(default_to_id=1)
417 for entry in value:
418 if key:
419 l.append(link.get(entry, link.getkey()))
420 else:
421 l.append(entry)
422 value = ', '.join(l)
423 m.append('%s: %s'%(name, value))
425 # now create the message
426 content = '\n'.join(m)
427 message_id = self.db.msg.create(author=self.getuid(),
428 recipients=[], date=date.Date('.'), summary=summary,
429 content=content)
430 messages = cl.get(nid, 'messages')
431 messages.append(message_id)
432 props = {'messages': messages}
433 cl.set(nid, **props)
435 def newnode(self, message=None):
436 ''' Add a new node to the database.
438 The form works in two modes: blank form and submission (that is,
439 the submission goes to the same URL). **Eventually this means that
440 the form will have previously entered information in it if
441 submission fails.
443 The new node will be created with the properties specified in the
444 form submission. For multilinks, multiple form entries are handled,
445 as are prop=value,value,value. You can't mix them though.
447 If the new node is to be referenced from somewhere else immediately
448 (ie. the new node is a file that is to be attached to a support
449 issue) then supply one of these arguments in addition to the usual
450 form entries:
451 :link=designator:property
452 :multilink=designator:property
453 ... which means that once the new node is created, the "property"
454 on the node given by "designator" should now reference the new
455 node's id. The node id will be appended to the multilink.
456 '''
457 cn = self.classname
458 cl = self.db.classes[cn]
460 # possibly perform a create
461 keys = self.form.keys()
462 if [i for i in keys if i[0] != ':']:
463 props = {}
464 try:
465 nid = self._createnode()
466 self._post_editnode(nid)
467 # and some nice feedback for the user
468 message = '%s created ok'%cn
469 except:
470 s = StringIO.StringIO()
471 traceback.print_exc(None, s)
472 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
473 self.pagehead('New %s'%self.classname.capitalize(), message)
474 htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
475 self.form)
476 self.pagefoot()
477 newissue = newnode
478 newuser = newnode
480 def newfile(self, message=None):
481 ''' Add a new file to the database.
483 This form works very much the same way as newnode - it just has a
484 file upload.
485 '''
486 cn = self.classname
487 cl = self.db.classes[cn]
489 # possibly perform a create
490 keys = self.form.keys()
491 if [i for i in keys if i[0] != ':']:
492 try:
493 file = self.form['content']
494 self._post_editnode(cl.create(content=file.file.read(),
495 type=mimetypes.guess_type(file.filename)[0],
496 name=file.filename))
497 # and some nice feedback for the user
498 message = '%s created ok'%cn
499 except:
500 s = StringIO.StringIO()
501 traceback.print_exc(None, s)
502 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
504 self.pagehead('New %s'%self.classname.capitalize(), message)
505 htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
506 self.form)
507 self.pagefoot()
509 def classes(self, message=None):
510 ''' display a list of all the classes in the database
511 '''
512 if self.user == 'admin':
513 self.pagehead('Table of classes', message)
514 classnames = self.db.classes.keys()
515 classnames.sort()
516 self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
517 for cn in classnames:
518 cl = self.db.getclass(cn)
519 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
520 for key, value in cl.properties.items():
521 if value is None: value = ''
522 else: value = str(value)
523 self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
524 key, cgi.escape(value)))
525 self.write('</table>')
526 self.pagefoot()
527 else:
528 raise Unauthorised
530 def main(self, dre=re.compile(r'([^\d]+)(\d+)'), nre=re.compile(r'new(\w+)')):
531 path = self.split_path
532 if not path or path[0] in ('', 'index'):
533 self.index()
534 elif len(path) == 1:
535 if path[0] == 'list_classes':
536 self.classes()
537 return
538 m = dre.match(path[0])
539 if m:
540 self.classname = m.group(1)
541 self.nodeid = m.group(2)
542 getattr(self, 'show%s'%self.classname)()
543 return
544 m = nre.match(path[0])
545 if m:
546 self.classname = m.group(1)
547 getattr(self, 'new%s'%self.classname)()
548 return
549 self.classname = path[0]
550 self.list()
551 else:
552 raise 'ValueError', 'Path not understood'
554 def __del__(self):
555 self.db.close()
557 #
558 # $Log: not supported by cvs2svn $
559 # Revision 1.17 2001/08/02 06:38:17 richard
560 # Roundupdb now appends "mailing list" information to its messages which
561 # include the e-mail address and web interface address. Templates may
562 # override this in their db classes to include specific information (support
563 # instructions, etc).
564 #
565 # Revision 1.16 2001/08/02 05:55:25 richard
566 # Web edit messages aren't sent to the person who did the edit any more. No
567 # message is generated if they are the only person on the nosy list.
568 #
569 # Revision 1.15 2001/08/02 00:34:10 richard
570 # bleah syntax error
571 #
572 # Revision 1.14 2001/08/02 00:26:16 richard
573 # Changed the order of the information in the message generated by web edits.
574 #
575 # Revision 1.13 2001/07/30 08:12:17 richard
576 # Added time logging and file uploading to the templates.
577 #
578 # Revision 1.12 2001/07/30 06:26:31 richard
579 # Added some documentation on how the newblah works.
580 #
581 # Revision 1.11 2001/07/30 06:17:45 richard
582 # Features:
583 # . Added ability for cgi newblah forms to indicate that the new node
584 # should be linked somewhere.
585 # Fixed:
586 # . Fixed the agument handling for the roundup-admin find command.
587 # . Fixed handling of summary when no note supplied for newblah. Again.
588 # . Fixed detection of no form in htmltemplate Field display.
589 #
590 # Revision 1.10 2001/07/30 02:37:34 richard
591 # Temporary measure until we have decent schema migration...
592 #
593 # Revision 1.9 2001/07/30 01:25:07 richard
594 # Default implementation is now "classic" rather than "extended" as one would
595 # expect.
596 #
597 # Revision 1.8 2001/07/29 08:27:40 richard
598 # Fixed handling of passed-in values in form elements (ie. during a
599 # drill-down)
600 #
601 # Revision 1.7 2001/07/29 07:01:39 richard
602 # Added vim command to all source so that we don't get no steenkin' tabs :)
603 #
604 # Revision 1.6 2001/07/29 04:04:00 richard
605 # Moved some code around allowing for subclassing to change behaviour.
606 #
607 # Revision 1.5 2001/07/28 08:16:52 richard
608 # New issue form handles lack of note better now.
609 #
610 # Revision 1.4 2001/07/28 00:34:34 richard
611 # Fixed some non-string node ids.
612 #
613 # Revision 1.3 2001/07/23 03:56:30 richard
614 # oops, missed a config removal
615 #
616 # Revision 1.2 2001/07/22 12:09:32 richard
617 # Final commit of Grande Splite
618 #
619 # Revision 1.1 2001/07/22 11:58:35 richard
620 # More Grande Splite
621 #
622 #
623 # vim: set filetype=python ts=4 sw=4 et si