Code

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