Code

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