Code

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