Code

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