Code

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