Code

d35cfa552f15ec0aec4a3d204743856bdd8f33ce
[roundup.git] / roundup / cgi_client.py
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.33 2001-10-17 00:18:41 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.
42     '''
44     def __init__(self, instance, out, env):
45         self.instance = instance
46         self.out = out
47         self.env = env
48         self.path = env['PATH_INFO']
49         self.split_path = self.path.split('/')
51         self.headers_done = 0
52         self.form = cgi.FieldStorage(environ=env)
53         self.headers_done = 0
54         self.debug = 0
56     def getuid(self):
57         return self.db.user.lookup(self.user)
59     def header(self, headers={'Content-Type':'text/html'}):
60         if not headers.has_key('Content-Type'):
61             headers['Content-Type'] = 'text/html'
62         for entry in headers.items():
63             self.out.write('%s: %s\n'%entry)
64         self.out.write('\n')
65         self.headers_done = 1
67     def pagehead(self, title, message=None):
68         url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
69         machine = self.env['SERVER_NAME']
70         port = self.env['SERVER_PORT']
71         if port != '80': machine = machine + ':' + port
72         base = urlparse.urlunparse(('http', machine, url, None, None, None))
73         if message is not None:
74             message = '<div class="system-msg">%s</div>'%message
75         else:
76             message = ''
77         style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
78         if self.user is not None:
79             userid = self.db.user.lookup(self.user)
80             user_info = '(login: <a href="user%s">%s</a>)'%(userid, self.user)
81         else:
82             user_info = ''
83         self.write('''<html><head>
84 <title>%s</title>
85 <style type="text/css">%s</style>
86 </head>
87 <body bgcolor=#ffffff>
88 %s
89 <table width=100%% border=0 cellspacing=0 cellpadding=2>
90 <tr class="location-bar"><td><big><strong>%s</strong></big> %s</td></tr>
91 </table>
92 '''%(title, style, message, title, user_info))
94     def pagefoot(self):
95         if self.debug:
96             self.write('<hr><small><dl>')
97             self.write('<dt><b>Path</b></dt>')
98             self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
99             keys = self.form.keys()
100             keys.sort()
101             if keys:
102                 self.write('<dt><b>Form entries</b></dt>')
103                 for k in self.form.keys():
104                     v = str(self.form[k].value)
105                     self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
106             keys = self.env.keys()
107             keys.sort()
108             self.write('<dt><b>CGI environment</b></dt>')
109             for k in keys:
110                 v = self.env[k]
111                 self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
112             self.write('</dl></small>')
113         self.write('</body></html>')
115     def write(self, content):
116         if not self.headers_done:
117             self.header()
118         self.out.write(content)
120     def index_arg(self, arg):
121         ''' handle the args to index - they might be a list from the form
122             (ie. submitted from a form) or they might be a command-separated
123             single string (ie. manually constructed GET args)
124         '''
125         if self.form.has_key(arg):
126             arg =  self.form[arg]
127             if type(arg) == type([]):
128                 return [arg.value for arg in arg]
129             return arg.value.split(',')
130         return []
132     def index_filterspec(self):
133         ''' pull the index filter spec from the form
135         Links and multilinks want to be lists - the rest are straight
136         strings.
137         '''
138         props = self.db.classes[self.classname].getprops()
139         # all the form args not starting with ':' are filters
140         filterspec = {}
141         for key in self.form.keys():
142             if key[0] == ':': continue
143             if not props.has_key(key): continue
144             prop = props[key]
145             value = self.form[key]
146             if (isinstance(prop, hyperdb.Link) or
147                     isinstance(prop, hyperdb.Multilink)):
148                 if type(value) == type([]):
149                     value = [arg.value for arg in value]
150                 else:
151                     value = value.value.split(',')
152                 l = filterspec.get(key, [])
153                 l = l + value
154                 filterspec[key] = l
155             else:
156                 filterspec[key] = value.value
157         return filterspec
159     default_index_sort = ['-activity']
160     default_index_group = ['priority']
161     default_index_filter = []
162     default_index_columns = ['id','activity','title','status','assignedto']
163     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
164     def index(self):
165         ''' put up an index
166         '''
167         self.classname = 'issue'
168         if self.form.has_key(':sort'): sort = self.index_arg(':sort')
169         else: sort = self.default_index_sort
170         if self.form.has_key(':group'): group = self.index_arg(':group')
171         else: group = self.default_index_group
172         if self.form.has_key(':filter'): filter = self.index_arg(':filter')
173         else: filter = self.default_index_filter
174         if self.form.has_key(':columns'): columns = self.index_arg(':columns')
175         else: columns = self.default_index_columns
176         filterspec = self.index_filterspec()
177         if not filterspec:
178             filterspec = self.default_index_filterspec
179         return self.list(columns=columns, filter=filter, group=group,
180             sort=sort, filterspec=filterspec)
182     # XXX deviates from spec - loses the '+' (that's a reserved character
183     # in URLS
184     def list(self, sort=None, group=None, filter=None, columns=None,
185             filterspec=None):
186         ''' call the template index with the args
188             :sort    - sort by prop name, optionally preceeded with '-'
189                      to give descending or nothing for ascending sorting.
190             :group   - group by prop name, optionally preceeded with '-' or
191                      to sort in descending or nothing for ascending order.
192             :filter  - selects which props should be displayed in the filter
193                      section. Default is all.
194             :columns - selects the columns that should be displayed.
195                      Default is all.
197         '''
198         cn = self.classname
199         self.pagehead('Index of %s'%cn)
200         if sort is None: sort = self.index_arg(':sort')
201         if group is None: group = self.index_arg(':group')
202         if filter is None: filter = self.index_arg(':filter')
203         if columns is None: columns = self.index_arg(':columns')
204         if filterspec is None: filterspec = self.index_filterspec()
206         htmltemplate.index(self, self.TEMPLATES, self.db, cn, filterspec,
207             filter, columns, sort, group)
208         self.pagefoot()
210     def shownode(self, message=None):
211         ''' display an item
212         '''
213         cn = self.classname
214         cl = self.db.classes[cn]
216         # possibly perform an edit
217         keys = self.form.keys()
218         num_re = re.compile('^\d+$')
219         if keys:
220             try:
221                 props, changed = parsePropsFromForm(self.db, cl, self.form,
222                     self.nodeid)
223                 cl.set(self.nodeid, **props)
224                 self._post_editnode(self.nodeid, changed)
225                 # and some nice feedback for the user
226                 message = '%s edited ok'%', '.join(changed)
227             except:
228                 s = StringIO.StringIO()
229                 traceback.print_exc(None, s)
230                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
232         # now the display
233         id = self.nodeid
234         if cl.getkey():
235             id = cl.get(id, cl.getkey())
236         self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
238         nodeid = self.nodeid
240         # use the template to display the item
241         htmltemplate.item(self, self.TEMPLATES, self.db, self.classname, nodeid)
242         self.pagefoot()
243     showissue = shownode
244     showmsg = shownode
246     def showuser(self, message=None):
247         ''' display an item
248         '''
249         if self.user in ('admin', self.db.user.get(self.nodeid, 'username')):
250             self.shownode(message)
251         else:
252             raise Unauthorised
254     def showfile(self):
255         ''' display a file
256         '''
257         nodeid = self.nodeid
258         cl = self.db.file
259         type = cl.get(nodeid, 'type')
260         if type == 'message/rfc822':
261             type = 'text/plain'
262         self.header(headers={'Content-Type': type})
263         self.write(cl.get(nodeid, 'content'))
265     def _createnode(self):
266         ''' create a node based on the contents of the form
267         '''
268         cl = self.db.classes[self.classname]
269         props, dummy = parsePropsFromForm(self.db, cl, self.form)
270         return cl.create(**props)
272     def _post_editnode(self, nid, changes=None):
273         ''' do the linking and message sending part of the node creation
274         '''
275         cn = self.classname
276         cl = self.db.classes[cn]
277         # link if necessary
278         keys = self.form.keys()
279         for key in keys:
280             if key == ':multilink':
281                 value = self.form[key].value
282                 if type(value) != type([]): value = [value]
283                 for value in value:
284                     designator, property = value.split(':')
285                     link, nodeid = roundupdb.splitDesignator(designator)
286                     link = self.db.classes[link]
287                     value = link.get(nodeid, property)
288                     value.append(nid)
289                     link.set(nodeid, **{property: value})
290             elif key == ':link':
291                 value = self.form[key].value
292                 if type(value) != type([]): value = [value]
293                 for value in value:
294                     designator, property = value.split(':')
295                     link, nodeid = roundupdb.splitDesignator(designator)
296                     link = self.db.classes[link]
297                     link.set(nodeid, **{property: nid})
299         # generate an edit message
300         # don't bother if there's no messages or nosy list 
301         props = cl.getprops()
302         note = None
303         if self.form.has_key('__note'):
304             note = self.form['__note']
305             note = note.value
306         send = len(cl.get(nid, 'nosy', [])) or note
307         if (send and props.has_key('messages') and
308                 isinstance(props['messages'], hyperdb.Multilink) and
309                 props['messages'].classname == 'msg'):
311             # handle the note
312             if note:
313                 if '\n' in note:
314                     summary = re.split(r'\n\r?', note)[0]
315                 else:
316                     summary = note
317                 m = ['%s\n'%note]
318             else:
319                 summary = 'This %s has been edited through the web.\n'%cn
320                 m = [summary]
322             first = 1
323             for name, prop in props.items():
324                 if changes is not None and name not in changes: continue
325                 if first:
326                     m.append('\n-------')
327                     first = 0
328                 value = cl.get(nid, name, None)
329                 if isinstance(prop, hyperdb.Link):
330                     link = self.db.classes[prop.classname]
331                     key = link.labelprop(default_to_id=1)
332                     if value is not None and key:
333                         value = link.get(value, key)
334                     else:
335                         value = '-'
336                 elif isinstance(prop, hyperdb.Multilink):
337                     if value is None: value = []
338                     l = []
339                     link = self.db.classes[prop.classname]
340                     key = link.labelprop(default_to_id=1)
341                     for entry in value:
342                         if key:
343                             l.append(link.get(entry, key))
344                         else:
345                             l.append(entry)
346                     value = ', '.join(l)
347                 m.append('%s: %s'%(name, value))
349             # now create the message
350             content = '\n'.join(m)
351             message_id = self.db.msg.create(author=self.getuid(),
352                 recipients=[], date=date.Date('.'), summary=summary,
353                 content=content)
354             messages = cl.get(nid, 'messages')
355             messages.append(message_id)
356             props = {'messages': messages}
357             cl.set(nid, **props)
359     def newnode(self, message=None):
360         ''' Add a new node to the database.
361         
362         The form works in two modes: blank form and submission (that is,
363         the submission goes to the same URL). **Eventually this means that
364         the form will have previously entered information in it if
365         submission fails.
367         The new node will be created with the properties specified in the
368         form submission. For multilinks, multiple form entries are handled,
369         as are prop=value,value,value. You can't mix them though.
371         If the new node is to be referenced from somewhere else immediately
372         (ie. the new node is a file that is to be attached to a support
373         issue) then supply one of these arguments in addition to the usual
374         form entries:
375             :link=designator:property
376             :multilink=designator:property
377         ... which means that once the new node is created, the "property"
378         on the node given by "designator" should now reference the new
379         node's id. The node id will be appended to the multilink.
380         '''
381         cn = self.classname
382         cl = self.db.classes[cn]
384         # possibly perform a create
385         keys = self.form.keys()
386         if [i for i in keys if i[0] != ':']:
387             props = {}
388             try:
389                 nid = self._createnode()
390                 self._post_editnode(nid)
391                 # and some nice feedback for the user
392                 message = '%s created ok'%cn
393             except:
394                 s = StringIO.StringIO()
395                 traceback.print_exc(None, s)
396                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
397         self.pagehead('New %s'%self.classname.capitalize(), message)
398         htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
399             self.form)
400         self.pagefoot()
401     newissue = newnode
402     newuser = newnode
404     def newfile(self, message=None):
405         ''' Add a new file to the database.
406         
407         This form works very much the same way as newnode - it just has a
408         file upload.
409         '''
410         cn = self.classname
411         cl = self.db.classes[cn]
413         # possibly perform a create
414         keys = self.form.keys()
415         if [i for i in keys if i[0] != ':']:
416             try:
417                 file = self.form['content']
418                 type = mimetypes.guess_type(file.filename)[0]
419                 if not type:
420                     type = "application/octet-stream"
421                 self._post_editnode(cl.create(content=file.file.read(),
422                     type=type, name=file.filename))
423                 # and some nice feedback for the user
424                 message = '%s created ok'%cn
425             except:
426                 s = StringIO.StringIO()
427                 traceback.print_exc(None, s)
428                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
430         self.pagehead('New %s'%self.classname.capitalize(), message)
431         htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
432             self.form)
433         self.pagefoot()
435     def classes(self, message=None):
436         ''' display a list of all the classes in the database
437         '''
438         if self.user == 'admin':
439             self.pagehead('Table of classes', message)
440             classnames = self.db.classes.keys()
441             classnames.sort()
442             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
443             for cn in classnames:
444                 cl = self.db.getclass(cn)
445                 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
446                 for key, value in cl.properties.items():
447                     if value is None: value = ''
448                     else: value = str(value)
449                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
450                         key, cgi.escape(value)))
451             self.write('</table>')
452             self.pagefoot()
453         else:
454             raise Unauthorised
456     def login(self, message=None):
457         self.pagehead('Login to roundup', message)
458         self.write('''
459 <table>
460 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
461 <form action="login_action" method=POST>
462 <tr><td align=right>Login name: </td>
463     <td><input name="__login_name"></td></tr>
464 <tr><td align=right>Password: </td>
465     <td><input type="password" name="__login_password"></td></tr>
466 <tr><td></td>
467     <td><input type="submit" value="Log In"></td></tr>
468 </form>
470 <p>
471 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
472 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
473 <form action="newuser_action" method=POST>
474 <tr><td align=right><em>Name: </em></td>
475     <td><input name="__newuser_realname"></td></tr>
476 <tr><td align=right><em>Organisation: </em></td>
477     <td><input name="__newuser_organisation"></td></tr>
478 <tr><td align=right>E-Mail Address: </td>
479     <td><input name="__newuser_address"></td></tr>
480 <tr><td align=right><em>Phone: </em></td>
481     <td><input name="__newuser_phone"></td></tr>
482 <tr><td align=right>Preferred Login name: </td>
483     <td><input name="__newuser_username"></td></tr>
484 <tr><td align=right>Password: </td>
485     <td><input type="password" name="__newuser_password"></td></tr>
486 <tr><td align=right>Password Again: </td>
487     <td><input type="password" name="__newuser_confirm"></td></tr>
488 <tr><td></td>
489     <td><input type="submit" value="Register"></td></tr>
490 </form>
491 </table>
492 ''')
494     def login_action(self, message=None):
495         self.user = self.form['__login_name'].value
496         password = self.form['__login_password'].value
497         # make sure the user exists
498         try:
499             uid = self.db.user.lookup(self.user)
500         except KeyError:
501             name = self.user
502             self.make_user_anonymous()
503             return self.login(message='No such user "%s"'%name)
505         # and that the password is correct
506         pw = self.db.user.get(uid, 'password')
507         if password != self.db.user.get(uid, 'password'):
508             self.make_user_anonymous()
509             return self.login(message='Incorrect password')
511         # construct the cookie
512         uid = self.db.user.lookup(self.user)
513         user = base64.encodestring('%s:%s'%(self.user, password))[:-1]
514         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
515             ''))
516         self.header({'Set-Cookie': 'roundup_user=%s; Path=%s;'%(user, path)})
517         return self.index()
519     def make_user_anonymous(self):
520         # make us anonymous if we can
521         try:
522             self.db.user.lookup('anonymous')
523             self.user = 'anonymous'
524         except KeyError:
525             self.user = None
527     def logout(self, message=None):
528         self.make_user_anonymous()
529         # construct the logout cookie
530         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
531             ''))
532         now = Cookie._getdate()
533         self.header({'Set-Cookie':
534             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)})
535         return self.index()
537     def newuser_action(self, message=None):
538         ''' create a new user based on the contents of the form and then
539         set the cookie
540         '''
541         # TODO: pre-check the required fields and username key property
542         cl = self.db.classes['user']
543         props, dummy = parsePropsFromForm(self.db, cl, self.form)
544         uid = cl.create(**props)
545         self.user = self.db.user.get(uid, 'username')
546         password = self.db.user.get(uid, 'password')
547         # construct the cookie
548         uid = self.db.user.lookup(self.user)
549         user = base64.encodestring('%s:%s'%(self.user, password))[:-1]
550         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
551             ''))
552         self.header({'Set-Cookie': 'roundup_user=%s; Path=%s;'%(user, path)})
553         return self.index()
555     def main(self, dre=re.compile(r'([^\d]+)(\d+)'),
556             nre=re.compile(r'new(\w+)')):
558         # determine the uid to use
559         self.db = self.instance.open('admin')
560         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
561         user = 'anonymous'
562         if (cookie.has_key('roundup_user') and
563                 cookie['roundup_user'].value != 'deleted'):
564             cookie = cookie['roundup_user'].value
565             user, password = base64.decodestring(cookie).split(':')
566             # make sure the user exists
567             try:
568                 uid = self.db.user.lookup(user)
569                 # now validate the password
570                 if password != self.db.user.get(uid, 'password'):
571                     user = 'anonymous'
572             except KeyError:
573                 user = 'anonymous'
575         # make sure the anonymous user is valid if we're using it
576         if user == 'anonymous':
577             self.make_user_anonymous()
578         else:
579             self.user = user
580         self.db.close()
582         # re-open the database for real, using the user
583         self.db = self.instance.open(self.user)
585         # now figure which function to call
586         path = self.split_path
587         if not path or path[0] in ('', 'index'):
588             self.index()
589         elif len(path) == 1:
590             if path[0] == 'list_classes':
591                 self.classes()
592                 return
593             if path[0] == 'login':
594                 self.login()
595                 return
596             if path[0] == 'login_action':
597                 self.login_action()
598                 return
599             if path[0] == 'newuser_action':
600                 self.newuser_action()
601                 return
602             if path[0] == 'logout':
603                 self.logout()
604                 return
605             m = dre.match(path[0])
606             if m:
607                 self.classname = m.group(1)
608                 self.nodeid = m.group(2)
609                 try:
610                     cl = self.db.classes[self.classname]
611                 except KeyError:
612                     raise NotFound
613                 try:
614                     cl.get(self.nodeid, 'id')
615                 except IndexError:
616                     raise NotFound
617                 try:
618                     func = getattr(self, 'show%s'%self.classname)
619                 except AttributeError:
620                     raise NotFound
621                 func()
622                 return
623             m = nre.match(path[0])
624             if m:
625                 self.classname = m.group(1)
626                 try:
627                     func = getattr(self, 'new%s'%self.classname)
628                 except AttributeError:
629                     raise NotFound
630                 func()
631                 return
632             self.classname = path[0]
633             try:
634                 self.db.getclass(self.classname)
635             except KeyError:
636                 raise NotFound
637             self.list()
638         else:
639             raise 'ValueError', 'Path not understood'
641     def __del__(self):
642         self.db.close()
645 class ExtendedClient(Client): 
646     '''Includes pages and page heading information that relate to the
647        extended schema.
648     ''' 
649     showsupport = Client.shownode
650     showtimelog = Client.shownode
651     newsupport = Client.newnode
652     newtimelog = Client.newnode
654     default_index_sort = ['-activity']
655     default_index_group = ['priority']
656     default_index_filter = []
657     default_index_columns = ['activity','status','title','assignedto']
658     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
660     def pagehead(self, title, message=None):
661         url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
662         machine = self.env['SERVER_NAME']
663         port = self.env['SERVER_PORT']
664         if port != '80': machine = machine + ':' + port
665         base = urlparse.urlunparse(('http', machine, url, None, None, None))
666         if message is not None:
667             message = '<div class="system-msg">%s</div>'%message
668         else:
669             message = ''
670         style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
671         user_name = self.user or ''
672         if self.user == 'admin':
673             admin_links = ' | <a href="list_classes">Class List</a>'
674         else:
675             admin_links = ''
676         if self.user not in (None, 'anonymous'):
677             userid = self.db.user.lookup(self.user)
678             user_info = '''
679 <a href="issue?assignedto=%s&status=unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:columns=id,activity,status,title,assignedto&:group=priority">My Issues</a> |
680 <a href="support?assignedto=%s&status=unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:columns=id,activity,status,title,assignedto&:group=customername">My Support</a> |
681 <a href="user%s">My Details</a> | <a href="logout">Logout</a>
682 '''%(userid, userid, userid)
683         else:
684             user_info = '<a href="login">Login</a>'
685         if self.user is not None:
686             add_links = '''
687 | Add
688 <a href="newissue">Issue</a>,
689 <a href="newsupport">Support</a>,
690 <a href="newuser">User</a>
691 '''
692         else:
693             add_links = ''
694         self.write('''<html><head>
695 <title>%s</title>
696 <style type="text/css">%s</style>
697 </head>
698 <body bgcolor=#ffffff>
699 %s
700 <table width=100%% border=0 cellspacing=0 cellpadding=2>
701 <tr class="location-bar"><td><big><strong>%s</strong></big></td>
702 <td align=right valign=bottom>%s</td></tr>
703 <tr class="location-bar">
704 <td align=left>All
705 <a href="issue?status=unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:columns=id,activity,status,title,assignedto&:group=priority">Issues</a>,
706 <a href="support?status=unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:columns=id,activity,status,title,assignedto&:group=customername">Support</a>
707 | Unassigned
708 <a href="issue?assignedto=admin&status=unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:columns=id,activity,status,title,assignedto&:group=priority">Issues</a>,
709 <a href="support?assignedto=admin&status=unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:columns=id,activity,status,title,assignedto&:group=customername">Support</a>
710 %s
711 %s</td>
712 <td align=right>%s</td>
713 </table>
714 '''%(title, style, message, title, user_name, add_links, admin_links,
715     user_info))
717 def parsePropsFromForm(db, cl, form, nodeid=0):
718     '''Pull properties for the given class out of the form.
719     '''
720     props = {}
721     changed = []
722     keys = form.keys()
723     num_re = re.compile('^\d+$')
724     for key in keys:
725         if not cl.properties.has_key(key):
726             continue
727         proptype = cl.properties[key]
728         if isinstance(proptype, hyperdb.String):
729             value = form[key].value.strip()
730         elif isinstance(proptype, hyperdb.Password):
731             value = password.Password(form[key].value.strip())
732         elif isinstance(proptype, hyperdb.Date):
733             value = date.Date(form[key].value.strip())
734         elif isinstance(proptype, hyperdb.Interval):
735             value = date.Interval(form[key].value.strip())
736         elif isinstance(proptype, hyperdb.Link):
737             value = form[key].value.strip()
738             # handle key values
739             link = cl.properties[key].classname
740             if not num_re.match(value):
741                 try:
742                     value = db.classes[link].lookup(value)
743                 except KeyError:
744                     raise ValueError, 'property "%s": %s not a %s'%(
745                         key, value, link)
746         elif isinstance(proptype, hyperdb.Multilink):
747             value = form[key]
748             if type(value) != type([]):
749                 value = [i.strip() for i in value.value.split(',')]
750             else:
751                 value = [i.value.strip() for i in value]
752             link = cl.properties[key].classname
753             l = []
754             for entry in map(str, value):
755                 if not num_re.match(entry):
756                     try:
757                         entry = db.classes[link].lookup(entry)
758                     except KeyError:
759                         raise ValueError, \
760                             'property "%s": "%s" not an entry of %s'%(key,
761                             entry, link.capitalize())
762                 l.append(entry)
763             l.sort()
764             value = l
765         props[key] = value
766         # if changed, set it
767         if nodeid and value != cl.get(nodeid, key):
768             changed.append(key)
769             props[key] = value
770     return props, changed
773 # $Log: not supported by cvs2svn $
774 # Revision 1.32  2001/10/16 03:36:21  richard
775 # CGI interface wasn't handling checkboxes at all.
777 # Revision 1.31  2001/10/14 10:55:00  richard
778 # Handle empty strings in HTML template Link function
780 # Revision 1.30  2001/10/09 07:38:58  richard
781 # Pushed the base code for the extended schema CGI interface back into the
782 # code cgi_client module so that future updates will be less painful.
783 # Also removed a debugging print statement from cgi_client.
785 # Revision 1.29  2001/10/09 07:25:59  richard
786 # Added the Password property type. See "pydoc roundup.password" for
787 # implementation details. Have updated some of the documentation too.
789 # Revision 1.28  2001/10/08 00:34:31  richard
790 # Change message was stuffing up for multilinks with no key property.
792 # Revision 1.27  2001/10/05 02:23:24  richard
793 #  . roundup-admin create now prompts for property info if none is supplied
794 #    on the command-line.
795 #  . hyperdb Class getprops() method may now return only the mutable
796 #    properties.
797 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
798 #    now support anonymous user access (read-only, unless there's an
799 #    "anonymous" user, in which case write access is permitted). Login
800 #    handling has been moved into cgi_client.Client.main()
801 #  . The "extended" schema is now the default in roundup init.
802 #  . The schemas have had their page headings modified to cope with the new
803 #    login handling. Existing installations should copy the interfaces.py
804 #    file from the roundup lib directory to their instance home.
805 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
806 #    Ping - has been removed.
807 #  . Fixed a whole bunch of places in the CGI interface where we should have
808 #    been returning Not Found instead of throwing an exception.
809 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
810 #    an item now throws an exception.
812 # Revision 1.26  2001/09/12 08:31:42  richard
813 # handle cases where mime type is not guessable
815 # Revision 1.25  2001/08/29 05:30:49  richard
816 # change messages weren't being saved when there was no-one on the nosy list.
818 # Revision 1.24  2001/08/29 04:49:39  richard
819 # didn't clean up fully after debugging :(
821 # Revision 1.23  2001/08/29 04:47:18  richard
822 # Fixed CGI client change messages so they actually include the properties
823 # changed (again).
825 # Revision 1.22  2001/08/17 00:08:10  richard
826 # reverted back to sending messages always regardless of who is doing the web
827 # edit. change notes weren't being saved. bleah. hackish.
829 # Revision 1.21  2001/08/15 23:43:18  richard
830 # Fixed some isFooTypes that I missed.
831 # Refactored some code in the CGI code.
833 # Revision 1.20  2001/08/12 06:32:36  richard
834 # using isinstance(blah, Foo) now instead of isFooType
836 # Revision 1.19  2001/08/07 00:24:42  richard
837 # stupid typo
839 # Revision 1.18  2001/08/07 00:15:51  richard
840 # Added the copyright/license notice to (nearly) all files at request of
841 # Bizar Software.
843 # Revision 1.17  2001/08/02 06:38:17  richard
844 # Roundupdb now appends "mailing list" information to its messages which
845 # include the e-mail address and web interface address. Templates may
846 # override this in their db classes to include specific information (support
847 # instructions, etc).
849 # Revision 1.16  2001/08/02 05:55:25  richard
850 # Web edit messages aren't sent to the person who did the edit any more. No
851 # message is generated if they are the only person on the nosy list.
853 # Revision 1.15  2001/08/02 00:34:10  richard
854 # bleah syntax error
856 # Revision 1.14  2001/08/02 00:26:16  richard
857 # Changed the order of the information in the message generated by web edits.
859 # Revision 1.13  2001/07/30 08:12:17  richard
860 # Added time logging and file uploading to the templates.
862 # Revision 1.12  2001/07/30 06:26:31  richard
863 # Added some documentation on how the newblah works.
865 # Revision 1.11  2001/07/30 06:17:45  richard
866 # Features:
867 #  . Added ability for cgi newblah forms to indicate that the new node
868 #    should be linked somewhere.
869 # Fixed:
870 #  . Fixed the agument handling for the roundup-admin find command.
871 #  . Fixed handling of summary when no note supplied for newblah. Again.
872 #  . Fixed detection of no form in htmltemplate Field display.
874 # Revision 1.10  2001/07/30 02:37:34  richard
875 # Temporary measure until we have decent schema migration...
877 # Revision 1.9  2001/07/30 01:25:07  richard
878 # Default implementation is now "classic" rather than "extended" as one would
879 # expect.
881 # Revision 1.8  2001/07/29 08:27:40  richard
882 # Fixed handling of passed-in values in form elements (ie. during a
883 # drill-down)
885 # Revision 1.7  2001/07/29 07:01:39  richard
886 # Added vim command to all source so that we don't get no steenkin' tabs :)
888 # Revision 1.6  2001/07/29 04:04:00  richard
889 # Moved some code around allowing for subclassing to change behaviour.
891 # Revision 1.5  2001/07/28 08:16:52  richard
892 # New issue form handles lack of note better now.
894 # Revision 1.4  2001/07/28 00:34:34  richard
895 # Fixed some non-string node ids.
897 # Revision 1.3  2001/07/23 03:56:30  richard
898 # oops, missed a config removal
900 # Revision 1.2  2001/07/22 12:09:32  richard
901 # Final commit of Grande Splite
903 # Revision 1.1  2001/07/22 11:58:35  richard
904 # More Grande Splite
907 # vim: set filetype=python ts=4 sw=4 et si