Code

Oops, missed this before the beta:
[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.90 2002-01-08 03:56:55 richard Exp $
20 __doc__ = """
21 WWW request handler (also used in the stand-alone server).
22 """
24 import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes
25 import binascii, Cookie, time, random
27 import roundupdb, htmltemplate, date, hyperdb, password
28 from roundup.i18n import _
30 class Unauthorised(ValueError):
31     pass
33 class NotFound(ValueError):
34     pass
36 class Client:
37     '''
38     A note about login
39     ------------------
41     If the user has no login cookie, then they are anonymous. There
42     are two levels of anonymous use. If there is no 'anonymous' user, there
43     is no login at all and the database is opened in read-only mode. If the
44     'anonymous' user exists, the user is logged in using that user (though
45     there is no cookie). This allows them to modify the database, and all
46     modifications are attributed to the 'anonymous' user.
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                           ' | <a href="newuser">Add User</a>')
113         else:
114             admin_links = ''
115         if self.user not in (None, 'anonymous'):
116             userid = self.db.user.lookup(self.user)
117             user_info = _('''
118 <a href="issue?assignedto=%(userid)s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=-activity&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">My Issues</a> |
119 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
120 ''')%locals()
121         else:
122             user_info = _('<a href="login">Login</a>')
123         if self.user is not None:
124             add_links = _('''
125 | Add
126 <a href="newissue">Issue</a>
127 ''')
128         else:
129             add_links = ''
130         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 _add_assignedto_to_nosy(self, props):
354         ''' add the assignedto value from the props to the nosy list
355         '''
356         if not props.has_key('assignedto'):
357             return
358         assignedto_id = props['assignedto']
359         if props.has_key('nosy') and assignedto_id not in props['nosy']:
360             props['nosy'].append(assignedto_id)
361         else:
362             props['nosy'] = cl.get(self.nodeid, 'nosy')
363             props['nosy'].append(assignedto_id)
365     def _changenode(self, props):
366         ''' change the node based on the contents of the form
367         '''
368         cl = self.db.classes[self.classname]
369         # set status to chatting if 'unread' or 'resolved'
370         try:
371             # determine the id of 'unread','resolved' and 'chatting'
372             unread_id = self.db.status.lookup('unread')
373             resolved_id = self.db.status.lookup('resolved')
374             chatting_id = self.db.status.lookup('chatting')
375             current_status = cl.get(self.nodeid, 'status')
376             if props.has_key('status'):
377                 new_status = props['status']
378             else:
379                 # apparently there's a chance that some browsers don't
380                 # send status...
381                 new_status = current_status
382         except KeyError:
383             pass
384         else:
385             if new_status == unread_id or (new_status == resolved_id
386                     and current_status == resolved_id):
387                 props['status'] = chatting_id
389         self._add_assignedto_to_nosy(props)
391         # create the message
392         message, files = self._handle_message()
393         if message:
394             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
395         if files:
396             props['files'] = cl.get(self.nodeid, 'files') + files
397         # make the changes
398         cl.set(self.nodeid, **props)
400     def _createnode(self):
401         ''' create a node based on the contents of the form
402         '''
403         cl = self.db.classes[self.classname]
404         props, dummy = parsePropsFromForm(self.db, cl, self.form)
406         # set status to 'unread' if not specified - a status of '- no
407         # selection -' doesn't make sense
408         if not props.has_key('status'):
409             try:
410                 unread_id = self.db.status.lookup('unread')
411             except KeyError:
412                 pass
413             else:
414                 props['status'] = unread_id
416         self._add_assignedto_to_nosy(props)
418         # check for messages and files
419         message, files = self._handle_message()
420         if message:
421             props['messages'] = [message]
422         if files:
423             props['files'] = files
424         # create the node and return it's id
425         return cl.create(**props)
427     def _handle_message(self):
428         ''' generate and edit message
429         '''
430         # handle file attachments 
431         files = []
432         if self.form.has_key('__file'):
433             file = self.form['__file']
434             if file.filename:
435                 filename = file.filename.split('\\')[-1]
436                 mime_type = mimetypes.guess_type(filename)[0]
437                 if not mime_type:
438                     mime_type = "application/octet-stream"
439                 # create the new file entry
440                 files.append(self.db.file.create(type=mime_type,
441                     name=filename, content=file.file.read()))
443         # we don't want to do a message if none of the following is true...
444         cn = self.classname
445         cl = self.db.classes[self.classname]
446         props = cl.getprops()
447         note = None
448         # in a nutshell, don't do anything if there's no note or there's no
449         # NOSY
450         if self.form.has_key('__note'):
451             note = self.form['__note'].value
452         if not props.has_key('messages'):
453             return None, files
454         if not isinstance(props['messages'], hyperdb.Multilink):
455             return None, files
456         if not props['messages'].classname == 'msg':
457             return None, files
458         if not (self.form.has_key('nosy') or note):
459             return None, files
461         # handle the note
462         if note:
463             if '\n' in note:
464                 summary = re.split(r'\n\r?', note)[0]
465             else:
466                 summary = note
467             m = ['%s\n'%note]
468         elif not files:
469             # don't generate a useless message
470             return None, files
472         # handle the messageid
473         # TODO: handle inreplyto
474         messageid = "%s.%s.%s-%s"%(time.time(), random.random(),
475             self.classname, self.MAIL_DOMAIN)
477         # now create the message, attaching the files
478         content = '\n'.join(m)
479         message_id = self.db.msg.create(author=self.getuid(),
480             recipients=[], date=date.Date('.'), summary=summary,
481             content=content, files=files, messageid=messageid)
483         # update the messages property
484         return message_id, files
486     def _post_editnode(self, nid):
487         '''Do the linking part of the node creation.
489            If a form element has :link or :multilink appended to it, its
490            value specifies a node designator and the property on that node
491            to add _this_ node to as a link or multilink.
493            This is typically used on, eg. the file upload page to indicated
494            which issue to link the file to.
496            TODO: I suspect that this and newfile will go away now that
497            there's the ability to upload a file using the issue __file form
498            element!
499         '''
500         cn = self.classname
501         cl = self.db.classes[cn]
502         # link if necessary
503         keys = self.form.keys()
504         for key in keys:
505             if key == ':multilink':
506                 value = self.form[key].value
507                 if type(value) != type([]): value = [value]
508                 for value in value:
509                     designator, property = value.split(':')
510                     link, nodeid = roundupdb.splitDesignator(designator)
511                     link = self.db.classes[link]
512                     value = link.get(nodeid, property)
513                     value.append(nid)
514                     link.set(nodeid, **{property: value})
515             elif key == ':link':
516                 value = self.form[key].value
517                 if type(value) != type([]): value = [value]
518                 for value in value:
519                     designator, property = value.split(':')
520                     link, nodeid = roundupdb.splitDesignator(designator)
521                     link = self.db.classes[link]
522                     link.set(nodeid, **{property: nid})
524     def newnode(self, message=None):
525         ''' Add a new node to the database.
526         
527         The form works in two modes: blank form and submission (that is,
528         the submission goes to the same URL). **Eventually this means that
529         the form will have previously entered information in it if
530         submission fails.
532         The new node will be created with the properties specified in the
533         form submission. For multilinks, multiple form entries are handled,
534         as are prop=value,value,value. You can't mix them though.
536         If the new node is to be referenced from somewhere else immediately
537         (ie. the new node is a file that is to be attached to a support
538         issue) then supply one of these arguments in addition to the usual
539         form entries:
540             :link=designator:property
541             :multilink=designator:property
542         ... which means that once the new node is created, the "property"
543         on the node given by "designator" should now reference the new
544         node's id. The node id will be appended to the multilink.
545         '''
546         cn = self.classname
547         cl = self.db.classes[cn]
549         # possibly perform a create
550         keys = self.form.keys()
551         if [i for i in keys if i[0] != ':']:
552             props = {}
553             try:
554                 nid = self._createnode()
555                 # handle linked nodes 
556                 self._post_editnode(nid)
557                 # and some nice feedback for the user
558                 message = _('%(classname)s created ok')%{'classname': cn}
560                 # render the newly created issue
561                 self.db.commit()
562                 self.nodeid = nid
563                 self.pagehead('%s: %s'%(self.classname.capitalize(), nid),
564                     message)
565                 item = htmltemplate.ItemTemplate(self, self.TEMPLATES, 
566                     self.classname)
567                 item.render(nid)
568                 self.pagefoot()
569                 return
570             except:
571                 self.db.rollback()
572                 s = StringIO.StringIO()
573                 traceback.print_exc(None, s)
574                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
575         self.pagehead(_('New %(classname)s')%{'classname':
576             self.classname.capitalize()}, message)
578         # call the template
579         newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
580             self.classname)
581         newitem.render(self.form)
583         self.pagefoot()
584     newissue = newnode
586     def newuser(self, message=None):
587         ''' Add a new user to the database.
589             Don't do any of the message or file handling, just create the node.
590         '''
591         cn = self.classname
592         cl = self.db.classes[cn]
594         # possibly perform a create
595         keys = self.form.keys()
596         if [i for i in keys if i[0] != ':']:
597             try:
598                 props, dummy = parsePropsFromForm(self.db, cl, self.form)
599                 nid = cl.create(**props)
600                 # handle linked nodes 
601                 self._post_editnode(nid)
602                 # and some nice feedback for the user
603                 message = _('%(classname)s created ok')%{'classname': cn}
604             except:
605                 self.db.rollback()
606                 s = StringIO.StringIO()
607                 traceback.print_exc(None, s)
608                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
609         self.pagehead(_('New %(classname)s')%{'classname':
610              self.classname.capitalize()}, message)
612         # call the template
613         newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
614             self.classname)
615         newitem.render(self.form)
617         self.pagefoot()
619     def newfile(self, message=None):
620         ''' Add a new file to the database.
621         
622         This form works very much the same way as newnode - it just has a
623         file upload.
624         '''
625         cn = self.classname
626         cl = self.db.classes[cn]
628         # possibly perform a create
629         keys = self.form.keys()
630         if [i for i in keys if i[0] != ':']:
631             try:
632                 file = self.form['content']
633                 mime_type = mimetypes.guess_type(file.filename)[0]
634                 if not mime_type:
635                     mime_type = "application/octet-stream"
636                 # save the file
637                 nid = cl.create(content=file.file.read(), type=mime_type,
638                     name=file.filename)
639                 # handle linked nodes
640                 self._post_editnode(nid)
641                 # and some nice feedback for the user
642                 message = _('%(classname)s created ok')%{'classname': cn}
643             except:
644                 self.db.rollback()
645                 s = StringIO.StringIO()
646                 traceback.print_exc(None, s)
647                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
649         self.pagehead(_('New %(classname)s')%{'classname':
650              self.classname.capitalize()}, message)
651         newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
652             self.classname)
653         newitem.render(self.form)
654         self.pagefoot()
656     def showuser(self, message=None):
657         '''Display a user page for editing. Make sure the user is allowed
658             to edit this node, and also check for password changes.
659         '''
660         if self.user == 'anonymous':
661             raise Unauthorised
663         user = self.db.user
665         # get the username of the node being edited
666         node_user = user.get(self.nodeid, 'username')
668         if self.user not in ('admin', node_user):
669             raise Unauthorised
671         #
672         # perform any editing
673         #
674         keys = self.form.keys()
675         num_re = re.compile('^\d+$')
676         if keys:
677             try:
678                 props, changed = parsePropsFromForm(self.db, user, self.form,
679                     self.nodeid)
680                 set_cookie = 0
681                 if self.nodeid == self.getuid() and changed.has_key('password'):
682                     password = self.form['password'].value.strip()
683                     if password:
684                         set_cookie = password
685                     else:
686                         # no password was supplied - don't change it
687                         del props['password']
688                         del changed['password']
689                 user.set(self.nodeid, **props)
690                 # and some feedback for the user
691                 message = _('%(changes)s edited ok')%{'changes':
692                     ', '.join(changed.keys())}
693             except:
694                 self.db.rollback()
695                 s = StringIO.StringIO()
696                 traceback.print_exc(None, s)
697                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
698         else:
699             set_cookie = 0
701         # fix the cookie if the password has changed
702         if set_cookie:
703             self.set_cookie(self.user, set_cookie)
705         #
706         # now the display
707         #
708         self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
710         # use the template to display the item
711         item = htmltemplate.ItemTemplate(self, self.TEMPLATES, 'user')
712         item.render(self.nodeid)
713         self.pagefoot()
715     def showfile(self):
716         ''' display a file
717         '''
718         nodeid = self.nodeid
719         cl = self.db.file
720         mime_type = cl.get(nodeid, 'type')
721         if mime_type == 'message/rfc822':
722             mime_type = 'text/plain'
723         self.header(headers={'Content-Type': mime_type})
724         self.write(cl.get(nodeid, 'content'))
726     def classes(self, message=None):
727         ''' display a list of all the classes in the database
728         '''
729         if self.user == 'admin':
730             self.pagehead(_('Table of classes'), message)
731             classnames = self.db.classes.keys()
732             classnames.sort()
733             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
734             for cn in classnames:
735                 cl = self.db.getclass(cn)
736                 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
737                 for key, value in cl.properties.items():
738                     if value is None: value = ''
739                     else: value = str(value)
740                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
741                         key, cgi.escape(value)))
742             self.write('</table>')
743             self.pagefoot()
744         else:
745             raise Unauthorised
747     def login(self, message=None, newuser_form=None, action='index'):
748         '''Display a login page.
749         '''
750         self.pagehead(_('Login to roundup'), message)
751         self.write(_('''
752 <table>
753 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
754 <form action="login_action" method=POST>
755 <input type="hidden" name="__destination_url" value="%(action)s">
756 <tr><td align=right>Login name: </td>
757     <td><input name="__login_name"></td></tr>
758 <tr><td align=right>Password: </td>
759     <td><input type="password" name="__login_password"></td></tr>
760 <tr><td></td>
761     <td><input type="submit" value="Log In"></td></tr>
762 </form>
763 ''')%locals())
764         if self.user is None and self.ANONYMOUS_REGISTER == 'deny':
765             self.write('</table>')
766             self.pagefoot()
767             return
768         values = {'realname': '', 'organisation': '', 'address': '',
769             'phone': '', 'username': '', 'password': '', 'confirm': '',
770             'action': action}
771         if newuser_form is not None:
772             for key in newuser_form.keys():
773                 values[key] = newuser_form[key].value
774         self.write(_('''
775 <p>
776 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
777 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
778 <form action="newuser_action" method=POST>
779 <input type="hidden" name="__destination_url" value="%(action)s">
780 <tr><td align=right><em>Name: </em></td>
781     <td><input name="realname" value="%(realname)s"></td></tr>
782 <tr><td align=right><em>Organisation: </em></td>
783     <td><input name="organisation" value="%(organisation)s"></td></tr>
784 <tr><td align=right>E-Mail Address: </td>
785     <td><input name="address" value="%(address)s"></td></tr>
786 <tr><td align=right><em>Phone: </em></td>
787     <td><input name="phone" value="%(phone)s"></td></tr>
788 <tr><td align=right>Preferred Login name: </td>
789     <td><input name="username" value="%(username)s"></td></tr>
790 <tr><td align=right>Password: </td>
791     <td><input type="password" name="password" value="%(password)s"></td></tr>
792 <tr><td align=right>Password Again: </td>
793     <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
794 <tr><td></td>
795     <td><input type="submit" value="Register"></td></tr>
796 </form>
797 </table>
798 ''')%values)
799         self.pagefoot()
801     def login_action(self, message=None):
802         '''Attempt to log a user in and set the cookie
804         returns 0 if a page is generated as a result of this call, and
805         1 if not (ie. the login is successful
806         '''
807         if not self.form.has_key('__login_name'):
808             self.login(message=_('Username required'))
809             return 0
810         self.user = self.form['__login_name'].value
811         if self.form.has_key('__login_password'):
812             password = self.form['__login_password'].value
813         else:
814             password = ''
815         # make sure the user exists
816         try:
817             uid = self.db.user.lookup(self.user)
818         except KeyError:
819             name = self.user
820             self.make_user_anonymous()
821             action = self.form['__destination_url'].value
822             self.login(message=_('No such user "%(name)s"')%locals(),
823                 action=action)
824             return 0
826         # and that the password is correct
827         pw = self.db.user.get(uid, 'password')
828         if password != pw:
829             self.make_user_anonymous()
830             action = self.form['__destination_url'].value
831             self.login(message=_('Incorrect password'), action=action)
832             return 0
834         self.set_cookie(self.user, password)
835         return 1
837     def newuser_action(self, message=None):
838         '''Attempt to create a new user based on the contents of the form
839         and then set the cookie.
841         return 1 on successful login
842         '''
843         # re-open the database as "admin"
844         self.db = self.instance.open('admin')
846         # TODO: pre-check the required fields and username key property
847         cl = self.db.user
848         try:
849             props, dummy = parsePropsFromForm(self.db, cl, self.form)
850             uid = cl.create(**props)
851         except ValueError, message:
852             action = self.form['__destination_url'].value
853             self.login(message, action=action)
854             return 0
855         self.user = cl.get(uid, 'username')
856         password = cl.get(uid, 'password')
857         self.set_cookie(self.user, self.form['password'].value)
858         return 1
860     def set_cookie(self, user, password):
861         # construct the cookie
862         user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
863         if user[-1] == '=':
864           if user[-2] == '=':
865             user = user[:-2]
866           else:
867             user = user[:-1]
868         expire = Cookie._getdate(86400*365)
869         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
870         self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
871             user, expire, path)})
873     def make_user_anonymous(self):
874         # make us anonymous if we can
875         try:
876             self.db.user.lookup('anonymous')
877             self.user = 'anonymous'
878         except KeyError:
879             self.user = None
881     def logout(self, message=None):
882         self.make_user_anonymous()
883         # construct the logout cookie
884         now = Cookie._getdate()
885         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
886         self.header({'Set-Cookie':
887             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
888             path)})
889         self.login()
892     def main(self):
893         '''Wrap the database accesses so we can close the database cleanly
894         '''
895         # determine the uid to use
896         self.db = self.instance.open('admin')
897         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
898         user = 'anonymous'
899         if (cookie.has_key('roundup_user') and
900                 cookie['roundup_user'].value != 'deleted'):
901             cookie = cookie['roundup_user'].value
902             if len(cookie)%4:
903               cookie = cookie + '='*(4-len(cookie)%4)
904             try:
905                 user, password = binascii.a2b_base64(cookie).split(':')
906             except (TypeError, binascii.Error, binascii.Incomplete):
907                 # damaged cookie!
908                 user, password = 'anonymous', ''
910             # make sure the user exists
911             try:
912                 uid = self.db.user.lookup(user)
913                 # now validate the password
914                 if password != self.db.user.get(uid, 'password'):
915                     user = 'anonymous'
916             except KeyError:
917                 user = 'anonymous'
919         # make sure the anonymous user is valid if we're using it
920         if user == 'anonymous':
921             self.make_user_anonymous()
922         else:
923             self.user = user
925         # re-open the database for real, using the user
926         self.db = self.instance.open(self.user)
928         # now figure which function to call
929         path = self.split_path
931         # default action to index if the path has no information in it
932         if not path or path[0] in ('', 'index'):
933             action = 'index'
934         else:
935             action = path[0]
937         # Everthing ignores path[1:]
938         #  - The file download link generator actually relies on this - it
939         #    appends the name of the file to the URL so the download file name
940         #    is correct, but doesn't actually use it.
942         # everyone is allowed to try to log in
943         if action == 'login_action':
944             # try to login
945             if not self.login_action():
946                 return
947             # figure the resulting page
948             action = self.form['__destination_url'].value
949             if not action:
950                 action = 'index'
951             self.do_action(action)
952             return
954         # allow anonymous people to register
955         if action == 'newuser_action':
956             # if we don't have a login and anonymous people aren't allowed to
957             # register, then spit up the login form
958             if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
959                 if action == 'login':
960                     self.login()         # go to the index after login
961                 else:
962                     self.login(action=action)
963                 return
964             # try to add the user
965             if not self.newuser_action():
966                 return
967             # figure the resulting page
968             action = self.form['__destination_url'].value
969             if not action:
970                 action = 'index'
972         # no login or registration, make sure totally anonymous access is OK
973         elif self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
974             if action == 'login':
975                 self.login()             # go to the index after login
976             else:
977                 self.login(action=action)
978             return
980         # just a regular action
981         self.do_action(action)
983         # commit all changes to the database
984         self.db.commit()
986     def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
987             nre=re.compile(r'new(\w+)')):
988         '''Figure the user's action and do it.
989         '''
990         # here be the "normal" functionality
991         if action == 'index':
992             self.index()
993             return
994         if action == 'list_classes':
995             self.classes()
996             return
997         if action == 'login':
998             self.login()
999             return
1000         if action == 'logout':
1001             self.logout()
1002             return
1003         m = dre.match(action)
1004         if m:
1005             self.classname = m.group(1)
1006             self.nodeid = m.group(2)
1007             try:
1008                 cl = self.db.classes[self.classname]
1009             except KeyError:
1010                 raise NotFound
1011             try:
1012                 cl.get(self.nodeid, 'id')
1013             except IndexError:
1014                 raise NotFound
1015             try:
1016                 func = getattr(self, 'show%s'%self.classname)
1017             except AttributeError:
1018                 raise NotFound
1019             func()
1020             return
1021         m = nre.match(action)
1022         if m:
1023             self.classname = m.group(1)
1024             try:
1025                 func = getattr(self, 'new%s'%self.classname)
1026             except AttributeError:
1027                 raise NotFound
1028             func()
1029             return
1030         self.classname = action
1031         try:
1032             self.db.getclass(self.classname)
1033         except KeyError:
1034             raise NotFound
1035         self.list()
1038 class ExtendedClient(Client): 
1039     '''Includes pages and page heading information that relate to the
1040        extended schema.
1041     ''' 
1042     showsupport = Client.shownode
1043     showtimelog = Client.shownode
1044     newsupport = Client.newnode
1045     newtimelog = Client.newnode
1047     default_index_sort = ['-activity']
1048     default_index_group = ['priority']
1049     default_index_filter = ['status']
1050     default_index_columns = ['activity','status','title','assignedto']
1051     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
1053     def pagehead(self, title, message=None):
1054         url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
1055         machine = self.env['SERVER_NAME']
1056         port = self.env['SERVER_PORT']
1057         if port != '80': machine = machine + ':' + port
1058         base = urlparse.urlunparse(('http', machine, url, None, None, None))
1059         if message is not None:
1060             message = _('<div class="system-msg">%(message)s</div>')%locals()
1061         else:
1062             message = ''
1063         style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
1064         user_name = self.user or ''
1065         if self.user == 'admin':
1066             admin_links = _(' | <a href="list_classes">Class List</a>' \
1067                           ' | <a href="user">User List</a>' \
1068                           ' | <a href="newuser">Add User</a>')
1069         else:
1070             admin_links = ''
1071         if self.user not in (None, 'anonymous'):
1072             userid = self.db.user.lookup(self.user)
1073             user_info = _('''
1074 <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> |
1075 <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> |
1076 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
1077 ''')%locals()
1078         else:
1079             user_info = _('<a href="login">Login</a>')
1080         if self.user is not None:
1081             add_links = _('''
1082 | Add
1083 <a href="newissue">Issue</a>,
1084 <a href="newsupport">Support</a>,
1085 ''')
1086         else:
1087             add_links = ''
1088         self.write(_('''<html><head>
1089 <title>%(title)s</title>
1090 <style type="text/css">%(style)s</style>
1091 </head>
1092 <body bgcolor=#ffffff>
1093 %(message)s
1094 <table width=100%% border=0 cellspacing=0 cellpadding=2>
1095 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
1096 <td align=right valign=bottom>%(user_name)s</td></tr>
1097 <tr class="location-bar">
1098 <td align=left>All
1099 <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>,
1100 <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>
1101 | Unassigned
1102 <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>,
1103 <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>
1104 %(add_links)s
1105 %(admin_links)s</td>
1106 <td align=right>%(user_info)s</td>
1107 </table>
1108 ''')%locals())
1110 def parsePropsFromForm(db, cl, form, nodeid=0):
1111     '''Pull properties for the given class out of the form.
1112     '''
1113     props = {}
1114     changed = {}
1115     keys = form.keys()
1116     num_re = re.compile('^\d+$')
1117     for key in keys:
1118         if not cl.properties.has_key(key):
1119             continue
1120         proptype = cl.properties[key]
1121         if isinstance(proptype, hyperdb.String):
1122             value = form[key].value.strip()
1123         elif isinstance(proptype, hyperdb.Password):
1124             value = password.Password(form[key].value.strip())
1125         elif isinstance(proptype, hyperdb.Date):
1126             value = date.Date(form[key].value.strip())
1127         elif isinstance(proptype, hyperdb.Interval):
1128             value = date.Interval(form[key].value.strip())
1129         elif isinstance(proptype, hyperdb.Link):
1130             value = form[key].value.strip()
1131             # see if it's the "no selection" choice
1132             if value == '-1':
1133                 # don't set this property
1134                 continue
1135             else:
1136                 # handle key values
1137                 link = cl.properties[key].classname
1138                 if not num_re.match(value):
1139                     try:
1140                         value = db.classes[link].lookup(value)
1141                     except KeyError:
1142                         raise ValueError, _('property "%(propname)s": '
1143                             '%(value)s not a %(classname)s')%{'propname':key, 
1144                             'value': value, 'classname': link}
1145         elif isinstance(proptype, hyperdb.Multilink):
1146             value = form[key]
1147             if type(value) != type([]):
1148                 value = [i.strip() for i in value.value.split(',')]
1149             else:
1150                 value = [i.value.strip() for i in value]
1151             link = cl.properties[key].classname
1152             l = []
1153             for entry in map(str, value):
1154                 if entry == '': continue
1155                 if not num_re.match(entry):
1156                     try:
1157                         entry = db.classes[link].lookup(entry)
1158                     except KeyError:
1159                         raise ValueError, _('property "%(propname)s": '
1160                             '"%(value)s" not an entry of %(classname)s')%{
1161                             'propname':key, 'value': entry, 'classname': link}
1162                 l.append(entry)
1163             l.sort()
1164             value = l
1165         props[key] = value
1167         # get the old value
1168         if nodeid:
1169             try:
1170                 existing = cl.get(nodeid, key)
1171             except KeyError:
1172                 # this might be a new property for which there is no existing
1173                 # value
1174                 if not cl.properties.has_key(key): raise
1176         # if changed, set it
1177         if nodeid and value != existing:
1178             changed[key] = value
1179             props[key] = value
1180     return props, changed
1183 # $Log: not supported by cvs2svn $
1184 # Revision 1.89  2002/01/07 20:24:45  richard
1185 # *mutter* stupid cutnpaste
1187 # Revision 1.88  2002/01/02 02:31:38  richard
1188 # Sorry for the huge checkin message - I was only intending to implement #496356
1189 # but I found a number of places where things had been broken by transactions:
1190 #  . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1191 #    for _all_ roundup-generated smtp messages to be sent to.
1192 #  . the transaction cache had broken the roundupdb.Class set() reactors
1193 #  . newly-created author users in the mailgw weren't being committed to the db
1195 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1196 # on when I found that stuff :):
1197 #  . #496356 ] Use threading in messages
1198 #  . detectors were being registered multiple times
1199 #  . added tests for mailgw
1200 #  . much better attaching of erroneous messages in the mail gateway
1202 # Revision 1.87  2001/12/23 23:18:49  richard
1203 # We already had an admin-specific section of the web heading, no need to add
1204 # another one :)
1206 # Revision 1.86  2001/12/20 15:43:01  rochecompaan
1207 # Features added:
1208 #  .  Multilink properties are now displayed as comma separated values in
1209 #     a textbox
1210 #  .  The add user link is now only visible to the admin user
1211 #  .  Modified the mail gateway to reject submissions from unknown
1212 #     addresses if ANONYMOUS_ACCESS is denied
1214 # Revision 1.85  2001/12/20 06:13:24  rochecompaan
1215 # Bugs fixed:
1216 #   . Exception handling in hyperdb for strings-that-look-like numbers got
1217 #     lost somewhere
1218 #   . Internet Explorer submits full path for filename - we now strip away
1219 #     the path
1220 # Features added:
1221 #   . Link and multilink properties are now displayed sorted in the cgi
1222 #     interface
1224 # Revision 1.84  2001/12/18 15:30:30  rochecompaan
1225 # Fixed bugs:
1226 #  .  Fixed file creation and retrieval in same transaction in anydbm
1227 #     backend
1228 #  .  Cgi interface now renders new issue after issue creation
1229 #  .  Could not set issue status to resolved through cgi interface
1230 #  .  Mail gateway was changing status back to 'chatting' if status was
1231 #     omitted as an argument
1233 # Revision 1.83  2001/12/15 23:51:01  richard
1234 # Tested the changes and fixed a few problems:
1235 #  . files are now attached to the issue as well as the message
1236 #  . newuser is a real method now since we don't want to do the message/file
1237 #    stuff for it
1238 #  . added some documentation
1239 # The really big changes in the diff are a result of me moving some code
1240 # around to keep like methods together a bit better.
1242 # Revision 1.82  2001/12/15 19:24:39  rochecompaan
1243 #  . Modified cgi interface to change properties only once all changes are
1244 #    collected, files created and messages generated.
1245 #  . Moved generation of change note to nosyreactors.
1246 #  . We now check for changes to "assignedto" to ensure it's added to the
1247 #    nosy list.
1249 # Revision 1.81  2001/12/12 23:55:00  richard
1250 # Fixed some problems with user editing
1252 # Revision 1.80  2001/12/12 23:27:14  richard
1253 # Added a Zope frontend for roundup.
1255 # Revision 1.79  2001/12/10 22:20:01  richard
1256 # Enabled transaction support in the bsddb backend. It uses the anydbm code
1257 # where possible, only replacing methods where the db is opened (it uses the
1258 # btree opener specifically.)
1259 # Also cleaned up some change note generation.
1260 # Made the backends package work with pydoc too.
1262 # Revision 1.78  2001/12/07 05:59:27  rochecompaan
1263 # Fixed small bug that prevented adding issues through the web.
1265 # Revision 1.77  2001/12/06 22:48:29  richard
1266 # files multilink was being nuked in post_edit_node
1268 # Revision 1.76  2001/12/05 14:26:44  rochecompaan
1269 # Removed generation of change note from "sendmessage" in roundupdb.py.
1270 # The change note is now generated when the message is created.
1272 # Revision 1.75  2001/12/04 01:25:08  richard
1273 # Added some rollbacks where we were catching exceptions that would otherwise
1274 # have stopped committing.
1276 # Revision 1.74  2001/12/02 05:06:16  richard
1277 # . We now use weakrefs in the Classes to keep the database reference, so
1278 #   the close() method on the database is no longer needed.
1279 #   I bumped the minimum python requirement up to 2.1 accordingly.
1280 # . #487480 ] roundup-server
1281 # . #487476 ] INSTALL.txt
1283 # I also cleaned up the change message / post-edit stuff in the cgi client.
1284 # There's now a clearly marked "TODO: append the change note" where I believe
1285 # the change note should be added there. The "changes" list will obviously
1286 # have to be modified to be a dict of the changes, or somesuch.
1288 # More testing needed.
1290 # Revision 1.73  2001/12/01 07:17:50  richard
1291 # . We now have basic transaction support! Information is only written to
1292 #   the database when the commit() method is called. Only the anydbm
1293 #   backend is modified in this way - neither of the bsddb backends have been.
1294 #   The mail, admin and cgi interfaces all use commit (except the admin tool
1295 #   doesn't have a commit command, so interactive users can't commit...)
1296 # . Fixed login/registration forwarding the user to the right page (or not,
1297 #   on a failure)
1299 # Revision 1.72  2001/11/30 20:47:58  rochecompaan
1300 # Links in page header are now consistent with default sort order.
1302 # Fixed bugs:
1303 #     - When login failed the list of issues were still rendered.
1304 #     - User was redirected to index page and not to his destination url
1305 #       if his first login attempt failed.
1307 # Revision 1.71  2001/11/30 20:28:10  rochecompaan
1308 # Property changes are now completely traceable, whether changes are
1309 # made through the web or by email
1311 # Revision 1.70  2001/11/30 00:06:29  richard
1312 # Converted roundup/cgi_client.py to use _()
1313 # Added the status file, I18N_PROGRESS.txt
1315 # Revision 1.69  2001/11/29 23:19:51  richard
1316 # Removed the "This issue has been edited through the web" when a valid
1317 # change note is supplied.
1319 # Revision 1.68  2001/11/29 04:57:23  richard
1320 # a little comment
1322 # Revision 1.67  2001/11/28 21:55:35  richard
1323 #  . login_action and newuser_action return values were being ignored
1324 #  . Woohoo! Found that bloody re-login bug that was killing the mail
1325 #    gateway.
1326 #  (also a minor cleanup in hyperdb)
1328 # Revision 1.66  2001/11/27 03:00:50  richard
1329 # couple of bugfixes from latest patch integration
1331 # Revision 1.65  2001/11/26 23:00:53  richard
1332 # This config stuff is getting to be a real mess...
1334 # Revision 1.64  2001/11/26 22:56:35  richard
1335 # typo
1337 # Revision 1.63  2001/11/26 22:55:56  richard
1338 # Feature:
1339 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
1340 #    the instance.
1341 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1342 #    signature info in e-mails.
1343 #  . Some more flexibility in the mail gateway and more error handling.
1344 #  . Login now takes you to the page you back to the were denied access to.
1346 # Fixed:
1347 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
1349 # Revision 1.62  2001/11/24 00:45:42  jhermann
1350 # typeof() instead of type(): avoid clash with database field(?) "type"
1352 # Fixes this traceback:
1354 # Traceback (most recent call last):
1355 #   File "roundup\cgi_client.py", line 535, in newnode
1356 #     self._post_editnode(nid)
1357 #   File "roundup\cgi_client.py", line 415, in _post_editnode
1358 #     if type(value) != type([]): value = [value]
1359 # UnboundLocalError: local variable 'type' referenced before assignment
1361 # Revision 1.61  2001/11/22 15:46:42  jhermann
1362 # Added module docstrings to all modules.
1364 # Revision 1.60  2001/11/21 22:57:28  jhermann
1365 # Added dummy hooks for I18N and some preliminary (test) markup of
1366 # translatable messages
1368 # Revision 1.59  2001/11/21 03:21:13  richard
1369 # oops
1371 # Revision 1.58  2001/11/21 03:11:28  richard
1372 # Better handling of new properties.
1374 # Revision 1.57  2001/11/15 10:24:27  richard
1375 # handle the case where there is no file attached
1377 # Revision 1.56  2001/11/14 21:35:21  richard
1378 #  . users may attach files to issues (and support in ext) through the web now
1380 # Revision 1.55  2001/11/07 02:34:06  jhermann
1381 # Handling of damaged login cookies
1383 # Revision 1.54  2001/11/07 01:16:12  richard
1384 # Remove the '=' padding from cookie value so quoting isn't an issue.
1386 # Revision 1.53  2001/11/06 23:22:05  jhermann
1387 # More IE fixes: it does not like quotes around cookie values; in the
1388 # hope this does not break anything for other browser; if it does, we
1389 # need to check HTTP_USER_AGENT
1391 # Revision 1.52  2001/11/06 23:11:22  jhermann
1392 # Fixed debug output in page footer; added expiry date to the login cookie
1393 # (expires 1 year in the future) to prevent probs with certain versions
1394 # of IE
1396 # Revision 1.51  2001/11/06 22:00:34  jhermann
1397 # Get debug level from ROUNDUP_DEBUG env var
1399 # Revision 1.50  2001/11/05 23:45:40  richard
1400 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1401 # Also made it present nicer error messages (not tracebacks).
1403 # Revision 1.49  2001/11/04 03:07:12  richard
1404 # Fixed various cookie-related bugs:
1405 #  . bug #477685 ] base64.decodestring breaks
1406 #  . bug #477837 ] lynx does not like the cookie
1407 #  . bug #477892 ] Password edit doesn't fix login cookie
1408 # Also closed a security hole - a logged-in user could edit another user's
1409 # details.
1411 # Revision 1.48  2001/11/03 01:30:18  richard
1412 # Oops. uses pagefoot now.
1414 # Revision 1.47  2001/11/03 01:29:28  richard
1415 # Login page didn't have all close tags.
1417 # Revision 1.46  2001/11/03 01:26:55  richard
1418 # possibly fix truncated base64'ed user:pass
1420 # Revision 1.45  2001/11/01 22:04:37  richard
1421 # Started work on supporting a pop3-fetching server
1422 # Fixed bugs:
1423 #  . bug #477104 ] HTML tag error in roundup-server
1424 #  . bug #477107 ] HTTP header problem
1426 # Revision 1.44  2001/10/28 23:03:08  richard
1427 # Added more useful header to the classic schema.
1429 # Revision 1.43  2001/10/24 00:01:42  richard
1430 # More fixes to lockout logic.
1432 # Revision 1.42  2001/10/23 23:56:03  richard
1433 # HTML typo
1435 # Revision 1.41  2001/10/23 23:52:35  richard
1436 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1438 # Revision 1.40  2001/10/23 23:06:39  richard
1439 # Some cleanup.
1441 # Revision 1.39  2001/10/23 01:00:18  richard
1442 # Re-enabled login and registration access after lopping them off via
1443 # disabling access for anonymous users.
1444 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1445 # a couple of bugs while I was there. Probably introduced a couple, but
1446 # things seem to work OK at the moment.
1448 # Revision 1.38  2001/10/22 03:25:01  richard
1449 # Added configuration for:
1450 #  . anonymous user access and registration (deny/allow)
1451 #  . filter "widget" location on index page (top, bottom, both)
1452 # Updated some documentation.
1454 # Revision 1.37  2001/10/21 07:26:35  richard
1455 # feature #473127: Filenames. I modified the file.index and htmltemplate
1456 #  source so that the filename is used in the link and the creation
1457 #  information is displayed.
1459 # Revision 1.36  2001/10/21 04:44:50  richard
1460 # bug #473124: UI inconsistency with Link fields.
1461 #    This also prompted me to fix a fairly long-standing usability issue -
1462 #    that of being able to turn off certain filters.
1464 # Revision 1.35  2001/10/21 00:17:54  richard
1465 # CGI interface view customisation section may now be hidden (patch from
1466 #  Roch'e Compaan.)
1468 # Revision 1.34  2001/10/20 11:58:48  richard
1469 # Catch errors in login - no username or password supplied.
1470 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1472 # Revision 1.33  2001/10/17 00:18:41  richard
1473 # Manually constructing cookie headers now.
1475 # Revision 1.32  2001/10/16 03:36:21  richard
1476 # CGI interface wasn't handling checkboxes at all.
1478 # Revision 1.31  2001/10/14 10:55:00  richard
1479 # Handle empty strings in HTML template Link function
1481 # Revision 1.30  2001/10/09 07:38:58  richard
1482 # Pushed the base code for the extended schema CGI interface back into the
1483 # code cgi_client module so that future updates will be less painful.
1484 # Also removed a debugging print statement from cgi_client.
1486 # Revision 1.29  2001/10/09 07:25:59  richard
1487 # Added the Password property type. See "pydoc roundup.password" for
1488 # implementation details. Have updated some of the documentation too.
1490 # Revision 1.28  2001/10/08 00:34:31  richard
1491 # Change message was stuffing up for multilinks with no key property.
1493 # Revision 1.27  2001/10/05 02:23:24  richard
1494 #  . roundup-admin create now prompts for property info if none is supplied
1495 #    on the command-line.
1496 #  . hyperdb Class getprops() method may now return only the mutable
1497 #    properties.
1498 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
1499 #    now support anonymous user access (read-only, unless there's an
1500 #    "anonymous" user, in which case write access is permitted). Login
1501 #    handling has been moved into cgi_client.Client.main()
1502 #  . The "extended" schema is now the default in roundup init.
1503 #  . The schemas have had their page headings modified to cope with the new
1504 #    login handling. Existing installations should copy the interfaces.py
1505 #    file from the roundup lib directory to their instance home.
1506 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1507 #    Ping - has been removed.
1508 #  . Fixed a whole bunch of places in the CGI interface where we should have
1509 #    been returning Not Found instead of throwing an exception.
1510 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
1511 #    an item now throws an exception.
1513 # Revision 1.26  2001/09/12 08:31:42  richard
1514 # handle cases where mime type is not guessable
1516 # Revision 1.25  2001/08/29 05:30:49  richard
1517 # change messages weren't being saved when there was no-one on the nosy list.
1519 # Revision 1.24  2001/08/29 04:49:39  richard
1520 # didn't clean up fully after debugging :(
1522 # Revision 1.23  2001/08/29 04:47:18  richard
1523 # Fixed CGI client change messages so they actually include the properties
1524 # changed (again).
1526 # Revision 1.22  2001/08/17 00:08:10  richard
1527 # reverted back to sending messages always regardless of who is doing the web
1528 # edit. change notes weren't being saved. bleah. hackish.
1530 # Revision 1.21  2001/08/15 23:43:18  richard
1531 # Fixed some isFooTypes that I missed.
1532 # Refactored some code in the CGI code.
1534 # Revision 1.20  2001/08/12 06:32:36  richard
1535 # using isinstance(blah, Foo) now instead of isFooType
1537 # Revision 1.19  2001/08/07 00:24:42  richard
1538 # stupid typo
1540 # Revision 1.18  2001/08/07 00:15:51  richard
1541 # Added the copyright/license notice to (nearly) all files at request of
1542 # Bizar Software.
1544 # Revision 1.17  2001/08/02 06:38:17  richard
1545 # Roundupdb now appends "mailing list" information to its messages which
1546 # include the e-mail address and web interface address. Templates may
1547 # override this in their db classes to include specific information (support
1548 # instructions, etc).
1550 # Revision 1.16  2001/08/02 05:55:25  richard
1551 # Web edit messages aren't sent to the person who did the edit any more. No
1552 # message is generated if they are the only person on the nosy list.
1554 # Revision 1.15  2001/08/02 00:34:10  richard
1555 # bleah syntax error
1557 # Revision 1.14  2001/08/02 00:26:16  richard
1558 # Changed the order of the information in the message generated by web edits.
1560 # Revision 1.13  2001/07/30 08:12:17  richard
1561 # Added time logging and file uploading to the templates.
1563 # Revision 1.12  2001/07/30 06:26:31  richard
1564 # Added some documentation on how the newblah works.
1566 # Revision 1.11  2001/07/30 06:17:45  richard
1567 # Features:
1568 #  . Added ability for cgi newblah forms to indicate that the new node
1569 #    should be linked somewhere.
1570 # Fixed:
1571 #  . Fixed the agument handling for the roundup-admin find command.
1572 #  . Fixed handling of summary when no note supplied for newblah. Again.
1573 #  . Fixed detection of no form in htmltemplate Field display.
1575 # Revision 1.10  2001/07/30 02:37:34  richard
1576 # Temporary measure until we have decent schema migration...
1578 # Revision 1.9  2001/07/30 01:25:07  richard
1579 # Default implementation is now "classic" rather than "extended" as one would
1580 # expect.
1582 # Revision 1.8  2001/07/29 08:27:40  richard
1583 # Fixed handling of passed-in values in form elements (ie. during a
1584 # drill-down)
1586 # Revision 1.7  2001/07/29 07:01:39  richard
1587 # Added vim command to all source so that we don't get no steenkin' tabs :)
1589 # Revision 1.6  2001/07/29 04:04:00  richard
1590 # Moved some code around allowing for subclassing to change behaviour.
1592 # Revision 1.5  2001/07/28 08:16:52  richard
1593 # New issue form handles lack of note better now.
1595 # Revision 1.4  2001/07/28 00:34:34  richard
1596 # Fixed some non-string node ids.
1598 # Revision 1.3  2001/07/23 03:56:30  richard
1599 # oops, missed a config removal
1601 # Revision 1.2  2001/07/22 12:09:32  richard
1602 # Final commit of Grande Splite
1604 # Revision 1.1  2001/07/22 11:58:35  richard
1605 # More Grande Splite
1608 # vim: set filetype=python ts=4 sw=4 et si