Code

. All forms now have "double-submit" protection when Javascript is enabled
[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.101 2002-02-14 23:39:18 richard 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, random
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.
47     '''
49     def __init__(self, instance, request, env, form=None):
50         self.instance = instance
51         self.request = request
52         self.env = env
53         self.path = env['PATH_INFO']
54         self.split_path = self.path.split('/')
56         if form is None:
57             self.form = cgi.FieldStorage(environ=env)
58         else:
59             self.form = form
60         self.headers_done = 0
61         try:
62             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
63         except ValueError:
64             # someone gave us a non-int debug level, turn it off
65             self.debug = 0
67     def getuid(self):
68         return self.db.user.lookup(self.user)
70     def header(self, headers={'Content-Type':'text/html'}):
71         '''Put up the appropriate header.
72         '''
73         if not headers.has_key('Content-Type'):
74             headers['Content-Type'] = 'text/html'
75         self.request.send_response(200)
76         for entry in headers.items():
77             self.request.send_header(*entry)
78         self.request.end_headers()
79         self.headers_done = 1
80         if self.debug:
81             self.headers_sent = headers
83     single_submit_script = '''
84 <script language="javascript">
85 submitted = false;
86 function submit_once() {
87     if (submitted) {
88         alert("Your request is being processed.\\nPlease be patient.");
89         return 0;
90     }
91     submitted = true;
92     return 1;
93 }
94 </script>
95 '''
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.instance.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                           ' | <a href="newuser">Add User</a>')
113         else:
114             admin_links = ''
115         if self.user not in (None, 'anonymous'):
116             userid = self.db.user.lookup(self.user)
117             user_info = _('''
118 <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> |
119 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
120 ''')%locals()
121         else:
122             user_info = _('<a href="login">Login</a>')
123         if self.user is not None:
124             add_links = _('''
125 | Add
126 <a href="newissue">Issue</a>
127 ''')
128         else:
129             add_links = ''
130         single_submit_script = self.single_submit_script
131         self.write(_('''<html><head>
132 <title>%(title)s</title>
133 <style type="text/css">%(style)s</style>
134 </head>
135 %(single_submit_script)s
136 <body bgcolor=#ffffff>
137 %(message)s
138 <table width=100%% border=0 cellspacing=0 cellpadding=2>
139 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
140 <td align=right valign=bottom>%(user_name)s</td></tr>
141 <tr class="location-bar">
142 <td align=left>All
143 <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>
144 | Unassigned
145 <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>
146 %(add_links)s
147 %(admin_links)s</td>
148 <td align=right>%(user_info)s</td>
149 </table>
150 ''')%locals())
152     def pagefoot(self):
153         if self.debug:
154             self.write(_('<hr><small><dl><dt><b>Path</b></dt>'))
155             self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
156             keys = self.form.keys()
157             keys.sort()
158             if keys:
159                 self.write(_('<dt><b>Form entries</b></dt>'))
160                 for k in self.form.keys():
161                     v = self.form.getvalue(k, "<empty>")
162                     if type(v) is type([]):
163                         # Multiple username fields specified
164                         v = "|".join(v)
165                     self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
166             keys = self.headers_sent.keys()
167             keys.sort()
168             self.write(_('<dt><b>Sent these HTTP headers</b></dt>'))
169             for k in keys:
170                 v = self.headers_sent[k]
171                 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
172             keys = self.env.keys()
173             keys.sort()
174             self.write(_('<dt><b>CGI environment</b></dt>'))
175             for k in keys:
176                 v = self.env[k]
177                 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
178             self.write('</dl></small>')
179         self.write('</body></html>')
181     def write(self, content):
182         if not self.headers_done:
183             self.header()
184         self.request.wfile.write(content)
186     def index_arg(self, arg):
187         ''' handle the args to index - they might be a list from the form
188             (ie. submitted from a form) or they might be a command-separated
189             single string (ie. manually constructed GET args)
190         '''
191         if self.form.has_key(arg):
192             arg =  self.form[arg]
193             if type(arg) == type([]):
194                 return [arg.value for arg in arg]
195             return arg.value.split(',')
196         return []
198     def index_filterspec(self, filter):
199         ''' pull the index filter spec from the form
201         Links and multilinks want to be lists - the rest are straight
202         strings.
203         '''
204         props = self.db.classes[self.classname].getprops()
205         # all the form args not starting with ':' are filters
206         filterspec = {}
207         for key in self.form.keys():
208             if key[0] == ':': continue
209             if not props.has_key(key): continue
210             if key not in filter: continue
211             prop = props[key]
212             value = self.form[key]
213             if (isinstance(prop, hyperdb.Link) or
214                     isinstance(prop, hyperdb.Multilink)):
215                 if type(value) == type([]):
216                     value = [arg.value for arg in value]
217                 else:
218                     value = value.value.split(',')
219                 l = filterspec.get(key, [])
220                 l = l + value
221                 filterspec[key] = l
222             else:
223                 filterspec[key] = value.value
224         return filterspec
226     def customization_widget(self):
227         ''' The customization widget is visible by default. The widget
228             visibility is remembered by show_customization.  Visibility
229             is not toggled if the action value is "Redisplay"
230         '''
231         if not self.form.has_key('show_customization'):
232             visible = 1
233         else:
234             visible = int(self.form['show_customization'].value)
235             if self.form.has_key('action'):
236                 if self.form['action'].value != 'Redisplay':
237                     visible = self.form['action'].value == '+'
238             
239         return visible
241     default_index_sort = ['-activity']
242     default_index_group = ['priority']
243     default_index_filter = ['status']
244     default_index_columns = ['id','activity','title','status','assignedto']
245     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
246     def index(self):
247         ''' put up an index
248         '''
249         self.classname = 'issue'
250         # see if the web has supplied us with any customisation info
251         defaults = 1
252         for key in ':sort', ':group', ':filter', ':columns':
253             if self.form.has_key(key):
254                 defaults = 0
255                 break
256         if defaults:
257             # no info supplied - use the defaults
258             sort = self.default_index_sort
259             group = self.default_index_group
260             filter = self.default_index_filter
261             columns = self.default_index_columns
262             filterspec = self.default_index_filterspec
263         else:
264             sort = self.index_arg(':sort')
265             group = self.index_arg(':group')
266             filter = self.index_arg(':filter')
267             columns = self.index_arg(':columns')
268             filterspec = self.index_filterspec(filter)
269         return self.list(columns=columns, filter=filter, group=group,
270             sort=sort, filterspec=filterspec)
272     # XXX deviates from spec - loses the '+' (that's a reserved character
273     # in URLS
274     def list(self, sort=None, group=None, filter=None, columns=None,
275             filterspec=None, show_customization=None):
276         ''' call the template index with the args
278             :sort    - sort by prop name, optionally preceeded with '-'
279                      to give descending or nothing for ascending sorting.
280             :group   - group by prop name, optionally preceeded with '-' or
281                      to sort in descending or nothing for ascending order.
282             :filter  - selects which props should be displayed in the filter
283                      section. Default is all.
284             :columns - selects the columns that should be displayed.
285                      Default is all.
287         '''
288         cn = self.classname
289         cl = self.db.classes[cn]
290         self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
291             'classname': cn, 'instancename': self.instance.INSTANCE_NAME})
292         if sort is None: sort = self.index_arg(':sort')
293         if group is None: group = self.index_arg(':group')
294         if filter is None: filter = self.index_arg(':filter')
295         if columns is None: columns = self.index_arg(':columns')
296         if filterspec is None: filterspec = self.index_filterspec(filter)
297         if show_customization is None:
298             show_customization = self.customization_widget()
300         index = htmltemplate.IndexTemplate(self, self.instance.TEMPLATES, cn)
301         index.render(filterspec, filter, columns, sort, group,
302             show_customization=show_customization)
303         self.pagefoot()
305     def shownode(self, message=None):
306         ''' display an item
307         '''
308         cn = self.classname
309         cl = self.db.classes[cn]
311         # possibly perform an edit
312         keys = self.form.keys()
313         num_re = re.compile('^\d+$')
314         # don't try to set properties if the user has just logged in
315         if keys and not self.form.has_key('__login_name'):
316             try:
317                 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
318                 # make changes to the node
319                 self._changenode(props)
320                 # handle linked nodes 
321                 self._post_editnode(self.nodeid)
322                 # and some nice feedback for the user
323                 if props:
324                     message = _('%(changes)s edited ok')%{'changes':
325                         ', '.join(props.keys())}
326                 elif self.form.has_key('__note') and self.form['__note'].value:
327                     message = _('note added')
328                 elif (self.form.has_key('__file') and
329                         self.form['__file'].filename):
330                     message = _('file added')
331                 else:
332                     message = _('nothing changed')
333             except:
334                 self.db.rollback()
335                 s = StringIO.StringIO()
336                 traceback.print_exc(None, s)
337                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
339         # now the display
340         id = self.nodeid
341         if cl.getkey():
342             id = cl.get(id, cl.getkey())
343         self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
345         nodeid = self.nodeid
347         # use the template to display the item
348         item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES,
349             self.classname)
350         item.render(nodeid)
352         self.pagefoot()
353     showissue = shownode
354     showmsg = shownode
356     def _add_assignedto_to_nosy(self, props):
357         ''' add the assignedto value from the props to the nosy list
358         '''
359         if not props.has_key('assignedto'):
360             return
361         assignedto_id = props['assignedto']
362         if not props.has_key('nosy'):
363             # load current nosy
364             if self.nodeid:
365                 cl = self.db.classes[self.classname]
366                 l = cl.get(self.nodeid, 'nosy')
367                 if assignedto_id in l:
368                     return
369                 props['nosy'] = l
370             else:
371                 props['nosy'] = []
372         if assignedto_id not in props['nosy']:
373             props['nosy'].append(assignedto_id)
375     def _changenode(self, props):
376         ''' change the node based on the contents of the form
377         '''
378         cl = self.db.classes[self.classname]
379         # set status to chatting if 'unread' or 'resolved'
380         try:
381             # determine the id of 'unread','resolved' and 'chatting'
382             unread_id = self.db.status.lookup('unread')
383             resolved_id = self.db.status.lookup('resolved')
384             chatting_id = self.db.status.lookup('chatting')
385             current_status = cl.get(self.nodeid, 'status')
386             if props.has_key('status'):
387                 new_status = props['status']
388             else:
389                 # apparently there's a chance that some browsers don't
390                 # send status...
391                 new_status = current_status
392         except KeyError:
393             pass
394         else:
395             if new_status == unread_id or (new_status == resolved_id
396                     and current_status == resolved_id):
397                 props['status'] = chatting_id
399         self._add_assignedto_to_nosy(props)
401         # create the message
402         message, files = self._handle_message()
403         if message:
404             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
405         if files:
406             props['files'] = cl.get(self.nodeid, 'files') + files
408         # make the changes
409         cl.set(self.nodeid, **props)
411     def _createnode(self):
412         ''' create a node based on the contents of the form
413         '''
414         cl = self.db.classes[self.classname]
415         props = parsePropsFromForm(self.db, cl, self.form)
417         # set status to 'unread' if not specified - a status of '- no
418         # selection -' doesn't make sense
419         if not props.has_key('status'):
420             try:
421                 unread_id = self.db.status.lookup('unread')
422             except KeyError:
423                 pass
424             else:
425                 props['status'] = unread_id
427         self._add_assignedto_to_nosy(props)
429         # check for messages and files
430         message, files = self._handle_message()
431         if message:
432             props['messages'] = [message]
433         if files:
434             props['files'] = files
435         # create the node and return it's id
436         return cl.create(**props)
438     def _handle_message(self):
439         ''' generate an edit message
440         '''
441         # handle file attachments 
442         files = []
443         if self.form.has_key('__file'):
444             file = self.form['__file']
445             if file.filename:
446                 filename = file.filename.split('\\')[-1]
447                 mime_type = mimetypes.guess_type(filename)[0]
448                 if not mime_type:
449                     mime_type = "application/octet-stream"
450                 # create the new file entry
451                 files.append(self.db.file.create(type=mime_type,
452                     name=filename, content=file.file.read()))
454         # we don't want to do a message if none of the following is true...
455         cn = self.classname
456         cl = self.db.classes[self.classname]
457         props = cl.getprops()
458         note = None
459         # in a nutshell, don't do anything if there's no note or there's no
460         # NOSY
461         if self.form.has_key('__note'):
462             note = self.form['__note'].value
463         if not props.has_key('messages'):
464             return None, files
465         if not isinstance(props['messages'], hyperdb.Multilink):
466             return None, files
467         if not props['messages'].classname == 'msg':
468             return None, files
469         if not (self.form.has_key('nosy') or note):
470             return None, files
472         # handle the note
473         if note:
474             if '\n' in note:
475                 summary = re.split(r'\n\r?', note)[0]
476             else:
477                 summary = note
478             m = ['%s\n'%note]
479         elif not files:
480             # don't generate a useless message
481             return None, files
483         # handle the messageid
484         # TODO: handle inreplyto
485         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
486             self.classname, self.instance.MAIL_DOMAIN)
488         # now create the message, attaching the files
489         content = '\n'.join(m)
490         message_id = self.db.msg.create(author=self.getuid(),
491             recipients=[], date=date.Date('.'), summary=summary,
492             content=content, files=files, messageid=messageid)
494         # update the messages property
495         return message_id, files
497     def _post_editnode(self, nid):
498         '''Do the linking part of the node creation.
500            If a form element has :link or :multilink appended to it, its
501            value specifies a node designator and the property on that node
502            to add _this_ node to as a link or multilink.
504            This is typically used on, eg. the file upload page to indicated
505            which issue to link the file to.
507            TODO: I suspect that this and newfile will go away now that
508            there's the ability to upload a file using the issue __file form
509            element!
510         '''
511         cn = self.classname
512         cl = self.db.classes[cn]
513         # link if necessary
514         keys = self.form.keys()
515         for key in keys:
516             if key == ':multilink':
517                 value = self.form[key].value
518                 if type(value) != type([]): value = [value]
519                 for value in value:
520                     designator, property = value.split(':')
521                     link, nodeid = roundupdb.splitDesignator(designator)
522                     link = self.db.classes[link]
523                     value = link.get(nodeid, property)
524                     value.append(nid)
525                     link.set(nodeid, **{property: value})
526             elif key == ':link':
527                 value = self.form[key].value
528                 if type(value) != type([]): value = [value]
529                 for value in value:
530                     designator, property = value.split(':')
531                     link, nodeid = roundupdb.splitDesignator(designator)
532                     link = self.db.classes[link]
533                     link.set(nodeid, **{property: nid})
535     def newnode(self, message=None):
536         ''' Add a new node to the database.
537         
538         The form works in two modes: blank form and submission (that is,
539         the submission goes to the same URL). **Eventually this means that
540         the form will have previously entered information in it if
541         submission fails.
543         The new node will be created with the properties specified in the
544         form submission. For multilinks, multiple form entries are handled,
545         as are prop=value,value,value. You can't mix them though.
547         If the new node is to be referenced from somewhere else immediately
548         (ie. the new node is a file that is to be attached to a support
549         issue) then supply one of these arguments in addition to the usual
550         form entries:
551             :link=designator:property
552             :multilink=designator:property
553         ... which means that once the new node is created, the "property"
554         on the node given by "designator" should now reference the new
555         node's id. The node id will be appended to the multilink.
556         '''
557         cn = self.classname
558         cl = self.db.classes[cn]
560         # possibly perform a create
561         keys = self.form.keys()
562         if [i for i in keys if i[0] != ':']:
563             props = {}
564             try:
565                 nid = self._createnode()
566                 # handle linked nodes 
567                 self._post_editnode(nid)
568                 # and some nice feedback for the user
569                 message = _('%(classname)s created ok')%{'classname': cn}
571                 # render the newly created issue
572                 self.db.commit()
573                 self.nodeid = nid
574                 self.pagehead('%s: %s'%(self.classname.capitalize(), nid),
575                     message)
576                 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 
577                     self.classname)
578                 item.render(nid)
579                 self.pagefoot()
580                 return
581             except:
582                 self.db.rollback()
583                 s = StringIO.StringIO()
584                 traceback.print_exc(None, s)
585                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
586         self.pagehead(_('New %(classname)s')%{'classname':
587             self.classname.capitalize()}, message)
589         # call the template
590         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
591             self.classname)
592         newitem.render(self.form)
594         self.pagefoot()
595     newissue = newnode
597     def newuser(self, message=None):
598         ''' Add a new user to the database.
600             Don't do any of the message or file handling, just create the node.
601         '''
602         cn = self.classname
603         cl = self.db.classes[cn]
605         # possibly perform a create
606         keys = self.form.keys()
607         if [i for i in keys if i[0] != ':']:
608             try:
609                 props = parsePropsFromForm(self.db, cl, self.form)
610                 nid = cl.create(**props)
611                 # handle linked nodes 
612                 self._post_editnode(nid)
613                 # and some nice feedback for the user
614                 message = _('%(classname)s created ok')%{'classname': cn}
615             except:
616                 self.db.rollback()
617                 s = StringIO.StringIO()
618                 traceback.print_exc(None, s)
619                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
620         self.pagehead(_('New %(classname)s')%{'classname':
621              self.classname.capitalize()}, message)
623         # call the template
624         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
625             self.classname)
626         newitem.render(self.form)
628         self.pagefoot()
630     def newfile(self, message=None):
631         ''' Add a new file to the database.
632         
633         This form works very much the same way as newnode - it just has a
634         file upload.
635         '''
636         cn = self.classname
637         cl = self.db.classes[cn]
639         # possibly perform a create
640         keys = self.form.keys()
641         if [i for i in keys if i[0] != ':']:
642             try:
643                 file = self.form['content']
644                 mime_type = mimetypes.guess_type(file.filename)[0]
645                 if not mime_type:
646                     mime_type = "application/octet-stream"
647                 # save the file
648                 nid = cl.create(content=file.file.read(), type=mime_type,
649                     name=file.filename)
650                 # handle linked nodes
651                 self._post_editnode(nid)
652                 # and some nice feedback for the user
653                 message = _('%(classname)s created ok')%{'classname': cn}
654             except:
655                 self.db.rollback()
656                 s = StringIO.StringIO()
657                 traceback.print_exc(None, s)
658                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
660         self.pagehead(_('New %(classname)s')%{'classname':
661              self.classname.capitalize()}, message)
662         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
663             self.classname)
664         newitem.render(self.form)
665         self.pagefoot()
667     def showuser(self, message=None):
668         '''Display a user page for editing. Make sure the user is allowed
669             to edit this node, and also check for password changes.
670         '''
671         if self.user == 'anonymous':
672             raise Unauthorised
674         user = self.db.user
676         # get the username of the node being edited
677         node_user = user.get(self.nodeid, 'username')
679         if self.user not in ('admin', node_user):
680             raise Unauthorised
682         #
683         # perform any editing
684         #
685         keys = self.form.keys()
686         num_re = re.compile('^\d+$')
687         if keys:
688             try:
689                 props = parsePropsFromForm(self.db, user, self.form,
690                     self.nodeid)
691                 set_cookie = 0
692                 if props.has_key('password'):
693                     password = self.form['password'].value.strip()
694                     if not password:
695                         # no password was supplied - don't change it
696                         del props['password']
697                     elif self.nodeid == self.getuid():
698                         # this is the logged-in user's password
699                         set_cookie = password
700                 user.set(self.nodeid, **props)
701                 # and some feedback for the user
702                 message = _('%(changes)s edited ok')%{'changes':
703                     ', '.join(props.keys())}
704             except:
705                 self.db.rollback()
706                 s = StringIO.StringIO()
707                 traceback.print_exc(None, s)
708                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
709         else:
710             set_cookie = 0
712         # fix the cookie if the password has changed
713         if set_cookie:
714             self.set_cookie(self.user, set_cookie)
716         #
717         # now the display
718         #
719         self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
721         # use the template to display the item
722         item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 'user')
723         item.render(self.nodeid)
724         self.pagefoot()
726     def showfile(self):
727         ''' display a file
728         '''
729         nodeid = self.nodeid
730         cl = self.db.file
731         mime_type = cl.get(nodeid, 'type')
732         if mime_type == 'message/rfc822':
733             mime_type = 'text/plain'
734         self.header(headers={'Content-Type': mime_type})
735         self.write(cl.get(nodeid, 'content'))
737     def classes(self, message=None):
738         ''' display a list of all the classes in the database
739         '''
740         if self.user == 'admin':
741             self.pagehead(_('Table of classes'), message)
742             classnames = self.db.classes.keys()
743             classnames.sort()
744             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
745             for cn in classnames:
746                 cl = self.db.getclass(cn)
747                 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
748                 for key, value in cl.properties.items():
749                     if value is None: value = ''
750                     else: value = str(value)
751                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
752                         key, cgi.escape(value)))
753             self.write('</table>')
754             self.pagefoot()
755         else:
756             raise Unauthorised
758     def login(self, message=None, newuser_form=None, action='index'):
759         '''Display a login page.
760         '''
761         self.pagehead(_('Login to roundup'), message)
762         self.write(_('''
763 <table>
764 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
765 <form onSubmit="return submit_once()" action="login_action" method=POST>
766 <input type="hidden" name="__destination_url" value="%(action)s">
767 <tr><td align=right>Login name: </td>
768     <td><input name="__login_name"></td></tr>
769 <tr><td align=right>Password: </td>
770     <td><input type="password" name="__login_password"></td></tr>
771 <tr><td></td>
772     <td><input type="submit" value="Log In"></td></tr>
773 </form>
774 ''')%locals())
775         if self.user is None and self.instance.ANONYMOUS_REGISTER == 'deny':
776             self.write('</table>')
777             self.pagefoot()
778             return
779         values = {'realname': '', 'organisation': '', 'address': '',
780             'phone': '', 'username': '', 'password': '', 'confirm': '',
781             'action': action}
782         if newuser_form is not None:
783             for key in newuser_form.keys():
784                 values[key] = newuser_form[key].value
785         self.write(_('''
786 <p>
787 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
788 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
789 <form onSubmit="return submit_once()" action="newuser_action" method=POST>
790 <input type="hidden" name="__destination_url" value="%(action)s">
791 <tr><td align=right><em>Name: </em></td>
792     <td><input name="realname" value="%(realname)s"></td></tr>
793 <tr><td align=right><em>Organisation: </em></td>
794     <td><input name="organisation" value="%(organisation)s"></td></tr>
795 <tr><td align=right>E-Mail Address: </td>
796     <td><input name="address" value="%(address)s"></td></tr>
797 <tr><td align=right><em>Phone: </em></td>
798     <td><input name="phone" value="%(phone)s"></td></tr>
799 <tr><td align=right>Preferred Login name: </td>
800     <td><input name="username" value="%(username)s"></td></tr>
801 <tr><td align=right>Password: </td>
802     <td><input type="password" name="password" value="%(password)s"></td></tr>
803 <tr><td align=right>Password Again: </td>
804     <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
805 <tr><td></td>
806     <td><input type="submit" value="Register"></td></tr>
807 </form>
808 </table>
809 ''')%values)
810         self.pagefoot()
812     def login_action(self, message=None):
813         '''Attempt to log a user in and set the cookie
815         returns 0 if a page is generated as a result of this call, and
816         1 if not (ie. the login is successful
817         '''
818         if not self.form.has_key('__login_name'):
819             self.login(message=_('Username required'))
820             return 0
821         self.user = self.form['__login_name'].value
822         if self.form.has_key('__login_password'):
823             password = self.form['__login_password'].value
824         else:
825             password = ''
826         # make sure the user exists
827         try:
828             uid = self.db.user.lookup(self.user)
829         except KeyError:
830             name = self.user
831             self.make_user_anonymous()
832             action = self.form['__destination_url'].value
833             self.login(message=_('No such user "%(name)s"')%locals(),
834                 action=action)
835             return 0
837         # and that the password is correct
838         pw = self.db.user.get(uid, 'password')
839         if password != pw:
840             self.make_user_anonymous()
841             action = self.form['__destination_url'].value
842             self.login(message=_('Incorrect password'), action=action)
843             return 0
845         self.set_cookie(self.user, password)
846         return 1
848     def newuser_action(self, message=None):
849         '''Attempt to create a new user based on the contents of the form
850         and then set the cookie.
852         return 1 on successful login
853         '''
854         # re-open the database as "admin"
855         self.db = self.instance.open('admin')
857         # TODO: pre-check the required fields and username key property
858         cl = self.db.user
859         try:
860             props = parsePropsFromForm(self.db, cl, self.form)
861             uid = cl.create(**props)
862         except ValueError, message:
863             action = self.form['__destination_url'].value
864             self.login(message, action=action)
865             return 0
866         self.user = cl.get(uid, 'username')
867         password = cl.get(uid, 'password')
868         self.set_cookie(self.user, self.form['password'].value)
869         return 1
871     def set_cookie(self, user, password):
872         # construct the cookie
873         user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
874         if user[-1] == '=':
875           if user[-2] == '=':
876             user = user[:-2]
877           else:
878             user = user[:-1]
879         expire = Cookie._getdate(86400*365)
880         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
881         self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
882             user, expire, path)})
884     def make_user_anonymous(self):
885         # make us anonymous if we can
886         try:
887             self.db.user.lookup('anonymous')
888             self.user = 'anonymous'
889         except KeyError:
890             self.user = None
892     def logout(self, message=None):
893         self.make_user_anonymous()
894         # construct the logout cookie
895         now = Cookie._getdate()
896         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
897         self.header({'Set-Cookie':
898             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
899             path)})
900         self.login()
902     def main(self):
903         '''Wrap the database accesses so we can close the database cleanly
904         '''
905         # determine the uid to use
906         self.db = self.instance.open('admin')
907         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
908         user = 'anonymous'
909         if (cookie.has_key('roundup_user') and
910                 cookie['roundup_user'].value != 'deleted'):
911             cookie = cookie['roundup_user'].value
912             if len(cookie)%4:
913               cookie = cookie + '='*(4-len(cookie)%4)
914             try:
915                 user, password = binascii.a2b_base64(cookie).split(':')
916             except (TypeError, binascii.Error, binascii.Incomplete):
917                 # damaged cookie!
918                 user, password = 'anonymous', ''
920             # make sure the user exists
921             try:
922                 uid = self.db.user.lookup(user)
923                 # now validate the password
924                 if password != self.db.user.get(uid, 'password'):
925                     user = 'anonymous'
926             except KeyError:
927                 user = 'anonymous'
929         # make sure the anonymous user is valid if we're using it
930         if user == 'anonymous':
931             self.make_user_anonymous()
932         else:
933             self.user = user
935         # re-open the database for real, using the user
936         self.db = self.instance.open(self.user)
938         # now figure which function to call
939         path = self.split_path
941         # default action to index if the path has no information in it
942         if not path or path[0] in ('', 'index'):
943             action = 'index'
944         else:
945             action = path[0]
947         # Everthing ignores path[1:]
948         #  - The file download link generator actually relies on this - it
949         #    appends the name of the file to the URL so the download file name
950         #    is correct, but doesn't actually use it.
952         # everyone is allowed to try to log in
953         if action == 'login_action':
954             # try to login
955             if not self.login_action():
956                 return
957             # figure the resulting page
958             action = self.form['__destination_url'].value
959             if not action:
960                 action = 'index'
961             self.do_action(action)
962             return
964         # allow anonymous people to register
965         if action == 'newuser_action':
966             # if we don't have a login and anonymous people aren't allowed to
967             # register, then spit up the login form
968             if self.instance.ANONYMOUS_REGISTER == 'deny' and self.user is None:
969                 if action == 'login':
970                     self.login()         # go to the index after login
971                 else:
972                     self.login(action=action)
973                 return
974             # try to add the user
975             if not self.newuser_action():
976                 return
977             # figure the resulting page
978             action = self.form['__destination_url'].value
979             if not action:
980                 action = 'index'
982         # no login or registration, make sure totally anonymous access is OK
983         elif self.instance.ANONYMOUS_ACCESS == 'deny' and self.user is None:
984             if action == 'login':
985                 self.login()             # go to the index after login
986             else:
987                 self.login(action=action)
988             return
990         # just a regular action
991         self.do_action(action)
993         # commit all changes to the database
994         self.db.commit()
996     def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
997             nre=re.compile(r'new(\w+)')):
998         '''Figure the user's action and do it.
999         '''
1000         # here be the "normal" functionality
1001         if action == 'index':
1002             self.index()
1003             return
1004         if action == 'list_classes':
1005             self.classes()
1006             return
1007         if action == 'login':
1008             self.login()
1009             return
1010         if action == 'logout':
1011             self.logout()
1012             return
1013         m = dre.match(action)
1014         if m:
1015             self.classname = m.group(1)
1016             self.nodeid = m.group(2)
1017             try:
1018                 cl = self.db.classes[self.classname]
1019             except KeyError:
1020                 raise NotFound
1021             try:
1022                 cl.get(self.nodeid, 'id')
1023             except IndexError:
1024                 raise NotFound
1025             try:
1026                 func = getattr(self, 'show%s'%self.classname)
1027             except AttributeError:
1028                 raise NotFound
1029             func()
1030             return
1031         m = nre.match(action)
1032         if m:
1033             self.classname = m.group(1)
1034             try:
1035                 func = getattr(self, 'new%s'%self.classname)
1036             except AttributeError:
1037                 raise NotFound
1038             func()
1039             return
1040         self.classname = action
1041         try:
1042             self.db.getclass(self.classname)
1043         except KeyError:
1044             raise NotFound
1045         self.list()
1048 class ExtendedClient(Client): 
1049     '''Includes pages and page heading information that relate to the
1050        extended schema.
1051     ''' 
1052     showsupport = Client.shownode
1053     showtimelog = Client.shownode
1054     newsupport = Client.newnode
1055     newtimelog = Client.newnode
1057     default_index_sort = ['-activity']
1058     default_index_group = ['priority']
1059     default_index_filter = ['status']
1060     default_index_columns = ['activity','status','title','assignedto']
1061     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
1063     def pagehead(self, title, message=None):
1064         url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
1065         machine = self.env['SERVER_NAME']
1066         port = self.env['SERVER_PORT']
1067         if port != '80': machine = machine + ':' + port
1068         base = urlparse.urlunparse(('http', machine, url, None, None, None))
1069         if message is not None:
1070             message = _('<div class="system-msg">%(message)s</div>')%locals()
1071         else:
1072             message = ''
1073         style = open(os.path.join(self.instance.TEMPLATES, 'style.css')).read()
1074         user_name = self.user or ''
1075         if self.user == 'admin':
1076             admin_links = _(' | <a href="list_classes">Class List</a>' \
1077                           ' | <a href="user">User List</a>' \
1078                           ' | <a href="newuser">Add User</a>')
1079         else:
1080             admin_links = ''
1081         if self.user not in (None, 'anonymous'):
1082             userid = self.db.user.lookup(self.user)
1083             user_info = _('''
1084 <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> |
1085 <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> |
1086 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
1087 ''')%locals()
1088         else:
1089             user_info = _('<a href="login">Login</a>')
1090         if self.user is not None:
1091             add_links = _('''
1092 | Add
1093 <a href="newissue">Issue</a>,
1094 <a href="newsupport">Support</a>,
1095 ''')
1096         else:
1097             add_links = ''
1098         single_submit_script = self.single_submit_script
1099         self.write(_('''<html><head>
1100 <title>%(title)s</title>
1101 <style type="text/css">%(style)s</style>
1102 </head>
1103 %(single_submit_script)s
1104 <body bgcolor=#ffffff>
1105 %(message)s
1106 <table width=100%% border=0 cellspacing=0 cellpadding=2>
1107 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
1108 <td align=right valign=bottom>%(user_name)s</td></tr>
1109 <tr class="location-bar">
1110 <td align=left>All
1111 <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>,
1112 <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>
1113 | Unassigned
1114 <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>,
1115 <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>
1116 %(add_links)s
1117 %(admin_links)s</td>
1118 <td align=right>%(user_info)s</td>
1119 </table>
1120 ''')%locals())
1122 def parsePropsFromForm(db, cl, form, nodeid=0):
1123     '''Pull properties for the given class out of the form.
1124     '''
1125     props = {}
1126     keys = form.keys()
1127     num_re = re.compile('^\d+$')
1128     for key in keys:
1129         if not cl.properties.has_key(key):
1130             continue
1131         proptype = cl.properties[key]
1132         if isinstance(proptype, hyperdb.String):
1133             value = form[key].value.strip()
1134         elif isinstance(proptype, hyperdb.Password):
1135             value = password.Password(form[key].value.strip())
1136         elif isinstance(proptype, hyperdb.Date):
1137             value = form[key].value.strip()
1138             if value:
1139                 value = date.Date(form[key].value.strip())
1140             else:
1141                 value = None
1142         elif isinstance(proptype, hyperdb.Interval):
1143             value = form[key].value.strip()
1144             if value:
1145                 value = date.Interval(form[key].value.strip())
1146             else:
1147                 value = None
1148         elif isinstance(proptype, hyperdb.Link):
1149             value = form[key].value.strip()
1150             # see if it's the "no selection" choice
1151             if value == '-1':
1152                 # don't set this property
1153                 continue
1154             else:
1155                 # handle key values
1156                 link = cl.properties[key].classname
1157                 if not num_re.match(value):
1158                     try:
1159                         value = db.classes[link].lookup(value)
1160                     except KeyError:
1161                         raise ValueError, _('property "%(propname)s": '
1162                             '%(value)s not a %(classname)s')%{'propname':key, 
1163                             'value': value, 'classname': link}
1164         elif isinstance(proptype, hyperdb.Multilink):
1165             value = form[key]
1166             if type(value) != type([]):
1167                 value = [i.strip() for i in value.value.split(',')]
1168             else:
1169                 value = [i.value.strip() for i in value]
1170             link = cl.properties[key].classname
1171             l = []
1172             for entry in map(str, value):
1173                 if entry == '': continue
1174                 if not num_re.match(entry):
1175                     try:
1176                         entry = db.classes[link].lookup(entry)
1177                     except KeyError:
1178                         raise ValueError, _('property "%(propname)s": '
1179                             '"%(value)s" not an entry of %(classname)s')%{
1180                             'propname':key, 'value': entry, 'classname': link}
1181                 l.append(entry)
1182             l.sort()
1183             value = l
1185         # get the old value
1186         if nodeid:
1187             try:
1188                 existing = cl.get(nodeid, key)
1189             except KeyError:
1190                 # this might be a new property for which there is no existing
1191                 # value
1192                 if not cl.properties.has_key(key): raise
1194             # if changed, set it
1195             if value != existing:
1196                 props[key] = value
1197         else:
1198             props[key] = value
1199     return props
1202 # $Log: not supported by cvs2svn $
1203 # Revision 1.100  2002/01/16 07:02:57  richard
1204 #  . lots of date/interval related changes:
1205 #    - more relaxed date format for input
1207 # Revision 1.99  2002/01/16 03:02:42  richard
1208 # #503793 ] changing assignedto resets nosy list
1210 # Revision 1.98  2002/01/14 02:20:14  richard
1211 #  . changed all config accesses so they access either the instance or the
1212 #    config attriubute on the db. This means that all config is obtained from
1213 #    instance_config instead of the mish-mash of classes. This will make
1214 #    switching to a ConfigParser setup easier too, I hope.
1216 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1217 # 0.5.0 switch, I hope!)
1219 # Revision 1.97  2002/01/11 23:22:29  richard
1220 #  . #502437 ] rogue reactor and unittest
1221 #    in short, the nosy reactor was modifying the nosy list. That code had
1222 #    been there for a long time, and I suspsect it was there because we
1223 #    weren't generating the nosy list correctly in other places of the code.
1224 #    We're now doing that, so the nosy-modifying code can go away from the
1225 #    nosy reactor.
1227 # Revision 1.96  2002/01/10 05:26:10  richard
1228 # missed a parsePropsFromForm in last update
1230 # Revision 1.95  2002/01/10 03:39:45  richard
1231 #  . fixed some problems with web editing and change detection
1233 # Revision 1.94  2002/01/09 13:54:21  grubert
1234 # _add_assignedto_to_nosy did set nosy to assignedto only, no adding.
1236 # Revision 1.93  2002/01/08 11:57:12  richard
1237 # crying out for real configuration handling... :(
1239 # Revision 1.92  2002/01/08 04:12:05  richard
1240 # Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
1242 # Revision 1.91  2002/01/08 04:03:47  richard
1243 # I mucked the intent of the code up.
1245 # Revision 1.90  2002/01/08 03:56:55  richard
1246 # Oops, missed this before the beta:
1247 #  . #495392 ] empty nosy -patch
1249 # Revision 1.89  2002/01/07 20:24:45  richard
1250 # *mutter* stupid cutnpaste
1252 # Revision 1.88  2002/01/02 02:31:38  richard
1253 # Sorry for the huge checkin message - I was only intending to implement #496356
1254 # but I found a number of places where things had been broken by transactions:
1255 #  . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1256 #    for _all_ roundup-generated smtp messages to be sent to.
1257 #  . the transaction cache had broken the roundupdb.Class set() reactors
1258 #  . newly-created author users in the mailgw weren't being committed to the db
1260 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1261 # on when I found that stuff :):
1262 #  . #496356 ] Use threading in messages
1263 #  . detectors were being registered multiple times
1264 #  . added tests for mailgw
1265 #  . much better attaching of erroneous messages in the mail gateway
1267 # Revision 1.87  2001/12/23 23:18:49  richard
1268 # We already had an admin-specific section of the web heading, no need to add
1269 # another one :)
1271 # Revision 1.86  2001/12/20 15:43:01  rochecompaan
1272 # Features added:
1273 #  .  Multilink properties are now displayed as comma separated values in
1274 #     a textbox
1275 #  .  The add user link is now only visible to the admin user
1276 #  .  Modified the mail gateway to reject submissions from unknown
1277 #     addresses if ANONYMOUS_ACCESS is denied
1279 # Revision 1.85  2001/12/20 06:13:24  rochecompaan
1280 # Bugs fixed:
1281 #   . Exception handling in hyperdb for strings-that-look-like numbers got
1282 #     lost somewhere
1283 #   . Internet Explorer submits full path for filename - we now strip away
1284 #     the path
1285 # Features added:
1286 #   . Link and multilink properties are now displayed sorted in the cgi
1287 #     interface
1289 # Revision 1.84  2001/12/18 15:30:30  rochecompaan
1290 # Fixed bugs:
1291 #  .  Fixed file creation and retrieval in same transaction in anydbm
1292 #     backend
1293 #  .  Cgi interface now renders new issue after issue creation
1294 #  .  Could not set issue status to resolved through cgi interface
1295 #  .  Mail gateway was changing status back to 'chatting' if status was
1296 #     omitted as an argument
1298 # Revision 1.83  2001/12/15 23:51:01  richard
1299 # Tested the changes and fixed a few problems:
1300 #  . files are now attached to the issue as well as the message
1301 #  . newuser is a real method now since we don't want to do the message/file
1302 #    stuff for it
1303 #  . added some documentation
1304 # The really big changes in the diff are a result of me moving some code
1305 # around to keep like methods together a bit better.
1307 # Revision 1.82  2001/12/15 19:24:39  rochecompaan
1308 #  . Modified cgi interface to change properties only once all changes are
1309 #    collected, files created and messages generated.
1310 #  . Moved generation of change note to nosyreactors.
1311 #  . We now check for changes to "assignedto" to ensure it's added to the
1312 #    nosy list.
1314 # Revision 1.81  2001/12/12 23:55:00  richard
1315 # Fixed some problems with user editing
1317 # Revision 1.80  2001/12/12 23:27:14  richard
1318 # Added a Zope frontend for roundup.
1320 # Revision 1.79  2001/12/10 22:20:01  richard
1321 # Enabled transaction support in the bsddb backend. It uses the anydbm code
1322 # where possible, only replacing methods where the db is opened (it uses the
1323 # btree opener specifically.)
1324 # Also cleaned up some change note generation.
1325 # Made the backends package work with pydoc too.
1327 # Revision 1.78  2001/12/07 05:59:27  rochecompaan
1328 # Fixed small bug that prevented adding issues through the web.
1330 # Revision 1.77  2001/12/06 22:48:29  richard
1331 # files multilink was being nuked in post_edit_node
1333 # Revision 1.76  2001/12/05 14:26:44  rochecompaan
1334 # Removed generation of change note from "sendmessage" in roundupdb.py.
1335 # The change note is now generated when the message is created.
1337 # Revision 1.75  2001/12/04 01:25:08  richard
1338 # Added some rollbacks where we were catching exceptions that would otherwise
1339 # have stopped committing.
1341 # Revision 1.74  2001/12/02 05:06:16  richard
1342 # . We now use weakrefs in the Classes to keep the database reference, so
1343 #   the close() method on the database is no longer needed.
1344 #   I bumped the minimum python requirement up to 2.1 accordingly.
1345 # . #487480 ] roundup-server
1346 # . #487476 ] INSTALL.txt
1348 # I also cleaned up the change message / post-edit stuff in the cgi client.
1349 # There's now a clearly marked "TODO: append the change note" where I believe
1350 # the change note should be added there. The "changes" list will obviously
1351 # have to be modified to be a dict of the changes, or somesuch.
1353 # More testing needed.
1355 # Revision 1.73  2001/12/01 07:17:50  richard
1356 # . We now have basic transaction support! Information is only written to
1357 #   the database when the commit() method is called. Only the anydbm
1358 #   backend is modified in this way - neither of the bsddb backends have been.
1359 #   The mail, admin and cgi interfaces all use commit (except the admin tool
1360 #   doesn't have a commit command, so interactive users can't commit...)
1361 # . Fixed login/registration forwarding the user to the right page (or not,
1362 #   on a failure)
1364 # Revision 1.72  2001/11/30 20:47:58  rochecompaan
1365 # Links in page header are now consistent with default sort order.
1367 # Fixed bugs:
1368 #     - When login failed the list of issues were still rendered.
1369 #     - User was redirected to index page and not to his destination url
1370 #       if his first login attempt failed.
1372 # Revision 1.71  2001/11/30 20:28:10  rochecompaan
1373 # Property changes are now completely traceable, whether changes are
1374 # made through the web or by email
1376 # Revision 1.70  2001/11/30 00:06:29  richard
1377 # Converted roundup/cgi_client.py to use _()
1378 # Added the status file, I18N_PROGRESS.txt
1380 # Revision 1.69  2001/11/29 23:19:51  richard
1381 # Removed the "This issue has been edited through the web" when a valid
1382 # change note is supplied.
1384 # Revision 1.68  2001/11/29 04:57:23  richard
1385 # a little comment
1387 # Revision 1.67  2001/11/28 21:55:35  richard
1388 #  . login_action and newuser_action return values were being ignored
1389 #  . Woohoo! Found that bloody re-login bug that was killing the mail
1390 #    gateway.
1391 #  (also a minor cleanup in hyperdb)
1393 # Revision 1.66  2001/11/27 03:00:50  richard
1394 # couple of bugfixes from latest patch integration
1396 # Revision 1.65  2001/11/26 23:00:53  richard
1397 # This config stuff is getting to be a real mess...
1399 # Revision 1.64  2001/11/26 22:56:35  richard
1400 # typo
1402 # Revision 1.63  2001/11/26 22:55:56  richard
1403 # Feature:
1404 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
1405 #    the instance.
1406 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1407 #    signature info in e-mails.
1408 #  . Some more flexibility in the mail gateway and more error handling.
1409 #  . Login now takes you to the page you back to the were denied access to.
1411 # Fixed:
1412 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
1414 # Revision 1.62  2001/11/24 00:45:42  jhermann
1415 # typeof() instead of type(): avoid clash with database field(?) "type"
1417 # Fixes this traceback:
1419 # Traceback (most recent call last):
1420 #   File "roundup\cgi_client.py", line 535, in newnode
1421 #     self._post_editnode(nid)
1422 #   File "roundup\cgi_client.py", line 415, in _post_editnode
1423 #     if type(value) != type([]): value = [value]
1424 # UnboundLocalError: local variable 'type' referenced before assignment
1426 # Revision 1.61  2001/11/22 15:46:42  jhermann
1427 # Added module docstrings to all modules.
1429 # Revision 1.60  2001/11/21 22:57:28  jhermann
1430 # Added dummy hooks for I18N and some preliminary (test) markup of
1431 # translatable messages
1433 # Revision 1.59  2001/11/21 03:21:13  richard
1434 # oops
1436 # Revision 1.58  2001/11/21 03:11:28  richard
1437 # Better handling of new properties.
1439 # Revision 1.57  2001/11/15 10:24:27  richard
1440 # handle the case where there is no file attached
1442 # Revision 1.56  2001/11/14 21:35:21  richard
1443 #  . users may attach files to issues (and support in ext) through the web now
1445 # Revision 1.55  2001/11/07 02:34:06  jhermann
1446 # Handling of damaged login cookies
1448 # Revision 1.54  2001/11/07 01:16:12  richard
1449 # Remove the '=' padding from cookie value so quoting isn't an issue.
1451 # Revision 1.53  2001/11/06 23:22:05  jhermann
1452 # More IE fixes: it does not like quotes around cookie values; in the
1453 # hope this does not break anything for other browser; if it does, we
1454 # need to check HTTP_USER_AGENT
1456 # Revision 1.52  2001/11/06 23:11:22  jhermann
1457 # Fixed debug output in page footer; added expiry date to the login cookie
1458 # (expires 1 year in the future) to prevent probs with certain versions
1459 # of IE
1461 # Revision 1.51  2001/11/06 22:00:34  jhermann
1462 # Get debug level from ROUNDUP_DEBUG env var
1464 # Revision 1.50  2001/11/05 23:45:40  richard
1465 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1466 # Also made it present nicer error messages (not tracebacks).
1468 # Revision 1.49  2001/11/04 03:07:12  richard
1469 # Fixed various cookie-related bugs:
1470 #  . bug #477685 ] base64.decodestring breaks
1471 #  . bug #477837 ] lynx does not like the cookie
1472 #  . bug #477892 ] Password edit doesn't fix login cookie
1473 # Also closed a security hole - a logged-in user could edit another user's
1474 # details.
1476 # Revision 1.48  2001/11/03 01:30:18  richard
1477 # Oops. uses pagefoot now.
1479 # Revision 1.47  2001/11/03 01:29:28  richard
1480 # Login page didn't have all close tags.
1482 # Revision 1.46  2001/11/03 01:26:55  richard
1483 # possibly fix truncated base64'ed user:pass
1485 # Revision 1.45  2001/11/01 22:04:37  richard
1486 # Started work on supporting a pop3-fetching server
1487 # Fixed bugs:
1488 #  . bug #477104 ] HTML tag error in roundup-server
1489 #  . bug #477107 ] HTTP header problem
1491 # Revision 1.44  2001/10/28 23:03:08  richard
1492 # Added more useful header to the classic schema.
1494 # Revision 1.43  2001/10/24 00:01:42  richard
1495 # More fixes to lockout logic.
1497 # Revision 1.42  2001/10/23 23:56:03  richard
1498 # HTML typo
1500 # Revision 1.41  2001/10/23 23:52:35  richard
1501 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1503 # Revision 1.40  2001/10/23 23:06:39  richard
1504 # Some cleanup.
1506 # Revision 1.39  2001/10/23 01:00:18  richard
1507 # Re-enabled login and registration access after lopping them off via
1508 # disabling access for anonymous users.
1509 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1510 # a couple of bugs while I was there. Probably introduced a couple, but
1511 # things seem to work OK at the moment.
1513 # Revision 1.38  2001/10/22 03:25:01  richard
1514 # Added configuration for:
1515 #  . anonymous user access and registration (deny/allow)
1516 #  . filter "widget" location on index page (top, bottom, both)
1517 # Updated some documentation.
1519 # Revision 1.37  2001/10/21 07:26:35  richard
1520 # feature #473127: Filenames. I modified the file.index and htmltemplate
1521 #  source so that the filename is used in the link and the creation
1522 #  information is displayed.
1524 # Revision 1.36  2001/10/21 04:44:50  richard
1525 # bug #473124: UI inconsistency with Link fields.
1526 #    This also prompted me to fix a fairly long-standing usability issue -
1527 #    that of being able to turn off certain filters.
1529 # Revision 1.35  2001/10/21 00:17:54  richard
1530 # CGI interface view customisation section may now be hidden (patch from
1531 #  Roch'e Compaan.)
1533 # Revision 1.34  2001/10/20 11:58:48  richard
1534 # Catch errors in login - no username or password supplied.
1535 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1537 # Revision 1.33  2001/10/17 00:18:41  richard
1538 # Manually constructing cookie headers now.
1540 # Revision 1.32  2001/10/16 03:36:21  richard
1541 # CGI interface wasn't handling checkboxes at all.
1543 # Revision 1.31  2001/10/14 10:55:00  richard
1544 # Handle empty strings in HTML template Link function
1546 # Revision 1.30  2001/10/09 07:38:58  richard
1547 # Pushed the base code for the extended schema CGI interface back into the
1548 # code cgi_client module so that future updates will be less painful.
1549 # Also removed a debugging print statement from cgi_client.
1551 # Revision 1.29  2001/10/09 07:25:59  richard
1552 # Added the Password property type. See "pydoc roundup.password" for
1553 # implementation details. Have updated some of the documentation too.
1555 # Revision 1.28  2001/10/08 00:34:31  richard
1556 # Change message was stuffing up for multilinks with no key property.
1558 # Revision 1.27  2001/10/05 02:23:24  richard
1559 #  . roundup-admin create now prompts for property info if none is supplied
1560 #    on the command-line.
1561 #  . hyperdb Class getprops() method may now return only the mutable
1562 #    properties.
1563 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
1564 #    now support anonymous user access (read-only, unless there's an
1565 #    "anonymous" user, in which case write access is permitted). Login
1566 #    handling has been moved into cgi_client.Client.main()
1567 #  . The "extended" schema is now the default in roundup init.
1568 #  . The schemas have had their page headings modified to cope with the new
1569 #    login handling. Existing installations should copy the interfaces.py
1570 #    file from the roundup lib directory to their instance home.
1571 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1572 #    Ping - has been removed.
1573 #  . Fixed a whole bunch of places in the CGI interface where we should have
1574 #    been returning Not Found instead of throwing an exception.
1575 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
1576 #    an item now throws an exception.
1578 # Revision 1.26  2001/09/12 08:31:42  richard
1579 # handle cases where mime type is not guessable
1581 # Revision 1.25  2001/08/29 05:30:49  richard
1582 # change messages weren't being saved when there was no-one on the nosy list.
1584 # Revision 1.24  2001/08/29 04:49:39  richard
1585 # didn't clean up fully after debugging :(
1587 # Revision 1.23  2001/08/29 04:47:18  richard
1588 # Fixed CGI client change messages so they actually include the properties
1589 # changed (again).
1591 # Revision 1.22  2001/08/17 00:08:10  richard
1592 # reverted back to sending messages always regardless of who is doing the web
1593 # edit. change notes weren't being saved. bleah. hackish.
1595 # Revision 1.21  2001/08/15 23:43:18  richard
1596 # Fixed some isFooTypes that I missed.
1597 # Refactored some code in the CGI code.
1599 # Revision 1.20  2001/08/12 06:32:36  richard
1600 # using isinstance(blah, Foo) now instead of isFooType
1602 # Revision 1.19  2001/08/07 00:24:42  richard
1603 # stupid typo
1605 # Revision 1.18  2001/08/07 00:15:51  richard
1606 # Added the copyright/license notice to (nearly) all files at request of
1607 # Bizar Software.
1609 # Revision 1.17  2001/08/02 06:38:17  richard
1610 # Roundupdb now appends "mailing list" information to its messages which
1611 # include the e-mail address and web interface address. Templates may
1612 # override this in their db classes to include specific information (support
1613 # instructions, etc).
1615 # Revision 1.16  2001/08/02 05:55:25  richard
1616 # Web edit messages aren't sent to the person who did the edit any more. No
1617 # message is generated if they are the only person on the nosy list.
1619 # Revision 1.15  2001/08/02 00:34:10  richard
1620 # bleah syntax error
1622 # Revision 1.14  2001/08/02 00:26:16  richard
1623 # Changed the order of the information in the message generated by web edits.
1625 # Revision 1.13  2001/07/30 08:12:17  richard
1626 # Added time logging and file uploading to the templates.
1628 # Revision 1.12  2001/07/30 06:26:31  richard
1629 # Added some documentation on how the newblah works.
1631 # Revision 1.11  2001/07/30 06:17:45  richard
1632 # Features:
1633 #  . Added ability for cgi newblah forms to indicate that the new node
1634 #    should be linked somewhere.
1635 # Fixed:
1636 #  . Fixed the agument handling for the roundup-admin find command.
1637 #  . Fixed handling of summary when no note supplied for newblah. Again.
1638 #  . Fixed detection of no form in htmltemplate Field display.
1640 # Revision 1.10  2001/07/30 02:37:34  richard
1641 # Temporary measure until we have decent schema migration...
1643 # Revision 1.9  2001/07/30 01:25:07  richard
1644 # Default implementation is now "classic" rather than "extended" as one would
1645 # expect.
1647 # Revision 1.8  2001/07/29 08:27:40  richard
1648 # Fixed handling of passed-in values in form elements (ie. during a
1649 # drill-down)
1651 # Revision 1.7  2001/07/29 07:01:39  richard
1652 # Added vim command to all source so that we don't get no steenkin' tabs :)
1654 # Revision 1.6  2001/07/29 04:04:00  richard
1655 # Moved some code around allowing for subclassing to change behaviour.
1657 # Revision 1.5  2001/07/28 08:16:52  richard
1658 # New issue form handles lack of note better now.
1660 # Revision 1.4  2001/07/28 00:34:34  richard
1661 # Fixed some non-string node ids.
1663 # Revision 1.3  2001/07/23 03:56:30  richard
1664 # oops, missed a config removal
1666 # Revision 1.2  2001/07/22 12:09:32  richard
1667 # Final commit of Grande Splite
1669 # Revision 1.1  2001/07/22 11:58:35  richard
1670 # More Grande Splite
1673 # vim: set filetype=python ts=4 sw=4 et si