Code

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