Code

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