1 # $Id: cgi_client.py,v 1.17 2001-08-02 06:38:17 richard Exp $
3 import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes
5 import roundupdb, htmltemplate, date
7 class Unauthorised(ValueError):
8 pass
10 class Client:
11 def __init__(self, out, db, env, user):
12 self.out = out
13 self.db = db
14 self.env = env
15 self.user = user
16 self.path = env['PATH_INFO']
17 self.split_path = self.path.split('/')
19 self.headers_done = 0
20 self.form = cgi.FieldStorage(environ=env)
21 self.headers_done = 0
22 self.debug = 0
24 def getuid(self):
25 return self.db.user.lookup(self.user)
27 def header(self, headers={'Content-Type':'text/html'}):
28 if not headers.has_key('Content-Type'):
29 headers['Content-Type'] = 'text/html'
30 for entry in headers.items():
31 self.out.write('%s: %s\n'%entry)
32 self.out.write('\n')
33 self.headers_done = 1
35 def pagehead(self, title, message=None):
36 url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
37 machine = self.env['SERVER_NAME']
38 port = self.env['SERVER_PORT']
39 if port != '80': machine = machine + ':' + port
40 base = urlparse.urlunparse(('http', machine, url, None, None, None))
41 if message is not None:
42 message = '<div class="system-msg">%s</div>'%message
43 else:
44 message = ''
45 style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
46 userid = self.db.user.lookup(self.user)
47 self.write('''<html><head>
48 <title>%s</title>
49 <style type="text/css">%s</style>
50 </head>
51 <body bgcolor=#ffffff>
52 %s
53 <table width=100%% border=0 cellspacing=0 cellpadding=2>
54 <tr class="location-bar"><td><big><strong>%s</strong></big>
55 (login: <a href="user%s">%s</a>)</td></tr>
56 </table>
57 '''%(title, style, message, title, userid, self.user))
59 def pagefoot(self):
60 if self.debug:
61 self.write('<hr><small><dl>')
62 self.write('<dt><b>Path</b></dt>')
63 self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
64 keys = self.form.keys()
65 keys.sort()
66 if keys:
67 self.write('<dt><b>Form entries</b></dt>')
68 for k in self.form.keys():
69 v = str(self.form[k].value)
70 self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
71 keys = self.env.keys()
72 keys.sort()
73 self.write('<dt><b>CGI environment</b></dt>')
74 for k in keys:
75 v = self.env[k]
76 self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
77 self.write('</dl></small>')
78 self.write('</body></html>')
80 def write(self, content):
81 if not self.headers_done:
82 self.header()
83 self.out.write(content)
85 def index_arg(self, arg):
86 ''' handle the args to index - they might be a list from the form
87 (ie. submitted from a form) or they might be a command-separated
88 single string (ie. manually constructed GET args)
89 '''
90 if self.form.has_key(arg):
91 arg = self.form[arg]
92 if type(arg) == type([]):
93 return [arg.value for arg in arg]
94 return arg.value.split(',')
95 return []
97 def index_filterspec(self):
98 ''' pull the index filter spec from the form
100 Links and multilinks want to be lists - the rest are straight
101 strings.
102 '''
103 props = self.db.classes[self.classname].getprops()
104 # all the form args not starting with ':' are filters
105 filterspec = {}
106 for key in self.form.keys():
107 if key[0] == ':': continue
108 prop = props[key]
109 value = self.form[key]
110 if prop.isLinkType or prop.isMultilinkType:
111 if type(value) == type([]):
112 value = [arg.value for arg in value]
113 else:
114 value = value.value.split(',')
115 l = filterspec.get(key, [])
116 l = l + value
117 filterspec[key] = l
118 else:
119 filterspec[key] = value.value
120 return filterspec
122 default_index_sort = ['-activity']
123 default_index_group = ['priority']
124 default_index_filter = []
125 default_index_columns = ['id','activity','title','status','assignedto']
126 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
127 def index(self):
128 ''' put up an index
129 '''
130 self.classname = 'issue'
131 if self.form.has_key(':sort'): sort = self.index_arg(':sort')
132 else: sort = self.default_index_sort
133 if self.form.has_key(':group'): group = self.index_arg(':group')
134 else: group = self.default_index_group
135 if self.form.has_key(':filter'): filter = self.index_arg(':filter')
136 else: filter = self.default_index_filter
137 if self.form.has_key(':columns'): columns = self.index_arg(':columns')
138 else: columns = self.default_index_columns
139 filterspec = self.index_filterspec()
140 if not filterspec:
141 filterspec = self.default_index_filterspec
142 return self.list(columns=columns, filter=filter, group=group,
143 sort=sort, filterspec=filterspec)
145 # XXX deviates from spec - loses the '+' (that's a reserved character
146 # in URLS
147 def list(self, sort=None, group=None, filter=None, columns=None,
148 filterspec=None):
149 ''' call the template index with the args
151 :sort - sort by prop name, optionally preceeded with '-'
152 to give descending or nothing for ascending sorting.
153 :group - group by prop name, optionally preceeded with '-' or
154 to sort in descending or nothing for ascending order.
155 :filter - selects which props should be displayed in the filter
156 section. Default is all.
157 :columns - selects the columns that should be displayed.
158 Default is all.
160 '''
161 cn = self.classname
162 self.pagehead('Index of %s'%cn)
163 if sort is None: sort = self.index_arg(':sort')
164 if group is None: group = self.index_arg(':group')
165 if filter is None: filter = self.index_arg(':filter')
166 if columns is None: columns = self.index_arg(':columns')
167 if filterspec is None: filterspec = self.index_filterspec()
169 htmltemplate.index(self, self.TEMPLATES, self.db, cn, filterspec,
170 filter, columns, sort, group)
171 self.pagefoot()
173 def shownode(self, message=None):
174 ''' display an item
175 '''
176 cn = self.classname
177 cl = self.db.classes[cn]
179 # possibly perform an edit
180 keys = self.form.keys()
181 num_re = re.compile('^\d+$')
182 if keys:
183 changed = []
184 props = {}
185 try:
186 keys = self.form.keys()
187 for key in keys:
188 if not cl.properties.has_key(key):
189 continue
190 proptype = cl.properties[key]
191 if proptype.isStringType:
192 value = str(self.form[key].value).strip()
193 elif proptype.isDateType:
194 value = date.Date(str(self.form[key].value))
195 elif proptype.isIntervalType:
196 value = date.Interval(str(self.form[key].value))
197 elif proptype.isLinkType:
198 value = str(self.form[key].value).strip()
199 # handle key values
200 link = cl.properties[key].classname
201 if not num_re.match(value):
202 try:
203 value = self.db.classes[link].lookup(value)
204 except:
205 raise ValueError, 'property "%s": %s not a %s'%(
206 key, value, link)
207 elif proptype.isMultilinkType:
208 value = self.form[key]
209 if type(value) != type([]):
210 value = [i.strip() for i in str(value.value).split(',')]
211 else:
212 value = [str(i.value).strip() for i in value]
213 link = cl.properties[key].classname
214 l = []
215 for entry in map(str, value):
216 if not num_re.match(entry):
217 try:
218 entry = self.db.classes[link].lookup(entry)
219 except:
220 raise ValueError, \
221 'property "%s": %s not a %s'%(key,
222 entry, link)
223 l.append(entry)
224 l.sort()
225 value = l
226 # if changed, set it
227 if value != cl.get(self.nodeid, key):
228 changed.append(key)
229 props[key] = value
230 cl.set(self.nodeid, **props)
232 self._post_editnode(self.nodeid, changed)
233 # and some nice feedback for the user
234 message = '%s edited ok'%', '.join(changed)
235 except:
236 s = StringIO.StringIO()
237 traceback.print_exc(None, s)
238 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
240 # now the display
241 id = self.nodeid
242 if cl.getkey():
243 id = cl.get(id, cl.getkey())
244 self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
246 nodeid = self.nodeid
248 # use the template to display the item
249 htmltemplate.item(self, self.TEMPLATES, self.db, self.classname, nodeid)
250 self.pagefoot()
251 showissue = shownode
252 showmsg = shownode
254 def showuser(self, message=None):
255 ''' display an item
256 '''
257 if self.user in ('admin', self.db.user.get(self.nodeid, 'username')):
258 self.shownode(message)
259 else:
260 raise Unauthorised
262 def showfile(self):
263 ''' display a file
264 '''
265 nodeid = self.nodeid
266 cl = self.db.file
267 type = cl.get(nodeid, 'type')
268 if type == 'message/rfc822':
269 type = 'text/plain'
270 self.header(headers={'Content-Type': type})
271 self.write(cl.get(nodeid, 'content'))
273 def _createnode(self):
274 ''' create a node based on the contents of the form
275 '''
276 cn = self.classname
277 cl = self.db.classes[cn]
278 props = {}
279 keys = self.form.keys()
280 num_re = re.compile('^\d+$')
281 for key in keys:
282 if not cl.properties.has_key(key):
283 continue
284 proptype = cl.properties[key]
285 if proptype.isStringType:
286 value = self.form[key].value.strip()
287 elif proptype.isDateType:
288 value = date.Date(self.form[key].value.strip())
289 elif proptype.isIntervalType:
290 value = date.Interval(self.form[key].value.strip())
291 elif proptype.isLinkType:
292 value = self.form[key].value.strip()
293 # handle key values
294 link = cl.properties[key].classname
295 if not num_re.match(value):
296 try:
297 value = self.db.classes[link].lookup(value)
298 except:
299 raise ValueError, 'property "%s": %s not a %s'%(
300 key, value, link)
301 elif proptype.isMultilinkType:
302 value = self.form[key]
303 if type(value) != type([]):
304 value = [i.strip() for i in value.value.split(',')]
305 else:
306 value = [i.value.strip() for i in value]
307 link = cl.properties[key].classname
308 l = []
309 for entry in map(str, value):
310 if not num_re.match(entry):
311 try:
312 entry = self.db.classes[link].lookup(entry)
313 except:
314 raise ValueError, \
315 'property "%s": %s not a %s'%(key,
316 entry, link)
317 l.append(entry)
318 l.sort()
319 value = l
320 props[key] = value
321 return cl.create(**props)
323 def _post_editnode(self, nid, changes=None):
324 ''' do the linking and message sending part of the node creation
325 '''
326 cn = self.classname
327 cl = self.db.classes[cn]
328 # link if necessary
329 keys = self.form.keys()
330 for key in keys:
331 if key == ':multilink':
332 value = self.form[key].value
333 if type(value) != type([]): value = [value]
334 for value in value:
335 designator, property = value.split(':')
336 link, nodeid = roundupdb.splitDesignator(designator)
337 link = self.db.classes[link]
338 value = link.get(nodeid, property)
339 value.append(nid)
340 link.set(nodeid, **{property: value})
341 elif key == ':link':
342 value = self.form[key].value
343 if type(value) != type([]): value = [value]
344 for value in value:
345 designator, property = value.split(':')
346 link, nodeid = roundupdb.splitDesignator(designator)
347 link = self.db.classes[link]
348 link.set(nodeid, **{property: nid})
350 # see if we want to send a message to the nosy list...
351 props = cl.getprops()
352 # don't do the message thing if there's no nosy list, or the editor
353 # of the node is the only person on the nosy list - they're already
354 # aware of the change.
355 nosy = 0
356 if props.has_key('nosy'):
357 nosy = cl.get(nid, 'nosy')
358 uid = self.getuid()
359 if len(nosy) == 1 and uid in nosy:
360 nosy = 0
361 if (nosy and props.has_key('messages') and
362 props['messages'].isMultilinkType and
363 props['messages'].classname == 'msg'):
365 # handle the note
366 note = None
367 if self.form.has_key('__note'):
368 note = self.form['__note']
369 if note is not None and note.value:
370 note = note.value
371 if '\n' in note:
372 summary = re.split(r'\n\r?', note)[0]
373 else:
374 summary = note
375 m = ['%s\n'%note]
376 else:
377 summary = 'This %s has been edited through the web.\n'%cn
378 m = [summary]
380 # generate an edit message - nosyreactor will send it
381 first = 1
382 for name, prop in props.items():
383 if changes is not None and name not in changes: continue
384 if first:
385 m.append('\n-------')
386 first = 0
387 value = cl.get(nid, name, None)
388 if prop.isLinkType:
389 link = self.db.classes[prop.classname]
390 key = link.labelprop(default_to_id=1)
391 if value is not None and key:
392 value = link.get(value, key)
393 else:
394 value = '-'
395 elif prop.isMultilinkType:
396 if value is None: value = []
397 l = []
398 link = self.db.classes[prop.classname]
399 key = link.labelprop(default_to_id=1)
400 for entry in value:
401 if key:
402 l.append(link.get(entry, link.getkey()))
403 else:
404 l.append(entry)
405 value = ', '.join(l)
406 m.append('%s: %s'%(name, value))
408 # now create the message
409 content = '\n'.join(m)
410 message_id = self.db.msg.create(author=self.getuid(),
411 recipients=[], date=date.Date('.'), summary=summary,
412 content=content)
413 messages = cl.get(nid, 'messages')
414 messages.append(message_id)
415 props = {'messages': messages}
416 cl.set(nid, **props)
418 def newnode(self, message=None):
419 ''' Add a new node to the database.
421 The form works in two modes: blank form and submission (that is,
422 the submission goes to the same URL). **Eventually this means that
423 the form will have previously entered information in it if
424 submission fails.
426 The new node will be created with the properties specified in the
427 form submission. For multilinks, multiple form entries are handled,
428 as are prop=value,value,value. You can't mix them though.
430 If the new node is to be referenced from somewhere else immediately
431 (ie. the new node is a file that is to be attached to a support
432 issue) then supply one of these arguments in addition to the usual
433 form entries:
434 :link=designator:property
435 :multilink=designator:property
436 ... which means that once the new node is created, the "property"
437 on the node given by "designator" should now reference the new
438 node's id. The node id will be appended to the multilink.
439 '''
440 cn = self.classname
441 cl = self.db.classes[cn]
443 # possibly perform a create
444 keys = self.form.keys()
445 if [i for i in keys if i[0] != ':']:
446 props = {}
447 try:
448 nid = self._createnode()
449 self._post_editnode(nid)
450 # and some nice feedback for the user
451 message = '%s created ok'%cn
452 except:
453 s = StringIO.StringIO()
454 traceback.print_exc(None, s)
455 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
456 self.pagehead('New %s'%self.classname.capitalize(), message)
457 htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
458 self.form)
459 self.pagefoot()
460 newissue = newnode
461 newuser = newnode
463 def newfile(self, message=None):
464 ''' Add a new file to the database.
466 This form works very much the same way as newnode - it just has a
467 file upload.
468 '''
469 cn = self.classname
470 cl = self.db.classes[cn]
472 # possibly perform a create
473 keys = self.form.keys()
474 if [i for i in keys if i[0] != ':']:
475 try:
476 file = self.form['content']
477 self._post_editnode(cl.create(content=file.file.read(),
478 type=mimetypes.guess_type(file.filename)[0],
479 name=file.filename))
480 # and some nice feedback for the user
481 message = '%s created ok'%cn
482 except:
483 s = StringIO.StringIO()
484 traceback.print_exc(None, s)
485 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
487 self.pagehead('New %s'%self.classname.capitalize(), message)
488 htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
489 self.form)
490 self.pagefoot()
492 def classes(self, message=None):
493 ''' display a list of all the classes in the database
494 '''
495 if self.user == 'admin':
496 self.pagehead('Table of classes', message)
497 classnames = self.db.classes.keys()
498 classnames.sort()
499 self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
500 for cn in classnames:
501 cl = self.db.getclass(cn)
502 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
503 for key, value in cl.properties.items():
504 if value is None: value = ''
505 else: value = str(value)
506 self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
507 key, cgi.escape(value)))
508 self.write('</table>')
509 self.pagefoot()
510 else:
511 raise Unauthorised
513 def main(self, dre=re.compile(r'([^\d]+)(\d+)'), nre=re.compile(r'new(\w+)')):
514 path = self.split_path
515 if not path or path[0] in ('', 'index'):
516 self.index()
517 elif len(path) == 1:
518 if path[0] == 'list_classes':
519 self.classes()
520 return
521 m = dre.match(path[0])
522 if m:
523 self.classname = m.group(1)
524 self.nodeid = m.group(2)
525 getattr(self, 'show%s'%self.classname)()
526 return
527 m = nre.match(path[0])
528 if m:
529 self.classname = m.group(1)
530 getattr(self, 'new%s'%self.classname)()
531 return
532 self.classname = path[0]
533 self.list()
534 else:
535 raise 'ValueError', 'Path not understood'
537 def __del__(self):
538 self.db.close()
540 #
541 # $Log: not supported by cvs2svn $
542 # Revision 1.16 2001/08/02 05:55:25 richard
543 # Web edit messages aren't sent to the person who did the edit any more. No
544 # message is generated if they are the only person on the nosy list.
545 #
546 # Revision 1.15 2001/08/02 00:34:10 richard
547 # bleah syntax error
548 #
549 # Revision 1.14 2001/08/02 00:26:16 richard
550 # Changed the order of the information in the message generated by web edits.
551 #
552 # Revision 1.13 2001/07/30 08:12:17 richard
553 # Added time logging and file uploading to the templates.
554 #
555 # Revision 1.12 2001/07/30 06:26:31 richard
556 # Added some documentation on how the newblah works.
557 #
558 # Revision 1.11 2001/07/30 06:17:45 richard
559 # Features:
560 # . Added ability for cgi newblah forms to indicate that the new node
561 # should be linked somewhere.
562 # Fixed:
563 # . Fixed the agument handling for the roundup-admin find command.
564 # . Fixed handling of summary when no note supplied for newblah. Again.
565 # . Fixed detection of no form in htmltemplate Field display.
566 #
567 # Revision 1.10 2001/07/30 02:37:34 richard
568 # Temporary measure until we have decent schema migration...
569 #
570 # Revision 1.9 2001/07/30 01:25:07 richard
571 # Default implementation is now "classic" rather than "extended" as one would
572 # expect.
573 #
574 # Revision 1.8 2001/07/29 08:27:40 richard
575 # Fixed handling of passed-in values in form elements (ie. during a
576 # drill-down)
577 #
578 # Revision 1.7 2001/07/29 07:01:39 richard
579 # Added vim command to all source so that we don't get no steenkin' tabs :)
580 #
581 # Revision 1.6 2001/07/29 04:04:00 richard
582 # Moved some code around allowing for subclassing to change behaviour.
583 #
584 # Revision 1.5 2001/07/28 08:16:52 richard
585 # New issue form handles lack of note better now.
586 #
587 # Revision 1.4 2001/07/28 00:34:34 richard
588 # Fixed some non-string node ids.
589 #
590 # Revision 1.3 2001/07/23 03:56:30 richard
591 # oops, missed a config removal
592 #
593 # Revision 1.2 2001/07/22 12:09:32 richard
594 # Final commit of Grande Splite
595 #
596 # Revision 1.1 2001/07/22 11:58:35 richard
597 # More Grande Splite
598 #
599 #
600 # vim: set filetype=python ts=4 sw=4 et si