Code

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