Code

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