Code

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