Code

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