Code

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