Code

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