Code

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