Code

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