Code

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