Code

Better handling of unauth attempt to edit stuff
[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.129 2002-06-20 23:52:11 richard Exp $
20 __doc__ = """
21 WWW request handler (also used in the stand-alone server).
22 """
24 import os, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
25 import binascii, Cookie, time, random
27 import roundupdb, htmltemplate, date, hyperdb, password
28 from roundup.i18n import _
29 from roundup_indexer import RoundupIndexer
31 class Unauthorised(ValueError):
32     pass
34 class NotFound(ValueError):
35     pass
37 class Client:
38     '''
39     A note about login
40     ------------------
42     If the user has no login cookie, then they are anonymous. There
43     are two levels of anonymous use. If there is no 'anonymous' user, there
44     is no login at all and the database is opened in read-only mode. If the
45     'anonymous' user exists, the user is logged in using that user (though
46     there is no cookie). This allows them to modify the database, and all
47     modifications are attributed to the 'anonymous' user.
48     '''
50     def __init__(self, instance, request, env, form=None):
51         hyperdb.traceMark()
52         self.instance = instance
53         self.request = request
54         self.env = env
55         self.path = env['PATH_INFO']
56         self.split_path = self.path.split('/')
57         self.instance_path_name = env['INSTANCE_NAME']
58         url = self.env['SCRIPT_NAME'] + '/'
59         machine = self.env['SERVER_NAME']
60         port = self.env['SERVER_PORT']
61         if port != '80': machine = machine + ':' + port
62         self.base = urlparse.urlunparse(('http', env['HTTP_HOST'], url,
63             None, None, None))
65         if form is None:
66             self.form = cgi.FieldStorage(environ=env)
67         else:
68             self.form = form
69         self.headers_done = 0
70         try:
71             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
72         except ValueError:
73             # someone gave us a non-int debug level, turn it off
74             self.debug = 0
75         self.indexer = RoundupIndexer('%s/db'%instance.INSTANCE_HOME)
77     def getuid(self):
78         try:
79             return self.db.user.lookup(self.user)
80         except KeyError:
81             if self.user is None:
82                 # user is not logged in and username 'anonymous' doesn't
83                 # exist in the database
84                 err = _('anonymous users have read-only access only')
85             else:
86                 err = _("sanity check: unknown user name `%s'")%self.user
87             raise Unauthorised, errmsg
89     def header(self, headers=None):
90         '''Put up the appropriate header.
91         '''
92         if headers is None:
93             headers = {'Content-Type':'text/html'}
94         if not headers.has_key('Content-Type'):
95             headers['Content-Type'] = 'text/html'
96         self.request.send_response(200)
97         for entry in headers.items():
98             self.request.send_header(*entry)
99         self.request.end_headers()
100         self.headers_done = 1
101         if self.debug:
102             self.headers_sent = headers
104     global_javascript = '''
105 <script language="javascript">
106 submitted = false;
107 function submit_once() {
108     if (submitted) {
109         alert("Your request is being processed.\\nPlease be patient.");
110         return 0;
111     }
112     submitted = true;
113     return 1;
116 function help_window(helpurl, width, height) {
117     HelpWin = window.open('%(base)s%(instance_path_name)s/' + helpurl, 'HelpWindow', 'scrollbars=yes,resizable=yes,toolbar=no,height='+height+',width='+width);
120 </script>
121 '''
122     def make_index_link(self, name):
123         '''Turn a configuration entry into a hyperlink...
124         '''
125         # get the link label and spec
126         spec = getattr(self.instance, name+'_INDEX')
128         d = {}
129         d[':sort'] = ','.join(map(urllib.quote, spec['SORT']))
130         d[':group'] = ','.join(map(urllib.quote, spec['GROUP']))
131         d[':filter'] = ','.join(map(urllib.quote, spec['FILTER']))
132         d[':columns'] = ','.join(map(urllib.quote, spec['COLUMNS']))
134         # snarf the filterspec
135         filterspec = spec['FILTERSPEC'].copy()
137         # now format the filterspec
138         for k, l in filterspec.items():
139             # fix up the CURRENT USER if needed (handle None too since that's
140             # the old flag value)
141             if l in (None, 'CURRENT USER'):
142                 if not self.user:
143                     continue
144                 l = [self.db.user.lookup(self.user)]
146             # add
147             d[urllib.quote(k)] = ','.join(map(urllib.quote, l))
149         # finally, format the URL
150         return '<a href="%s?%s">%s</a>'%(spec['CLASS'],
151             '&'.join([k+'='+v for k,v in d.items()]), spec['LABEL'])
154     def pagehead(self, title, message=None):
155         '''Display the page heading, with information about the tracker and
156             links to more information
157         '''
159         # include any important message
160         if message is not None:
161             message = _('<div class="system-msg">%(message)s</div>')%locals()
162         else:
163             message = ''
165         # style sheet (CSS)
166         style = open(os.path.join(self.instance.TEMPLATES, 'style.css')).read()
168         # figure who the user is
169         user_name = self.user or ''
170         if user_name not in ('', 'anonymous'):
171             userid = self.db.user.lookup(self.user)
172         else:
173             userid = None
175         # figure all the header links
176         if hasattr(self.instance, 'HEADER_INDEX_LINKS'):
177             links = []
178             for name in self.instance.HEADER_INDEX_LINKS:
179                 spec = getattr(self.instance, name + '_INDEX')
180                 # skip if we need to fill in the logged-in user id there's
181                 # no user logged in
182                 if (spec['FILTERSPEC'].has_key('assignedto') and
183                         spec['FILTERSPEC']['assignedto'] in ('CURRENT USER',
184                         None) and userid is None):
185                     continue
186                 links.append(self.make_index_link(name))
187         else:
188             # no config spec - hard-code
189             links = [
190                 _('All <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>'),
191                 _('Unassigned <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>')
192             ]
194         # if they're logged in, include links to their information, and the
195         # ability to add an issue
196         if user_name not in ('', 'anonymous'):
197             user_info = _('''
198 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
199 ''')%locals()
201             # figure the "add class" links
202             if hasattr(self.instance, 'HEADER_ADD_LINKS'):
203                 classes = self.instance.HEADER_ADD_LINKS
204             else:
205                 classes = ['issue']
206             l = []
207             for class_name in classes:
208                 cap_class = class_name.capitalize()
209                 links.append(_('Add <a href="new%(class_name)s">'
210                     '%(cap_class)s</a>')%locals())
212             # if there's no config header link spec, force a user link here
213             if not hasattr(self.instance, 'HEADER_INDEX_LINKS'):
214                 links.append(_('<a href="issue?assignedto=%(userid)s&status=-1,unread,chatting,open,pending&:filter=status,resolution,assignedto&:sort=-activity&:columns=id,activity,status,resolution,title,creator&:group=type&show_customization=1">My Issues</a>')%locals())
215         else:
216             user_info = _('<a href="login">Login</a>')
217             add_links = ''
219         # if the user is admin, include admin links
220         admin_links = ''
221         if user_name == 'admin':
222             links.append(_('<a href="list_classes">Class List</a>'))
223             links.append(_('<a href="user">User List</a>'))
224             links.append(_('<a href="newuser">Add User</a>'))
226         # add the search links
227         if hasattr(self.instance, 'HEADER_SEARCH_LINKS'):
228             classes = self.instance.HEADER_SEARCH_LINKS
229         else:
230             classes = ['issue']
231         l = []
232         for class_name in classes:
233             cap_class = class_name.capitalize()
234             links.append(_('Search <a href="search%(class_name)s">'
235                 '%(cap_class)s</a>')%locals())
237         # now we have all the links, join 'em
238         links = '\n | '.join(links)
240         # include the javascript bit
241         global_javascript = self.global_javascript%self.__dict__
243         # finally, format the header
244         self.write(_('''<html><head>
245 <title>%(title)s</title>
246 <style type="text/css">%(style)s</style>
247 </head>
248 %(global_javascript)s
249 <body bgcolor=#ffffff>
250 %(message)s
251 <table width=100%% border=0 cellspacing=0 cellpadding=2>
252 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
253 <td align=right valign=bottom>%(user_name)s</td></tr>
254 <tr class="location-bar">
255 <td align=left>%(links)s</td>
256 <td align=right>%(user_info)s</td>
257 </table>
258 ''')%locals())
260     def pagefoot(self):
261         if self.debug:
262             self.write(_('<hr><small><dl><dt><b>Path</b></dt>'))
263             self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
264             keys = self.form.keys()
265             keys.sort()
266             if keys:
267                 self.write(_('<dt><b>Form entries</b></dt>'))
268                 for k in self.form.keys():
269                     v = self.form.getvalue(k, "<empty>")
270                     if type(v) is type([]):
271                         # Multiple username fields specified
272                         v = "|".join(v)
273                     self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
274             keys = self.headers_sent.keys()
275             keys.sort()
276             self.write(_('<dt><b>Sent these HTTP headers</b></dt>'))
277             for k in keys:
278                 v = self.headers_sent[k]
279                 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
280             keys = self.env.keys()
281             keys.sort()
282             self.write(_('<dt><b>CGI environment</b></dt>'))
283             for k in keys:
284                 v = self.env[k]
285                 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
286             self.write('</dl></small>')
287         self.write('</body></html>')
289     def write(self, content):
290         if not self.headers_done:
291             self.header()
292         self.request.wfile.write(content)
294     def index_arg(self, arg):
295         ''' handle the args to index - they might be a list from the form
296             (ie. submitted from a form) or they might be a command-separated
297             single string (ie. manually constructed GET args)
298         '''
299         if self.form.has_key(arg):
300             arg =  self.form[arg]
301             if type(arg) == type([]):
302                 return [arg.value for arg in arg]
303             return arg.value.split(',')
304         return []
306     def index_filterspec(self, filter):
307         ''' pull the index filter spec from the form
309         Links and multilinks want to be lists - the rest are straight
310         strings.
311         '''
312         props = self.db.classes[self.classname].getprops()
313         # all the form args not starting with ':' are filters
314         filterspec = {}
315         for key in self.form.keys():
316             if key[0] == ':': continue
317             if not props.has_key(key): continue
318             if key not in filter: continue
319             prop = props[key]
320             value = self.form[key]
321             if (isinstance(prop, hyperdb.Link) or
322                     isinstance(prop, hyperdb.Multilink)):
323                 if type(value) == type([]):
324                     value = [arg.value for arg in value]
325                 else:
326                     value = value.value.split(',')
327                 l = filterspec.get(key, [])
328                 l = l + value
329                 filterspec[key] = l
330             else:
331                 filterspec[key] = value.value
332         return filterspec
334     def customization_widget(self):
335         ''' The customization widget is visible by default. The widget
336             visibility is remembered by show_customization.  Visibility
337             is not toggled if the action value is "Redisplay"
338         '''
339         if not self.form.has_key('show_customization'):
340             visible = 1
341         else:
342             visible = int(self.form['show_customization'].value)
343             if self.form.has_key('action'):
344                 if self.form['action'].value != 'Redisplay':
345                     visible = self.form['action'].value == '+'
346             
347         return visible
349     # TODO: make this go away some day...
350     default_index_sort = ['-activity']
351     default_index_group = ['priority']
352     default_index_filter = ['status']
353     default_index_columns = ['id','activity','title','status','assignedto']
354     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
356     def _get_customisation_info(self):
357         # see if the web has supplied us with any customisation info
358         defaults = 1
359         for key in ':sort', ':group', ':filter', ':columns':
360             if self.form.has_key(key):
361                 defaults = 0
362                 break
363         if defaults:
364             # try the instance config first
365             if hasattr(self.instance, 'DEFAULT_INDEX'):
366                 d = self.instance.DEFAULT_INDEX
367                 self.classname = d['CLASS']
368                 sort = d['SORT']
369                 group = d['GROUP']
370                 filter = d['FILTER']
371                 columns = d['COLUMNS']
372                 filterspec = d['FILTERSPEC']
374             else:
375                 # nope - fall back on the old way of doing it
376                 self.classname = 'issue'
377                 sort = self.default_index_sort
378                 group = self.default_index_group
379                 filter = self.default_index_filter
380                 columns = self.default_index_columns
381                 filterspec = self.default_index_filterspec
382         else:
383             # make list() extract the info from the CGI environ
384             self.classname = 'issue'
385             sort = group = filter = columns = filterspec = None
386         return columns, filter, group, sort, filterspec
388     def index(self):
389         ''' put up an index - no class specified
390         '''
391         columns, filter, group, sort, filterspec = \
392             self._get_customisation_info()
393         return self.list(columns=columns, filter=filter, group=group,
394             sort=sort, filterspec=filterspec)
396     def searchnode(self):
397         columns, filter, group, sort, filterspec = \
398             self._get_customisation_info()
399         show_nodes = 1
400         if len(self.form.keys()) == 0:
401             # get the default search filters from instance_config
402             if hasattr(self.instance, 'SEARCH_FILTERS'):
403                 for f in self.instance.SEARCH_FILTERS:
404                     spec = getattr(self.instance, f)
405                     if spec['CLASS'] == self.classname:
406                         filter = spec['FILTER']
407                 
408             show_nodes = 0
409             show_customization = 1
410         return self.list(columns=columns, filter=filter, group=group,
411             sort=sort, filterspec=filterspec,
412             show_customization=show_customization, show_nodes=show_nodes)
413         
415     # XXX deviates from spec - loses the '+' (that's a reserved character
416     # in URLS
417     def list(self, sort=None, group=None, filter=None, columns=None,
418             filterspec=None, show_customization=None, show_nodes=1):
419         ''' call the template index with the args
421             :sort    - sort by prop name, optionally preceeded with '-'
422                      to give descending or nothing for ascending sorting.
423             :group   - group by prop name, optionally preceeded with '-' or
424                      to sort in descending or nothing for ascending order.
425             :filter  - selects which props should be displayed in the filter
426                      section. Default is all.
427             :columns - selects the columns that should be displayed.
428                      Default is all.
430         '''
431         cn = self.classname
432         cl = self.db.classes[cn]
433         self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
434             'classname': cn, 'instancename': self.instance.INSTANCE_NAME})
435         if sort is None: sort = self.index_arg(':sort')
436         if group is None: group = self.index_arg(':group')
437         if filter is None: filter = self.index_arg(':filter')
438         if columns is None: columns = self.index_arg(':columns')
439         if filterspec is None: filterspec = self.index_filterspec(filter)
440         if show_customization is None:
441             show_customization = self.customization_widget()
442         if self.form.has_key('search_text'):
443             search_text = self.form['search_text'].value
444         else:
445             search_text = ''
447         index = htmltemplate.IndexTemplate(self, self.instance.TEMPLATES, cn)
448         try:
449             index.render(filterspec, search_text, filter, columns, sort, 
450                 group, show_customization=show_customization, 
451                 show_nodes=show_nodes)
452         except htmltemplate.MissingTemplateError:
453             self.basicClassEditPage()
454         self.pagefoot()
456     def basicClassEditPage(self):
457         '''Display a basic edit page that allows simple editing of the
458            nodes of the current class
459         '''
460         if self.user != 'admin':
461             raise Unauthorised
462         w = self.write
463         cn = self.classname
464         cl = self.db.classes[cn]
465         idlessprops = cl.getprops(protected=0).keys()
466         props = ['id'] + idlessprops
469         # get the CSV module
470         try:
471             import csv
472         except ImportError:
473             w(_('Sorry, you need the csv module to use this function.<br>\n'
474                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
475             return
477         # do the edit
478         if self.form.has_key('rows'):
479             rows = self.form['rows'].value.splitlines()
480             p = csv.parser()
481             found = {}
482             line = 0
483             for row in rows:
484                 line += 1
485                 values = p.parse(row)
486                 # not a complete row, keep going
487                 if not values: continue
489                 # extract the nodeid
490                 nodeid, values = values[0], values[1:]
491                 found[nodeid] = 1
493                 # confirm correct weight
494                 if len(idlessprops) != len(values):
495                     w(_('Not enough values on line %(line)s'%{'line':line}))
496                     return
498                 # extract the new values
499                 d = {}
500                 for name, value in zip(idlessprops, values):
501                     d[name] = value.strip()
503                 # perform the edit
504                 if cl.hasnode(nodeid):
505                     # edit existing
506                     cl.set(nodeid, **d)
507                 else:
508                     # new node
509                     found[cl.create(**d)] = 1
511             # retire the removed entries
512             for nodeid in cl.list():
513                 if not found.has_key(nodeid):
514                     cl.retire(nodeid)
516         w(_('''<p class="form-help">You may edit the contents of the
517         "%(classname)s" class using this form. The lines are full-featured
518         Comma-Separated-Value lines, so you may include commas and even
519         newlines by enclosing the values in double-quotes ("). Double
520         quotes themselves must be quoted by doubling ("").</p>
521         <p class="form-help">Remove entries by deleting their line. Add
522         new entries by appending
523         them to the table - put an X in the id column.</p>''')%{'classname':cn})
525         l = []
526         for name in props:
527             l.append(name)
528         w('<tt>')
529         w(', '.join(l) + '\n')
530         w('</tt>')
532         w('<form onSubmit="return submit_once()" method="POST">')
533         w('<textarea name="rows" cols=80 rows=15>')
534         p = csv.parser()
535         for nodeid in cl.list():
536             l = []
537             for name in props:
538                 l.append(cgi.escape(str(cl.get(nodeid, name))))
539             w(p.join(l) + '\n')
541         w(_('</textarea><br><input type="submit" value="Save Changes"></form>'))
543     def classhelp(self):
544         '''Display a table of class info
545         '''
546         w = self.write
547         cn = self.form['classname'].value
548         cl = self.db.classes[cn]
549         props = self.form['properties'].value.split(',')
551         w('<table border=1 cellspacing=0 cellpaddin=2>')
552         w('<tr>')
553         for name in props:
554             w('<th align=left>%s</th>'%name)
555         w('</tr>')
556         for nodeid in cl.list():
557             w('<tr>')
558             for name in props:
559                 value = cgi.escape(str(cl.get(nodeid, name)))
560                 w('<td align="left" valign="top">%s</td>'%value)
561             w('</tr>')
562         w('</table>')
564     def shownode(self, message=None):
565         ''' display an item
566         '''
567         cn = self.classname
568         cl = self.db.classes[cn]
570         # possibly perform an edit
571         keys = self.form.keys()
572         num_re = re.compile('^\d+$')
573         # don't try to set properties if the user has just logged in
574         if keys and not self.form.has_key('__login_name'):
575             try:
576                 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
577                 # make changes to the node
578                 self._changenode(props)
579                 # handle linked nodes 
580                 self._post_editnode(self.nodeid)
581                 # and some nice feedback for the user
582                 if props:
583                     message = _('%(changes)s edited ok')%{'changes':
584                         ', '.join(props.keys())}
585                 elif self.form.has_key('__note') and self.form['__note'].value:
586                     message = _('note added')
587                 elif (self.form.has_key('__file') and
588                         self.form['__file'].filename):
589                     message = _('file added')
590                 else:
591                     message = _('nothing changed')
592             except:
593                 self.db.rollback()
594                 s = StringIO.StringIO()
595                 traceback.print_exc(None, s)
596                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
598         # now the display
599         id = self.nodeid
600         if cl.getkey():
601             id = cl.get(id, cl.getkey())
602         self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
604         nodeid = self.nodeid
606         # use the template to display the item
607         item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES,
608             self.classname)
609         item.render(nodeid)
611         self.pagefoot()
612     showissue = shownode
613     showmsg = shownode
614     searchissue = searchnode
616     def _changenode(self, props):
617         ''' change the node based on the contents of the form
618         '''
619         cl = self.db.classes[self.classname]
621         # create the message
622         message, files = self._handle_message()
623         if message:
624             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
625         if files:
626             props['files'] = cl.get(self.nodeid, 'files') + files
628         # make the changes
629         cl.set(self.nodeid, **props)
631     def _createnode(self):
632         ''' create a node based on the contents of the form
633         '''
634         cl = self.db.classes[self.classname]
635         props = parsePropsFromForm(self.db, cl, self.form)
637         # check for messages and files
638         message, files = self._handle_message()
639         if message:
640             props['messages'] = [message]
641         if files:
642             props['files'] = files
643         # create the node and return it's id
644         return cl.create(**props)
646     def _handle_message(self):
647         ''' generate an edit message
648         '''
649         # handle file attachments 
650         files = []
651         if self.form.has_key('__file'):
652             file = self.form['__file']
653             if file.filename:
654                 filename = file.filename.split('\\')[-1]
655                 mime_type = mimetypes.guess_type(filename)[0]
656                 if not mime_type:
657                     mime_type = "application/octet-stream"
658                 # create the new file entry
659                 files.append(self.db.file.create(type=mime_type,
660                     name=filename, content=file.file.read()))
662         # we don't want to do a message if none of the following is true...
663         cn = self.classname
664         cl = self.db.classes[self.classname]
665         props = cl.getprops()
666         note = None
667         # in a nutshell, don't do anything if there's no note or there's no
668         # NOSY
669         if self.form.has_key('__note'):
670             note = self.form['__note'].value.strip()
671         if not note:
672             return None, files
673         if not props.has_key('messages'):
674             return None, files
675         if not isinstance(props['messages'], hyperdb.Multilink):
676             return None, files
677         if not props['messages'].classname == 'msg':
678             return None, files
679         if not (self.form.has_key('nosy') or note):
680             return None, files
682         # handle the note
683         if '\n' in note:
684             summary = re.split(r'\n\r?', note)[0]
685         else:
686             summary = note
687         m = ['%s\n'%note]
689         # handle the messageid
690         # TODO: handle inreplyto
691         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
692             self.classname, self.instance.MAIL_DOMAIN)
694         # now create the message, attaching the files
695         content = '\n'.join(m)
696         message_id = self.db.msg.create(author=self.getuid(),
697             recipients=[], date=date.Date('.'), summary=summary,
698             content=content, files=files, messageid=messageid)
700         # update the messages property
701         return message_id, files
703     def _post_editnode(self, nid):
704         '''Do the linking part of the node creation.
706            If a form element has :link or :multilink appended to it, its
707            value specifies a node designator and the property on that node
708            to add _this_ node to as a link or multilink.
710            This is typically used on, eg. the file upload page to indicated
711            which issue to link the file to.
713            TODO: I suspect that this and newfile will go away now that
714            there's the ability to upload a file using the issue __file form
715            element!
716         '''
717         cn = self.classname
718         cl = self.db.classes[cn]
719         # link if necessary
720         keys = self.form.keys()
721         for key in keys:
722             if key == ':multilink':
723                 value = self.form[key].value
724                 if type(value) != type([]): value = [value]
725                 for value in value:
726                     designator, property = value.split(':')
727                     link, nodeid = roundupdb.splitDesignator(designator)
728                     link = self.db.classes[link]
729                     # take a dupe of the list so we're not changing the cache
730                     value = link.get(nodeid, property)[:]
731                     value.append(nid)
732                     link.set(nodeid, **{property: value})
733             elif key == ':link':
734                 value = self.form[key].value
735                 if type(value) != type([]): value = [value]
736                 for value in value:
737                     designator, property = value.split(':')
738                     link, nodeid = roundupdb.splitDesignator(designator)
739                     link = self.db.classes[link]
740                     link.set(nodeid, **{property: nid})
742     def newnode(self, message=None):
743         ''' Add a new node to the database.
744         
745         The form works in two modes: blank form and submission (that is,
746         the submission goes to the same URL). **Eventually this means that
747         the form will have previously entered information in it if
748         submission fails.
750         The new node will be created with the properties specified in the
751         form submission. For multilinks, multiple form entries are handled,
752         as are prop=value,value,value. You can't mix them though.
754         If the new node is to be referenced from somewhere else immediately
755         (ie. the new node is a file that is to be attached to a support
756         issue) then supply one of these arguments in addition to the usual
757         form entries:
758             :link=designator:property
759             :multilink=designator:property
760         ... which means that once the new node is created, the "property"
761         on the node given by "designator" should now reference the new
762         node's id. The node id will be appended to the multilink.
763         '''
764         cn = self.classname
765         cl = self.db.classes[cn]
767         # possibly perform a create
768         keys = self.form.keys()
769         if [i for i in keys if i[0] != ':']:
770             props = {}
771             try:
772                 nid = self._createnode()
773                 # handle linked nodes 
774                 self._post_editnode(nid)
775                 # and some nice feedback for the user
776                 message = _('%(classname)s created ok')%{'classname': cn}
778                 # render the newly created issue
779                 self.db.commit()
780                 self.nodeid = nid
781                 self.pagehead('%s: %s'%(self.classname.capitalize(), nid),
782                     message)
783                 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 
784                     self.classname)
785                 item.render(nid)
786                 self.pagefoot()
787                 return
788             except:
789                 self.db.rollback()
790                 s = StringIO.StringIO()
791                 traceback.print_exc(None, s)
792                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
793         self.pagehead(_('New %(classname)s')%{'classname':
794             self.classname.capitalize()}, message)
796         # call the template
797         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
798             self.classname)
799         newitem.render(self.form)
801         self.pagefoot()
802     newissue = newnode
804     def newuser(self, message=None):
805         ''' Add a new user to the database.
807             Don't do any of the message or file handling, just create the node.
808         '''
809         cn = self.classname
810         cl = self.db.classes[cn]
812         # possibly perform a create
813         keys = self.form.keys()
814         if [i for i in keys if i[0] != ':']:
815             try:
816                 props = parsePropsFromForm(self.db, cl, self.form)
817                 nid = cl.create(**props)
818                 # handle linked nodes 
819                 self._post_editnode(nid)
820                 # and some nice feedback for the user
821                 message = _('%(classname)s created ok')%{'classname': cn}
822             except:
823                 self.db.rollback()
824                 s = StringIO.StringIO()
825                 traceback.print_exc(None, s)
826                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
827         self.pagehead(_('New %(classname)s')%{'classname':
828              self.classname.capitalize()}, message)
830         # call the template
831         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
832             self.classname)
833         newitem.render(self.form)
835         self.pagefoot()
837     def newfile(self, message=None):
838         ''' Add a new file to the database.
839         
840         This form works very much the same way as newnode - it just has a
841         file upload.
842         '''
843         cn = self.classname
844         cl = self.db.classes[cn]
845         props = parsePropsFromForm(self.db, cl, self.form)
847         # possibly perform a create
848         keys = self.form.keys()
849         if [i for i in keys if i[0] != ':']:
850             try:
851                 file = self.form['content']
852                 mime_type = mimetypes.guess_type(file.filename)[0]
853                 if not mime_type:
854                     mime_type = "application/octet-stream"
855                 # save the file
856                 props['type'] = mime_type
857                 props['name'] = file.filename
858                 props['content'] = file.file.read()
859                 nid = cl.create(**props)
860                 # handle linked nodes
861                 self._post_editnode(nid)
862                 # and some nice feedback for the user
863                 message = _('%(classname)s created ok')%{'classname': cn}
864             except:
865                 self.db.rollback()
866                 s = StringIO.StringIO()
867                 traceback.print_exc(None, s)
868                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
870         self.pagehead(_('New %(classname)s')%{'classname':
871              self.classname.capitalize()}, message)
872         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
873             self.classname)
874         newitem.render(self.form)
875         self.pagefoot()
877     def showuser(self, message=None):
878         '''Display a user page for editing. Make sure the user is allowed
879             to edit this node, and also check for password changes.
880         '''
881         if self.user == 'anonymous':
882             raise Unauthorised
884         user = self.db.user
886         # get the username of the node being edited
887         node_user = user.get(self.nodeid, 'username')
889         if self.user not in ('admin', node_user):
890             raise Unauthorised
892         #
893         # perform any editing
894         #
895         keys = self.form.keys()
896         num_re = re.compile('^\d+$')
897         if keys:
898             try:
899                 props = parsePropsFromForm(self.db, user, self.form,
900                     self.nodeid)
901                 set_cookie = 0
902                 if props.has_key('password'):
903                     password = self.form['password'].value.strip()
904                     if not password:
905                         # no password was supplied - don't change it
906                         del props['password']
907                     elif self.nodeid == self.getuid():
908                         # this is the logged-in user's password
909                         set_cookie = password
910                 user.set(self.nodeid, **props)
911                 # and some feedback for the user
912                 message = _('%(changes)s edited ok')%{'changes':
913                     ', '.join(props.keys())}
914             except:
915                 self.db.rollback()
916                 s = StringIO.StringIO()
917                 traceback.print_exc(None, s)
918                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
919         else:
920             set_cookie = 0
922         # fix the cookie if the password has changed
923         if set_cookie:
924             self.set_cookie(self.user, set_cookie)
926         #
927         # now the display
928         #
929         self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
931         # use the template to display the item
932         item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 'user')
933         item.render(self.nodeid)
934         self.pagefoot()
936     def showfile(self):
937         ''' display a file
938         '''
939         nodeid = self.nodeid
940         cl = self.db.classes[self.classname]
941         mime_type = cl.get(nodeid, 'type')
942         if mime_type == 'message/rfc822':
943             mime_type = 'text/plain'
944         self.header(headers={'Content-Type': mime_type})
945         self.write(cl.get(nodeid, 'content'))
947     def classes(self, message=None):
948         ''' display a list of all the classes in the database
949         '''
950         if self.user == 'admin':
951             self.pagehead(_('Table of classes'), message)
952             classnames = self.db.classes.keys()
953             classnames.sort()
954             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
955             for cn in classnames:
956                 cl = self.db.getclass(cn)
957                 self.write('<tr class="list-header"><th colspan=2 align=left>'
958                     '<a href="%s">%s</a></th></tr>'%(cn, cn.capitalize()))
959                 for key, value in cl.properties.items():
960                     if value is None: value = ''
961                     else: value = str(value)
962                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
963                         key, cgi.escape(value)))
964             self.write('</table>')
965             self.pagefoot()
966         else:
967             raise Unauthorised
969     def login(self, message=None, newuser_form=None, action='index'):
970         '''Display a login page.
971         '''
972         self.pagehead(_('Login to roundup'), message)
973         self.write(_('''
974 <table>
975 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
976 <form onSubmit="return submit_once()" action="login_action" method=POST>
977 <input type="hidden" name="__destination_url" value="%(action)s">
978 <tr><td align=right>Login name: </td>
979     <td><input name="__login_name"></td></tr>
980 <tr><td align=right>Password: </td>
981     <td><input type="password" name="__login_password"></td></tr>
982 <tr><td></td>
983     <td><input type="submit" value="Log In"></td></tr>
984 </form>
985 ''')%locals())
986         if self.user is None and self.instance.ANONYMOUS_REGISTER == 'deny':
987             self.write('</table>')
988             self.pagefoot()
989             return
990         values = {'realname': '', 'organisation': '', 'address': '',
991             'phone': '', 'username': '', 'password': '', 'confirm': '',
992             'action': action, 'alternate_addresses': ''}
993         if newuser_form is not None:
994             for key in newuser_form.keys():
995                 values[key] = newuser_form[key].value
996         self.write(_('''
997 <p>
998 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
999 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
1000 <form onSubmit="return submit_once()" action="newuser_action" method=POST>
1001 <input type="hidden" name="__destination_url" value="%(action)s">
1002 <tr><td align=right><em>Name: </em></td>
1003     <td><input name="realname" value="%(realname)s" size=40></td></tr>
1004 <tr><td align=right><em>Organisation: </em></td>
1005     <td><input name="organisation" value="%(organisation)s" size=40></td></tr>
1006 <tr><td align=right>E-Mail Address: </td>
1007     <td><input name="address" value="%(address)s" size=40></td></tr>
1008 <tr><td align=right><em>Alternate E-mail Addresses: </em></td>
1009     <td><textarea name="alternate_addresses" rows=5 cols=40>%(alternate_addresses)s</textarea></td></tr>
1010 <tr><td align=right><em>Phone: </em></td>
1011     <td><input name="phone" value="%(phone)s"></td></tr>
1012 <tr><td align=right>Preferred Login name: </td>
1013     <td><input name="username" value="%(username)s"></td></tr>
1014 <tr><td align=right>Password: </td>
1015     <td><input type="password" name="password" value="%(password)s"></td></tr>
1016 <tr><td align=right>Password Again: </td>
1017     <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
1018 <tr><td></td>
1019     <td><input type="submit" value="Register"></td></tr>
1020 </form>
1021 </table>
1022 ''')%values)
1023         self.pagefoot()
1025     def login_action(self, message=None):
1026         '''Attempt to log a user in and set the cookie
1028         returns 0 if a page is generated as a result of this call, and
1029         1 if not (ie. the login is successful
1030         '''
1031         if not self.form.has_key('__login_name'):
1032             self.login(message=_('Username required'))
1033             return 0
1034         self.user = self.form['__login_name'].value
1035         if self.form.has_key('__login_password'):
1036             password = self.form['__login_password'].value
1037         else:
1038             password = ''
1039         # make sure the user exists
1040         try:
1041             uid = self.db.user.lookup(self.user)
1042         except KeyError:
1043             name = self.user
1044             self.make_user_anonymous()
1045             action = self.form['__destination_url'].value
1046             self.login(message=_('No such user "%(name)s"')%locals(),
1047                 action=action)
1048             return 0
1050         # and that the password is correct
1051         pw = self.db.user.get(uid, 'password')
1052         if password != pw:
1053             self.make_user_anonymous()
1054             action = self.form['__destination_url'].value
1055             self.login(message=_('Incorrect password'), action=action)
1056             return 0
1058         self.set_cookie(self.user, password)
1059         return 1
1061     def newuser_action(self, message=None):
1062         '''Attempt to create a new user based on the contents of the form
1063         and then set the cookie.
1065         return 1 on successful login
1066         '''
1067         # re-open the database as "admin"
1068         self.db = self.instance.open('admin')
1070         # TODO: pre-check the required fields and username key property
1071         cl = self.db.user
1072         try:
1073             props = parsePropsFromForm(self.db, cl, self.form)
1074             uid = cl.create(**props)
1075         except ValueError, message:
1076             action = self.form['__destination_url'].value
1077             self.login(message, action=action)
1078             return 0
1079         self.user = cl.get(uid, 'username')
1080         password = cl.get(uid, 'password')
1081         self.set_cookie(self.user, self.form['password'].value)
1082         return 1
1084     def set_cookie(self, user, password):
1085         # construct the cookie
1086         user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
1087         if user[-1] == '=':
1088           if user[-2] == '=':
1089             user = user[:-2]
1090           else:
1091             user = user[:-1]
1092         expire = Cookie._getdate(86400*365)
1093         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
1094         self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
1095             user, expire, path)})
1097     def make_user_anonymous(self):
1098         # make us anonymous if we can
1099         try:
1100             self.db.user.lookup('anonymous')
1101             self.user = 'anonymous'
1102         except KeyError:
1103             self.user = None
1105     def logout(self, message=None):
1106         self.make_user_anonymous()
1107         # construct the logout cookie
1108         now = Cookie._getdate()
1109         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
1110         self.header({'Set-Cookie':
1111             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
1112             path)})
1113         self.login()
1115     def main(self):
1116         '''Wrap the database accesses so we can close the database cleanly
1117         '''
1118         # determine the uid to use
1119         self.db = self.instance.open('admin')
1120         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
1121         user = 'anonymous'
1122         if (cookie.has_key('roundup_user') and
1123                 cookie['roundup_user'].value != 'deleted'):
1124             cookie = cookie['roundup_user'].value
1125             if len(cookie)%4:
1126               cookie = cookie + '='*(4-len(cookie)%4)
1127             try:
1128                 user, password = binascii.a2b_base64(cookie).split(':')
1129             except (TypeError, binascii.Error, binascii.Incomplete):
1130                 # damaged cookie!
1131                 user, password = 'anonymous', ''
1133             # make sure the user exists
1134             try:
1135                 uid = self.db.user.lookup(user)
1136                 # now validate the password
1137                 if password != self.db.user.get(uid, 'password'):
1138                     user = 'anonymous'
1139             except KeyError:
1140                 user = 'anonymous'
1142         # make sure the anonymous user is valid if we're using it
1143         if user == 'anonymous':
1144             self.make_user_anonymous()
1145         else:
1146             self.user = user
1148         # re-open the database for real, using the user
1149         self.db = self.instance.open(self.user)
1151         # now figure which function to call
1152         path = self.split_path
1154         # default action to index if the path has no information in it
1155         if not path or path[0] in ('', 'index'):
1156             action = 'index'
1157         else:
1158             action = path[0]
1160         # Everthing ignores path[1:]
1161         #  - The file download link generator actually relies on this - it
1162         #    appends the name of the file to the URL so the download file name
1163         #    is correct, but doesn't actually use it.
1165         # everyone is allowed to try to log in
1166         if action == 'login_action':
1167             # try to login
1168             if not self.login_action():
1169                 return
1170             # figure the resulting page
1171             action = self.form['__destination_url'].value
1172             if not action:
1173                 action = 'index'
1174             self.do_action(action)
1175             return
1177         # allow anonymous people to register
1178         if action == 'newuser_action':
1179             # if we don't have a login and anonymous people aren't allowed to
1180             # register, then spit up the login form
1181             if self.instance.ANONYMOUS_REGISTER == 'deny' and self.user is None:
1182                 if action == 'login':
1183                     self.login()         # go to the index after login
1184                 else:
1185                     self.login(action=action)
1186                 return
1187             # try to add the user
1188             if not self.newuser_action():
1189                 return
1190             # figure the resulting page
1191             action = self.form['__destination_url'].value
1192             if not action:
1193                 action = 'index'
1195         # no login or registration, make sure totally anonymous access is OK
1196         elif self.instance.ANONYMOUS_ACCESS == 'deny' and self.user is None:
1197             if action == 'login':
1198                 self.login()             # go to the index after login
1199             else:
1200                 self.login(action=action)
1201             return
1203         # just a regular action
1204         self.do_action(action)
1206         # commit all changes to the database
1207         self.db.commit()
1209     def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
1210             nre=re.compile(r'new(\w+)'), sre=re.compile(r'search(\w+)')):
1211         '''Figure the user's action and do it.
1212         '''
1213         # here be the "normal" functionality
1214         if action == 'index':
1215             self.index()
1216             return
1217         if action == 'list_classes':
1218             self.classes()
1219             return
1220         if action == 'classhelp':
1221             self.classhelp()
1222             return
1223         if action == 'login':
1224             self.login()
1225             return
1226         if action == 'logout':
1227             self.logout()
1228             return
1230         # see if we're to display an existing node
1231         m = dre.match(action)
1232         if m:
1233             self.classname = m.group(1)
1234             self.nodeid = m.group(2)
1235             try:
1236                 cl = self.db.classes[self.classname]
1237             except KeyError:
1238                 raise NotFound, self.classname
1239             try:
1240                 cl.get(self.nodeid, 'id')
1241             except IndexError:
1242                 raise NotFound, self.nodeid
1243             try:
1244                 func = getattr(self, 'show%s'%self.classname)
1245             except AttributeError:
1246                 raise NotFound, 'show%s'%self.classname
1247             func()
1248             return
1250         # see if we're to put up the new node page
1251         m = nre.match(action)
1252         if m:
1253             self.classname = m.group(1)
1254             try:
1255                 func = getattr(self, 'new%s'%self.classname)
1256             except AttributeError:
1257                 raise NotFound, 'new%s'%self.classname
1258             func()
1259             return
1261         # see if we're to put up the new node page
1262         m = sre.match(action)
1263         if m:
1264             self.classname = m.group(1)
1265             try:
1266                 func = getattr(self, 'search%s'%self.classname)
1267             except AttributeError:
1268                 raise NotFound
1269             func()
1270             return
1272         # otherwise, display the named class
1273         self.classname = action
1274         try:
1275             self.db.getclass(self.classname)
1276         except KeyError:
1277             raise NotFound, self.classname
1278         self.list()
1281 class ExtendedClient(Client): 
1282     '''Includes pages and page heading information that relate to the
1283        extended schema.
1284     ''' 
1285     showsupport = Client.shownode
1286     showtimelog = Client.shownode
1287     newsupport = Client.newnode
1288     newtimelog = Client.newnode
1289     searchsupport = Client.searchnode
1291     default_index_sort = ['-activity']
1292     default_index_group = ['priority']
1293     default_index_filter = ['status']
1294     default_index_columns = ['activity','status','title','assignedto']
1295     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
1297 def parsePropsFromForm(db, cl, form, nodeid=0):
1298     '''Pull properties for the given class out of the form.
1299     '''
1300     props = {}
1301     keys = form.keys()
1302     num_re = re.compile('^\d+$')
1303     for key in keys:
1304         if not cl.properties.has_key(key):
1305             continue
1306         proptype = cl.properties[key]
1307         if isinstance(proptype, hyperdb.String):
1308             value = form[key].value.strip()
1309         elif isinstance(proptype, hyperdb.Password):
1310             value = password.Password(form[key].value.strip())
1311         elif isinstance(proptype, hyperdb.Date):
1312             value = form[key].value.strip()
1313             if value:
1314                 value = date.Date(form[key].value.strip())
1315             else:
1316                 value = None
1317         elif isinstance(proptype, hyperdb.Interval):
1318             value = form[key].value.strip()
1319             if value:
1320                 value = date.Interval(form[key].value.strip())
1321             else:
1322                 value = None
1323         elif isinstance(proptype, hyperdb.Link):
1324             value = form[key].value.strip()
1325             # see if it's the "no selection" choice
1326             if value == '-1':
1327                 # don't set this property
1328                 continue
1329             else:
1330                 # handle key values
1331                 link = cl.properties[key].classname
1332                 if not num_re.match(value):
1333                     try:
1334                         value = db.classes[link].lookup(value)
1335                     except KeyError:
1336                         raise ValueError, _('property "%(propname)s": '
1337                             '%(value)s not a %(classname)s')%{'propname':key, 
1338                             'value': value, 'classname': link}
1339         elif isinstance(proptype, hyperdb.Multilink):
1340             value = form[key]
1341             if type(value) != type([]):
1342                 value = [i.strip() for i in value.value.split(',')]
1343             else:
1344                 value = [i.value.strip() for i in value]
1345             link = cl.properties[key].classname
1346             l = []
1347             for entry in map(str, value):
1348                 if entry == '': continue
1349                 if not num_re.match(entry):
1350                     try:
1351                         entry = db.classes[link].lookup(entry)
1352                     except KeyError:
1353                         raise ValueError, _('property "%(propname)s": '
1354                             '"%(value)s" not an entry of %(classname)s')%{
1355                             'propname':key, 'value': entry, 'classname': link}
1356                 l.append(entry)
1357             l.sort()
1358             value = l
1360         # get the old value
1361         if nodeid:
1362             try:
1363                 existing = cl.get(nodeid, key)
1364             except KeyError:
1365                 # this might be a new property for which there is no existing
1366                 # value
1367                 if not cl.properties.has_key(key): raise
1369             # if changed, set it
1370             if value != existing:
1371                 props[key] = value
1372         else:
1373             props[key] = value
1374     return props
1377 # $Log: not supported by cvs2svn $
1378 # Revision 1.128  2002/06/12 21:28:25  gmcm
1379 # Allow form to set user-properties on a Fileclass.
1380 # Don't assume that a Fileclass is named "files".
1382 # Revision 1.127  2002/06/11 06:38:24  richard
1383 #  . #565996 ] The "Attach a File to this Issue" fails
1385 # Revision 1.126  2002/05/29 01:16:17  richard
1386 # Sorry about this huge checkin! It's fixing a lot of related stuff in one go
1387 # though.
1389 # . #541941 ] changing multilink properties by mail
1390 # . #526730 ] search for messages capability
1391 # . #505180 ] split MailGW.handle_Message
1392 #   - also changed cgi client since it was duplicating the functionality
1393 # . build htmlbase if tests are run using CVS checkout (removed note from
1394 #   installation.txt)
1395 # . don't create an empty message on email issue creation if the email is empty
1397 # Revision 1.125  2002/05/25 07:16:24  rochecompaan
1398 # Merged search_indexing-branch with HEAD
1400 # Revision 1.124  2002/05/24 02:09:24  richard
1401 # Nothing like a live demo to show up the bugs ;)
1403 # Revision 1.123  2002/05/22 05:04:13  richard
1404 # Oops
1406 # Revision 1.122  2002/05/22 04:12:05  richard
1407 #  . applied patch #558876 ] cgi client customization
1408 #    ... with significant additions and modifications ;)
1409 #    - extended handling of ML assignedto to all places it's handled
1410 #    - added more NotFound info
1412 # Revision 1.121  2002/05/21 06:08:10  richard
1413 # Handle migration
1415 # Revision 1.120  2002/05/21 06:05:53  richard
1416 #  . #551483 ] assignedto in Client.make_index_link
1418 # Revision 1.119  2002/05/15 06:21:21  richard
1419 #  . node caching now works, and gives a small boost in performance
1421 # As a part of this, I cleaned up the DEBUG output and implemented TRACE
1422 # output (HYPERDBTRACE='file to trace to') with checkpoints at the start of
1423 # CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff
1424 # (using if __debug__ which is compiled out with -O)
1426 # Revision 1.118  2002/05/12 23:46:33  richard
1427 # ehem, part 2
1429 # Revision 1.117  2002/05/12 23:42:29  richard
1430 # ehem
1432 # Revision 1.116  2002/05/02 08:07:49  richard
1433 # Added the ADD_AUTHOR_TO_NOSY handling to the CGI interface.
1435 # Revision 1.115  2002/04/02 01:56:10  richard
1436 #  . stop sending blank (whitespace-only) notes
1438 # Revision 1.114.2.4  2002/05/02 11:49:18  rochecompaan
1439 # Allow customization of the search filters that should be displayed
1440 # on the search page.
1442 # Revision 1.114.2.3  2002/04/20 13:23:31  rochecompaan
1443 # We now have a separate search page for nodes.  Search links for
1444 # different classes can be customized in instance_config similar to
1445 # index links.
1447 # Revision 1.114.2.2  2002/04/19 19:54:42  rochecompaan
1448 # cgi_client.py
1449 #     removed search link for the time being
1450 #     moved rendering of matches to htmltemplate
1451 # hyperdb.py
1452 #     filtering of nodes on full text search incorporated in filter method
1453 # roundupdb.py
1454 #     added paramater to call of filter method
1455 # roundup_indexer.py
1456 #     added search method to RoundupIndexer class
1458 # Revision 1.114.2.1  2002/04/03 11:55:57  rochecompaan
1459 #  . Added feature #526730 - search for messages capability
1461 # Revision 1.114  2002/03/17 23:06:05  richard
1462 # oops
1464 # Revision 1.113  2002/03/14 23:59:24  richard
1465 #  . #517734 ] web header customisation is obscure
1467 # Revision 1.112  2002/03/12 22:52:26  richard
1468 # more pychecker warnings removed
1470 # Revision 1.111  2002/02/25 04:32:21  richard
1471 # ahem
1473 # Revision 1.110  2002/02/21 07:19:08  richard
1474 # ... and label, width and height control for extra flavour!
1476 # Revision 1.109  2002/02/21 07:08:19  richard
1477 # oops
1479 # Revision 1.108  2002/02/21 07:02:54  richard
1480 # The correct var is "HTTP_HOST"
1482 # Revision 1.107  2002/02/21 06:57:38  richard
1483 #  . Added popup help for classes using the classhelp html template function.
1484 #    - add <display call="classhelp('priority', 'id,name,description')">
1485 #      to an item page, and it generates a link to a popup window which displays
1486 #      the id, name and description for the priority class. The description
1487 #      field won't exist in most installations, but it will be added to the
1488 #      default templates.
1490 # Revision 1.106  2002/02/21 06:23:00  richard
1491 # *** empty log message ***
1493 # Revision 1.105  2002/02/20 05:52:10  richard
1494 # better error handling
1496 # Revision 1.104  2002/02/20 05:45:17  richard
1497 # Use the csv module for generating the form entry so it's correct.
1498 # [also noted the sf.net feature request id in the change log]
1500 # Revision 1.103  2002/02/20 05:05:28  richard
1501 #  . Added simple editing for classes that don't define a templated interface.
1502 #    - access using the admin "class list" interface
1503 #    - limited to admin-only
1504 #    - requires the csv module from object-craft (url given if it's missing)
1506 # Revision 1.102  2002/02/15 07:08:44  richard
1507 #  . Alternate email addresses are now available for users. See the MIGRATION
1508 #    file for info on how to activate the feature.
1510 # Revision 1.101  2002/02/14 23:39:18  richard
1511 # . All forms now have "double-submit" protection when Javascript is enabled
1512 #   on the client-side.
1514 # Revision 1.100  2002/01/16 07:02:57  richard
1515 #  . lots of date/interval related changes:
1516 #    - more relaxed date format for input
1518 # Revision 1.99  2002/01/16 03:02:42  richard
1519 # #503793 ] changing assignedto resets nosy list
1521 # Revision 1.98  2002/01/14 02:20:14  richard
1522 #  . changed all config accesses so they access either the instance or the
1523 #    config attriubute on the db. This means that all config is obtained from
1524 #    instance_config instead of the mish-mash of classes. This will make
1525 #    switching to a ConfigParser setup easier too, I hope.
1527 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1528 # 0.5.0 switch, I hope!)
1530 # Revision 1.97  2002/01/11 23:22:29  richard
1531 #  . #502437 ] rogue reactor and unittest
1532 #    in short, the nosy reactor was modifying the nosy list. That code had
1533 #    been there for a long time, and I suspsect it was there because we
1534 #    weren't generating the nosy list correctly in other places of the code.
1535 #    We're now doing that, so the nosy-modifying code can go away from the
1536 #    nosy reactor.
1538 # Revision 1.96  2002/01/10 05:26:10  richard
1539 # missed a parsePropsFromForm in last update
1541 # Revision 1.95  2002/01/10 03:39:45  richard
1542 #  . fixed some problems with web editing and change detection
1544 # Revision 1.94  2002/01/09 13:54:21  grubert
1545 # _add_assignedto_to_nosy did set nosy to assignedto only, no adding.
1547 # Revision 1.93  2002/01/08 11:57:12  richard
1548 # crying out for real configuration handling... :(
1550 # Revision 1.92  2002/01/08 04:12:05  richard
1551 # Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
1553 # Revision 1.91  2002/01/08 04:03:47  richard
1554 # I mucked the intent of the code up.
1556 # Revision 1.90  2002/01/08 03:56:55  richard
1557 # Oops, missed this before the beta:
1558 #  . #495392 ] empty nosy -patch
1560 # Revision 1.89  2002/01/07 20:24:45  richard
1561 # *mutter* stupid cutnpaste
1563 # Revision 1.88  2002/01/02 02:31:38  richard
1564 # Sorry for the huge checkin message - I was only intending to implement #496356
1565 # but I found a number of places where things had been broken by transactions:
1566 #  . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1567 #    for _all_ roundup-generated smtp messages to be sent to.
1568 #  . the transaction cache had broken the roundupdb.Class set() reactors
1569 #  . newly-created author users in the mailgw weren't being committed to the db
1571 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1572 # on when I found that stuff :):
1573 #  . #496356 ] Use threading in messages
1574 #  . detectors were being registered multiple times
1575 #  . added tests for mailgw
1576 #  . much better attaching of erroneous messages in the mail gateway
1578 # Revision 1.87  2001/12/23 23:18:49  richard
1579 # We already had an admin-specific section of the web heading, no need to add
1580 # another one :)
1582 # Revision 1.86  2001/12/20 15:43:01  rochecompaan
1583 # Features added:
1584 #  .  Multilink properties are now displayed as comma separated values in
1585 #     a textbox
1586 #  .  The add user link is now only visible to the admin user
1587 #  .  Modified the mail gateway to reject submissions from unknown
1588 #     addresses if ANONYMOUS_ACCESS is denied
1590 # Revision 1.85  2001/12/20 06:13:24  rochecompaan
1591 # Bugs fixed:
1592 #   . Exception handling in hyperdb for strings-that-look-like numbers got
1593 #     lost somewhere
1594 #   . Internet Explorer submits full path for filename - we now strip away
1595 #     the path
1596 # Features added:
1597 #   . Link and multilink properties are now displayed sorted in the cgi
1598 #     interface
1600 # Revision 1.84  2001/12/18 15:30:30  rochecompaan
1601 # Fixed bugs:
1602 #  .  Fixed file creation and retrieval in same transaction in anydbm
1603 #     backend
1604 #  .  Cgi interface now renders new issue after issue creation
1605 #  .  Could not set issue status to resolved through cgi interface
1606 #  .  Mail gateway was changing status back to 'chatting' if status was
1607 #     omitted as an argument
1609 # Revision 1.83  2001/12/15 23:51:01  richard
1610 # Tested the changes and fixed a few problems:
1611 #  . files are now attached to the issue as well as the message
1612 #  . newuser is a real method now since we don't want to do the message/file
1613 #    stuff for it
1614 #  . added some documentation
1615 # The really big changes in the diff are a result of me moving some code
1616 # around to keep like methods together a bit better.
1618 # Revision 1.82  2001/12/15 19:24:39  rochecompaan
1619 #  . Modified cgi interface to change properties only once all changes are
1620 #    collected, files created and messages generated.
1621 #  . Moved generation of change note to nosyreactors.
1622 #  . We now check for changes to "assignedto" to ensure it's added to the
1623 #    nosy list.
1625 # Revision 1.81  2001/12/12 23:55:00  richard
1626 # Fixed some problems with user editing
1628 # Revision 1.80  2001/12/12 23:27:14  richard
1629 # Added a Zope frontend for roundup.
1631 # Revision 1.79  2001/12/10 22:20:01  richard
1632 # Enabled transaction support in the bsddb backend. It uses the anydbm code
1633 # where possible, only replacing methods where the db is opened (it uses the
1634 # btree opener specifically.)
1635 # Also cleaned up some change note generation.
1636 # Made the backends package work with pydoc too.
1638 # Revision 1.78  2001/12/07 05:59:27  rochecompaan
1639 # Fixed small bug that prevented adding issues through the web.
1641 # Revision 1.77  2001/12/06 22:48:29  richard
1642 # files multilink was being nuked in post_edit_node
1644 # Revision 1.76  2001/12/05 14:26:44  rochecompaan
1645 # Removed generation of change note from "sendmessage" in roundupdb.py.
1646 # The change note is now generated when the message is created.
1648 # Revision 1.75  2001/12/04 01:25:08  richard
1649 # Added some rollbacks where we were catching exceptions that would otherwise
1650 # have stopped committing.
1652 # Revision 1.74  2001/12/02 05:06:16  richard
1653 # . We now use weakrefs in the Classes to keep the database reference, so
1654 #   the close() method on the database is no longer needed.
1655 #   I bumped the minimum python requirement up to 2.1 accordingly.
1656 # . #487480 ] roundup-server
1657 # . #487476 ] INSTALL.txt
1659 # I also cleaned up the change message / post-edit stuff in the cgi client.
1660 # There's now a clearly marked "TODO: append the change note" where I believe
1661 # the change note should be added there. The "changes" list will obviously
1662 # have to be modified to be a dict of the changes, or somesuch.
1664 # More testing needed.
1666 # Revision 1.73  2001/12/01 07:17:50  richard
1667 # . We now have basic transaction support! Information is only written to
1668 #   the database when the commit() method is called. Only the anydbm
1669 #   backend is modified in this way - neither of the bsddb backends have been.
1670 #   The mail, admin and cgi interfaces all use commit (except the admin tool
1671 #   doesn't have a commit command, so interactive users can't commit...)
1672 # . Fixed login/registration forwarding the user to the right page (or not,
1673 #   on a failure)
1675 # Revision 1.72  2001/11/30 20:47:58  rochecompaan
1676 # Links in page header are now consistent with default sort order.
1678 # Fixed bugs:
1679 #     - When login failed the list of issues were still rendered.
1680 #     - User was redirected to index page and not to his destination url
1681 #       if his first login attempt failed.
1683 # Revision 1.71  2001/11/30 20:28:10  rochecompaan
1684 # Property changes are now completely traceable, whether changes are
1685 # made through the web or by email
1687 # Revision 1.70  2001/11/30 00:06:29  richard
1688 # Converted roundup/cgi_client.py to use _()
1689 # Added the status file, I18N_PROGRESS.txt
1691 # Revision 1.69  2001/11/29 23:19:51  richard
1692 # Removed the "This issue has been edited through the web" when a valid
1693 # change note is supplied.
1695 # Revision 1.68  2001/11/29 04:57:23  richard
1696 # a little comment
1698 # Revision 1.67  2001/11/28 21:55:35  richard
1699 #  . login_action and newuser_action return values were being ignored
1700 #  . Woohoo! Found that bloody re-login bug that was killing the mail
1701 #    gateway.
1702 #  (also a minor cleanup in hyperdb)
1704 # Revision 1.66  2001/11/27 03:00:50  richard
1705 # couple of bugfixes from latest patch integration
1707 # Revision 1.65  2001/11/26 23:00:53  richard
1708 # This config stuff is getting to be a real mess...
1710 # Revision 1.64  2001/11/26 22:56:35  richard
1711 # typo
1713 # Revision 1.63  2001/11/26 22:55:56  richard
1714 # Feature:
1715 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
1716 #    the instance.
1717 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1718 #    signature info in e-mails.
1719 #  . Some more flexibility in the mail gateway and more error handling.
1720 #  . Login now takes you to the page you back to the were denied access to.
1722 # Fixed:
1723 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
1725 # Revision 1.62  2001/11/24 00:45:42  jhermann
1726 # typeof() instead of type(): avoid clash with database field(?) "type"
1728 # Fixes this traceback:
1730 # Traceback (most recent call last):
1731 #   File "roundup\cgi_client.py", line 535, in newnode
1732 #     self._post_editnode(nid)
1733 #   File "roundup\cgi_client.py", line 415, in _post_editnode
1734 #     if type(value) != type([]): value = [value]
1735 # UnboundLocalError: local variable 'type' referenced before assignment
1737 # Revision 1.61  2001/11/22 15:46:42  jhermann
1738 # Added module docstrings to all modules.
1740 # Revision 1.60  2001/11/21 22:57:28  jhermann
1741 # Added dummy hooks for I18N and some preliminary (test) markup of
1742 # translatable messages
1744 # Revision 1.59  2001/11/21 03:21:13  richard
1745 # oops
1747 # Revision 1.58  2001/11/21 03:11:28  richard
1748 # Better handling of new properties.
1750 # Revision 1.57  2001/11/15 10:24:27  richard
1751 # handle the case where there is no file attached
1753 # Revision 1.56  2001/11/14 21:35:21  richard
1754 #  . users may attach files to issues (and support in ext) through the web now
1756 # Revision 1.55  2001/11/07 02:34:06  jhermann
1757 # Handling of damaged login cookies
1759 # Revision 1.54  2001/11/07 01:16:12  richard
1760 # Remove the '=' padding from cookie value so quoting isn't an issue.
1762 # Revision 1.53  2001/11/06 23:22:05  jhermann
1763 # More IE fixes: it does not like quotes around cookie values; in the
1764 # hope this does not break anything for other browser; if it does, we
1765 # need to check HTTP_USER_AGENT
1767 # Revision 1.52  2001/11/06 23:11:22  jhermann
1768 # Fixed debug output in page footer; added expiry date to the login cookie
1769 # (expires 1 year in the future) to prevent probs with certain versions
1770 # of IE
1772 # Revision 1.51  2001/11/06 22:00:34  jhermann
1773 # Get debug level from ROUNDUP_DEBUG env var
1775 # Revision 1.50  2001/11/05 23:45:40  richard
1776 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1777 # Also made it present nicer error messages (not tracebacks).
1779 # Revision 1.49  2001/11/04 03:07:12  richard
1780 # Fixed various cookie-related bugs:
1781 #  . bug #477685 ] base64.decodestring breaks
1782 #  . bug #477837 ] lynx does not like the cookie
1783 #  . bug #477892 ] Password edit doesn't fix login cookie
1784 # Also closed a security hole - a logged-in user could edit another user's
1785 # details.
1787 # Revision 1.48  2001/11/03 01:30:18  richard
1788 # Oops. uses pagefoot now.
1790 # Revision 1.47  2001/11/03 01:29:28  richard
1791 # Login page didn't have all close tags.
1793 # Revision 1.46  2001/11/03 01:26:55  richard
1794 # possibly fix truncated base64'ed user:pass
1796 # Revision 1.45  2001/11/01 22:04:37  richard
1797 # Started work on supporting a pop3-fetching server
1798 # Fixed bugs:
1799 #  . bug #477104 ] HTML tag error in roundup-server
1800 #  . bug #477107 ] HTTP header problem
1802 # Revision 1.44  2001/10/28 23:03:08  richard
1803 # Added more useful header to the classic schema.
1805 # Revision 1.43  2001/10/24 00:01:42  richard
1806 # More fixes to lockout logic.
1808 # Revision 1.42  2001/10/23 23:56:03  richard
1809 # HTML typo
1811 # Revision 1.41  2001/10/23 23:52:35  richard
1812 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1814 # Revision 1.40  2001/10/23 23:06:39  richard
1815 # Some cleanup.
1817 # Revision 1.39  2001/10/23 01:00:18  richard
1818 # Re-enabled login and registration access after lopping them off via
1819 # disabling access for anonymous users.
1820 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1821 # a couple of bugs while I was there. Probably introduced a couple, but
1822 # things seem to work OK at the moment.
1824 # Revision 1.38  2001/10/22 03:25:01  richard
1825 # Added configuration for:
1826 #  . anonymous user access and registration (deny/allow)
1827 #  . filter "widget" location on index page (top, bottom, both)
1828 # Updated some documentation.
1830 # Revision 1.37  2001/10/21 07:26:35  richard
1831 # feature #473127: Filenames. I modified the file.index and htmltemplate
1832 #  source so that the filename is used in the link and the creation
1833 #  information is displayed.
1835 # Revision 1.36  2001/10/21 04:44:50  richard
1836 # bug #473124: UI inconsistency with Link fields.
1837 #    This also prompted me to fix a fairly long-standing usability issue -
1838 #    that of being able to turn off certain filters.
1840 # Revision 1.35  2001/10/21 00:17:54  richard
1841 # CGI interface view customisation section may now be hidden (patch from
1842 #  Roch'e Compaan.)
1844 # Revision 1.34  2001/10/20 11:58:48  richard
1845 # Catch errors in login - no username or password supplied.
1846 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1848 # Revision 1.33  2001/10/17 00:18:41  richard
1849 # Manually constructing cookie headers now.
1851 # Revision 1.32  2001/10/16 03:36:21  richard
1852 # CGI interface wasn't handling checkboxes at all.
1854 # Revision 1.31  2001/10/14 10:55:00  richard
1855 # Handle empty strings in HTML template Link function
1857 # Revision 1.30  2001/10/09 07:38:58  richard
1858 # Pushed the base code for the extended schema CGI interface back into the
1859 # code cgi_client module so that future updates will be less painful.
1860 # Also removed a debugging print statement from cgi_client.
1862 # Revision 1.29  2001/10/09 07:25:59  richard
1863 # Added the Password property type. See "pydoc roundup.password" for
1864 # implementation details. Have updated some of the documentation too.
1866 # Revision 1.28  2001/10/08 00:34:31  richard
1867 # Change message was stuffing up for multilinks with no key property.
1869 # Revision 1.27  2001/10/05 02:23:24  richard
1870 #  . roundup-admin create now prompts for property info if none is supplied
1871 #    on the command-line.
1872 #  . hyperdb Class getprops() method may now return only the mutable
1873 #    properties.
1874 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
1875 #    now support anonymous user access (read-only, unless there's an
1876 #    "anonymous" user, in which case write access is permitted). Login
1877 #    handling has been moved into cgi_client.Client.main()
1878 #  . The "extended" schema is now the default in roundup init.
1879 #  . The schemas have had their page headings modified to cope with the new
1880 #    login handling. Existing installations should copy the interfaces.py
1881 #    file from the roundup lib directory to their instance home.
1882 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1883 #    Ping - has been removed.
1884 #  . Fixed a whole bunch of places in the CGI interface where we should have
1885 #    been returning Not Found instead of throwing an exception.
1886 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
1887 #    an item now throws an exception.
1889 # Revision 1.26  2001/09/12 08:31:42  richard
1890 # handle cases where mime type is not guessable
1892 # Revision 1.25  2001/08/29 05:30:49  richard
1893 # change messages weren't being saved when there was no-one on the nosy list.
1895 # Revision 1.24  2001/08/29 04:49:39  richard
1896 # didn't clean up fully after debugging :(
1898 # Revision 1.23  2001/08/29 04:47:18  richard
1899 # Fixed CGI client change messages so they actually include the properties
1900 # changed (again).
1902 # Revision 1.22  2001/08/17 00:08:10  richard
1903 # reverted back to sending messages always regardless of who is doing the web
1904 # edit. change notes weren't being saved. bleah. hackish.
1906 # Revision 1.21  2001/08/15 23:43:18  richard
1907 # Fixed some isFooTypes that I missed.
1908 # Refactored some code in the CGI code.
1910 # Revision 1.20  2001/08/12 06:32:36  richard
1911 # using isinstance(blah, Foo) now instead of isFooType
1913 # Revision 1.19  2001/08/07 00:24:42  richard
1914 # stupid typo
1916 # Revision 1.18  2001/08/07 00:15:51  richard
1917 # Added the copyright/license notice to (nearly) all files at request of
1918 # Bizar Software.
1920 # Revision 1.17  2001/08/02 06:38:17  richard
1921 # Roundupdb now appends "mailing list" information to its messages which
1922 # include the e-mail address and web interface address. Templates may
1923 # override this in their db classes to include specific information (support
1924 # instructions, etc).
1926 # Revision 1.16  2001/08/02 05:55:25  richard
1927 # Web edit messages aren't sent to the person who did the edit any more. No
1928 # message is generated if they are the only person on the nosy list.
1930 # Revision 1.15  2001/08/02 00:34:10  richard
1931 # bleah syntax error
1933 # Revision 1.14  2001/08/02 00:26:16  richard
1934 # Changed the order of the information in the message generated by web edits.
1936 # Revision 1.13  2001/07/30 08:12:17  richard
1937 # Added time logging and file uploading to the templates.
1939 # Revision 1.12  2001/07/30 06:26:31  richard
1940 # Added some documentation on how the newblah works.
1942 # Revision 1.11  2001/07/30 06:17:45  richard
1943 # Features:
1944 #  . Added ability for cgi newblah forms to indicate that the new node
1945 #    should be linked somewhere.
1946 # Fixed:
1947 #  . Fixed the agument handling for the roundup-admin find command.
1948 #  . Fixed handling of summary when no note supplied for newblah. Again.
1949 #  . Fixed detection of no form in htmltemplate Field display.
1951 # Revision 1.10  2001/07/30 02:37:34  richard
1952 # Temporary measure until we have decent schema migration...
1954 # Revision 1.9  2001/07/30 01:25:07  richard
1955 # Default implementation is now "classic" rather than "extended" as one would
1956 # expect.
1958 # Revision 1.8  2001/07/29 08:27:40  richard
1959 # Fixed handling of passed-in values in form elements (ie. during a
1960 # drill-down)
1962 # Revision 1.7  2001/07/29 07:01:39  richard
1963 # Added vim command to all source so that we don't get no steenkin' tabs :)
1965 # Revision 1.6  2001/07/29 04:04:00  richard
1966 # Moved some code around allowing for subclassing to change behaviour.
1968 # Revision 1.5  2001/07/28 08:16:52  richard
1969 # New issue form handles lack of note better now.
1971 # Revision 1.4  2001/07/28 00:34:34  richard
1972 # Fixed some non-string node ids.
1974 # Revision 1.3  2001/07/23 03:56:30  richard
1975 # oops, missed a config removal
1977 # Revision 1.2  2001/07/22 12:09:32  richard
1978 # Final commit of Grande Splite
1980 # Revision 1.1  2001/07/22 11:58:35  richard
1981 # More Grande Splite
1984 # vim: set filetype=python ts=4 sw=4 et si