Code

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