Code

Oops
[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.123 2002-05-22 05:04:13 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         if not props.has_key('assignedto'):
586             return
587         cl = self.db.classes[self.classname]
588         propdef = cl.getprops()
589         if not propdef.has_key('assignedto') or not propdef.has_key('nosy'):
590             return
592         # get the assignedto(s)
593         if isinstance(propdef['assignedto'], hyperdb.Link):
594             assignedto_ids = [props['assignedto']]
595         elif isinstance(propdef['assignedto'], hyperdb.Multilink):
596             assignedto_ids = props['assignedto']
597         else:
598             return
600         # ok, now get the nosy list value
601         if not props.has_key('nosy'):
602             # load current nosy
603             if self.nodeid:
604                 props['nosy'] = cl.get(self.nodeid, 'nosy')
605             else:
606                 props['nosy'] = []
608         # and update for assignedto id(s)
609         for assignedto_id in assignedto_ids:
610             if assignedto_id not in props['nosy']:
611                 props['nosy'].append(assignedto_id)
613     def _changenode(self, props):
614         ''' change the node based on the contents of the form
615         '''
616         cl = self.db.classes[self.classname]
617         # set status to chatting if 'unread' or 'resolved'
618         try:
619             # determine the id of 'unread','resolved' and 'chatting'
620             unread_id = self.db.status.lookup('unread')
621             resolved_id = self.db.status.lookup('resolved')
622             chatting_id = self.db.status.lookup('chatting')
623             current_status = cl.get(self.nodeid, 'status')
624             if props.has_key('status'):
625                 new_status = props['status']
626             else:
627                 # apparently there's a chance that some browsers don't
628                 # send status...
629                 new_status = current_status
630         except KeyError:
631             pass
632         else:
633             if new_status == unread_id or (new_status == resolved_id
634                     and current_status == resolved_id):
635                 props['status'] = chatting_id
637         self._add_assignedto_to_nosy(props)
639         # possibly add the author of the change to the nosy list
640         if self.db.config.ADD_AUTHOR_TO_NOSY == 'yes':
641             self._add_author_to_nosy(props)
643         # create the message
644         message, files = self._handle_message()
645         if message:
646             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
647         if files:
648             props['files'] = cl.get(self.nodeid, 'files') + files
650         # make the changes
651         cl.set(self.nodeid, **props)
653     def _createnode(self):
654         ''' create a node based on the contents of the form
655         '''
656         cl = self.db.classes[self.classname]
657         props = parsePropsFromForm(self.db, cl, self.form)
659         # set status to 'unread' if not specified - a status of '- no
660         # selection -' doesn't make sense
661         if not props.has_key('status') and cl.getprops().has_key('status'):
662             try:
663                 unread_id = self.db.status.lookup('unread')
664             except KeyError:
665                 pass
666             else:
667                 props['status'] = unread_id
669         self._add_assignedto_to_nosy(props)
671         # possibly add the author of the new node to the nosy list
672         if self.db.config.ADD_AUTHOR_TO_NOSY in ('new', 'yes'):
673             self._add_author_to_nosy(props)
675         # check for messages and files
676         message, files = self._handle_message()
677         if message:
678             props['messages'] = [message]
679         if files:
680             props['files'] = files
681         # create the node and return it's id
682         return cl.create(**props)
684     def _handle_message(self):
685         ''' generate an edit message
686         '''
687         # handle file attachments 
688         files = []
689         if self.form.has_key('__file'):
690             file = self.form['__file']
691             if file.filename:
692                 filename = file.filename.split('\\')[-1]
693                 mime_type = mimetypes.guess_type(filename)[0]
694                 if not mime_type:
695                     mime_type = "application/octet-stream"
696                 # create the new file entry
697                 files.append(self.db.file.create(type=mime_type,
698                     name=filename, content=file.file.read()))
700         # we don't want to do a message if none of the following is true...
701         cn = self.classname
702         cl = self.db.classes[self.classname]
703         props = cl.getprops()
704         note = None
705         # in a nutshell, don't do anything if there's no note or there's no
706         # NOSY
707         if self.form.has_key('__note'):
708             note = self.form['__note'].value.strip()
709         if not note:
710             return None, files
711         if not props.has_key('messages'):
712             return None, files
713         if not isinstance(props['messages'], hyperdb.Multilink):
714             return None, files
715         if not props['messages'].classname == 'msg':
716             return None, files
717         if not (self.form.has_key('nosy') or note):
718             return None, files
720         # handle the note
721         if '\n' in note:
722             summary = re.split(r'\n\r?', note)[0]
723         else:
724             summary = note
725         m = ['%s\n'%note]
727         # handle the messageid
728         # TODO: handle inreplyto
729         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
730             self.classname, self.instance.MAIL_DOMAIN)
732         # now create the message, attaching the files
733         content = '\n'.join(m)
734         message_id = self.db.msg.create(author=self.getuid(),
735             recipients=[], date=date.Date('.'), summary=summary,
736             content=content, files=files, messageid=messageid)
738         # update the messages property
739         return message_id, files
741     def _post_editnode(self, nid):
742         '''Do the linking part of the node creation.
744            If a form element has :link or :multilink appended to it, its
745            value specifies a node designator and the property on that node
746            to add _this_ node to as a link or multilink.
748            This is typically used on, eg. the file upload page to indicated
749            which issue to link the file to.
751            TODO: I suspect that this and newfile will go away now that
752            there's the ability to upload a file using the issue __file form
753            element!
754         '''
755         cn = self.classname
756         cl = self.db.classes[cn]
757         # link if necessary
758         keys = self.form.keys()
759         for key in keys:
760             if key == ':multilink':
761                 value = self.form[key].value
762                 if type(value) != type([]): value = [value]
763                 for value in value:
764                     designator, property = value.split(':')
765                     link, nodeid = roundupdb.splitDesignator(designator)
766                     link = self.db.classes[link]
767                     value = link.get(nodeid, property)
768                     value.append(nid)
769                     link.set(nodeid, **{property: value})
770             elif key == ':link':
771                 value = self.form[key].value
772                 if type(value) != type([]): value = [value]
773                 for value in value:
774                     designator, property = value.split(':')
775                     link, nodeid = roundupdb.splitDesignator(designator)
776                     link = self.db.classes[link]
777                     link.set(nodeid, **{property: nid})
779     def newnode(self, message=None):
780         ''' Add a new node to the database.
781         
782         The form works in two modes: blank form and submission (that is,
783         the submission goes to the same URL). **Eventually this means that
784         the form will have previously entered information in it if
785         submission fails.
787         The new node will be created with the properties specified in the
788         form submission. For multilinks, multiple form entries are handled,
789         as are prop=value,value,value. You can't mix them though.
791         If the new node is to be referenced from somewhere else immediately
792         (ie. the new node is a file that is to be attached to a support
793         issue) then supply one of these arguments in addition to the usual
794         form entries:
795             :link=designator:property
796             :multilink=designator:property
797         ... which means that once the new node is created, the "property"
798         on the node given by "designator" should now reference the new
799         node's id. The node id will be appended to the multilink.
800         '''
801         cn = self.classname
802         cl = self.db.classes[cn]
804         # possibly perform a create
805         keys = self.form.keys()
806         if [i for i in keys if i[0] != ':']:
807             props = {}
808             try:
809                 nid = self._createnode()
810                 # handle linked nodes 
811                 self._post_editnode(nid)
812                 # and some nice feedback for the user
813                 message = _('%(classname)s created ok')%{'classname': cn}
815                 # render the newly created issue
816                 self.db.commit()
817                 self.nodeid = nid
818                 self.pagehead('%s: %s'%(self.classname.capitalize(), nid),
819                     message)
820                 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 
821                     self.classname)
822                 item.render(nid)
823                 self.pagefoot()
824                 return
825             except:
826                 self.db.rollback()
827                 s = StringIO.StringIO()
828                 traceback.print_exc(None, s)
829                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
830         self.pagehead(_('New %(classname)s')%{'classname':
831             self.classname.capitalize()}, message)
833         # call the template
834         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
835             self.classname)
836         newitem.render(self.form)
838         self.pagefoot()
839     newissue = newnode
841     def newuser(self, message=None):
842         ''' Add a new user to the database.
844             Don't do any of the message or file handling, just create the node.
845         '''
846         cn = self.classname
847         cl = self.db.classes[cn]
849         # possibly perform a create
850         keys = self.form.keys()
851         if [i for i in keys if i[0] != ':']:
852             try:
853                 props = parsePropsFromForm(self.db, cl, self.form)
854                 nid = cl.create(**props)
855                 # handle linked nodes 
856                 self._post_editnode(nid)
857                 # and some nice feedback for the user
858                 message = _('%(classname)s created ok')%{'classname': cn}
859             except:
860                 self.db.rollback()
861                 s = StringIO.StringIO()
862                 traceback.print_exc(None, s)
863                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
864         self.pagehead(_('New %(classname)s')%{'classname':
865              self.classname.capitalize()}, message)
867         # call the template
868         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
869             self.classname)
870         newitem.render(self.form)
872         self.pagefoot()
874     def newfile(self, message=None):
875         ''' Add a new file to the database.
876         
877         This form works very much the same way as newnode - it just has a
878         file upload.
879         '''
880         cn = self.classname
881         cl = self.db.classes[cn]
883         # possibly perform a create
884         keys = self.form.keys()
885         if [i for i in keys if i[0] != ':']:
886             try:
887                 file = self.form['content']
888                 mime_type = mimetypes.guess_type(file.filename)[0]
889                 if not mime_type:
890                     mime_type = "application/octet-stream"
891                 # save the file
892                 nid = cl.create(content=file.file.read(), type=mime_type,
893                     name=file.filename)
894                 # handle linked nodes
895                 self._post_editnode(nid)
896                 # and some nice feedback for the user
897                 message = _('%(classname)s created ok')%{'classname': cn}
898             except:
899                 self.db.rollback()
900                 s = StringIO.StringIO()
901                 traceback.print_exc(None, s)
902                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
904         self.pagehead(_('New %(classname)s')%{'classname':
905              self.classname.capitalize()}, message)
906         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
907             self.classname)
908         newitem.render(self.form)
909         self.pagefoot()
911     def showuser(self, message=None):
912         '''Display a user page for editing. Make sure the user is allowed
913             to edit this node, and also check for password changes.
914         '''
915         if self.user == 'anonymous':
916             raise Unauthorised
918         user = self.db.user
920         # get the username of the node being edited
921         node_user = user.get(self.nodeid, 'username')
923         if self.user not in ('admin', node_user):
924             raise Unauthorised
926         #
927         # perform any editing
928         #
929         keys = self.form.keys()
930         num_re = re.compile('^\d+$')
931         if keys:
932             try:
933                 props = parsePropsFromForm(self.db, user, self.form,
934                     self.nodeid)
935                 set_cookie = 0
936                 if props.has_key('password'):
937                     password = self.form['password'].value.strip()
938                     if not password:
939                         # no password was supplied - don't change it
940                         del props['password']
941                     elif self.nodeid == self.getuid():
942                         # this is the logged-in user's password
943                         set_cookie = password
944                 user.set(self.nodeid, **props)
945                 # and some feedback for the user
946                 message = _('%(changes)s edited ok')%{'changes':
947                     ', '.join(props.keys())}
948             except:
949                 self.db.rollback()
950                 s = StringIO.StringIO()
951                 traceback.print_exc(None, s)
952                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
953         else:
954             set_cookie = 0
956         # fix the cookie if the password has changed
957         if set_cookie:
958             self.set_cookie(self.user, set_cookie)
960         #
961         # now the display
962         #
963         self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
965         # use the template to display the item
966         item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 'user')
967         item.render(self.nodeid)
968         self.pagefoot()
970     def showfile(self):
971         ''' display a file
972         '''
973         nodeid = self.nodeid
974         cl = self.db.file
975         mime_type = cl.get(nodeid, 'type')
976         if mime_type == 'message/rfc822':
977             mime_type = 'text/plain'
978         self.header(headers={'Content-Type': mime_type})
979         self.write(cl.get(nodeid, 'content'))
981     def classes(self, message=None):
982         ''' display a list of all the classes in the database
983         '''
984         if self.user == 'admin':
985             self.pagehead(_('Table of classes'), message)
986             classnames = self.db.classes.keys()
987             classnames.sort()
988             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
989             for cn in classnames:
990                 cl = self.db.getclass(cn)
991                 self.write('<tr class="list-header"><th colspan=2 align=left>'
992                     '<a href="%s">%s</a></th></tr>'%(cn, cn.capitalize()))
993                 for key, value in cl.properties.items():
994                     if value is None: value = ''
995                     else: value = str(value)
996                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
997                         key, cgi.escape(value)))
998             self.write('</table>')
999             self.pagefoot()
1000         else:
1001             raise Unauthorised
1003     def login(self, message=None, newuser_form=None, action='index'):
1004         '''Display a login page.
1005         '''
1006         self.pagehead(_('Login to roundup'), message)
1007         self.write(_('''
1008 <table>
1009 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
1010 <form onSubmit="return submit_once()" action="login_action" method=POST>
1011 <input type="hidden" name="__destination_url" value="%(action)s">
1012 <tr><td align=right>Login name: </td>
1013     <td><input name="__login_name"></td></tr>
1014 <tr><td align=right>Password: </td>
1015     <td><input type="password" name="__login_password"></td></tr>
1016 <tr><td></td>
1017     <td><input type="submit" value="Log In"></td></tr>
1018 </form>
1019 ''')%locals())
1020         if self.user is None and self.instance.ANONYMOUS_REGISTER == 'deny':
1021             self.write('</table>')
1022             self.pagefoot()
1023             return
1024         values = {'realname': '', 'organisation': '', 'address': '',
1025             'phone': '', 'username': '', 'password': '', 'confirm': '',
1026             'action': action, 'alternate_addresses': ''}
1027         if newuser_form is not None:
1028             for key in newuser_form.keys():
1029                 values[key] = newuser_form[key].value
1030         self.write(_('''
1031 <p>
1032 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
1033 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
1034 <form onSubmit="return submit_once()" action="newuser_action" method=POST>
1035 <input type="hidden" name="__destination_url" value="%(action)s">
1036 <tr><td align=right><em>Name: </em></td>
1037     <td><input name="realname" value="%(realname)s" size=40></td></tr>
1038 <tr><td align=right><em>Organisation: </em></td>
1039     <td><input name="organisation" value="%(organisation)s" size=40></td></tr>
1040 <tr><td align=right>E-Mail Address: </td>
1041     <td><input name="address" value="%(address)s" size=40></td></tr>
1042 <tr><td align=right><em>Alternate E-mail Addresses: </em></td>
1043     <td><textarea name="alternate_addresses" rows=5 cols=40>%(alternate_addresses)s</textarea></td></tr>
1044 <tr><td align=right><em>Phone: </em></td>
1045     <td><input name="phone" value="%(phone)s"></td></tr>
1046 <tr><td align=right>Preferred Login name: </td>
1047     <td><input name="username" value="%(username)s"></td></tr>
1048 <tr><td align=right>Password: </td>
1049     <td><input type="password" name="password" value="%(password)s"></td></tr>
1050 <tr><td align=right>Password Again: </td>
1051     <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
1052 <tr><td></td>
1053     <td><input type="submit" value="Register"></td></tr>
1054 </form>
1055 </table>
1056 ''')%values)
1057         self.pagefoot()
1059     def login_action(self, message=None):
1060         '''Attempt to log a user in and set the cookie
1062         returns 0 if a page is generated as a result of this call, and
1063         1 if not (ie. the login is successful
1064         '''
1065         if not self.form.has_key('__login_name'):
1066             self.login(message=_('Username required'))
1067             return 0
1068         self.user = self.form['__login_name'].value
1069         if self.form.has_key('__login_password'):
1070             password = self.form['__login_password'].value
1071         else:
1072             password = ''
1073         # make sure the user exists
1074         try:
1075             uid = self.db.user.lookup(self.user)
1076         except KeyError:
1077             name = self.user
1078             self.make_user_anonymous()
1079             action = self.form['__destination_url'].value
1080             self.login(message=_('No such user "%(name)s"')%locals(),
1081                 action=action)
1082             return 0
1084         # and that the password is correct
1085         pw = self.db.user.get(uid, 'password')
1086         if password != pw:
1087             self.make_user_anonymous()
1088             action = self.form['__destination_url'].value
1089             self.login(message=_('Incorrect password'), action=action)
1090             return 0
1092         self.set_cookie(self.user, password)
1093         return 1
1095     def newuser_action(self, message=None):
1096         '''Attempt to create a new user based on the contents of the form
1097         and then set the cookie.
1099         return 1 on successful login
1100         '''
1101         # re-open the database as "admin"
1102         self.db = self.instance.open('admin')
1104         # TODO: pre-check the required fields and username key property
1105         cl = self.db.user
1106         try:
1107             props = parsePropsFromForm(self.db, cl, self.form)
1108             uid = cl.create(**props)
1109         except ValueError, message:
1110             action = self.form['__destination_url'].value
1111             self.login(message, action=action)
1112             return 0
1113         self.user = cl.get(uid, 'username')
1114         password = cl.get(uid, 'password')
1115         self.set_cookie(self.user, self.form['password'].value)
1116         return 1
1118     def set_cookie(self, user, password):
1119         # construct the cookie
1120         user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
1121         if user[-1] == '=':
1122           if user[-2] == '=':
1123             user = user[:-2]
1124           else:
1125             user = user[:-1]
1126         expire = Cookie._getdate(86400*365)
1127         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
1128         self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
1129             user, expire, path)})
1131     def make_user_anonymous(self):
1132         # make us anonymous if we can
1133         try:
1134             self.db.user.lookup('anonymous')
1135             self.user = 'anonymous'
1136         except KeyError:
1137             self.user = None
1139     def logout(self, message=None):
1140         self.make_user_anonymous()
1141         # construct the logout cookie
1142         now = Cookie._getdate()
1143         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
1144         self.header({'Set-Cookie':
1145             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
1146             path)})
1147         self.login()
1149     def main(self):
1150         '''Wrap the database accesses so we can close the database cleanly
1151         '''
1152         # determine the uid to use
1153         self.db = self.instance.open('admin')
1154         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
1155         user = 'anonymous'
1156         if (cookie.has_key('roundup_user') and
1157                 cookie['roundup_user'].value != 'deleted'):
1158             cookie = cookie['roundup_user'].value
1159             if len(cookie)%4:
1160               cookie = cookie + '='*(4-len(cookie)%4)
1161             try:
1162                 user, password = binascii.a2b_base64(cookie).split(':')
1163             except (TypeError, binascii.Error, binascii.Incomplete):
1164                 # damaged cookie!
1165                 user, password = 'anonymous', ''
1167             # make sure the user exists
1168             try:
1169                 uid = self.db.user.lookup(user)
1170                 # now validate the password
1171                 if password != self.db.user.get(uid, 'password'):
1172                     user = 'anonymous'
1173             except KeyError:
1174                 user = 'anonymous'
1176         # make sure the anonymous user is valid if we're using it
1177         if user == 'anonymous':
1178             self.make_user_anonymous()
1179         else:
1180             self.user = user
1182         # re-open the database for real, using the user
1183         self.db = self.instance.open(self.user)
1185         # now figure which function to call
1186         path = self.split_path
1188         # default action to index if the path has no information in it
1189         if not path or path[0] in ('', 'index'):
1190             action = 'index'
1191         else:
1192             action = path[0]
1194         # Everthing ignores path[1:]
1195         #  - The file download link generator actually relies on this - it
1196         #    appends the name of the file to the URL so the download file name
1197         #    is correct, but doesn't actually use it.
1199         # everyone is allowed to try to log in
1200         if action == 'login_action':
1201             # try to login
1202             if not self.login_action():
1203                 return
1204             # figure the resulting page
1205             action = self.form['__destination_url'].value
1206             if not action:
1207                 action = 'index'
1208             self.do_action(action)
1209             return
1211         # allow anonymous people to register
1212         if action == 'newuser_action':
1213             # if we don't have a login and anonymous people aren't allowed to
1214             # register, then spit up the login form
1215             if self.instance.ANONYMOUS_REGISTER == 'deny' and self.user is None:
1216                 if action == 'login':
1217                     self.login()         # go to the index after login
1218                 else:
1219                     self.login(action=action)
1220                 return
1221             # try to add the user
1222             if not self.newuser_action():
1223                 return
1224             # figure the resulting page
1225             action = self.form['__destination_url'].value
1226             if not action:
1227                 action = 'index'
1229         # no login or registration, make sure totally anonymous access is OK
1230         elif self.instance.ANONYMOUS_ACCESS == 'deny' and self.user is None:
1231             if action == 'login':
1232                 self.login()             # go to the index after login
1233             else:
1234                 self.login(action=action)
1235             return
1237         # just a regular action
1238         self.do_action(action)
1240         # commit all changes to the database
1241         self.db.commit()
1243     def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
1244             nre=re.compile(r'new(\w+)')):
1245         '''Figure the user's action and do it.
1246         '''
1247         # here be the "normal" functionality
1248         if action == 'index':
1249             self.index()
1250             return
1251         if action == 'list_classes':
1252             self.classes()
1253             return
1254         if action == 'classhelp':
1255             self.classhelp()
1256             return
1257         if action == 'login':
1258             self.login()
1259             return
1260         if action == 'logout':
1261             self.logout()
1262             return
1264         # see if we're to display an existing node
1265         m = dre.match(action)
1266         if m:
1267             self.classname = m.group(1)
1268             self.nodeid = m.group(2)
1269             try:
1270                 cl = self.db.classes[self.classname]
1271             except KeyError:
1272                 raise NotFound, self.classname
1273             try:
1274                 cl.get(self.nodeid, 'id')
1275             except IndexError:
1276                 raise NotFound, self.nodeid
1277             try:
1278                 func = getattr(self, 'show%s'%self.classname)
1279             except AttributeError:
1280                 raise NotFound, 'show%s'%self.classname
1281             func()
1282             return
1284         # see if we're to put up the new node page
1285         m = nre.match(action)
1286         if m:
1287             self.classname = m.group(1)
1288             try:
1289                 func = getattr(self, 'new%s'%self.classname)
1290             except AttributeError:
1291                 raise NotFound, 'new%s'%self.classname
1292             func()
1293             return
1295         # otherwise, display the named class
1296         self.classname = action
1297         try:
1298             self.db.getclass(self.classname)
1299         except KeyError:
1300             raise NotFound, self.classname
1301         self.list()
1304 class ExtendedClient(Client): 
1305     '''Includes pages and page heading information that relate to the
1306        extended schema.
1307     ''' 
1308     showsupport = Client.shownode
1309     showtimelog = Client.shownode
1310     newsupport = Client.newnode
1311     newtimelog = Client.newnode
1313     default_index_sort = ['-activity']
1314     default_index_group = ['priority']
1315     default_index_filter = ['status']
1316     default_index_columns = ['activity','status','title','assignedto']
1317     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
1319 def parsePropsFromForm(db, cl, form, nodeid=0):
1320     '''Pull properties for the given class out of the form.
1321     '''
1322     props = {}
1323     keys = form.keys()
1324     num_re = re.compile('^\d+$')
1325     for key in keys:
1326         if not cl.properties.has_key(key):
1327             continue
1328         proptype = cl.properties[key]
1329         if isinstance(proptype, hyperdb.String):
1330             value = form[key].value.strip()
1331         elif isinstance(proptype, hyperdb.Password):
1332             value = password.Password(form[key].value.strip())
1333         elif isinstance(proptype, hyperdb.Date):
1334             value = form[key].value.strip()
1335             if value:
1336                 value = date.Date(form[key].value.strip())
1337             else:
1338                 value = None
1339         elif isinstance(proptype, hyperdb.Interval):
1340             value = form[key].value.strip()
1341             if value:
1342                 value = date.Interval(form[key].value.strip())
1343             else:
1344                 value = None
1345         elif isinstance(proptype, hyperdb.Link):
1346             value = form[key].value.strip()
1347             # see if it's the "no selection" choice
1348             if value == '-1':
1349                 # don't set this property
1350                 continue
1351             else:
1352                 # handle key values
1353                 link = cl.properties[key].classname
1354                 if not num_re.match(value):
1355                     try:
1356                         value = db.classes[link].lookup(value)
1357                     except KeyError:
1358                         raise ValueError, _('property "%(propname)s": '
1359                             '%(value)s not a %(classname)s')%{'propname':key, 
1360                             'value': value, 'classname': link}
1361         elif isinstance(proptype, hyperdb.Multilink):
1362             value = form[key]
1363             if type(value) != type([]):
1364                 value = [i.strip() for i in value.value.split(',')]
1365             else:
1366                 value = [i.value.strip() for i in value]
1367             link = cl.properties[key].classname
1368             l = []
1369             for entry in map(str, value):
1370                 if entry == '': continue
1371                 if not num_re.match(entry):
1372                     try:
1373                         entry = db.classes[link].lookup(entry)
1374                     except KeyError:
1375                         raise ValueError, _('property "%(propname)s": '
1376                             '"%(value)s" not an entry of %(classname)s')%{
1377                             'propname':key, 'value': entry, 'classname': link}
1378                 l.append(entry)
1379             l.sort()
1380             value = l
1382         # get the old value
1383         if nodeid:
1384             try:
1385                 existing = cl.get(nodeid, key)
1386             except KeyError:
1387                 # this might be a new property for which there is no existing
1388                 # value
1389                 if not cl.properties.has_key(key): raise
1391             # if changed, set it
1392             if value != existing:
1393                 props[key] = value
1394         else:
1395             props[key] = value
1396     return props
1399 # $Log: not supported by cvs2svn $
1400 # Revision 1.122  2002/05/22 04:12:05  richard
1401 #  . applied patch #558876 ] cgi client customization
1402 #    ... with significant additions and modifications ;)
1403 #    - extended handling of ML assignedto to all places it's handled
1404 #    - added more NotFound info
1406 # Revision 1.121  2002/05/21 06:08:10  richard
1407 # Handle migration
1409 # Revision 1.120  2002/05/21 06:05:53  richard
1410 #  . #551483 ] assignedto in Client.make_index_link
1412 # Revision 1.119  2002/05/15 06:21:21  richard
1413 #  . node caching now works, and gives a small boost in performance
1415 # As a part of this, I cleaned up the DEBUG output and implemented TRACE
1416 # output (HYPERDBTRACE='file to trace to') with checkpoints at the start of
1417 # CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff
1418 # (using if __debug__ which is compiled out with -O)
1420 # Revision 1.118  2002/05/12 23:46:33  richard
1421 # ehem, part 2
1423 # Revision 1.117  2002/05/12 23:42:29  richard
1424 # ehem
1426 # Revision 1.116  2002/05/02 08:07:49  richard
1427 # Added the ADD_AUTHOR_TO_NOSY handling to the CGI interface.
1429 # Revision 1.115  2002/04/02 01:56:10  richard
1430 #  . stop sending blank (whitespace-only) notes
1432 # Revision 1.114  2002/03/17 23:06:05  richard
1433 # oops
1435 # Revision 1.113  2002/03/14 23:59:24  richard
1436 #  . #517734 ] web header customisation is obscure
1438 # Revision 1.112  2002/03/12 22:52:26  richard
1439 # more pychecker warnings removed
1441 # Revision 1.111  2002/02/25 04:32:21  richard
1442 # ahem
1444 # Revision 1.110  2002/02/21 07:19:08  richard
1445 # ... and label, width and height control for extra flavour!
1447 # Revision 1.109  2002/02/21 07:08:19  richard
1448 # oops
1450 # Revision 1.108  2002/02/21 07:02:54  richard
1451 # The correct var is "HTTP_HOST"
1453 # Revision 1.107  2002/02/21 06:57:38  richard
1454 #  . Added popup help for classes using the classhelp html template function.
1455 #    - add <display call="classhelp('priority', 'id,name,description')">
1456 #      to an item page, and it generates a link to a popup window which displays
1457 #      the id, name and description for the priority class. The description
1458 #      field won't exist in most installations, but it will be added to the
1459 #      default templates.
1461 # Revision 1.106  2002/02/21 06:23:00  richard
1462 # *** empty log message ***
1464 # Revision 1.105  2002/02/20 05:52:10  richard
1465 # better error handling
1467 # Revision 1.104  2002/02/20 05:45:17  richard
1468 # Use the csv module for generating the form entry so it's correct.
1469 # [also noted the sf.net feature request id in the change log]
1471 # Revision 1.103  2002/02/20 05:05:28  richard
1472 #  . Added simple editing for classes that don't define a templated interface.
1473 #    - access using the admin "class list" interface
1474 #    - limited to admin-only
1475 #    - requires the csv module from object-craft (url given if it's missing)
1477 # Revision 1.102  2002/02/15 07:08:44  richard
1478 #  . Alternate email addresses are now available for users. See the MIGRATION
1479 #    file for info on how to activate the feature.
1481 # Revision 1.101  2002/02/14 23:39:18  richard
1482 # . All forms now have "double-submit" protection when Javascript is enabled
1483 #   on the client-side.
1485 # Revision 1.100  2002/01/16 07:02:57  richard
1486 #  . lots of date/interval related changes:
1487 #    - more relaxed date format for input
1489 # Revision 1.99  2002/01/16 03:02:42  richard
1490 # #503793 ] changing assignedto resets nosy list
1492 # Revision 1.98  2002/01/14 02:20:14  richard
1493 #  . changed all config accesses so they access either the instance or the
1494 #    config attriubute on the db. This means that all config is obtained from
1495 #    instance_config instead of the mish-mash of classes. This will make
1496 #    switching to a ConfigParser setup easier too, I hope.
1498 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1499 # 0.5.0 switch, I hope!)
1501 # Revision 1.97  2002/01/11 23:22:29  richard
1502 #  . #502437 ] rogue reactor and unittest
1503 #    in short, the nosy reactor was modifying the nosy list. That code had
1504 #    been there for a long time, and I suspsect it was there because we
1505 #    weren't generating the nosy list correctly in other places of the code.
1506 #    We're now doing that, so the nosy-modifying code can go away from the
1507 #    nosy reactor.
1509 # Revision 1.96  2002/01/10 05:26:10  richard
1510 # missed a parsePropsFromForm in last update
1512 # Revision 1.95  2002/01/10 03:39:45  richard
1513 #  . fixed some problems with web editing and change detection
1515 # Revision 1.94  2002/01/09 13:54:21  grubert
1516 # _add_assignedto_to_nosy did set nosy to assignedto only, no adding.
1518 # Revision 1.93  2002/01/08 11:57:12  richard
1519 # crying out for real configuration handling... :(
1521 # Revision 1.92  2002/01/08 04:12:05  richard
1522 # Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
1524 # Revision 1.91  2002/01/08 04:03:47  richard
1525 # I mucked the intent of the code up.
1527 # Revision 1.90  2002/01/08 03:56:55  richard
1528 # Oops, missed this before the beta:
1529 #  . #495392 ] empty nosy -patch
1531 # Revision 1.89  2002/01/07 20:24:45  richard
1532 # *mutter* stupid cutnpaste
1534 # Revision 1.88  2002/01/02 02:31:38  richard
1535 # Sorry for the huge checkin message - I was only intending to implement #496356
1536 # but I found a number of places where things had been broken by transactions:
1537 #  . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1538 #    for _all_ roundup-generated smtp messages to be sent to.
1539 #  . the transaction cache had broken the roundupdb.Class set() reactors
1540 #  . newly-created author users in the mailgw weren't being committed to the db
1542 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1543 # on when I found that stuff :):
1544 #  . #496356 ] Use threading in messages
1545 #  . detectors were being registered multiple times
1546 #  . added tests for mailgw
1547 #  . much better attaching of erroneous messages in the mail gateway
1549 # Revision 1.87  2001/12/23 23:18:49  richard
1550 # We already had an admin-specific section of the web heading, no need to add
1551 # another one :)
1553 # Revision 1.86  2001/12/20 15:43:01  rochecompaan
1554 # Features added:
1555 #  .  Multilink properties are now displayed as comma separated values in
1556 #     a textbox
1557 #  .  The add user link is now only visible to the admin user
1558 #  .  Modified the mail gateway to reject submissions from unknown
1559 #     addresses if ANONYMOUS_ACCESS is denied
1561 # Revision 1.85  2001/12/20 06:13:24  rochecompaan
1562 # Bugs fixed:
1563 #   . Exception handling in hyperdb for strings-that-look-like numbers got
1564 #     lost somewhere
1565 #   . Internet Explorer submits full path for filename - we now strip away
1566 #     the path
1567 # Features added:
1568 #   . Link and multilink properties are now displayed sorted in the cgi
1569 #     interface
1571 # Revision 1.84  2001/12/18 15:30:30  rochecompaan
1572 # Fixed bugs:
1573 #  .  Fixed file creation and retrieval in same transaction in anydbm
1574 #     backend
1575 #  .  Cgi interface now renders new issue after issue creation
1576 #  .  Could not set issue status to resolved through cgi interface
1577 #  .  Mail gateway was changing status back to 'chatting' if status was
1578 #     omitted as an argument
1580 # Revision 1.83  2001/12/15 23:51:01  richard
1581 # Tested the changes and fixed a few problems:
1582 #  . files are now attached to the issue as well as the message
1583 #  . newuser is a real method now since we don't want to do the message/file
1584 #    stuff for it
1585 #  . added some documentation
1586 # The really big changes in the diff are a result of me moving some code
1587 # around to keep like methods together a bit better.
1589 # Revision 1.82  2001/12/15 19:24:39  rochecompaan
1590 #  . Modified cgi interface to change properties only once all changes are
1591 #    collected, files created and messages generated.
1592 #  . Moved generation of change note to nosyreactors.
1593 #  . We now check for changes to "assignedto" to ensure it's added to the
1594 #    nosy list.
1596 # Revision 1.81  2001/12/12 23:55:00  richard
1597 # Fixed some problems with user editing
1599 # Revision 1.80  2001/12/12 23:27:14  richard
1600 # Added a Zope frontend for roundup.
1602 # Revision 1.79  2001/12/10 22:20:01  richard
1603 # Enabled transaction support in the bsddb backend. It uses the anydbm code
1604 # where possible, only replacing methods where the db is opened (it uses the
1605 # btree opener specifically.)
1606 # Also cleaned up some change note generation.
1607 # Made the backends package work with pydoc too.
1609 # Revision 1.78  2001/12/07 05:59:27  rochecompaan
1610 # Fixed small bug that prevented adding issues through the web.
1612 # Revision 1.77  2001/12/06 22:48:29  richard
1613 # files multilink was being nuked in post_edit_node
1615 # Revision 1.76  2001/12/05 14:26:44  rochecompaan
1616 # Removed generation of change note from "sendmessage" in roundupdb.py.
1617 # The change note is now generated when the message is created.
1619 # Revision 1.75  2001/12/04 01:25:08  richard
1620 # Added some rollbacks where we were catching exceptions that would otherwise
1621 # have stopped committing.
1623 # Revision 1.74  2001/12/02 05:06:16  richard
1624 # . We now use weakrefs in the Classes to keep the database reference, so
1625 #   the close() method on the database is no longer needed.
1626 #   I bumped the minimum python requirement up to 2.1 accordingly.
1627 # . #487480 ] roundup-server
1628 # . #487476 ] INSTALL.txt
1630 # I also cleaned up the change message / post-edit stuff in the cgi client.
1631 # There's now a clearly marked "TODO: append the change note" where I believe
1632 # the change note should be added there. The "changes" list will obviously
1633 # have to be modified to be a dict of the changes, or somesuch.
1635 # More testing needed.
1637 # Revision 1.73  2001/12/01 07:17:50  richard
1638 # . We now have basic transaction support! Information is only written to
1639 #   the database when the commit() method is called. Only the anydbm
1640 #   backend is modified in this way - neither of the bsddb backends have been.
1641 #   The mail, admin and cgi interfaces all use commit (except the admin tool
1642 #   doesn't have a commit command, so interactive users can't commit...)
1643 # . Fixed login/registration forwarding the user to the right page (or not,
1644 #   on a failure)
1646 # Revision 1.72  2001/11/30 20:47:58  rochecompaan
1647 # Links in page header are now consistent with default sort order.
1649 # Fixed bugs:
1650 #     - When login failed the list of issues were still rendered.
1651 #     - User was redirected to index page and not to his destination url
1652 #       if his first login attempt failed.
1654 # Revision 1.71  2001/11/30 20:28:10  rochecompaan
1655 # Property changes are now completely traceable, whether changes are
1656 # made through the web or by email
1658 # Revision 1.70  2001/11/30 00:06:29  richard
1659 # Converted roundup/cgi_client.py to use _()
1660 # Added the status file, I18N_PROGRESS.txt
1662 # Revision 1.69  2001/11/29 23:19:51  richard
1663 # Removed the "This issue has been edited through the web" when a valid
1664 # change note is supplied.
1666 # Revision 1.68  2001/11/29 04:57:23  richard
1667 # a little comment
1669 # Revision 1.67  2001/11/28 21:55:35  richard
1670 #  . login_action and newuser_action return values were being ignored
1671 #  . Woohoo! Found that bloody re-login bug that was killing the mail
1672 #    gateway.
1673 #  (also a minor cleanup in hyperdb)
1675 # Revision 1.66  2001/11/27 03:00:50  richard
1676 # couple of bugfixes from latest patch integration
1678 # Revision 1.65  2001/11/26 23:00:53  richard
1679 # This config stuff is getting to be a real mess...
1681 # Revision 1.64  2001/11/26 22:56:35  richard
1682 # typo
1684 # Revision 1.63  2001/11/26 22:55:56  richard
1685 # Feature:
1686 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
1687 #    the instance.
1688 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1689 #    signature info in e-mails.
1690 #  . Some more flexibility in the mail gateway and more error handling.
1691 #  . Login now takes you to the page you back to the were denied access to.
1693 # Fixed:
1694 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
1696 # Revision 1.62  2001/11/24 00:45:42  jhermann
1697 # typeof() instead of type(): avoid clash with database field(?) "type"
1699 # Fixes this traceback:
1701 # Traceback (most recent call last):
1702 #   File "roundup\cgi_client.py", line 535, in newnode
1703 #     self._post_editnode(nid)
1704 #   File "roundup\cgi_client.py", line 415, in _post_editnode
1705 #     if type(value) != type([]): value = [value]
1706 # UnboundLocalError: local variable 'type' referenced before assignment
1708 # Revision 1.61  2001/11/22 15:46:42  jhermann
1709 # Added module docstrings to all modules.
1711 # Revision 1.60  2001/11/21 22:57:28  jhermann
1712 # Added dummy hooks for I18N and some preliminary (test) markup of
1713 # translatable messages
1715 # Revision 1.59  2001/11/21 03:21:13  richard
1716 # oops
1718 # Revision 1.58  2001/11/21 03:11:28  richard
1719 # Better handling of new properties.
1721 # Revision 1.57  2001/11/15 10:24:27  richard
1722 # handle the case where there is no file attached
1724 # Revision 1.56  2001/11/14 21:35:21  richard
1725 #  . users may attach files to issues (and support in ext) through the web now
1727 # Revision 1.55  2001/11/07 02:34:06  jhermann
1728 # Handling of damaged login cookies
1730 # Revision 1.54  2001/11/07 01:16:12  richard
1731 # Remove the '=' padding from cookie value so quoting isn't an issue.
1733 # Revision 1.53  2001/11/06 23:22:05  jhermann
1734 # More IE fixes: it does not like quotes around cookie values; in the
1735 # hope this does not break anything for other browser; if it does, we
1736 # need to check HTTP_USER_AGENT
1738 # Revision 1.52  2001/11/06 23:11:22  jhermann
1739 # Fixed debug output in page footer; added expiry date to the login cookie
1740 # (expires 1 year in the future) to prevent probs with certain versions
1741 # of IE
1743 # Revision 1.51  2001/11/06 22:00:34  jhermann
1744 # Get debug level from ROUNDUP_DEBUG env var
1746 # Revision 1.50  2001/11/05 23:45:40  richard
1747 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1748 # Also made it present nicer error messages (not tracebacks).
1750 # Revision 1.49  2001/11/04 03:07:12  richard
1751 # Fixed various cookie-related bugs:
1752 #  . bug #477685 ] base64.decodestring breaks
1753 #  . bug #477837 ] lynx does not like the cookie
1754 #  . bug #477892 ] Password edit doesn't fix login cookie
1755 # Also closed a security hole - a logged-in user could edit another user's
1756 # details.
1758 # Revision 1.48  2001/11/03 01:30:18  richard
1759 # Oops. uses pagefoot now.
1761 # Revision 1.47  2001/11/03 01:29:28  richard
1762 # Login page didn't have all close tags.
1764 # Revision 1.46  2001/11/03 01:26:55  richard
1765 # possibly fix truncated base64'ed user:pass
1767 # Revision 1.45  2001/11/01 22:04:37  richard
1768 # Started work on supporting a pop3-fetching server
1769 # Fixed bugs:
1770 #  . bug #477104 ] HTML tag error in roundup-server
1771 #  . bug #477107 ] HTTP header problem
1773 # Revision 1.44  2001/10/28 23:03:08  richard
1774 # Added more useful header to the classic schema.
1776 # Revision 1.43  2001/10/24 00:01:42  richard
1777 # More fixes to lockout logic.
1779 # Revision 1.42  2001/10/23 23:56:03  richard
1780 # HTML typo
1782 # Revision 1.41  2001/10/23 23:52:35  richard
1783 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1785 # Revision 1.40  2001/10/23 23:06:39  richard
1786 # Some cleanup.
1788 # Revision 1.39  2001/10/23 01:00:18  richard
1789 # Re-enabled login and registration access after lopping them off via
1790 # disabling access for anonymous users.
1791 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1792 # a couple of bugs while I was there. Probably introduced a couple, but
1793 # things seem to work OK at the moment.
1795 # Revision 1.38  2001/10/22 03:25:01  richard
1796 # Added configuration for:
1797 #  . anonymous user access and registration (deny/allow)
1798 #  . filter "widget" location on index page (top, bottom, both)
1799 # Updated some documentation.
1801 # Revision 1.37  2001/10/21 07:26:35  richard
1802 # feature #473127: Filenames. I modified the file.index and htmltemplate
1803 #  source so that the filename is used in the link and the creation
1804 #  information is displayed.
1806 # Revision 1.36  2001/10/21 04:44:50  richard
1807 # bug #473124: UI inconsistency with Link fields.
1808 #    This also prompted me to fix a fairly long-standing usability issue -
1809 #    that of being able to turn off certain filters.
1811 # Revision 1.35  2001/10/21 00:17:54  richard
1812 # CGI interface view customisation section may now be hidden (patch from
1813 #  Roch'e Compaan.)
1815 # Revision 1.34  2001/10/20 11:58:48  richard
1816 # Catch errors in login - no username or password supplied.
1817 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1819 # Revision 1.33  2001/10/17 00:18:41  richard
1820 # Manually constructing cookie headers now.
1822 # Revision 1.32  2001/10/16 03:36:21  richard
1823 # CGI interface wasn't handling checkboxes at all.
1825 # Revision 1.31  2001/10/14 10:55:00  richard
1826 # Handle empty strings in HTML template Link function
1828 # Revision 1.30  2001/10/09 07:38:58  richard
1829 # Pushed the base code for the extended schema CGI interface back into the
1830 # code cgi_client module so that future updates will be less painful.
1831 # Also removed a debugging print statement from cgi_client.
1833 # Revision 1.29  2001/10/09 07:25:59  richard
1834 # Added the Password property type. See "pydoc roundup.password" for
1835 # implementation details. Have updated some of the documentation too.
1837 # Revision 1.28  2001/10/08 00:34:31  richard
1838 # Change message was stuffing up for multilinks with no key property.
1840 # Revision 1.27  2001/10/05 02:23:24  richard
1841 #  . roundup-admin create now prompts for property info if none is supplied
1842 #    on the command-line.
1843 #  . hyperdb Class getprops() method may now return only the mutable
1844 #    properties.
1845 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
1846 #    now support anonymous user access (read-only, unless there's an
1847 #    "anonymous" user, in which case write access is permitted). Login
1848 #    handling has been moved into cgi_client.Client.main()
1849 #  . The "extended" schema is now the default in roundup init.
1850 #  . The schemas have had their page headings modified to cope with the new
1851 #    login handling. Existing installations should copy the interfaces.py
1852 #    file from the roundup lib directory to their instance home.
1853 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1854 #    Ping - has been removed.
1855 #  . Fixed a whole bunch of places in the CGI interface where we should have
1856 #    been returning Not Found instead of throwing an exception.
1857 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
1858 #    an item now throws an exception.
1860 # Revision 1.26  2001/09/12 08:31:42  richard
1861 # handle cases where mime type is not guessable
1863 # Revision 1.25  2001/08/29 05:30:49  richard
1864 # change messages weren't being saved when there was no-one on the nosy list.
1866 # Revision 1.24  2001/08/29 04:49:39  richard
1867 # didn't clean up fully after debugging :(
1869 # Revision 1.23  2001/08/29 04:47:18  richard
1870 # Fixed CGI client change messages so they actually include the properties
1871 # changed (again).
1873 # Revision 1.22  2001/08/17 00:08:10  richard
1874 # reverted back to sending messages always regardless of who is doing the web
1875 # edit. change notes weren't being saved. bleah. hackish.
1877 # Revision 1.21  2001/08/15 23:43:18  richard
1878 # Fixed some isFooTypes that I missed.
1879 # Refactored some code in the CGI code.
1881 # Revision 1.20  2001/08/12 06:32:36  richard
1882 # using isinstance(blah, Foo) now instead of isFooType
1884 # Revision 1.19  2001/08/07 00:24:42  richard
1885 # stupid typo
1887 # Revision 1.18  2001/08/07 00:15:51  richard
1888 # Added the copyright/license notice to (nearly) all files at request of
1889 # Bizar Software.
1891 # Revision 1.17  2001/08/02 06:38:17  richard
1892 # Roundupdb now appends "mailing list" information to its messages which
1893 # include the e-mail address and web interface address. Templates may
1894 # override this in their db classes to include specific information (support
1895 # instructions, etc).
1897 # Revision 1.16  2001/08/02 05:55:25  richard
1898 # Web edit messages aren't sent to the person who did the edit any more. No
1899 # message is generated if they are the only person on the nosy list.
1901 # Revision 1.15  2001/08/02 00:34:10  richard
1902 # bleah syntax error
1904 # Revision 1.14  2001/08/02 00:26:16  richard
1905 # Changed the order of the information in the message generated by web edits.
1907 # Revision 1.13  2001/07/30 08:12:17  richard
1908 # Added time logging and file uploading to the templates.
1910 # Revision 1.12  2001/07/30 06:26:31  richard
1911 # Added some documentation on how the newblah works.
1913 # Revision 1.11  2001/07/30 06:17:45  richard
1914 # Features:
1915 #  . Added ability for cgi newblah forms to indicate that the new node
1916 #    should be linked somewhere.
1917 # Fixed:
1918 #  . Fixed the agument handling for the roundup-admin find command.
1919 #  . Fixed handling of summary when no note supplied for newblah. Again.
1920 #  . Fixed detection of no form in htmltemplate Field display.
1922 # Revision 1.10  2001/07/30 02:37:34  richard
1923 # Temporary measure until we have decent schema migration...
1925 # Revision 1.9  2001/07/30 01:25:07  richard
1926 # Default implementation is now "classic" rather than "extended" as one would
1927 # expect.
1929 # Revision 1.8  2001/07/29 08:27:40  richard
1930 # Fixed handling of passed-in values in form elements (ie. during a
1931 # drill-down)
1933 # Revision 1.7  2001/07/29 07:01:39  richard
1934 # Added vim command to all source so that we don't get no steenkin' tabs :)
1936 # Revision 1.6  2001/07/29 04:04:00  richard
1937 # Moved some code around allowing for subclassing to change behaviour.
1939 # Revision 1.5  2001/07/28 08:16:52  richard
1940 # New issue form handles lack of note better now.
1942 # Revision 1.4  2001/07/28 00:34:34  richard
1943 # Fixed some non-string node ids.
1945 # Revision 1.3  2001/07/23 03:56:30  richard
1946 # oops, missed a config removal
1948 # Revision 1.2  2001/07/22 12:09:32  richard
1949 # Final commit of Grande Splite
1951 # Revision 1.1  2001/07/22 11:58:35  richard
1952 # More Grande Splite
1955 # vim: set filetype=python ts=4 sw=4 et si