Code

. Modified cgi interface to change properties only once all changes are
[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.82 2001-12-15 19:24:39 rochecompaan Exp $
20 __doc__ = """
21 WWW request handler (also used in the stand-alone server).
22 """
24 import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes
25 import binascii, Cookie, time
27 import roundupdb, htmltemplate, date, hyperdb, password
28 from roundup.i18n import _
30 class Unauthorised(ValueError):
31     pass
33 class NotFound(ValueError):
34     pass
36 class Client:
37     '''
38     A note about login
39     ------------------
41     If the user has no login cookie, then they are anonymous. There
42     are two levels of anonymous use. If there is no 'anonymous' user, there
43     is no login at all and the database is opened in read-only mode. If the
44     'anonymous' user exists, the user is logged in using that user (though
45     there is no cookie). This allows them to modify the database, and all
46     modifications are attributed to the 'anonymous' user.
49     Customisation
50     -------------
51       FILTER_POSITION - one of 'top', 'bottom', 'top and bottom'
52       ANONYMOUS_ACCESS - one of 'deny', 'allow'
53       ANONYMOUS_REGISTER - one of 'deny', 'allow'
55     from the roundup class:
56       INSTANCE_NAME - defaults to 'Roundup issue tracker'
58     '''
59     FILTER_POSITION = 'bottom'       # one of 'top', 'bottom', 'top and bottom'
60     ANONYMOUS_ACCESS = 'deny'        # one of 'deny', 'allow'
61     ANONYMOUS_REGISTER = 'deny'      # one of 'deny', 'allow'
63     def __init__(self, instance, request, env, form=None):
64         self.instance = instance
65         self.request = request
66         self.env = env
67         self.path = env['PATH_INFO']
68         self.split_path = self.path.split('/')
70         if form is None:
71             self.form = cgi.FieldStorage(environ=env)
72         else:
73             self.form = form
74         self.headers_done = 0
75         try:
76             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
77         except ValueError:
78             # someone gave us a non-int debug level, turn it off
79             self.debug = 0
81     def getuid(self):
82         return self.db.user.lookup(self.user)
84     def header(self, headers={'Content-Type':'text/html'}):
85         '''Put up the appropriate header.
86         '''
87         if not headers.has_key('Content-Type'):
88             headers['Content-Type'] = 'text/html'
89         self.request.send_response(200)
90         for entry in headers.items():
91             self.request.send_header(*entry)
92         self.request.end_headers()
93         self.headers_done = 1
94         if self.debug:
95             self.headers_sent = headers
97     def pagehead(self, title, message=None):
98         url = self.env['SCRIPT_NAME'] + '/'
99         machine = self.env['SERVER_NAME']
100         port = self.env['SERVER_PORT']
101         if port != '80': machine = machine + ':' + port
102         base = urlparse.urlunparse(('http', machine, url, None, None, None))
103         if message is not None:
104             message = _('<div class="system-msg">%(message)s</div>')%locals()
105         else:
106             message = ''
107         style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
108         user_name = self.user or ''
109         if self.user == 'admin':
110             admin_links = _(' | <a href="list_classes">Class List</a>' \
111                           ' | <a href="user">User List</a>')
112         else:
113             admin_links = ''
114         if self.user not in (None, 'anonymous'):
115             userid = self.db.user.lookup(self.user)
116             user_info = _('''
117 <a href="issue?assignedto=%(userid)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> |
118 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
119 ''')%locals()
120         else:
121             user_info = _('<a href="login">Login</a>')
122         if self.user is not None:
123             add_links = _('''
124 | Add
125 <a href="newissue">Issue</a>,
126 <a href="newuser">User</a>
127 ''')
128         else:
129             add_links = ''
130         self.write(_('''<html><head>
131 <title>%(title)s</title>
132 <style type="text/css">%(style)s</style>
133 </head>
134 <body bgcolor=#ffffff>
135 %(message)s
136 <table width=100%% border=0 cellspacing=0 cellpadding=2>
137 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
138 <td align=right valign=bottom>%(user_name)s</td></tr>
139 <tr class="location-bar">
140 <td align=left>All
141 <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>
142 | Unassigned
143 <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>
144 %(add_links)s
145 %(admin_links)s</td>
146 <td align=right>%(user_info)s</td>
147 </table>
148 ''')%locals())
150     def pagefoot(self):
151         if self.debug:
152             self.write(_('<hr><small><dl><dt><b>Path</b></dt>'))
153             self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
154             keys = self.form.keys()
155             keys.sort()
156             if keys:
157                 self.write(_('<dt><b>Form entries</b></dt>'))
158                 for k in self.form.keys():
159                     v = self.form.getvalue(k, "<empty>")
160                     if type(v) is type([]):
161                         # Multiple username fields specified
162                         v = "|".join(v)
163                     self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
164             keys = self.headers_sent.keys()
165             keys.sort()
166             self.write(_('<dt><b>Sent these HTTP headers</b></dt>'))
167             for k in keys:
168                 v = self.headers_sent[k]
169                 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
170             keys = self.env.keys()
171             keys.sort()
172             self.write(_('<dt><b>CGI environment</b></dt>'))
173             for k in keys:
174                 v = self.env[k]
175                 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
176             self.write('</dl></small>')
177         self.write('</body></html>')
179     def write(self, content):
180         if not self.headers_done:
181             self.header()
182         self.request.wfile.write(content)
184     def index_arg(self, arg):
185         ''' handle the args to index - they might be a list from the form
186             (ie. submitted from a form) or they might be a command-separated
187             single string (ie. manually constructed GET args)
188         '''
189         if self.form.has_key(arg):
190             arg =  self.form[arg]
191             if type(arg) == type([]):
192                 return [arg.value for arg in arg]
193             return arg.value.split(',')
194         return []
196     def index_filterspec(self, filter):
197         ''' pull the index filter spec from the form
199         Links and multilinks want to be lists - the rest are straight
200         strings.
201         '''
202         props = self.db.classes[self.classname].getprops()
203         # all the form args not starting with ':' are filters
204         filterspec = {}
205         for key in self.form.keys():
206             if key[0] == ':': continue
207             if not props.has_key(key): continue
208             if key not in filter: continue
209             prop = props[key]
210             value = self.form[key]
211             if (isinstance(prop, hyperdb.Link) or
212                     isinstance(prop, hyperdb.Multilink)):
213                 if type(value) == type([]):
214                     value = [arg.value for arg in value]
215                 else:
216                     value = value.value.split(',')
217                 l = filterspec.get(key, [])
218                 l = l + value
219                 filterspec[key] = l
220             else:
221                 filterspec[key] = value.value
222         return filterspec
224     def customization_widget(self):
225         ''' The customization widget is visible by default. The widget
226             visibility is remembered by show_customization.  Visibility
227             is not toggled if the action value is "Redisplay"
228         '''
229         if not self.form.has_key('show_customization'):
230             visible = 1
231         else:
232             visible = int(self.form['show_customization'].value)
233             if self.form.has_key('action'):
234                 if self.form['action'].value != 'Redisplay':
235                     visible = self.form['action'].value == '+'
236             
237         return visible
239     default_index_sort = ['-activity']
240     default_index_group = ['priority']
241     default_index_filter = ['status']
242     default_index_columns = ['id','activity','title','status','assignedto']
243     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
244     def index(self):
245         ''' put up an index
246         '''
247         self.classname = 'issue'
248         # see if the web has supplied us with any customisation info
249         defaults = 1
250         for key in ':sort', ':group', ':filter', ':columns':
251             if self.form.has_key(key):
252                 defaults = 0
253                 break
254         if defaults:
255             # no info supplied - use the defaults
256             sort = self.default_index_sort
257             group = self.default_index_group
258             filter = self.default_index_filter
259             columns = self.default_index_columns
260             filterspec = self.default_index_filterspec
261         else:
262             sort = self.index_arg(':sort')
263             group = self.index_arg(':group')
264             filter = self.index_arg(':filter')
265             columns = self.index_arg(':columns')
266             filterspec = self.index_filterspec(filter)
267         return self.list(columns=columns, filter=filter, group=group,
268             sort=sort, filterspec=filterspec)
270     # XXX deviates from spec - loses the '+' (that's a reserved character
271     # in URLS
272     def list(self, sort=None, group=None, filter=None, columns=None,
273             filterspec=None, show_customization=None):
274         ''' call the template index with the args
276             :sort    - sort by prop name, optionally preceeded with '-'
277                      to give descending or nothing for ascending sorting.
278             :group   - group by prop name, optionally preceeded with '-' or
279                      to sort in descending or nothing for ascending order.
280             :filter  - selects which props should be displayed in the filter
281                      section. Default is all.
282             :columns - selects the columns that should be displayed.
283                      Default is all.
285         '''
286         cn = self.classname
287         cl = self.db.classes[cn]
288         self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
289             'classname': cn, 'instancename': self.INSTANCE_NAME})
290         if sort is None: sort = self.index_arg(':sort')
291         if group is None: group = self.index_arg(':group')
292         if filter is None: filter = self.index_arg(':filter')
293         if columns is None: columns = self.index_arg(':columns')
294         if filterspec is None: filterspec = self.index_filterspec(filter)
295         if show_customization is None:
296             show_customization = self.customization_widget()
298         index = htmltemplate.IndexTemplate(self, self.TEMPLATES, cn)
299         index.render(filterspec, filter, columns, sort, group,
300             show_customization=show_customization)
301         self.pagefoot()
303     def shownode(self, message=None):
304         ''' display an item
305         '''
306         cn = self.classname
307         cl = self.db.classes[cn]
309         # possibly perform an edit
310         keys = self.form.keys()
311         num_re = re.compile('^\d+$')
312         # don't try to set properties if the user has just logged in
313         if keys and not self.form.has_key('__login_name'):
314             try:
315                 props, changed = parsePropsFromForm(self.db, cl, self.form,
316                     self.nodeid)
317                 # make changes to the node
318                 self._changenode(props)
319                 # handle linked nodes 
320                 self._post_editnode(self.nodeid)
321                 # and some nice feedback for the user
322                 if changed:
323                     message = _('%(changes)s edited ok')%{'changes':
324                         ', '.join(changed.keys())}
325                 elif self.form.has_key('__note') and self.form['__note'].value:
326                     message = _('note added')
327                 else:
328                     message = _('nothing changed')
329             except:
330                 self.db.rollback()
331                 s = StringIO.StringIO()
332                 traceback.print_exc(None, s)
333                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
335         # now the display
336         id = self.nodeid
337         if cl.getkey():
338             id = cl.get(id, cl.getkey())
339         self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
341         nodeid = self.nodeid
343         # use the template to display the item
344         item = htmltemplate.ItemTemplate(self, self.TEMPLATES, self.classname)
345         item.render(nodeid)
347         self.pagefoot()
348     showissue = shownode
349     showmsg = shownode
351     def showuser(self, message=None):
352         '''Display a user page for editing. Make sure the user is allowed
353             to edit this node, and also check for password changes.
354         '''
355         if self.user == 'anonymous':
356             raise Unauthorised
358         user = self.db.user
360         # get the username of the node being edited
361         node_user = user.get(self.nodeid, 'username')
363         if self.user not in ('admin', node_user):
364             raise Unauthorised
366         #
367         # perform any editing
368         #
369         keys = self.form.keys()
370         num_re = re.compile('^\d+$')
371         if keys:
372             try:
373                 props, changed = parsePropsFromForm(self.db, user, self.form,
374                     self.nodeid)
375                 set_cookie = 0
376                 if self.nodeid == self.getuid() and changed.has_key('password'):
377                     password = self.form['password'].value.strip()
378                     if password:
379                         set_cookie = password
380                     else:
381                         # no password was supplied - don't change it
382                         del props['password']
383                         del changed['password']
384                 user.set(self.nodeid, **props)
385                 # and some feedback for the user
386                 message = _('%(changes)s edited ok')%{'changes':
387                     ', '.join(changed.keys())}
388             except:
389                 self.db.rollback()
390                 s = StringIO.StringIO()
391                 traceback.print_exc(None, s)
392                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
393         else:
394             set_cookie = 0
396         # fix the cookie if the password has changed
397         if set_cookie:
398             self.set_cookie(self.user, set_cookie)
400         #
401         # now the display
402         #
403         self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
405         # use the template to display the item
406         item = htmltemplate.ItemTemplate(self, self.TEMPLATES, 'user')
407         item.render(self.nodeid)
408         self.pagefoot()
410     def showfile(self):
411         ''' display a file
412         '''
413         nodeid = self.nodeid
414         cl = self.db.file
415         mime_type = cl.get(nodeid, 'type')
416         if mime_type == 'message/rfc822':
417             mime_type = 'text/plain'
418         self.header(headers={'Content-Type': mime_type})
419         self.write(cl.get(nodeid, 'content'))
421     def _createnode(self):
422         ''' create a node based on the contents of the form
423         '''
424         cl = self.db.classes[self.classname]
425         props, dummy = parsePropsFromForm(self.db, cl, self.form)
427         # set status to 'unread' if not specified - a status of '- no
428         # selection -' doesn't make sense
429         if not props.has_key('status'):
430             try:
431                 unread_id = self.db.status.lookup('unread')
432             except KeyError:
433                 pass
434             else:
435                 props['status'] = unread_id
436         # add assignedto to the nosy list
437         if props.has_key('assignedto'):
438             assignedto_id = props['assignedto']
439             if props.has_key('nosy') and not assignedto_id in props['nosy']:
440                 props['nosy'].append(assignedto_id)
441             else:
442                 props['nosy'] = [assignedto_id]
443         # check for messages
444         message = self._handle_message()
445         if message:
446             props['messages'] = [message]
447         # create the node and return it's id
448         return cl.create(**props)
450     def _changenode(self, props):
451         ''' change the node based on the contents of the form
452         '''
453         cl = self.db.classes[self.classname]
454         # set status to chatting if 'unread' or 'resolved'
455         try:
456             # determine the id of 'unread','resolved' and 'chatting'
457             unread_id = self.db.status.lookup('unread')
458             resolved_id = self.db.status.lookup('resolved')
459             chatting_id = self.db.status.lookup('chatting')
460         except KeyError:
461             pass
462         else:
463             if (props['status'] == unread_id or props['status'] == resolved_id):
464                 props['status'] = chatting_id
465         # add assignedto to the nosy list
466         if props.has_key('assignedto'):
467             assignedto_id = props['assignedto']
468             if not assignedto_id in props['nosy']:
469                 props['nosy'].append(assignedto_id)
470         # create the message
471         message = self._handle_message()
472         if message:
473             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
474         # make the changes
475         cl.set(self.nodeid, **props)
477     def _handle_message(self):
478         ''' generate and edit message '''
480         # handle file attachments 
481         files = []
482         if self.form.has_key('__file'):
483             file = self.form['__file']
484             if file.filename:
485                 mime_type = mimetypes.guess_type(file.filename)[0]
486                 if not mime_type:
487                     mime_type = "application/octet-stream"
488                 # create the new file entry
489                 files.append(self.db.file.create(type=mime_type,
490                     name=file.filename, content=file.file.read()))
492         # we don't want to do a message if none of the following is true...
493         cn = self.classname
494         cl = self.db.classes[self.classname]
495         props = cl.getprops()
496         note = None
497         if self.form.has_key('__note'):
498             note = self.form['__note'].value
499         if not props.has_key('messages'):
500             return
501         if not isinstance(props['messages'], hyperdb.Multilink):
502             return
503         if not props['messages'].classname == 'msg':
504             return
505         if not (self.form.has_key('nosy') or note):
506             return
508         # handle the note
509         if note:
510             if '\n' in note:
511                 summary = re.split(r'\n\r?', note)[0]
512             else:
513                 summary = note
514             m = ['%s\n'%note]
515         else:
516             summary = _('This %(classname)s has been edited through'
517                 ' the web.\n')%{'classname': cn}
518             m = [summary]
520         # now create the message
521         content = '\n'.join(m)
522         message_id = self.db.msg.create(author=self.getuid(),
523             recipients=[], date=date.Date('.'), summary=summary,
524             content=content, files=files)
526         # update the messages property
527         return message_id
529     def _post_editnode(self, nid):
530         ''' do the linking part of the node creation
531         '''
532         cn = self.classname
533         cl = self.db.classes[cn]
534         # link if necessary
535         keys = self.form.keys()
536         for key in keys:
537             if key == ':multilink':
538                 value = self.form[key].value
539                 if type(value) != type([]): value = [value]
540                 for value in value:
541                     designator, property = value.split(':')
542                     link, nodeid = roundupdb.splitDesignator(designator)
543                     link = self.db.classes[link]
544                     value = link.get(nodeid, property)
545                     value.append(nid)
546                     link.set(nodeid, **{property: value})
547             elif key == ':link':
548                 value = self.form[key].value
549                 if type(value) != type([]): value = [value]
550                 for value in value:
551                     designator, property = value.split(':')
552                     link, nodeid = roundupdb.splitDesignator(designator)
553                     link = self.db.classes[link]
554                     link.set(nodeid, **{property: nid})
556     def newnode(self, message=None):
557         ''' Add a new node to the database.
558         
559         The form works in two modes: blank form and submission (that is,
560         the submission goes to the same URL). **Eventually this means that
561         the form will have previously entered information in it if
562         submission fails.
564         The new node will be created with the properties specified in the
565         form submission. For multilinks, multiple form entries are handled,
566         as are prop=value,value,value. You can't mix them though.
568         If the new node is to be referenced from somewhere else immediately
569         (ie. the new node is a file that is to be attached to a support
570         issue) then supply one of these arguments in addition to the usual
571         form entries:
572             :link=designator:property
573             :multilink=designator:property
574         ... which means that once the new node is created, the "property"
575         on the node given by "designator" should now reference the new
576         node's id. The node id will be appended to the multilink.
577         '''
578         cn = self.classname
579         cl = self.db.classes[cn]
581         # possibly perform a create
582         keys = self.form.keys()
583         if [i for i in keys if i[0] != ':']:
584             props = {}
585             try:
586                 nid = self._createnode()
587                 # handle linked nodes 
588                 self._post_editnode(nid)
589                 # and some nice feedback for the user
590                 message = _('%(classname)s created ok')%{'classname': cn}
591             except:
592                 self.db.rollback()
593                 s = StringIO.StringIO()
594                 traceback.print_exc(None, s)
595                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
596         self.pagehead(_('New %(classname)s')%{'classname':
597              self.classname.capitalize()}, message)
599         # call the template
600         newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
601             self.classname)
602         newitem.render(self.form)
604         self.pagefoot()
605     newissue = newnode
606     newuser = newnode
608     def newfile(self, message=None):
609         ''' Add a new file to the database.
610         
611         This form works very much the same way as newnode - it just has a
612         file upload.
613         '''
614         cn = self.classname
615         cl = self.db.classes[cn]
617         # possibly perform a create
618         keys = self.form.keys()
619         if [i for i in keys if i[0] != ':']:
620             try:
621                 file = self.form['content']
622                 mime_type = mimetypes.guess_type(file.filename)[0]
623                 if not mime_type:
624                     mime_type = "application/octet-stream"
625                 # save the file
626                 nid = cl.create(content=file.file.read(), type=mime_type,
627                     name=file.filename)
628                 # handle linked nodes
629                 self._post_editnode(nid)
630                 # and some nice feedback for the user
631                 message = _('%(classname)s created ok')%{'classname': cn}
632             except:
633                 self.db.rollback()
634                 s = StringIO.StringIO()
635                 traceback.print_exc(None, s)
636                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
638         self.pagehead(_('New %(classname)s')%{'classname':
639              self.classname.capitalize()}, message)
640         newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
641             self.classname)
642         newitem.render(self.form)
643         self.pagefoot()
645     def classes(self, message=None):
646         ''' display a list of all the classes in the database
647         '''
648         if self.user == 'admin':
649             self.pagehead(_('Table of classes'), message)
650             classnames = self.db.classes.keys()
651             classnames.sort()
652             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
653             for cn in classnames:
654                 cl = self.db.getclass(cn)
655                 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
656                 for key, value in cl.properties.items():
657                     if value is None: value = ''
658                     else: value = str(value)
659                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
660                         key, cgi.escape(value)))
661             self.write('</table>')
662             self.pagefoot()
663         else:
664             raise Unauthorised
666     def login(self, message=None, newuser_form=None, action='index'):
667         '''Display a login page.
668         '''
669         self.pagehead(_('Login to roundup'), message)
670         self.write(_('''
671 <table>
672 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
673 <form action="login_action" method=POST>
674 <input type="hidden" name="__destination_url" value="%(action)s">
675 <tr><td align=right>Login name: </td>
676     <td><input name="__login_name"></td></tr>
677 <tr><td align=right>Password: </td>
678     <td><input type="password" name="__login_password"></td></tr>
679 <tr><td></td>
680     <td><input type="submit" value="Log In"></td></tr>
681 </form>
682 ''')%locals())
683         if self.user is None and self.ANONYMOUS_REGISTER == 'deny':
684             self.write('</table>')
685             self.pagefoot()
686             return
687         values = {'realname': '', 'organisation': '', 'address': '',
688             'phone': '', 'username': '', 'password': '', 'confirm': '',
689             'action': action}
690         if newuser_form is not None:
691             for key in newuser_form.keys():
692                 values[key] = newuser_form[key].value
693         self.write(_('''
694 <p>
695 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
696 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
697 <form action="newuser_action" method=POST>
698 <input type="hidden" name="__destination_url" value="%(action)s">
699 <tr><td align=right><em>Name: </em></td>
700     <td><input name="realname" value="%(realname)s"></td></tr>
701 <tr><td align=right><em>Organisation: </em></td>
702     <td><input name="organisation" value="%(organisation)s"></td></tr>
703 <tr><td align=right>E-Mail Address: </td>
704     <td><input name="address" value="%(address)s"></td></tr>
705 <tr><td align=right><em>Phone: </em></td>
706     <td><input name="phone" value="%(phone)s"></td></tr>
707 <tr><td align=right>Preferred Login name: </td>
708     <td><input name="username" value="%(username)s"></td></tr>
709 <tr><td align=right>Password: </td>
710     <td><input type="password" name="password" value="%(password)s"></td></tr>
711 <tr><td align=right>Password Again: </td>
712     <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
713 <tr><td></td>
714     <td><input type="submit" value="Register"></td></tr>
715 </form>
716 </table>
717 ''')%values)
718         self.pagefoot()
720     def login_action(self, message=None):
721         '''Attempt to log a user in and set the cookie
723         returns 0 if a page is generated as a result of this call, and
724         1 if not (ie. the login is successful
725         '''
726         if not self.form.has_key('__login_name'):
727             self.login(message=_('Username required'))
728             return 0
729         self.user = self.form['__login_name'].value
730         if self.form.has_key('__login_password'):
731             password = self.form['__login_password'].value
732         else:
733             password = ''
734         # make sure the user exists
735         try:
736             uid = self.db.user.lookup(self.user)
737         except KeyError:
738             name = self.user
739             self.make_user_anonymous()
740             action = self.form['__destination_url'].value
741             self.login(message=_('No such user "%(name)s"')%locals(),
742                 action=action)
743             return 0
745         # and that the password is correct
746         pw = self.db.user.get(uid, 'password')
747         if password != pw:
748             self.make_user_anonymous()
749             action = self.form['__destination_url'].value
750             self.login(message=_('Incorrect password'), action=action)
751             return 0
753         self.set_cookie(self.user, password)
754         return 1
756     def newuser_action(self, message=None):
757         '''Attempt to create a new user based on the contents of the form
758         and then set the cookie.
760         return 1 on successful login
761         '''
762         # re-open the database as "admin"
763         self.db = self.instance.open('admin')
765         # TODO: pre-check the required fields and username key property
766         cl = self.db.user
767         try:
768             props, dummy = parsePropsFromForm(self.db, cl, self.form)
769             uid = cl.create(**props)
770         except ValueError, message:
771             action = self.form['__destination_url'].value
772             self.login(message, action=action)
773             return 0
774         self.user = cl.get(uid, 'username')
775         password = cl.get(uid, 'password')
776         self.set_cookie(self.user, self.form['password'].value)
777         return 1
779     def set_cookie(self, user, password):
780         # construct the cookie
781         user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
782         if user[-1] == '=':
783           if user[-2] == '=':
784             user = user[:-2]
785           else:
786             user = user[:-1]
787         expire = Cookie._getdate(86400*365)
788         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
789         self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
790             user, expire, path)})
792     def make_user_anonymous(self):
793         # make us anonymous if we can
794         try:
795             self.db.user.lookup('anonymous')
796             self.user = 'anonymous'
797         except KeyError:
798             self.user = None
800     def logout(self, message=None):
801         self.make_user_anonymous()
802         # construct the logout cookie
803         now = Cookie._getdate()
804         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
805         self.header({'Set-Cookie':
806             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
807             path)})
808         self.login()
811     def main(self):
812         '''Wrap the database accesses so we can close the database cleanly
813         '''
814         # determine the uid to use
815         self.db = self.instance.open('admin')
816         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
817         user = 'anonymous'
818         if (cookie.has_key('roundup_user') and
819                 cookie['roundup_user'].value != 'deleted'):
820             cookie = cookie['roundup_user'].value
821             if len(cookie)%4:
822               cookie = cookie + '='*(4-len(cookie)%4)
823             try:
824                 user, password = binascii.a2b_base64(cookie).split(':')
825             except (TypeError, binascii.Error, binascii.Incomplete):
826                 # damaged cookie!
827                 user, password = 'anonymous', ''
829             # make sure the user exists
830             try:
831                 uid = self.db.user.lookup(user)
832                 # now validate the password
833                 if password != self.db.user.get(uid, 'password'):
834                     user = 'anonymous'
835             except KeyError:
836                 user = 'anonymous'
838         # make sure the anonymous user is valid if we're using it
839         if user == 'anonymous':
840             self.make_user_anonymous()
841         else:
842             self.user = user
844         # re-open the database for real, using the user
845         self.db = self.instance.open(self.user)
847         # now figure which function to call
848         path = self.split_path
850         # default action to index if the path has no information in it
851         if not path or path[0] in ('', 'index'):
852             action = 'index'
853         else:
854             action = path[0]
856         # Everthing ignores path[1:]
857         #  - The file download link generator actually relies on this - it
858         #    appends the name of the file to the URL so the download file name
859         #    is correct, but doesn't actually use it.
861         # everyone is allowed to try to log in
862         if action == 'login_action':
863             # try to login
864             if not self.login_action():
865                 return
866             # figure the resulting page
867             action = self.form['__destination_url'].value
868             if not action:
869                 action = 'index'
870             self.do_action(action)
871             return
873         # allow anonymous people to register
874         if action == 'newuser_action':
875             # if we don't have a login and anonymous people aren't allowed to
876             # register, then spit up the login form
877             if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
878                 if action == 'login':
879                     self.login()         # go to the index after login
880                 else:
881                     self.login(action=action)
882                 return
883             # try to add the user
884             if not self.newuser_action():
885                 return
886             # figure the resulting page
887             action = self.form['__destination_url'].value
888             if not action:
889                 action = 'index'
891         # no login or registration, make sure totally anonymous access is OK
892         elif self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
893             if action == 'login':
894                 self.login()             # go to the index after login
895             else:
896                 self.login(action=action)
897             return
899         # just a regular action
900         self.do_action(action)
902         # commit all changes to the database
903         self.db.commit()
905     def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
906             nre=re.compile(r'new(\w+)')):
907         '''Figure the user's action and do it.
908         '''
909         # here be the "normal" functionality
910         if action == 'index':
911             self.index()
912             return
913         if action == 'list_classes':
914             self.classes()
915             return
916         if action == 'login':
917             self.login()
918             return
919         if action == 'logout':
920             self.logout()
921             return
922         m = dre.match(action)
923         if m:
924             self.classname = m.group(1)
925             self.nodeid = m.group(2)
926             try:
927                 cl = self.db.classes[self.classname]
928             except KeyError:
929                 raise NotFound
930             try:
931                 cl.get(self.nodeid, 'id')
932             except IndexError:
933                 raise NotFound
934             try:
935                 func = getattr(self, 'show%s'%self.classname)
936             except AttributeError:
937                 raise NotFound
938             func()
939             return
940         m = nre.match(action)
941         if m:
942             self.classname = m.group(1)
943             try:
944                 func = getattr(self, 'new%s'%self.classname)
945             except AttributeError:
946                 raise NotFound
947             func()
948             return
949         self.classname = action
950         try:
951             self.db.getclass(self.classname)
952         except KeyError:
953             raise NotFound
954         self.list()
957 class ExtendedClient(Client): 
958     '''Includes pages and page heading information that relate to the
959        extended schema.
960     ''' 
961     showsupport = Client.shownode
962     showtimelog = Client.shownode
963     newsupport = Client.newnode
964     newtimelog = Client.newnode
966     default_index_sort = ['-activity']
967     default_index_group = ['priority']
968     default_index_filter = ['status']
969     default_index_columns = ['activity','status','title','assignedto']
970     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
972     def pagehead(self, title, message=None):
973         url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
974         machine = self.env['SERVER_NAME']
975         port = self.env['SERVER_PORT']
976         if port != '80': machine = machine + ':' + port
977         base = urlparse.urlunparse(('http', machine, url, None, None, None))
978         if message is not None:
979             message = _('<div class="system-msg">%(message)s</div>')%locals()
980         else:
981             message = ''
982         style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
983         user_name = self.user or ''
984         if self.user == 'admin':
985             admin_links = _(' | <a href="list_classes">Class List</a>' \
986                           ' | <a href="user">User List</a>')
987         else:
988             admin_links = ''
989         if self.user not in (None, 'anonymous'):
990             userid = self.db.user.lookup(self.user)
991             user_info = _('''
992 <a href="issue?assignedto=%(userid)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> |
993 <a href="support?assignedto=%(userid)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> |
994 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
995 ''')%locals()
996         else:
997             user_info = _('<a href="login">Login</a>')
998         if self.user is not None:
999             add_links = _('''
1000 | Add
1001 <a href="newissue">Issue</a>,
1002 <a href="newsupport">Support</a>,
1003 <a href="newuser">User</a>
1004 ''')
1005         else:
1006             add_links = ''
1007         self.write(_('''<html><head>
1008 <title>%(title)s</title>
1009 <style type="text/css">%(style)s</style>
1010 </head>
1011 <body bgcolor=#ffffff>
1012 %(message)s
1013 <table width=100%% border=0 cellspacing=0 cellpadding=2>
1014 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
1015 <td align=right valign=bottom>%(user_name)s</td></tr>
1016 <tr class="location-bar">
1017 <td align=left>All
1018 <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>,
1019 <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>
1020 | Unassigned
1021 <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>,
1022 <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>
1023 %(add_links)s
1024 %(admin_links)s</td>
1025 <td align=right>%(user_info)s</td>
1026 </table>
1027 ''')%locals())
1029 def parsePropsFromForm(db, cl, form, nodeid=0):
1030     '''Pull properties for the given class out of the form.
1031     '''
1032     props = {}
1033     changed = {}
1034     keys = form.keys()
1035     num_re = re.compile('^\d+$')
1036     for key in keys:
1037         if not cl.properties.has_key(key):
1038             continue
1039         proptype = cl.properties[key]
1040         if isinstance(proptype, hyperdb.String):
1041             value = form[key].value.strip()
1042         elif isinstance(proptype, hyperdb.Password):
1043             value = password.Password(form[key].value.strip())
1044         elif isinstance(proptype, hyperdb.Date):
1045             value = date.Date(form[key].value.strip())
1046         elif isinstance(proptype, hyperdb.Interval):
1047             value = date.Interval(form[key].value.strip())
1048         elif isinstance(proptype, hyperdb.Link):
1049             value = form[key].value.strip()
1050             # see if it's the "no selection" choice
1051             if value == '-1':
1052                 # don't set this property
1053                 continue
1054             else:
1055                 # handle key values
1056                 link = cl.properties[key].classname
1057                 if not num_re.match(value):
1058                     try:
1059                         value = db.classes[link].lookup(value)
1060                     except KeyError:
1061                         raise ValueError, _('property "%(propname)s": '
1062                             '%(value)s not a %(classname)s')%{'propname':key, 
1063                             'value': value, 'classname': link}
1064         elif isinstance(proptype, hyperdb.Multilink):
1065             value = form[key]
1066             if type(value) != type([]):
1067                 value = [i.strip() for i in value.value.split(',')]
1068             else:
1069                 value = [i.value.strip() for i in value]
1070             link = cl.properties[key].classname
1071             l = []
1072             for entry in map(str, value):
1073                 if entry == '': continue
1074                 if not num_re.match(entry):
1075                     try:
1076                         entry = db.classes[link].lookup(entry)
1077                     except KeyError:
1078                         raise ValueError, _('property "%(propname)s": '
1079                             '"%(value)s" not an entry of %(classname)s')%{
1080                             'propname':key, 'value': entry, 'classname': link}
1081                 l.append(entry)
1082             l.sort()
1083             value = l
1084         props[key] = value
1086         # get the old value
1087         if nodeid:
1088             try:
1089                 existing = cl.get(nodeid, key)
1090             except KeyError:
1091                 # this might be a new property for which there is no existing
1092                 # value
1093                 if not cl.properties.has_key(key): raise
1095         # if changed, set it
1096         if nodeid and value != existing:
1097             changed[key] = value
1098             props[key] = value
1099     return props, changed
1102 # $Log: not supported by cvs2svn $
1103 # Revision 1.81  2001/12/12 23:55:00  richard
1104 # Fixed some problems with user editing
1106 # Revision 1.80  2001/12/12 23:27:14  richard
1107 # Added a Zope frontend for roundup.
1109 # Revision 1.79  2001/12/10 22:20:01  richard
1110 # Enabled transaction support in the bsddb backend. It uses the anydbm code
1111 # where possible, only replacing methods where the db is opened (it uses the
1112 # btree opener specifically.)
1113 # Also cleaned up some change note generation.
1114 # Made the backends package work with pydoc too.
1116 # Revision 1.78  2001/12/07 05:59:27  rochecompaan
1117 # Fixed small bug that prevented adding issues through the web.
1119 # Revision 1.77  2001/12/06 22:48:29  richard
1120 # files multilink was being nuked in post_edit_node
1122 # Revision 1.76  2001/12/05 14:26:44  rochecompaan
1123 # Removed generation of change note from "sendmessage" in roundupdb.py.
1124 # The change note is now generated when the message is created.
1126 # Revision 1.75  2001/12/04 01:25:08  richard
1127 # Added some rollbacks where we were catching exceptions that would otherwise
1128 # have stopped committing.
1130 # Revision 1.74  2001/12/02 05:06:16  richard
1131 # . We now use weakrefs in the Classes to keep the database reference, so
1132 #   the close() method on the database is no longer needed.
1133 #   I bumped the minimum python requirement up to 2.1 accordingly.
1134 # . #487480 ] roundup-server
1135 # . #487476 ] INSTALL.txt
1137 # I also cleaned up the change message / post-edit stuff in the cgi client.
1138 # There's now a clearly marked "TODO: append the change note" where I believe
1139 # the change note should be added there. The "changes" list will obviously
1140 # have to be modified to be a dict of the changes, or somesuch.
1142 # More testing needed.
1144 # Revision 1.73  2001/12/01 07:17:50  richard
1145 # . We now have basic transaction support! Information is only written to
1146 #   the database when the commit() method is called. Only the anydbm
1147 #   backend is modified in this way - neither of the bsddb backends have been.
1148 #   The mail, admin and cgi interfaces all use commit (except the admin tool
1149 #   doesn't have a commit command, so interactive users can't commit...)
1150 # . Fixed login/registration forwarding the user to the right page (or not,
1151 #   on a failure)
1153 # Revision 1.72  2001/11/30 20:47:58  rochecompaan
1154 # Links in page header are now consistent with default sort order.
1156 # Fixed bugs:
1157 #     - When login failed the list of issues were still rendered.
1158 #     - User was redirected to index page and not to his destination url
1159 #       if his first login attempt failed.
1161 # Revision 1.71  2001/11/30 20:28:10  rochecompaan
1162 # Property changes are now completely traceable, whether changes are
1163 # made through the web or by email
1165 # Revision 1.70  2001/11/30 00:06:29  richard
1166 # Converted roundup/cgi_client.py to use _()
1167 # Added the status file, I18N_PROGRESS.txt
1169 # Revision 1.69  2001/11/29 23:19:51  richard
1170 # Removed the "This issue has been edited through the web" when a valid
1171 # change note is supplied.
1173 # Revision 1.68  2001/11/29 04:57:23  richard
1174 # a little comment
1176 # Revision 1.67  2001/11/28 21:55:35  richard
1177 #  . login_action and newuser_action return values were being ignored
1178 #  . Woohoo! Found that bloody re-login bug that was killing the mail
1179 #    gateway.
1180 #  (also a minor cleanup in hyperdb)
1182 # Revision 1.66  2001/11/27 03:00:50  richard
1183 # couple of bugfixes from latest patch integration
1185 # Revision 1.65  2001/11/26 23:00:53  richard
1186 # This config stuff is getting to be a real mess...
1188 # Revision 1.64  2001/11/26 22:56:35  richard
1189 # typo
1191 # Revision 1.63  2001/11/26 22:55:56  richard
1192 # Feature:
1193 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
1194 #    the instance.
1195 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1196 #    signature info in e-mails.
1197 #  . Some more flexibility in the mail gateway and more error handling.
1198 #  . Login now takes you to the page you back to the were denied access to.
1200 # Fixed:
1201 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
1203 # Revision 1.62  2001/11/24 00:45:42  jhermann
1204 # typeof() instead of type(): avoid clash with database field(?) "type"
1206 # Fixes this traceback:
1208 # Traceback (most recent call last):
1209 #   File "roundup\cgi_client.py", line 535, in newnode
1210 #     self._post_editnode(nid)
1211 #   File "roundup\cgi_client.py", line 415, in _post_editnode
1212 #     if type(value) != type([]): value = [value]
1213 # UnboundLocalError: local variable 'type' referenced before assignment
1215 # Revision 1.61  2001/11/22 15:46:42  jhermann
1216 # Added module docstrings to all modules.
1218 # Revision 1.60  2001/11/21 22:57:28  jhermann
1219 # Added dummy hooks for I18N and some preliminary (test) markup of
1220 # translatable messages
1222 # Revision 1.59  2001/11/21 03:21:13  richard
1223 # oops
1225 # Revision 1.58  2001/11/21 03:11:28  richard
1226 # Better handling of new properties.
1228 # Revision 1.57  2001/11/15 10:24:27  richard
1229 # handle the case where there is no file attached
1231 # Revision 1.56  2001/11/14 21:35:21  richard
1232 #  . users may attach files to issues (and support in ext) through the web now
1234 # Revision 1.55  2001/11/07 02:34:06  jhermann
1235 # Handling of damaged login cookies
1237 # Revision 1.54  2001/11/07 01:16:12  richard
1238 # Remove the '=' padding from cookie value so quoting isn't an issue.
1240 # Revision 1.53  2001/11/06 23:22:05  jhermann
1241 # More IE fixes: it does not like quotes around cookie values; in the
1242 # hope this does not break anything for other browser; if it does, we
1243 # need to check HTTP_USER_AGENT
1245 # Revision 1.52  2001/11/06 23:11:22  jhermann
1246 # Fixed debug output in page footer; added expiry date to the login cookie
1247 # (expires 1 year in the future) to prevent probs with certain versions
1248 # of IE
1250 # Revision 1.51  2001/11/06 22:00:34  jhermann
1251 # Get debug level from ROUNDUP_DEBUG env var
1253 # Revision 1.50  2001/11/05 23:45:40  richard
1254 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1255 # Also made it present nicer error messages (not tracebacks).
1257 # Revision 1.49  2001/11/04 03:07:12  richard
1258 # Fixed various cookie-related bugs:
1259 #  . bug #477685 ] base64.decodestring breaks
1260 #  . bug #477837 ] lynx does not like the cookie
1261 #  . bug #477892 ] Password edit doesn't fix login cookie
1262 # Also closed a security hole - a logged-in user could edit another user's
1263 # details.
1265 # Revision 1.48  2001/11/03 01:30:18  richard
1266 # Oops. uses pagefoot now.
1268 # Revision 1.47  2001/11/03 01:29:28  richard
1269 # Login page didn't have all close tags.
1271 # Revision 1.46  2001/11/03 01:26:55  richard
1272 # possibly fix truncated base64'ed user:pass
1274 # Revision 1.45  2001/11/01 22:04:37  richard
1275 # Started work on supporting a pop3-fetching server
1276 # Fixed bugs:
1277 #  . bug #477104 ] HTML tag error in roundup-server
1278 #  . bug #477107 ] HTTP header problem
1280 # Revision 1.44  2001/10/28 23:03:08  richard
1281 # Added more useful header to the classic schema.
1283 # Revision 1.43  2001/10/24 00:01:42  richard
1284 # More fixes to lockout logic.
1286 # Revision 1.42  2001/10/23 23:56:03  richard
1287 # HTML typo
1289 # Revision 1.41  2001/10/23 23:52:35  richard
1290 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1292 # Revision 1.40  2001/10/23 23:06:39  richard
1293 # Some cleanup.
1295 # Revision 1.39  2001/10/23 01:00:18  richard
1296 # Re-enabled login and registration access after lopping them off via
1297 # disabling access for anonymous users.
1298 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1299 # a couple of bugs while I was there. Probably introduced a couple, but
1300 # things seem to work OK at the moment.
1302 # Revision 1.38  2001/10/22 03:25:01  richard
1303 # Added configuration for:
1304 #  . anonymous user access and registration (deny/allow)
1305 #  . filter "widget" location on index page (top, bottom, both)
1306 # Updated some documentation.
1308 # Revision 1.37  2001/10/21 07:26:35  richard
1309 # feature #473127: Filenames. I modified the file.index and htmltemplate
1310 #  source so that the filename is used in the link and the creation
1311 #  information is displayed.
1313 # Revision 1.36  2001/10/21 04:44:50  richard
1314 # bug #473124: UI inconsistency with Link fields.
1315 #    This also prompted me to fix a fairly long-standing usability issue -
1316 #    that of being able to turn off certain filters.
1318 # Revision 1.35  2001/10/21 00:17:54  richard
1319 # CGI interface view customisation section may now be hidden (patch from
1320 #  Roch'e Compaan.)
1322 # Revision 1.34  2001/10/20 11:58:48  richard
1323 # Catch errors in login - no username or password supplied.
1324 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1326 # Revision 1.33  2001/10/17 00:18:41  richard
1327 # Manually constructing cookie headers now.
1329 # Revision 1.32  2001/10/16 03:36:21  richard
1330 # CGI interface wasn't handling checkboxes at all.
1332 # Revision 1.31  2001/10/14 10:55:00  richard
1333 # Handle empty strings in HTML template Link function
1335 # Revision 1.30  2001/10/09 07:38:58  richard
1336 # Pushed the base code for the extended schema CGI interface back into the
1337 # code cgi_client module so that future updates will be less painful.
1338 # Also removed a debugging print statement from cgi_client.
1340 # Revision 1.29  2001/10/09 07:25:59  richard
1341 # Added the Password property type. See "pydoc roundup.password" for
1342 # implementation details. Have updated some of the documentation too.
1344 # Revision 1.28  2001/10/08 00:34:31  richard
1345 # Change message was stuffing up for multilinks with no key property.
1347 # Revision 1.27  2001/10/05 02:23:24  richard
1348 #  . roundup-admin create now prompts for property info if none is supplied
1349 #    on the command-line.
1350 #  . hyperdb Class getprops() method may now return only the mutable
1351 #    properties.
1352 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
1353 #    now support anonymous user access (read-only, unless there's an
1354 #    "anonymous" user, in which case write access is permitted). Login
1355 #    handling has been moved into cgi_client.Client.main()
1356 #  . The "extended" schema is now the default in roundup init.
1357 #  . The schemas have had their page headings modified to cope with the new
1358 #    login handling. Existing installations should copy the interfaces.py
1359 #    file from the roundup lib directory to their instance home.
1360 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1361 #    Ping - has been removed.
1362 #  . Fixed a whole bunch of places in the CGI interface where we should have
1363 #    been returning Not Found instead of throwing an exception.
1364 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
1365 #    an item now throws an exception.
1367 # Revision 1.26  2001/09/12 08:31:42  richard
1368 # handle cases where mime type is not guessable
1370 # Revision 1.25  2001/08/29 05:30:49  richard
1371 # change messages weren't being saved when there was no-one on the nosy list.
1373 # Revision 1.24  2001/08/29 04:49:39  richard
1374 # didn't clean up fully after debugging :(
1376 # Revision 1.23  2001/08/29 04:47:18  richard
1377 # Fixed CGI client change messages so they actually include the properties
1378 # changed (again).
1380 # Revision 1.22  2001/08/17 00:08:10  richard
1381 # reverted back to sending messages always regardless of who is doing the web
1382 # edit. change notes weren't being saved. bleah. hackish.
1384 # Revision 1.21  2001/08/15 23:43:18  richard
1385 # Fixed some isFooTypes that I missed.
1386 # Refactored some code in the CGI code.
1388 # Revision 1.20  2001/08/12 06:32:36  richard
1389 # using isinstance(blah, Foo) now instead of isFooType
1391 # Revision 1.19  2001/08/07 00:24:42  richard
1392 # stupid typo
1394 # Revision 1.18  2001/08/07 00:15:51  richard
1395 # Added the copyright/license notice to (nearly) all files at request of
1396 # Bizar Software.
1398 # Revision 1.17  2001/08/02 06:38:17  richard
1399 # Roundupdb now appends "mailing list" information to its messages which
1400 # include the e-mail address and web interface address. Templates may
1401 # override this in their db classes to include specific information (support
1402 # instructions, etc).
1404 # Revision 1.16  2001/08/02 05:55:25  richard
1405 # Web edit messages aren't sent to the person who did the edit any more. No
1406 # message is generated if they are the only person on the nosy list.
1408 # Revision 1.15  2001/08/02 00:34:10  richard
1409 # bleah syntax error
1411 # Revision 1.14  2001/08/02 00:26:16  richard
1412 # Changed the order of the information in the message generated by web edits.
1414 # Revision 1.13  2001/07/30 08:12:17  richard
1415 # Added time logging and file uploading to the templates.
1417 # Revision 1.12  2001/07/30 06:26:31  richard
1418 # Added some documentation on how the newblah works.
1420 # Revision 1.11  2001/07/30 06:17:45  richard
1421 # Features:
1422 #  . Added ability for cgi newblah forms to indicate that the new node
1423 #    should be linked somewhere.
1424 # Fixed:
1425 #  . Fixed the agument handling for the roundup-admin find command.
1426 #  . Fixed handling of summary when no note supplied for newblah. Again.
1427 #  . Fixed detection of no form in htmltemplate Field display.
1429 # Revision 1.10  2001/07/30 02:37:34  richard
1430 # Temporary measure until we have decent schema migration...
1432 # Revision 1.9  2001/07/30 01:25:07  richard
1433 # Default implementation is now "classic" rather than "extended" as one would
1434 # expect.
1436 # Revision 1.8  2001/07/29 08:27:40  richard
1437 # Fixed handling of passed-in values in form elements (ie. during a
1438 # drill-down)
1440 # Revision 1.7  2001/07/29 07:01:39  richard
1441 # Added vim command to all source so that we don't get no steenkin' tabs :)
1443 # Revision 1.6  2001/07/29 04:04:00  richard
1444 # Moved some code around allowing for subclassing to change behaviour.
1446 # Revision 1.5  2001/07/28 08:16:52  richard
1447 # New issue form handles lack of note better now.
1449 # Revision 1.4  2001/07/28 00:34:34  richard
1450 # Fixed some non-string node ids.
1452 # Revision 1.3  2001/07/23 03:56:30  richard
1453 # oops, missed a config removal
1455 # Revision 1.2  2001/07/22 12:09:32  richard
1456 # Final commit of Grande Splite
1458 # Revision 1.1  2001/07/22 11:58:35  richard
1459 # More Grande Splite
1462 # vim: set filetype=python ts=4 sw=4 et si