Code

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