Code

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