Code

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