Code

#503793 ] changing assignedto resets nosy list
[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.99 2002-01-16 03:02:42 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'):
313                     message = _('file added')
314                 else:
315                     message = _('nothing changed')
316             except:
317                 self.db.rollback()
318                 s = StringIO.StringIO()
319                 traceback.print_exc(None, s)
320                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
322         # now the display
323         id = self.nodeid
324         if cl.getkey():
325             id = cl.get(id, cl.getkey())
326         self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
328         nodeid = self.nodeid
330         # use the template to display the item
331         item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES,
332             self.classname)
333         item.render(nodeid)
335         self.pagefoot()
336     showissue = shownode
337     showmsg = shownode
339     def _add_assignedto_to_nosy(self, props):
340         ''' add the assignedto value from the props to the nosy list
341         '''
342         if not props.has_key('assignedto'):
343             return
344         assignedto_id = props['assignedto']
345         if not props.has_key('nosy'):
346             # load current nosy
347             if self.nodeid:
348                 cl = self.db.classes[self.classname]
349                 l = cl.get(self.nodeid, 'nosy')
350                 if assignedto_id in l:
351                     return
352                 props['nosy'] = l
353             else:
354                 props['nosy'] = []
355         if assignedto_id not in props['nosy']:
356             props['nosy'].append(assignedto_id)
358     def _changenode(self, props):
359         ''' change the node based on the contents of the form
360         '''
361         cl = self.db.classes[self.classname]
362         # set status to chatting if 'unread' or 'resolved'
363         try:
364             # determine the id of 'unread','resolved' and 'chatting'
365             unread_id = self.db.status.lookup('unread')
366             resolved_id = self.db.status.lookup('resolved')
367             chatting_id = self.db.status.lookup('chatting')
368             current_status = cl.get(self.nodeid, 'status')
369             if props.has_key('status'):
370                 new_status = props['status']
371             else:
372                 # apparently there's a chance that some browsers don't
373                 # send status...
374                 new_status = current_status
375         except KeyError:
376             pass
377         else:
378             if new_status == unread_id or (new_status == resolved_id
379                     and current_status == resolved_id):
380                 props['status'] = chatting_id
382         self._add_assignedto_to_nosy(props)
384         # create the message
385         message, files = self._handle_message()
386         if message:
387             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
388         if files:
389             props['files'] = cl.get(self.nodeid, 'files') + files
391         # make the changes
392         cl.set(self.nodeid, **props)
394     def _createnode(self):
395         ''' create a node based on the contents of the form
396         '''
397         cl = self.db.classes[self.classname]
398         props = parsePropsFromForm(self.db, cl, self.form)
400         # set status to 'unread' if not specified - a status of '- no
401         # selection -' doesn't make sense
402         if not props.has_key('status'):
403             try:
404                 unread_id = self.db.status.lookup('unread')
405             except KeyError:
406                 pass
407             else:
408                 props['status'] = unread_id
410         self._add_assignedto_to_nosy(props)
412         # check for messages and files
413         message, files = self._handle_message()
414         if message:
415             props['messages'] = [message]
416         if files:
417             props['files'] = files
418         # create the node and return it's id
419         return cl.create(**props)
421     def _handle_message(self):
422         ''' generate an edit message
423         '''
424         # handle file attachments 
425         files = []
426         if self.form.has_key('__file'):
427             file = self.form['__file']
428             if file.filename:
429                 filename = file.filename.split('\\')[-1]
430                 mime_type = mimetypes.guess_type(filename)[0]
431                 if not mime_type:
432                     mime_type = "application/octet-stream"
433                 # create the new file entry
434                 files.append(self.db.file.create(type=mime_type,
435                     name=filename, content=file.file.read()))
437         # we don't want to do a message if none of the following is true...
438         cn = self.classname
439         cl = self.db.classes[self.classname]
440         props = cl.getprops()
441         note = None
442         # in a nutshell, don't do anything if there's no note or there's no
443         # NOSY
444         if self.form.has_key('__note'):
445             note = self.form['__note'].value
446         if not props.has_key('messages'):
447             return None, files
448         if not isinstance(props['messages'], hyperdb.Multilink):
449             return None, files
450         if not props['messages'].classname == 'msg':
451             return None, files
452         if not (self.form.has_key('nosy') or note):
453             return None, files
455         # handle the note
456         if note:
457             if '\n' in note:
458                 summary = re.split(r'\n\r?', note)[0]
459             else:
460                 summary = note
461             m = ['%s\n'%note]
462         elif not files:
463             # don't generate a useless message
464             return None, files
466         # handle the messageid
467         # TODO: handle inreplyto
468         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
469             self.classname, self.instance.MAIL_DOMAIN)
471         # now create the message, attaching the files
472         content = '\n'.join(m)
473         message_id = self.db.msg.create(author=self.getuid(),
474             recipients=[], date=date.Date('.'), summary=summary,
475             content=content, files=files, messageid=messageid)
477         # update the messages property
478         return message_id, files
480     def _post_editnode(self, nid):
481         '''Do the linking part of the node creation.
483            If a form element has :link or :multilink appended to it, its
484            value specifies a node designator and the property on that node
485            to add _this_ node to as a link or multilink.
487            This is typically used on, eg. the file upload page to indicated
488            which issue to link the file to.
490            TODO: I suspect that this and newfile will go away now that
491            there's the ability to upload a file using the issue __file form
492            element!
493         '''
494         cn = self.classname
495         cl = self.db.classes[cn]
496         # link if necessary
497         keys = self.form.keys()
498         for key in keys:
499             if key == ':multilink':
500                 value = self.form[key].value
501                 if type(value) != type([]): value = [value]
502                 for value in value:
503                     designator, property = value.split(':')
504                     link, nodeid = roundupdb.splitDesignator(designator)
505                     link = self.db.classes[link]
506                     value = link.get(nodeid, property)
507                     value.append(nid)
508                     link.set(nodeid, **{property: value})
509             elif key == ':link':
510                 value = self.form[key].value
511                 if type(value) != type([]): value = [value]
512                 for value in value:
513                     designator, property = value.split(':')
514                     link, nodeid = roundupdb.splitDesignator(designator)
515                     link = self.db.classes[link]
516                     link.set(nodeid, **{property: nid})
518     def newnode(self, message=None):
519         ''' Add a new node to the database.
520         
521         The form works in two modes: blank form and submission (that is,
522         the submission goes to the same URL). **Eventually this means that
523         the form will have previously entered information in it if
524         submission fails.
526         The new node will be created with the properties specified in the
527         form submission. For multilinks, multiple form entries are handled,
528         as are prop=value,value,value. You can't mix them though.
530         If the new node is to be referenced from somewhere else immediately
531         (ie. the new node is a file that is to be attached to a support
532         issue) then supply one of these arguments in addition to the usual
533         form entries:
534             :link=designator:property
535             :multilink=designator:property
536         ... which means that once the new node is created, the "property"
537         on the node given by "designator" should now reference the new
538         node's id. The node id will be appended to the multilink.
539         '''
540         cn = self.classname
541         cl = self.db.classes[cn]
543         # possibly perform a create
544         keys = self.form.keys()
545         if [i for i in keys if i[0] != ':']:
546             props = {}
547             try:
548                 nid = self._createnode()
549                 # handle linked nodes 
550                 self._post_editnode(nid)
551                 # and some nice feedback for the user
552                 message = _('%(classname)s created ok')%{'classname': cn}
554                 # render the newly created issue
555                 self.db.commit()
556                 self.nodeid = nid
557                 self.pagehead('%s: %s'%(self.classname.capitalize(), nid),
558                     message)
559                 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 
560                     self.classname)
561                 item.render(nid)
562                 self.pagefoot()
563                 return
564             except:
565                 self.db.rollback()
566                 s = StringIO.StringIO()
567                 traceback.print_exc(None, s)
568                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
569         self.pagehead(_('New %(classname)s')%{'classname':
570             self.classname.capitalize()}, message)
572         # call the template
573         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
574             self.classname)
575         newitem.render(self.form)
577         self.pagefoot()
578     newissue = newnode
580     def newuser(self, message=None):
581         ''' Add a new user to the database.
583             Don't do any of the message or file handling, just create the node.
584         '''
585         cn = self.classname
586         cl = self.db.classes[cn]
588         # possibly perform a create
589         keys = self.form.keys()
590         if [i for i in keys if i[0] != ':']:
591             try:
592                 props = parsePropsFromForm(self.db, cl, self.form)
593                 nid = cl.create(**props)
594                 # handle linked nodes 
595                 self._post_editnode(nid)
596                 # and some nice feedback for the user
597                 message = _('%(classname)s created ok')%{'classname': cn}
598             except:
599                 self.db.rollback()
600                 s = StringIO.StringIO()
601                 traceback.print_exc(None, s)
602                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
603         self.pagehead(_('New %(classname)s')%{'classname':
604              self.classname.capitalize()}, message)
606         # call the template
607         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
608             self.classname)
609         newitem.render(self.form)
611         self.pagefoot()
613     def newfile(self, message=None):
614         ''' Add a new file to the database.
615         
616         This form works very much the same way as newnode - it just has a
617         file upload.
618         '''
619         cn = self.classname
620         cl = self.db.classes[cn]
622         # possibly perform a create
623         keys = self.form.keys()
624         if [i for i in keys if i[0] != ':']:
625             try:
626                 file = self.form['content']
627                 mime_type = mimetypes.guess_type(file.filename)[0]
628                 if not mime_type:
629                     mime_type = "application/octet-stream"
630                 # save the file
631                 nid = cl.create(content=file.file.read(), type=mime_type,
632                     name=file.filename)
633                 # handle linked nodes
634                 self._post_editnode(nid)
635                 # and some nice feedback for the user
636                 message = _('%(classname)s created ok')%{'classname': cn}
637             except:
638                 self.db.rollback()
639                 s = StringIO.StringIO()
640                 traceback.print_exc(None, s)
641                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
643         self.pagehead(_('New %(classname)s')%{'classname':
644              self.classname.capitalize()}, message)
645         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
646             self.classname)
647         newitem.render(self.form)
648         self.pagefoot()
650     def showuser(self, message=None):
651         '''Display a user page for editing. Make sure the user is allowed
652             to edit this node, and also check for password changes.
653         '''
654         if self.user == 'anonymous':
655             raise Unauthorised
657         user = self.db.user
659         # get the username of the node being edited
660         node_user = user.get(self.nodeid, 'username')
662         if self.user not in ('admin', node_user):
663             raise Unauthorised
665         #
666         # perform any editing
667         #
668         keys = self.form.keys()
669         num_re = re.compile('^\d+$')
670         if keys:
671             try:
672                 props = parsePropsFromForm(self.db, user, self.form,
673                     self.nodeid)
674                 set_cookie = 0
675                 if props.has_key('password'):
676                     password = self.form['password'].value.strip()
677                     if not password:
678                         # no password was supplied - don't change it
679                         del props['password']
680                     elif self.nodeid == self.getuid():
681                         # this is the logged-in user's password
682                         set_cookie = password
683                 user.set(self.nodeid, **props)
684                 # and some feedback for the user
685                 message = _('%(changes)s edited ok')%{'changes':
686                     ', '.join(props.keys())}
687             except:
688                 self.db.rollback()
689                 s = StringIO.StringIO()
690                 traceback.print_exc(None, s)
691                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
692         else:
693             set_cookie = 0
695         # fix the cookie if the password has changed
696         if set_cookie:
697             self.set_cookie(self.user, set_cookie)
699         #
700         # now the display
701         #
702         self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
704         # use the template to display the item
705         item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 'user')
706         item.render(self.nodeid)
707         self.pagefoot()
709     def showfile(self):
710         ''' display a file
711         '''
712         nodeid = self.nodeid
713         cl = self.db.file
714         mime_type = cl.get(nodeid, 'type')
715         if mime_type == 'message/rfc822':
716             mime_type = 'text/plain'
717         self.header(headers={'Content-Type': mime_type})
718         self.write(cl.get(nodeid, 'content'))
720     def classes(self, message=None):
721         ''' display a list of all the classes in the database
722         '''
723         if self.user == 'admin':
724             self.pagehead(_('Table of classes'), message)
725             classnames = self.db.classes.keys()
726             classnames.sort()
727             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
728             for cn in classnames:
729                 cl = self.db.getclass(cn)
730                 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
731                 for key, value in cl.properties.items():
732                     if value is None: value = ''
733                     else: value = str(value)
734                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
735                         key, cgi.escape(value)))
736             self.write('</table>')
737             self.pagefoot()
738         else:
739             raise Unauthorised
741     def login(self, message=None, newuser_form=None, action='index'):
742         '''Display a login page.
743         '''
744         self.pagehead(_('Login to roundup'), message)
745         self.write(_('''
746 <table>
747 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
748 <form action="login_action" method=POST>
749 <input type="hidden" name="__destination_url" value="%(action)s">
750 <tr><td align=right>Login name: </td>
751     <td><input name="__login_name"></td></tr>
752 <tr><td align=right>Password: </td>
753     <td><input type="password" name="__login_password"></td></tr>
754 <tr><td></td>
755     <td><input type="submit" value="Log In"></td></tr>
756 </form>
757 ''')%locals())
758         if self.user is None and self.instance.ANONYMOUS_REGISTER == 'deny':
759             self.write('</table>')
760             self.pagefoot()
761             return
762         values = {'realname': '', 'organisation': '', 'address': '',
763             'phone': '', 'username': '', 'password': '', 'confirm': '',
764             'action': action}
765         if newuser_form is not None:
766             for key in newuser_form.keys():
767                 values[key] = newuser_form[key].value
768         self.write(_('''
769 <p>
770 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
771 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
772 <form action="newuser_action" method=POST>
773 <input type="hidden" name="__destination_url" value="%(action)s">
774 <tr><td align=right><em>Name: </em></td>
775     <td><input name="realname" value="%(realname)s"></td></tr>
776 <tr><td align=right><em>Organisation: </em></td>
777     <td><input name="organisation" value="%(organisation)s"></td></tr>
778 <tr><td align=right>E-Mail Address: </td>
779     <td><input name="address" value="%(address)s"></td></tr>
780 <tr><td align=right><em>Phone: </em></td>
781     <td><input name="phone" value="%(phone)s"></td></tr>
782 <tr><td align=right>Preferred Login name: </td>
783     <td><input name="username" value="%(username)s"></td></tr>
784 <tr><td align=right>Password: </td>
785     <td><input type="password" name="password" value="%(password)s"></td></tr>
786 <tr><td align=right>Password Again: </td>
787     <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
788 <tr><td></td>
789     <td><input type="submit" value="Register"></td></tr>
790 </form>
791 </table>
792 ''')%values)
793         self.pagefoot()
795     def login_action(self, message=None):
796         '''Attempt to log a user in and set the cookie
798         returns 0 if a page is generated as a result of this call, and
799         1 if not (ie. the login is successful
800         '''
801         if not self.form.has_key('__login_name'):
802             self.login(message=_('Username required'))
803             return 0
804         self.user = self.form['__login_name'].value
805         if self.form.has_key('__login_password'):
806             password = self.form['__login_password'].value
807         else:
808             password = ''
809         # make sure the user exists
810         try:
811             uid = self.db.user.lookup(self.user)
812         except KeyError:
813             name = self.user
814             self.make_user_anonymous()
815             action = self.form['__destination_url'].value
816             self.login(message=_('No such user "%(name)s"')%locals(),
817                 action=action)
818             return 0
820         # and that the password is correct
821         pw = self.db.user.get(uid, 'password')
822         if password != pw:
823             self.make_user_anonymous()
824             action = self.form['__destination_url'].value
825             self.login(message=_('Incorrect password'), action=action)
826             return 0
828         self.set_cookie(self.user, password)
829         return 1
831     def newuser_action(self, message=None):
832         '''Attempt to create a new user based on the contents of the form
833         and then set the cookie.
835         return 1 on successful login
836         '''
837         # re-open the database as "admin"
838         self.db = self.instance.open('admin')
840         # TODO: pre-check the required fields and username key property
841         cl = self.db.user
842         try:
843             props = parsePropsFromForm(self.db, cl, self.form)
844             uid = cl.create(**props)
845         except ValueError, message:
846             action = self.form['__destination_url'].value
847             self.login(message, action=action)
848             return 0
849         self.user = cl.get(uid, 'username')
850         password = cl.get(uid, 'password')
851         self.set_cookie(self.user, self.form['password'].value)
852         return 1
854     def set_cookie(self, user, password):
855         # construct the cookie
856         user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
857         if user[-1] == '=':
858           if user[-2] == '=':
859             user = user[:-2]
860           else:
861             user = user[:-1]
862         expire = Cookie._getdate(86400*365)
863         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
864         self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
865             user, expire, path)})
867     def make_user_anonymous(self):
868         # make us anonymous if we can
869         try:
870             self.db.user.lookup('anonymous')
871             self.user = 'anonymous'
872         except KeyError:
873             self.user = None
875     def logout(self, message=None):
876         self.make_user_anonymous()
877         # construct the logout cookie
878         now = Cookie._getdate()
879         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
880         self.header({'Set-Cookie':
881             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
882             path)})
883         self.login()
885     def main(self):
886         '''Wrap the database accesses so we can close the database cleanly
887         '''
888         # determine the uid to use
889         self.db = self.instance.open('admin')
890         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
891         user = 'anonymous'
892         if (cookie.has_key('roundup_user') and
893                 cookie['roundup_user'].value != 'deleted'):
894             cookie = cookie['roundup_user'].value
895             if len(cookie)%4:
896               cookie = cookie + '='*(4-len(cookie)%4)
897             try:
898                 user, password = binascii.a2b_base64(cookie).split(':')
899             except (TypeError, binascii.Error, binascii.Incomplete):
900                 # damaged cookie!
901                 user, password = 'anonymous', ''
903             # make sure the user exists
904             try:
905                 uid = self.db.user.lookup(user)
906                 # now validate the password
907                 if password != self.db.user.get(uid, 'password'):
908                     user = 'anonymous'
909             except KeyError:
910                 user = 'anonymous'
912         # make sure the anonymous user is valid if we're using it
913         if user == 'anonymous':
914             self.make_user_anonymous()
915         else:
916             self.user = user
918         # re-open the database for real, using the user
919         self.db = self.instance.open(self.user)
921         # now figure which function to call
922         path = self.split_path
924         # default action to index if the path has no information in it
925         if not path or path[0] in ('', 'index'):
926             action = 'index'
927         else:
928             action = path[0]
930         # Everthing ignores path[1:]
931         #  - The file download link generator actually relies on this - it
932         #    appends the name of the file to the URL so the download file name
933         #    is correct, but doesn't actually use it.
935         # everyone is allowed to try to log in
936         if action == 'login_action':
937             # try to login
938             if not self.login_action():
939                 return
940             # figure the resulting page
941             action = self.form['__destination_url'].value
942             if not action:
943                 action = 'index'
944             self.do_action(action)
945             return
947         # allow anonymous people to register
948         if action == 'newuser_action':
949             # if we don't have a login and anonymous people aren't allowed to
950             # register, then spit up the login form
951             if self.instance.ANONYMOUS_REGISTER == 'deny' and self.user is None:
952                 if action == 'login':
953                     self.login()         # go to the index after login
954                 else:
955                     self.login(action=action)
956                 return
957             # try to add the user
958             if not self.newuser_action():
959                 return
960             # figure the resulting page
961             action = self.form['__destination_url'].value
962             if not action:
963                 action = 'index'
965         # no login or registration, make sure totally anonymous access is OK
966         elif self.instance.ANONYMOUS_ACCESS == 'deny' and self.user is None:
967             if action == 'login':
968                 self.login()             # go to the index after login
969             else:
970                 self.login(action=action)
971             return
973         # just a regular action
974         self.do_action(action)
976         # commit all changes to the database
977         self.db.commit()
979     def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
980             nre=re.compile(r'new(\w+)')):
981         '''Figure the user's action and do it.
982         '''
983         # here be the "normal" functionality
984         if action == 'index':
985             self.index()
986             return
987         if action == 'list_classes':
988             self.classes()
989             return
990         if action == 'login':
991             self.login()
992             return
993         if action == 'logout':
994             self.logout()
995             return
996         m = dre.match(action)
997         if m:
998             self.classname = m.group(1)
999             self.nodeid = m.group(2)
1000             try:
1001                 cl = self.db.classes[self.classname]
1002             except KeyError:
1003                 raise NotFound
1004             try:
1005                 cl.get(self.nodeid, 'id')
1006             except IndexError:
1007                 raise NotFound
1008             try:
1009                 func = getattr(self, 'show%s'%self.classname)
1010             except AttributeError:
1011                 raise NotFound
1012             func()
1013             return
1014         m = nre.match(action)
1015         if m:
1016             self.classname = m.group(1)
1017             try:
1018                 func = getattr(self, 'new%s'%self.classname)
1019             except AttributeError:
1020                 raise NotFound
1021             func()
1022             return
1023         self.classname = action
1024         try:
1025             self.db.getclass(self.classname)
1026         except KeyError:
1027             raise NotFound
1028         self.list()
1031 class ExtendedClient(Client): 
1032     '''Includes pages and page heading information that relate to the
1033        extended schema.
1034     ''' 
1035     showsupport = Client.shownode
1036     showtimelog = Client.shownode
1037     newsupport = Client.newnode
1038     newtimelog = Client.newnode
1040     default_index_sort = ['-activity']
1041     default_index_group = ['priority']
1042     default_index_filter = ['status']
1043     default_index_columns = ['activity','status','title','assignedto']
1044     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
1046     def pagehead(self, title, message=None):
1047         url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
1048         machine = self.env['SERVER_NAME']
1049         port = self.env['SERVER_PORT']
1050         if port != '80': machine = machine + ':' + port
1051         base = urlparse.urlunparse(('http', machine, url, None, None, None))
1052         if message is not None:
1053             message = _('<div class="system-msg">%(message)s</div>')%locals()
1054         else:
1055             message = ''
1056         style = open(os.path.join(self.instance.TEMPLATES, 'style.css')).read()
1057         user_name = self.user or ''
1058         if self.user == 'admin':
1059             admin_links = _(' | <a href="list_classes">Class List</a>' \
1060                           ' | <a href="user">User List</a>' \
1061                           ' | <a href="newuser">Add User</a>')
1062         else:
1063             admin_links = ''
1064         if self.user not in (None, 'anonymous'):
1065             userid = self.db.user.lookup(self.user)
1066             user_info = _('''
1067 <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> |
1068 <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> |
1069 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
1070 ''')%locals()
1071         else:
1072             user_info = _('<a href="login">Login</a>')
1073         if self.user is not None:
1074             add_links = _('''
1075 | Add
1076 <a href="newissue">Issue</a>,
1077 <a href="newsupport">Support</a>,
1078 ''')
1079         else:
1080             add_links = ''
1081         self.write(_('''<html><head>
1082 <title>%(title)s</title>
1083 <style type="text/css">%(style)s</style>
1084 </head>
1085 <body bgcolor=#ffffff>
1086 %(message)s
1087 <table width=100%% border=0 cellspacing=0 cellpadding=2>
1088 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
1089 <td align=right valign=bottom>%(user_name)s</td></tr>
1090 <tr class="location-bar">
1091 <td align=left>All
1092 <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>,
1093 <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>
1094 | Unassigned
1095 <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>,
1096 <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>
1097 %(add_links)s
1098 %(admin_links)s</td>
1099 <td align=right>%(user_info)s</td>
1100 </table>
1101 ''')%locals())
1103 def parsePropsFromForm(db, cl, form, nodeid=0):
1104     '''Pull properties for the given class out of the form.
1105     '''
1106     props = {}
1107     keys = form.keys()
1108     num_re = re.compile('^\d+$')
1109     for key in keys:
1110         if not cl.properties.has_key(key):
1111             continue
1112         proptype = cl.properties[key]
1113         if isinstance(proptype, hyperdb.String):
1114             value = form[key].value.strip()
1115         elif isinstance(proptype, hyperdb.Password):
1116             value = password.Password(form[key].value.strip())
1117         elif isinstance(proptype, hyperdb.Date):
1118             value = date.Date(form[key].value.strip())
1119         elif isinstance(proptype, hyperdb.Interval):
1120             value = date.Interval(form[key].value.strip())
1121         elif isinstance(proptype, hyperdb.Link):
1122             value = form[key].value.strip()
1123             # see if it's the "no selection" choice
1124             if value == '-1':
1125                 # don't set this property
1126                 continue
1127             else:
1128                 # handle key values
1129                 link = cl.properties[key].classname
1130                 if not num_re.match(value):
1131                     try:
1132                         value = db.classes[link].lookup(value)
1133                     except KeyError:
1134                         raise ValueError, _('property "%(propname)s": '
1135                             '%(value)s not a %(classname)s')%{'propname':key, 
1136                             'value': value, 'classname': link}
1137         elif isinstance(proptype, hyperdb.Multilink):
1138             value = form[key]
1139             if type(value) != type([]):
1140                 value = [i.strip() for i in value.value.split(',')]
1141             else:
1142                 value = [i.value.strip() for i in value]
1143             link = cl.properties[key].classname
1144             l = []
1145             for entry in map(str, value):
1146                 if entry == '': continue
1147                 if not num_re.match(entry):
1148                     try:
1149                         entry = db.classes[link].lookup(entry)
1150                     except KeyError:
1151                         raise ValueError, _('property "%(propname)s": '
1152                             '"%(value)s" not an entry of %(classname)s')%{
1153                             'propname':key, 'value': entry, 'classname': link}
1154                 l.append(entry)
1155             l.sort()
1156             value = l
1158         # get the old value
1159         if nodeid:
1160             try:
1161                 existing = cl.get(nodeid, key)
1162             except KeyError:
1163                 # this might be a new property for which there is no existing
1164                 # value
1165                 if not cl.properties.has_key(key): raise
1167             # if changed, set it
1168             if value != existing:
1169                 props[key] = value
1170         else:
1171             props[key] = value
1172     return props
1175 # $Log: not supported by cvs2svn $
1176 # Revision 1.98  2002/01/14 02:20:14  richard
1177 #  . changed all config accesses so they access either the instance or the
1178 #    config attriubute on the db. This means that all config is obtained from
1179 #    instance_config instead of the mish-mash of classes. This will make
1180 #    switching to a ConfigParser setup easier too, I hope.
1182 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1183 # 0.5.0 switch, I hope!)
1185 # Revision 1.97  2002/01/11 23:22:29  richard
1186 #  . #502437 ] rogue reactor and unittest
1187 #    in short, the nosy reactor was modifying the nosy list. That code had
1188 #    been there for a long time, and I suspsect it was there because we
1189 #    weren't generating the nosy list correctly in other places of the code.
1190 #    We're now doing that, so the nosy-modifying code can go away from the
1191 #    nosy reactor.
1193 # Revision 1.96  2002/01/10 05:26:10  richard
1194 # missed a parsePropsFromForm in last update
1196 # Revision 1.95  2002/01/10 03:39:45  richard
1197 #  . fixed some problems with web editing and change detection
1199 # Revision 1.94  2002/01/09 13:54:21  grubert
1200 # _add_assignedto_to_nosy did set nosy to assignedto only, no adding.
1202 # Revision 1.93  2002/01/08 11:57:12  richard
1203 # crying out for real configuration handling... :(
1205 # Revision 1.92  2002/01/08 04:12:05  richard
1206 # Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
1208 # Revision 1.91  2002/01/08 04:03:47  richard
1209 # I mucked the intent of the code up.
1211 # Revision 1.90  2002/01/08 03:56:55  richard
1212 # Oops, missed this before the beta:
1213 #  . #495392 ] empty nosy -patch
1215 # Revision 1.89  2002/01/07 20:24:45  richard
1216 # *mutter* stupid cutnpaste
1218 # Revision 1.88  2002/01/02 02:31:38  richard
1219 # Sorry for the huge checkin message - I was only intending to implement #496356
1220 # but I found a number of places where things had been broken by transactions:
1221 #  . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1222 #    for _all_ roundup-generated smtp messages to be sent to.
1223 #  . the transaction cache had broken the roundupdb.Class set() reactors
1224 #  . newly-created author users in the mailgw weren't being committed to the db
1226 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1227 # on when I found that stuff :):
1228 #  . #496356 ] Use threading in messages
1229 #  . detectors were being registered multiple times
1230 #  . added tests for mailgw
1231 #  . much better attaching of erroneous messages in the mail gateway
1233 # Revision 1.87  2001/12/23 23:18:49  richard
1234 # We already had an admin-specific section of the web heading, no need to add
1235 # another one :)
1237 # Revision 1.86  2001/12/20 15:43:01  rochecompaan
1238 # Features added:
1239 #  .  Multilink properties are now displayed as comma separated values in
1240 #     a textbox
1241 #  .  The add user link is now only visible to the admin user
1242 #  .  Modified the mail gateway to reject submissions from unknown
1243 #     addresses if ANONYMOUS_ACCESS is denied
1245 # Revision 1.85  2001/12/20 06:13:24  rochecompaan
1246 # Bugs fixed:
1247 #   . Exception handling in hyperdb for strings-that-look-like numbers got
1248 #     lost somewhere
1249 #   . Internet Explorer submits full path for filename - we now strip away
1250 #     the path
1251 # Features added:
1252 #   . Link and multilink properties are now displayed sorted in the cgi
1253 #     interface
1255 # Revision 1.84  2001/12/18 15:30:30  rochecompaan
1256 # Fixed bugs:
1257 #  .  Fixed file creation and retrieval in same transaction in anydbm
1258 #     backend
1259 #  .  Cgi interface now renders new issue after issue creation
1260 #  .  Could not set issue status to resolved through cgi interface
1261 #  .  Mail gateway was changing status back to 'chatting' if status was
1262 #     omitted as an argument
1264 # Revision 1.83  2001/12/15 23:51:01  richard
1265 # Tested the changes and fixed a few problems:
1266 #  . files are now attached to the issue as well as the message
1267 #  . newuser is a real method now since we don't want to do the message/file
1268 #    stuff for it
1269 #  . added some documentation
1270 # The really big changes in the diff are a result of me moving some code
1271 # around to keep like methods together a bit better.
1273 # Revision 1.82  2001/12/15 19:24:39  rochecompaan
1274 #  . Modified cgi interface to change properties only once all changes are
1275 #    collected, files created and messages generated.
1276 #  . Moved generation of change note to nosyreactors.
1277 #  . We now check for changes to "assignedto" to ensure it's added to the
1278 #    nosy list.
1280 # Revision 1.81  2001/12/12 23:55:00  richard
1281 # Fixed some problems with user editing
1283 # Revision 1.80  2001/12/12 23:27:14  richard
1284 # Added a Zope frontend for roundup.
1286 # Revision 1.79  2001/12/10 22:20:01  richard
1287 # Enabled transaction support in the bsddb backend. It uses the anydbm code
1288 # where possible, only replacing methods where the db is opened (it uses the
1289 # btree opener specifically.)
1290 # Also cleaned up some change note generation.
1291 # Made the backends package work with pydoc too.
1293 # Revision 1.78  2001/12/07 05:59:27  rochecompaan
1294 # Fixed small bug that prevented adding issues through the web.
1296 # Revision 1.77  2001/12/06 22:48:29  richard
1297 # files multilink was being nuked in post_edit_node
1299 # Revision 1.76  2001/12/05 14:26:44  rochecompaan
1300 # Removed generation of change note from "sendmessage" in roundupdb.py.
1301 # The change note is now generated when the message is created.
1303 # Revision 1.75  2001/12/04 01:25:08  richard
1304 # Added some rollbacks where we were catching exceptions that would otherwise
1305 # have stopped committing.
1307 # Revision 1.74  2001/12/02 05:06:16  richard
1308 # . We now use weakrefs in the Classes to keep the database reference, so
1309 #   the close() method on the database is no longer needed.
1310 #   I bumped the minimum python requirement up to 2.1 accordingly.
1311 # . #487480 ] roundup-server
1312 # . #487476 ] INSTALL.txt
1314 # I also cleaned up the change message / post-edit stuff in the cgi client.
1315 # There's now a clearly marked "TODO: append the change note" where I believe
1316 # the change note should be added there. The "changes" list will obviously
1317 # have to be modified to be a dict of the changes, or somesuch.
1319 # More testing needed.
1321 # Revision 1.73  2001/12/01 07:17:50  richard
1322 # . We now have basic transaction support! Information is only written to
1323 #   the database when the commit() method is called. Only the anydbm
1324 #   backend is modified in this way - neither of the bsddb backends have been.
1325 #   The mail, admin and cgi interfaces all use commit (except the admin tool
1326 #   doesn't have a commit command, so interactive users can't commit...)
1327 # . Fixed login/registration forwarding the user to the right page (or not,
1328 #   on a failure)
1330 # Revision 1.72  2001/11/30 20:47:58  rochecompaan
1331 # Links in page header are now consistent with default sort order.
1333 # Fixed bugs:
1334 #     - When login failed the list of issues were still rendered.
1335 #     - User was redirected to index page and not to his destination url
1336 #       if his first login attempt failed.
1338 # Revision 1.71  2001/11/30 20:28:10  rochecompaan
1339 # Property changes are now completely traceable, whether changes are
1340 # made through the web or by email
1342 # Revision 1.70  2001/11/30 00:06:29  richard
1343 # Converted roundup/cgi_client.py to use _()
1344 # Added the status file, I18N_PROGRESS.txt
1346 # Revision 1.69  2001/11/29 23:19:51  richard
1347 # Removed the "This issue has been edited through the web" when a valid
1348 # change note is supplied.
1350 # Revision 1.68  2001/11/29 04:57:23  richard
1351 # a little comment
1353 # Revision 1.67  2001/11/28 21:55:35  richard
1354 #  . login_action and newuser_action return values were being ignored
1355 #  . Woohoo! Found that bloody re-login bug that was killing the mail
1356 #    gateway.
1357 #  (also a minor cleanup in hyperdb)
1359 # Revision 1.66  2001/11/27 03:00:50  richard
1360 # couple of bugfixes from latest patch integration
1362 # Revision 1.65  2001/11/26 23:00:53  richard
1363 # This config stuff is getting to be a real mess...
1365 # Revision 1.64  2001/11/26 22:56:35  richard
1366 # typo
1368 # Revision 1.63  2001/11/26 22:55:56  richard
1369 # Feature:
1370 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
1371 #    the instance.
1372 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1373 #    signature info in e-mails.
1374 #  . Some more flexibility in the mail gateway and more error handling.
1375 #  . Login now takes you to the page you back to the were denied access to.
1377 # Fixed:
1378 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
1380 # Revision 1.62  2001/11/24 00:45:42  jhermann
1381 # typeof() instead of type(): avoid clash with database field(?) "type"
1383 # Fixes this traceback:
1385 # Traceback (most recent call last):
1386 #   File "roundup\cgi_client.py", line 535, in newnode
1387 #     self._post_editnode(nid)
1388 #   File "roundup\cgi_client.py", line 415, in _post_editnode
1389 #     if type(value) != type([]): value = [value]
1390 # UnboundLocalError: local variable 'type' referenced before assignment
1392 # Revision 1.61  2001/11/22 15:46:42  jhermann
1393 # Added module docstrings to all modules.
1395 # Revision 1.60  2001/11/21 22:57:28  jhermann
1396 # Added dummy hooks for I18N and some preliminary (test) markup of
1397 # translatable messages
1399 # Revision 1.59  2001/11/21 03:21:13  richard
1400 # oops
1402 # Revision 1.58  2001/11/21 03:11:28  richard
1403 # Better handling of new properties.
1405 # Revision 1.57  2001/11/15 10:24:27  richard
1406 # handle the case where there is no file attached
1408 # Revision 1.56  2001/11/14 21:35:21  richard
1409 #  . users may attach files to issues (and support in ext) through the web now
1411 # Revision 1.55  2001/11/07 02:34:06  jhermann
1412 # Handling of damaged login cookies
1414 # Revision 1.54  2001/11/07 01:16:12  richard
1415 # Remove the '=' padding from cookie value so quoting isn't an issue.
1417 # Revision 1.53  2001/11/06 23:22:05  jhermann
1418 # More IE fixes: it does not like quotes around cookie values; in the
1419 # hope this does not break anything for other browser; if it does, we
1420 # need to check HTTP_USER_AGENT
1422 # Revision 1.52  2001/11/06 23:11:22  jhermann
1423 # Fixed debug output in page footer; added expiry date to the login cookie
1424 # (expires 1 year in the future) to prevent probs with certain versions
1425 # of IE
1427 # Revision 1.51  2001/11/06 22:00:34  jhermann
1428 # Get debug level from ROUNDUP_DEBUG env var
1430 # Revision 1.50  2001/11/05 23:45:40  richard
1431 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1432 # Also made it present nicer error messages (not tracebacks).
1434 # Revision 1.49  2001/11/04 03:07:12  richard
1435 # Fixed various cookie-related bugs:
1436 #  . bug #477685 ] base64.decodestring breaks
1437 #  . bug #477837 ] lynx does not like the cookie
1438 #  . bug #477892 ] Password edit doesn't fix login cookie
1439 # Also closed a security hole - a logged-in user could edit another user's
1440 # details.
1442 # Revision 1.48  2001/11/03 01:30:18  richard
1443 # Oops. uses pagefoot now.
1445 # Revision 1.47  2001/11/03 01:29:28  richard
1446 # Login page didn't have all close tags.
1448 # Revision 1.46  2001/11/03 01:26:55  richard
1449 # possibly fix truncated base64'ed user:pass
1451 # Revision 1.45  2001/11/01 22:04:37  richard
1452 # Started work on supporting a pop3-fetching server
1453 # Fixed bugs:
1454 #  . bug #477104 ] HTML tag error in roundup-server
1455 #  . bug #477107 ] HTTP header problem
1457 # Revision 1.44  2001/10/28 23:03:08  richard
1458 # Added more useful header to the classic schema.
1460 # Revision 1.43  2001/10/24 00:01:42  richard
1461 # More fixes to lockout logic.
1463 # Revision 1.42  2001/10/23 23:56:03  richard
1464 # HTML typo
1466 # Revision 1.41  2001/10/23 23:52:35  richard
1467 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1469 # Revision 1.40  2001/10/23 23:06:39  richard
1470 # Some cleanup.
1472 # Revision 1.39  2001/10/23 01:00:18  richard
1473 # Re-enabled login and registration access after lopping them off via
1474 # disabling access for anonymous users.
1475 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1476 # a couple of bugs while I was there. Probably introduced a couple, but
1477 # things seem to work OK at the moment.
1479 # Revision 1.38  2001/10/22 03:25:01  richard
1480 # Added configuration for:
1481 #  . anonymous user access and registration (deny/allow)
1482 #  . filter "widget" location on index page (top, bottom, both)
1483 # Updated some documentation.
1485 # Revision 1.37  2001/10/21 07:26:35  richard
1486 # feature #473127: Filenames. I modified the file.index and htmltemplate
1487 #  source so that the filename is used in the link and the creation
1488 #  information is displayed.
1490 # Revision 1.36  2001/10/21 04:44:50  richard
1491 # bug #473124: UI inconsistency with Link fields.
1492 #    This also prompted me to fix a fairly long-standing usability issue -
1493 #    that of being able to turn off certain filters.
1495 # Revision 1.35  2001/10/21 00:17:54  richard
1496 # CGI interface view customisation section may now be hidden (patch from
1497 #  Roch'e Compaan.)
1499 # Revision 1.34  2001/10/20 11:58:48  richard
1500 # Catch errors in login - no username or password supplied.
1501 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1503 # Revision 1.33  2001/10/17 00:18:41  richard
1504 # Manually constructing cookie headers now.
1506 # Revision 1.32  2001/10/16 03:36:21  richard
1507 # CGI interface wasn't handling checkboxes at all.
1509 # Revision 1.31  2001/10/14 10:55:00  richard
1510 # Handle empty strings in HTML template Link function
1512 # Revision 1.30  2001/10/09 07:38:58  richard
1513 # Pushed the base code for the extended schema CGI interface back into the
1514 # code cgi_client module so that future updates will be less painful.
1515 # Also removed a debugging print statement from cgi_client.
1517 # Revision 1.29  2001/10/09 07:25:59  richard
1518 # Added the Password property type. See "pydoc roundup.password" for
1519 # implementation details. Have updated some of the documentation too.
1521 # Revision 1.28  2001/10/08 00:34:31  richard
1522 # Change message was stuffing up for multilinks with no key property.
1524 # Revision 1.27  2001/10/05 02:23:24  richard
1525 #  . roundup-admin create now prompts for property info if none is supplied
1526 #    on the command-line.
1527 #  . hyperdb Class getprops() method may now return only the mutable
1528 #    properties.
1529 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
1530 #    now support anonymous user access (read-only, unless there's an
1531 #    "anonymous" user, in which case write access is permitted). Login
1532 #    handling has been moved into cgi_client.Client.main()
1533 #  . The "extended" schema is now the default in roundup init.
1534 #  . The schemas have had their page headings modified to cope with the new
1535 #    login handling. Existing installations should copy the interfaces.py
1536 #    file from the roundup lib directory to their instance home.
1537 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1538 #    Ping - has been removed.
1539 #  . Fixed a whole bunch of places in the CGI interface where we should have
1540 #    been returning Not Found instead of throwing an exception.
1541 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
1542 #    an item now throws an exception.
1544 # Revision 1.26  2001/09/12 08:31:42  richard
1545 # handle cases where mime type is not guessable
1547 # Revision 1.25  2001/08/29 05:30:49  richard
1548 # change messages weren't being saved when there was no-one on the nosy list.
1550 # Revision 1.24  2001/08/29 04:49:39  richard
1551 # didn't clean up fully after debugging :(
1553 # Revision 1.23  2001/08/29 04:47:18  richard
1554 # Fixed CGI client change messages so they actually include the properties
1555 # changed (again).
1557 # Revision 1.22  2001/08/17 00:08:10  richard
1558 # reverted back to sending messages always regardless of who is doing the web
1559 # edit. change notes weren't being saved. bleah. hackish.
1561 # Revision 1.21  2001/08/15 23:43:18  richard
1562 # Fixed some isFooTypes that I missed.
1563 # Refactored some code in the CGI code.
1565 # Revision 1.20  2001/08/12 06:32:36  richard
1566 # using isinstance(blah, Foo) now instead of isFooType
1568 # Revision 1.19  2001/08/07 00:24:42  richard
1569 # stupid typo
1571 # Revision 1.18  2001/08/07 00:15:51  richard
1572 # Added the copyright/license notice to (nearly) all files at request of
1573 # Bizar Software.
1575 # Revision 1.17  2001/08/02 06:38:17  richard
1576 # Roundupdb now appends "mailing list" information to its messages which
1577 # include the e-mail address and web interface address. Templates may
1578 # override this in their db classes to include specific information (support
1579 # instructions, etc).
1581 # Revision 1.16  2001/08/02 05:55:25  richard
1582 # Web edit messages aren't sent to the person who did the edit any more. No
1583 # message is generated if they are the only person on the nosy list.
1585 # Revision 1.15  2001/08/02 00:34:10  richard
1586 # bleah syntax error
1588 # Revision 1.14  2001/08/02 00:26:16  richard
1589 # Changed the order of the information in the message generated by web edits.
1591 # Revision 1.13  2001/07/30 08:12:17  richard
1592 # Added time logging and file uploading to the templates.
1594 # Revision 1.12  2001/07/30 06:26:31  richard
1595 # Added some documentation on how the newblah works.
1597 # Revision 1.11  2001/07/30 06:17:45  richard
1598 # Features:
1599 #  . Added ability for cgi newblah forms to indicate that the new node
1600 #    should be linked somewhere.
1601 # Fixed:
1602 #  . Fixed the agument handling for the roundup-admin find command.
1603 #  . Fixed handling of summary when no note supplied for newblah. Again.
1604 #  . Fixed detection of no form in htmltemplate Field display.
1606 # Revision 1.10  2001/07/30 02:37:34  richard
1607 # Temporary measure until we have decent schema migration...
1609 # Revision 1.9  2001/07/30 01:25:07  richard
1610 # Default implementation is now "classic" rather than "extended" as one would
1611 # expect.
1613 # Revision 1.8  2001/07/29 08:27:40  richard
1614 # Fixed handling of passed-in values in form elements (ie. during a
1615 # drill-down)
1617 # Revision 1.7  2001/07/29 07:01:39  richard
1618 # Added vim command to all source so that we don't get no steenkin' tabs :)
1620 # Revision 1.6  2001/07/29 04:04:00  richard
1621 # Moved some code around allowing for subclassing to change behaviour.
1623 # Revision 1.5  2001/07/28 08:16:52  richard
1624 # New issue form handles lack of note better now.
1626 # Revision 1.4  2001/07/28 00:34:34  richard
1627 # Fixed some non-string node ids.
1629 # Revision 1.3  2001/07/23 03:56:30  richard
1630 # oops, missed a config removal
1632 # Revision 1.2  2001/07/22 12:09:32  richard
1633 # Final commit of Grande Splite
1635 # Revision 1.1  2001/07/22 11:58:35  richard
1636 # More Grande Splite
1639 # vim: set filetype=python ts=4 sw=4 et si