88bdc5592f554ca82f493db5bad240cb62da90ef
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.42 2001-10-23 23:56:03 richard Exp $
20 import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes
21 import base64, Cookie, time
23 import roundupdb, htmltemplate, date, hyperdb, password
25 class Unauthorised(ValueError):
26 pass
28 class NotFound(ValueError):
29 pass
31 class Client:
32 '''
33 A note about login
34 ------------------
36 If the user has no login cookie, then they are anonymous. There
37 are two levels of anonymous use. If there is no 'anonymous' user, there
38 is no login at all and the database is opened in read-only mode. If the
39 'anonymous' user exists, the user is logged in using that user (though
40 there is no cookie). This allows them to modify the database, and all
41 modifications are attributed to the 'anonymous' user.
44 Customisation
45 -------------
46 FILTER_POSITION - one of 'top', 'bottom', 'top and bottom'
47 ANONYMOUS_ACCESS - one of 'deny', 'allow'
48 ANONYMOUS_REGISTER - one of 'deny', 'allow'
50 '''
51 FILTER_POSITION = 'bottom' # one of 'top', 'bottom', 'top and bottom'
52 ANONYMOUS_ACCESS = 'deny' # one of 'deny', 'allow'
53 ANONYMOUS_REGISTER = 'deny' # one of 'deny', 'allow'
55 def __init__(self, instance, out, env):
56 self.instance = instance
57 self.out = out
58 self.env = env
59 self.path = env['PATH_INFO']
60 self.split_path = self.path.split('/')
62 self.headers_done = 0
63 self.form = cgi.FieldStorage(environ=env)
64 self.headers_done = 0
65 self.debug = 0
67 def getuid(self):
68 return self.db.user.lookup(self.user)
70 def header(self, headers={'Content-Type':'text/html'}):
71 if not headers.has_key('Content-Type'):
72 headers['Content-Type'] = 'text/html'
73 for entry in headers.items():
74 self.out.write('%s: %s\n'%entry)
75 self.out.write('\n')
76 self.headers_done = 1
78 def pagehead(self, title, message=None):
79 url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
80 machine = self.env['SERVER_NAME']
81 port = self.env['SERVER_PORT']
82 if port != '80': machine = machine + ':' + port
83 base = urlparse.urlunparse(('http', machine, url, None, None, None))
84 if message is not None:
85 message = '<div class="system-msg">%s</div>'%message
86 else:
87 message = ''
88 style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
89 if self.user is not None:
90 userid = self.db.user.lookup(self.user)
91 user_info = '(login: <a href="user%s">%s</a>)'%(userid, self.user)
92 else:
93 user_info = ''
94 self.write('''<html><head>
95 <title>%s</title>
96 <style type="text/css">%s</style>
97 </head>
98 <body bgcolor=#ffffff>
99 %s
100 <table width=100%% border=0 cellspacing=0 cellpadding=2>
101 <tr class="location-bar"><td><big><strong>%s</strong></big> %s</td></tr>
102 </table>
103 '''%(title, style, message, title, user_info))
105 def pagefoot(self):
106 if self.debug:
107 self.write('<hr><small><dl>')
108 self.write('<dt><b>Path</b></dt>')
109 self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
110 keys = self.form.keys()
111 keys.sort()
112 if keys:
113 self.write('<dt><b>Form entries</b></dt>')
114 for k in self.form.keys():
115 v = str(self.form[k].value)
116 self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
117 keys = self.env.keys()
118 keys.sort()
119 self.write('<dt><b>CGI environment</b></dt>')
120 for k in keys:
121 v = self.env[k]
122 self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
123 self.write('</dl></small>')
124 self.write('</body></html>')
126 def write(self, content):
127 if not self.headers_done:
128 self.header()
129 self.out.write(content)
131 def index_arg(self, arg):
132 ''' handle the args to index - they might be a list from the form
133 (ie. submitted from a form) or they might be a command-separated
134 single string (ie. manually constructed GET args)
135 '''
136 if self.form.has_key(arg):
137 arg = self.form[arg]
138 if type(arg) == type([]):
139 return [arg.value for arg in arg]
140 return arg.value.split(',')
141 return []
143 def index_filterspec(self, filter):
144 ''' pull the index filter spec from the form
146 Links and multilinks want to be lists - the rest are straight
147 strings.
148 '''
149 props = self.db.classes[self.classname].getprops()
150 # all the form args not starting with ':' are filters
151 filterspec = {}
152 for key in self.form.keys():
153 if key[0] == ':': continue
154 if not props.has_key(key): continue
155 if key not in filter: continue
156 prop = props[key]
157 value = self.form[key]
158 if (isinstance(prop, hyperdb.Link) or
159 isinstance(prop, hyperdb.Multilink)):
160 if type(value) == type([]):
161 value = [arg.value for arg in value]
162 else:
163 value = value.value.split(',')
164 l = filterspec.get(key, [])
165 l = l + value
166 filterspec[key] = l
167 else:
168 filterspec[key] = value.value
169 return filterspec
171 def customization_widget(self):
172 ''' The customization widget is visible by default. The widget
173 visibility is remembered by show_customization. Visibility
174 is not toggled if the action value is "Redisplay"
175 '''
176 if not self.form.has_key('show_customization'):
177 visible = 1
178 else:
179 visible = int(self.form['show_customization'].value)
180 if self.form.has_key('action'):
181 if self.form['action'].value != 'Redisplay':
182 visible = self.form['action'].value == '+'
184 return visible
186 default_index_sort = ['-activity']
187 default_index_group = ['priority']
188 default_index_filter = ['status']
189 default_index_columns = ['id','activity','title','status','assignedto']
190 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
191 def index(self):
192 ''' put up an index
193 '''
194 self.classname = 'issue'
195 # see if the web has supplied us with any customisation info
196 defaults = 1
197 for key in ':sort', ':group', ':filter', ':columns':
198 if self.form.has_key(key):
199 defaults = 0
200 break
201 if defaults:
202 # no info supplied - use the defaults
203 sort = self.default_index_sort
204 group = self.default_index_group
205 filter = self.default_index_filter
206 columns = self.default_index_columns
207 filterspec = self.default_index_filterspec
208 else:
209 sort = self.index_arg(':sort')
210 group = self.index_arg(':group')
211 filter = self.index_arg(':filter')
212 columns = self.index_arg(':columns')
213 filterspec = self.index_filterspec(filter)
214 return self.list(columns=columns, filter=filter, group=group,
215 sort=sort, filterspec=filterspec)
217 # XXX deviates from spec - loses the '+' (that's a reserved character
218 # in URLS
219 def list(self, sort=None, group=None, filter=None, columns=None,
220 filterspec=None, show_customization=None):
221 ''' call the template index with the args
223 :sort - sort by prop name, optionally preceeded with '-'
224 to give descending or nothing for ascending sorting.
225 :group - group by prop name, optionally preceeded with '-' or
226 to sort in descending or nothing for ascending order.
227 :filter - selects which props should be displayed in the filter
228 section. Default is all.
229 :columns - selects the columns that should be displayed.
230 Default is all.
232 '''
233 cn = self.classname
234 self.pagehead('Index of %s'%cn)
235 if sort is None: sort = self.index_arg(':sort')
236 if group is None: group = self.index_arg(':group')
237 if filter is None: filter = self.index_arg(':filter')
238 if columns is None: columns = self.index_arg(':columns')
239 if filterspec is None: filterspec = self.index_filterspec(filter)
240 if show_customization is None:
241 show_customization = self.customization_widget()
243 index = htmltemplate.IndexTemplate(self, self.TEMPLATES, cn)
244 index.render(filterspec, filter, columns, sort, group,
245 show_customization=show_customization)
246 self.pagefoot()
248 def shownode(self, message=None):
249 ''' display an item
250 '''
251 cn = self.classname
252 cl = self.db.classes[cn]
254 # possibly perform an edit
255 keys = self.form.keys()
256 num_re = re.compile('^\d+$')
257 if keys:
258 try:
259 props, changed = parsePropsFromForm(self.db, cl, self.form,
260 self.nodeid)
261 cl.set(self.nodeid, **props)
262 self._post_editnode(self.nodeid, changed)
263 # and some nice feedback for the user
264 message = '%s edited ok'%', '.join(changed)
265 except:
266 s = StringIO.StringIO()
267 traceback.print_exc(None, s)
268 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
270 # now the display
271 id = self.nodeid
272 if cl.getkey():
273 id = cl.get(id, cl.getkey())
274 self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
276 nodeid = self.nodeid
278 # use the template to display the item
279 item = htmltemplate.ItemTemplate(self, self.TEMPLATES, self.classname)
280 item.render(nodeid)
282 self.pagefoot()
283 showissue = shownode
284 showmsg = shownode
286 def showuser(self, message=None):
287 ''' display an item
288 '''
289 if self.user in ('admin', self.db.user.get(self.nodeid, 'username')):
290 self.shownode(message)
291 else:
292 raise Unauthorised
294 def showfile(self):
295 ''' display a file
296 '''
297 nodeid = self.nodeid
298 cl = self.db.file
299 type = cl.get(nodeid, 'type')
300 if type == 'message/rfc822':
301 type = 'text/plain'
302 self.header(headers={'Content-Type': type})
303 self.write(cl.get(nodeid, 'content'))
305 def _createnode(self):
306 ''' create a node based on the contents of the form
307 '''
308 cl = self.db.classes[self.classname]
309 props, dummy = parsePropsFromForm(self.db, cl, self.form)
310 return cl.create(**props)
312 def _post_editnode(self, nid, changes=None):
313 ''' do the linking and message sending part of the node creation
314 '''
315 cn = self.classname
316 cl = self.db.classes[cn]
317 # link if necessary
318 keys = self.form.keys()
319 for key in keys:
320 if key == ':multilink':
321 value = self.form[key].value
322 if type(value) != type([]): value = [value]
323 for value in value:
324 designator, property = value.split(':')
325 link, nodeid = roundupdb.splitDesignator(designator)
326 link = self.db.classes[link]
327 value = link.get(nodeid, property)
328 value.append(nid)
329 link.set(nodeid, **{property: value})
330 elif key == ':link':
331 value = self.form[key].value
332 if type(value) != type([]): value = [value]
333 for value in value:
334 designator, property = value.split(':')
335 link, nodeid = roundupdb.splitDesignator(designator)
336 link = self.db.classes[link]
337 link.set(nodeid, **{property: nid})
339 # generate an edit message
340 # don't bother if there's no messages or nosy list
341 props = cl.getprops()
342 note = None
343 if self.form.has_key('__note'):
344 note = self.form['__note']
345 note = note.value
346 send = len(cl.get(nid, 'nosy', [])) or note
347 if (send and props.has_key('messages') and
348 isinstance(props['messages'], hyperdb.Multilink) and
349 props['messages'].classname == 'msg'):
351 # handle the note
352 if note:
353 if '\n' in note:
354 summary = re.split(r'\n\r?', note)[0]
355 else:
356 summary = note
357 m = ['%s\n'%note]
358 else:
359 summary = 'This %s has been edited through the web.\n'%cn
360 m = [summary]
362 first = 1
363 for name, prop in props.items():
364 if changes is not None and name not in changes: continue
365 if first:
366 m.append('\n-------')
367 first = 0
368 value = cl.get(nid, name, None)
369 if isinstance(prop, hyperdb.Link):
370 link = self.db.classes[prop.classname]
371 key = link.labelprop(default_to_id=1)
372 if value is not None and key:
373 value = link.get(value, key)
374 else:
375 value = '-'
376 elif isinstance(prop, hyperdb.Multilink):
377 if value is None: value = []
378 l = []
379 link = self.db.classes[prop.classname]
380 key = link.labelprop(default_to_id=1)
381 for entry in value:
382 if key:
383 l.append(link.get(entry, key))
384 else:
385 l.append(entry)
386 value = ', '.join(l)
387 m.append('%s: %s'%(name, value))
389 # now create the message
390 content = '\n'.join(m)
391 message_id = self.db.msg.create(author=self.getuid(),
392 recipients=[], date=date.Date('.'), summary=summary,
393 content=content)
394 messages = cl.get(nid, 'messages')
395 messages.append(message_id)
396 props = {'messages': messages}
397 cl.set(nid, **props)
399 def newnode(self, message=None):
400 ''' Add a new node to the database.
402 The form works in two modes: blank form and submission (that is,
403 the submission goes to the same URL). **Eventually this means that
404 the form will have previously entered information in it if
405 submission fails.
407 The new node will be created with the properties specified in the
408 form submission. For multilinks, multiple form entries are handled,
409 as are prop=value,value,value. You can't mix them though.
411 If the new node is to be referenced from somewhere else immediately
412 (ie. the new node is a file that is to be attached to a support
413 issue) then supply one of these arguments in addition to the usual
414 form entries:
415 :link=designator:property
416 :multilink=designator:property
417 ... which means that once the new node is created, the "property"
418 on the node given by "designator" should now reference the new
419 node's id. The node id will be appended to the multilink.
420 '''
421 cn = self.classname
422 cl = self.db.classes[cn]
424 # possibly perform a create
425 keys = self.form.keys()
426 if [i for i in keys if i[0] != ':']:
427 props = {}
428 try:
429 nid = self._createnode()
430 self._post_editnode(nid)
431 # and some nice feedback for the user
432 message = '%s created ok'%cn
433 except:
434 s = StringIO.StringIO()
435 traceback.print_exc(None, s)
436 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
437 self.pagehead('New %s'%self.classname.capitalize(), message)
439 # call the template
440 newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
441 self.classname)
442 newitem.render(self.form)
444 self.pagefoot()
445 newissue = newnode
446 newuser = newnode
448 def newfile(self, message=None):
449 ''' Add a new file to the database.
451 This form works very much the same way as newnode - it just has a
452 file upload.
453 '''
454 cn = self.classname
455 cl = self.db.classes[cn]
457 # possibly perform a create
458 keys = self.form.keys()
459 if [i for i in keys if i[0] != ':']:
460 try:
461 file = self.form['content']
462 type = mimetypes.guess_type(file.filename)[0]
463 if not type:
464 type = "application/octet-stream"
465 self._post_editnode(cl.create(content=file.file.read(),
466 type=type, name=file.filename))
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())
474 self.pagehead('New %s'%self.classname.capitalize(), message)
475 newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
476 self.classname)
477 newitem.render(self.form)
478 self.pagefoot()
480 def classes(self, message=None):
481 ''' display a list of all the classes in the database
482 '''
483 if self.user == 'admin':
484 self.pagehead('Table of classes', message)
485 classnames = self.db.classes.keys()
486 classnames.sort()
487 self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
488 for cn in classnames:
489 cl = self.db.getclass(cn)
490 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
491 for key, value in cl.properties.items():
492 if value is None: value = ''
493 else: value = str(value)
494 self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
495 key, cgi.escape(value)))
496 self.write('</table>')
497 self.pagefoot()
498 else:
499 raise Unauthorised
501 def login(self, message=None):
502 self.pagehead('Login to roundup', message)
503 self.write('''
504 <table>
505 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
506 <form action="login_action" method=POST>
507 <tr><td align=right>Login name: </td>
508 <td><input name="__login_name"></td></tr>
509 <tr><td align=right>Password: </td>
510 <td><input type="password" name="__login_password"></td></tr>
511 <tr><td></td>
512 <td><input type="submit" value="Log In"></td></tr>
513 </form>
514 ''')
515 if self.user is None and self.ANONYMOUS_REGISTER == 'deny':
516 self.write('</table>')
517 return
518 self.write('''
519 <p>
520 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
521 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
522 <form action="newuser_action" method=POST>
523 <tr><td align=right><em>Name: </em></td>
524 <td><input name="__newuser_realname"></td></tr>
525 <tr><td align=right><em>Organisation: </em></td>
526 <td><input name="__newuser_organisation"></td></tr>
527 <tr><td align=right>E-Mail Address: </td>
528 <td><input name="__newuser_address"></td></tr>
529 <tr><td align=right><em>Phone: </em></td>
530 <td><input name="__newuser_phone"></td></tr>
531 <tr><td align=right>Preferred Login name: </td>
532 <td><input name="__newuser_username"></td></tr>
533 <tr><td align=right>Password: </td>
534 <td><input type="password" name="__newuser_password"></td></tr>
535 <tr><td align=right>Password Again: </td>
536 <td><input type="password" name="__newuser_confirm"></td></tr>
537 <tr><td></td>
538 <td><input type="submit" value="Register"></td></tr>
539 </form>
540 </table>
541 ''')
543 def login_action(self, message=None):
544 if not self.form.has_key('__login_name'):
545 return self.login(message='Username required')
546 self.user = self.form['__login_name'].value
547 if self.form.has_key('__login_password'):
548 password = self.form['__login_password'].value
549 else:
550 password = ''
551 print self.user, password
552 # make sure the user exists
553 try:
554 uid = self.db.user.lookup(self.user)
555 except KeyError:
556 name = self.user
557 self.make_user_anonymous()
558 return self.login(message='No such user "%s"'%name)
560 # and that the password is correct
561 pw = self.db.user.get(uid, 'password')
562 if password != self.db.user.get(uid, 'password'):
563 self.make_user_anonymous()
564 return self.login(message='Incorrect password')
566 # construct the cookie
567 uid = self.db.user.lookup(self.user)
568 user = base64.encodestring('%s:%s'%(self.user, password))[:-1]
569 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
570 ''))
571 self.header({'Set-Cookie': 'roundup_user=%s; Path=%s;'%(user, path)})
572 return self.index()
574 def make_user_anonymous(self):
575 # make us anonymous if we can
576 try:
577 self.db.user.lookup('anonymous')
578 self.user = 'anonymous'
579 except KeyError:
580 self.user = None
582 def logout(self, message=None):
583 self.make_user_anonymous()
584 # construct the logout cookie
585 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
586 ''))
587 now = Cookie._getdate()
588 self.header({'Set-Cookie':
589 'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)})
590 return self.login()
592 def newuser_action(self, message=None):
593 ''' create a new user based on the contents of the form and then
594 set the cookie
595 '''
596 # re-open the database as "admin"
597 self.db.close()
598 self.db = self.instance.open('admin')
600 # TODO: pre-check the required fields and username key property
601 cl = self.db.classes['user']
602 props, dummy = parsePropsFromForm(self.db, cl, self.form)
603 uid = cl.create(**props)
604 self.user = self.db.user.get(uid, 'username')
605 password = self.db.user.get(uid, 'password')
606 # construct the cookie
607 uid = self.db.user.lookup(self.user)
608 user = base64.encodestring('%s:%s'%(self.user, password))[:-1]
609 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
610 ''))
611 self.header({'Set-Cookie': 'roundup_user=%s; Path=%s;'%(user, path)})
612 return self.index()
614 def main(self, dre=re.compile(r'([^\d]+)(\d+)'),
615 nre=re.compile(r'new(\w+)')):
617 # determine the uid to use
618 self.db = self.instance.open('admin')
619 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
620 user = 'anonymous'
621 if (cookie.has_key('roundup_user') and
622 cookie['roundup_user'].value != 'deleted'):
623 cookie = cookie['roundup_user'].value
624 user, password = base64.decodestring(cookie).split(':')
625 # make sure the user exists
626 try:
627 uid = self.db.user.lookup(user)
628 # now validate the password
629 if password != self.db.user.get(uid, 'password'):
630 user = 'anonymous'
631 except KeyError:
632 user = 'anonymous'
634 # make sure the anonymous user is valid if we're using it
635 if user == 'anonymous':
636 self.make_user_anonymous()
637 else:
638 self.user = user
639 self.db.close()
641 # re-open the database for real, using the user
642 self.db = self.instance.open(self.user)
644 # now figure which function to call
645 path = self.split_path
646 if not path or path[0] in ('', 'index'):
647 action = 'index'
648 else:
649 action = path[0]
651 # Everthing ignores path[1:]
652 # - The file download link generator actually relies on this - it
653 # appends the name of the file to the URL so the download file name
654 # is correct, but doesn't actually use it.
656 # everyone is allowed to try to log in
657 if action == 'login_action':
658 return self.login_action()
660 # if we don't have a login and anonymous people aren't allowed to
661 # register, then spit up the login form
662 if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
663 return self.login()
665 # allow anonymous people to register
666 if action == 'newuser_action':
667 return self.newuser_action()
669 # make sure totally anonymous access is OK
670 if self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
671 return self.login()
673 # here be the "normal" functionality
674 if action == 'index':
675 return self.index()
676 if action == 'list_classes':
677 return self.classes()
678 if action == 'login':
679 return self.login()
680 if action == 'logout':
681 return self.logout()
682 m = dre.match(action)
683 if m:
684 self.classname = m.group(1)
685 self.nodeid = m.group(2)
686 try:
687 cl = self.db.classes[self.classname]
688 except KeyError:
689 raise NotFound
690 try:
691 cl.get(self.nodeid, 'id')
692 except IndexError:
693 raise NotFound
694 try:
695 func = getattr(self, 'show%s'%self.classname)
696 except AttributeError:
697 raise NotFound
698 return func()
699 m = nre.match(action)
700 if m:
701 self.classname = m.group(1)
702 try:
703 func = getattr(self, 'new%s'%self.classname)
704 except AttributeError:
705 raise NotFound
706 return func()
707 self.classname = action
708 try:
709 self.db.getclass(self.classname)
710 except KeyError:
711 raise NotFound
712 self.list()
714 def __del__(self):
715 self.db.close()
718 class ExtendedClient(Client):
719 '''Includes pages and page heading information that relate to the
720 extended schema.
721 '''
722 showsupport = Client.shownode
723 showtimelog = Client.shownode
724 newsupport = Client.newnode
725 newtimelog = Client.newnode
727 default_index_sort = ['-activity']
728 default_index_group = ['priority']
729 default_index_filter = ['status']
730 default_index_columns = ['activity','status','title','assignedto']
731 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
733 def pagehead(self, title, message=None):
734 url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
735 machine = self.env['SERVER_NAME']
736 port = self.env['SERVER_PORT']
737 if port != '80': machine = machine + ':' + port
738 base = urlparse.urlunparse(('http', machine, url, None, None, None))
739 if message is not None:
740 message = '<div class="system-msg">%s</div>'%message
741 else:
742 message = ''
743 style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
744 user_name = self.user or ''
745 if self.user == 'admin':
746 admin_links = ' | <a href="list_classes">Class List</a>'
747 else:
748 admin_links = ''
749 if self.user not in (None, 'anonymous'):
750 userid = self.db.user.lookup(self.user)
751 user_info = '''
752 <a href="issue?assignedto=%s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=activity&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">My Issues</a> |
753 <a href="support?assignedto=%s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=activity&:columns=id,activity,status,title,assignedto&:group=customername&show_customization=1">My Support</a> |
754 <a href="user%s">My Details</a> | <a href="logout">Logout</a>
755 '''%(userid, userid, userid)
756 else:
757 user_info = '<a href="login">Login</a>'
758 if self.user is not None:
759 add_links = '''
760 | Add
761 <a href="newissue">Issue</a>,
762 <a href="newsupport">Support</a>,
763 <a href="newuser">User</a>
764 '''
765 else:
766 add_links = ''
767 self.write('''<html><head>
768 <title>%s</title>
769 <style type="text/css">%s</style>
770 </head>
771 <body bgcolor=#ffffff>
772 %s
773 <table width=100%% border=0 cellspacing=0 cellpadding=2>
774 <tr class="location-bar"><td><big><strong>%s</strong></big></td>
775 <td align=right valign=bottom>%s</td></tr>
776 <tr class="location-bar">
777 <td align=left>All
778 <a href="issue?status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>,
779 <a href="support?status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status&:columns=id,activity,status,title,assignedto&:group=customername&show_customization=1">Support</a>
780 | Unassigned
781 <a href="issue?assignedto=-1&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status,assignedto&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>,
782 <a href="support?assignedto=-1&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status,assignedto&:columns=id,activity,status,title,assignedto&:group=customername&show_customization=1">Support</a>
783 %s
784 %s</td>
785 <td align=right>%s</td>
786 </table>
787 '''%(title, style, message, title, user_name, add_links, admin_links,
788 user_info))
790 def parsePropsFromForm(db, cl, form, nodeid=0):
791 '''Pull properties for the given class out of the form.
792 '''
793 props = {}
794 changed = []
795 keys = form.keys()
796 num_re = re.compile('^\d+$')
797 for key in keys:
798 if not cl.properties.has_key(key):
799 continue
800 proptype = cl.properties[key]
801 if isinstance(proptype, hyperdb.String):
802 value = form[key].value.strip()
803 elif isinstance(proptype, hyperdb.Password):
804 value = password.Password(form[key].value.strip())
805 elif isinstance(proptype, hyperdb.Date):
806 value = date.Date(form[key].value.strip())
807 elif isinstance(proptype, hyperdb.Interval):
808 value = date.Interval(form[key].value.strip())
809 elif isinstance(proptype, hyperdb.Link):
810 value = form[key].value.strip()
811 # see if it's the "no selection" choice
812 if value == '-1':
813 # don't set this property
814 continue
815 else:
816 # handle key values
817 link = cl.properties[key].classname
818 if not num_re.match(value):
819 try:
820 value = db.classes[link].lookup(value)
821 except KeyError:
822 raise ValueError, 'property "%s": %s not a %s'%(
823 key, value, link)
824 elif isinstance(proptype, hyperdb.Multilink):
825 value = form[key]
826 if type(value) != type([]):
827 value = [i.strip() for i in value.value.split(',')]
828 else:
829 value = [i.value.strip() for i in value]
830 link = cl.properties[key].classname
831 l = []
832 for entry in map(str, value):
833 if not num_re.match(entry):
834 try:
835 entry = db.classes[link].lookup(entry)
836 except KeyError:
837 raise ValueError, \
838 'property "%s": "%s" not an entry of %s'%(key,
839 entry, link.capitalize())
840 l.append(entry)
841 l.sort()
842 value = l
843 props[key] = value
844 # if changed, set it
845 if nodeid and value != cl.get(nodeid, key):
846 changed.append(key)
847 props[key] = value
848 return props, changed
850 #
851 # $Log: not supported by cvs2svn $
852 # Revision 1.41 2001/10/23 23:52:35 richard
853 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
854 #
855 # Revision 1.40 2001/10/23 23:06:39 richard
856 # Some cleanup.
857 #
858 # Revision 1.39 2001/10/23 01:00:18 richard
859 # Re-enabled login and registration access after lopping them off via
860 # disabling access for anonymous users.
861 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
862 # a couple of bugs while I was there. Probably introduced a couple, but
863 # things seem to work OK at the moment.
864 #
865 # Revision 1.38 2001/10/22 03:25:01 richard
866 # Added configuration for:
867 # . anonymous user access and registration (deny/allow)
868 # . filter "widget" location on index page (top, bottom, both)
869 # Updated some documentation.
870 #
871 # Revision 1.37 2001/10/21 07:26:35 richard
872 # feature #473127: Filenames. I modified the file.index and htmltemplate
873 # source so that the filename is used in the link and the creation
874 # information is displayed.
875 #
876 # Revision 1.36 2001/10/21 04:44:50 richard
877 # bug #473124: UI inconsistency with Link fields.
878 # This also prompted me to fix a fairly long-standing usability issue -
879 # that of being able to turn off certain filters.
880 #
881 # Revision 1.35 2001/10/21 00:17:54 richard
882 # CGI interface view customisation section may now be hidden (patch from
883 # Roch'e Compaan.)
884 #
885 # Revision 1.34 2001/10/20 11:58:48 richard
886 # Catch errors in login - no username or password supplied.
887 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
888 #
889 # Revision 1.33 2001/10/17 00:18:41 richard
890 # Manually constructing cookie headers now.
891 #
892 # Revision 1.32 2001/10/16 03:36:21 richard
893 # CGI interface wasn't handling checkboxes at all.
894 #
895 # Revision 1.31 2001/10/14 10:55:00 richard
896 # Handle empty strings in HTML template Link function
897 #
898 # Revision 1.30 2001/10/09 07:38:58 richard
899 # Pushed the base code for the extended schema CGI interface back into the
900 # code cgi_client module so that future updates will be less painful.
901 # Also removed a debugging print statement from cgi_client.
902 #
903 # Revision 1.29 2001/10/09 07:25:59 richard
904 # Added the Password property type. See "pydoc roundup.password" for
905 # implementation details. Have updated some of the documentation too.
906 #
907 # Revision 1.28 2001/10/08 00:34:31 richard
908 # Change message was stuffing up for multilinks with no key property.
909 #
910 # Revision 1.27 2001/10/05 02:23:24 richard
911 # . roundup-admin create now prompts for property info if none is supplied
912 # on the command-line.
913 # . hyperdb Class getprops() method may now return only the mutable
914 # properties.
915 # . Login now uses cookies, which makes it a whole lot more flexible. We can
916 # now support anonymous user access (read-only, unless there's an
917 # "anonymous" user, in which case write access is permitted). Login
918 # handling has been moved into cgi_client.Client.main()
919 # . The "extended" schema is now the default in roundup init.
920 # . The schemas have had their page headings modified to cope with the new
921 # login handling. Existing installations should copy the interfaces.py
922 # file from the roundup lib directory to their instance home.
923 # . Incorrectly had a Bizar Software copyright on the cgitb.py module from
924 # Ping - has been removed.
925 # . Fixed a whole bunch of places in the CGI interface where we should have
926 # been returning Not Found instead of throwing an exception.
927 # . Fixed a deviation from the spec: trying to modify the 'id' property of
928 # an item now throws an exception.
929 #
930 # Revision 1.26 2001/09/12 08:31:42 richard
931 # handle cases where mime type is not guessable
932 #
933 # Revision 1.25 2001/08/29 05:30:49 richard
934 # change messages weren't being saved when there was no-one on the nosy list.
935 #
936 # Revision 1.24 2001/08/29 04:49:39 richard
937 # didn't clean up fully after debugging :(
938 #
939 # Revision 1.23 2001/08/29 04:47:18 richard
940 # Fixed CGI client change messages so they actually include the properties
941 # changed (again).
942 #
943 # Revision 1.22 2001/08/17 00:08:10 richard
944 # reverted back to sending messages always regardless of who is doing the web
945 # edit. change notes weren't being saved. bleah. hackish.
946 #
947 # Revision 1.21 2001/08/15 23:43:18 richard
948 # Fixed some isFooTypes that I missed.
949 # Refactored some code in the CGI code.
950 #
951 # Revision 1.20 2001/08/12 06:32:36 richard
952 # using isinstance(blah, Foo) now instead of isFooType
953 #
954 # Revision 1.19 2001/08/07 00:24:42 richard
955 # stupid typo
956 #
957 # Revision 1.18 2001/08/07 00:15:51 richard
958 # Added the copyright/license notice to (nearly) all files at request of
959 # Bizar Software.
960 #
961 # Revision 1.17 2001/08/02 06:38:17 richard
962 # Roundupdb now appends "mailing list" information to its messages which
963 # include the e-mail address and web interface address. Templates may
964 # override this in their db classes to include specific information (support
965 # instructions, etc).
966 #
967 # Revision 1.16 2001/08/02 05:55:25 richard
968 # Web edit messages aren't sent to the person who did the edit any more. No
969 # message is generated if they are the only person on the nosy list.
970 #
971 # Revision 1.15 2001/08/02 00:34:10 richard
972 # bleah syntax error
973 #
974 # Revision 1.14 2001/08/02 00:26:16 richard
975 # Changed the order of the information in the message generated by web edits.
976 #
977 # Revision 1.13 2001/07/30 08:12:17 richard
978 # Added time logging and file uploading to the templates.
979 #
980 # Revision 1.12 2001/07/30 06:26:31 richard
981 # Added some documentation on how the newblah works.
982 #
983 # Revision 1.11 2001/07/30 06:17:45 richard
984 # Features:
985 # . Added ability for cgi newblah forms to indicate that the new node
986 # should be linked somewhere.
987 # Fixed:
988 # . Fixed the agument handling for the roundup-admin find command.
989 # . Fixed handling of summary when no note supplied for newblah. Again.
990 # . Fixed detection of no form in htmltemplate Field display.
991 #
992 # Revision 1.10 2001/07/30 02:37:34 richard
993 # Temporary measure until we have decent schema migration...
994 #
995 # Revision 1.9 2001/07/30 01:25:07 richard
996 # Default implementation is now "classic" rather than "extended" as one would
997 # expect.
998 #
999 # Revision 1.8 2001/07/29 08:27:40 richard
1000 # Fixed handling of passed-in values in form elements (ie. during a
1001 # drill-down)
1002 #
1003 # Revision 1.7 2001/07/29 07:01:39 richard
1004 # Added vim command to all source so that we don't get no steenkin' tabs :)
1005 #
1006 # Revision 1.6 2001/07/29 04:04:00 richard
1007 # Moved some code around allowing for subclassing to change behaviour.
1008 #
1009 # Revision 1.5 2001/07/28 08:16:52 richard
1010 # New issue form handles lack of note better now.
1011 #
1012 # Revision 1.4 2001/07/28 00:34:34 richard
1013 # Fixed some non-string node ids.
1014 #
1015 # Revision 1.3 2001/07/23 03:56:30 richard
1016 # oops, missed a config removal
1017 #
1018 # Revision 1.2 2001/07/22 12:09:32 richard
1019 # Final commit of Grande Splite
1020 #
1021 # Revision 1.1 2001/07/22 11:58:35 richard
1022 # More Grande Splite
1023 #
1024 #
1025 # vim: set filetype=python ts=4 sw=4 et si