Code

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