Code

bugfix
[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.159 2002-08-16 04:29:41 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 def initialiseSecurity(security):
37     ''' Create some Permissions and Roles on the security object
39         This function is directly invoked by security.Security.__init__()
40         as a part of the Security object instantiation.
41     '''
42     security.addPermission(name="Web Registration",
43         description="User may register through the web")
44     security.addPermission(name="Web Access",
45         description="User may access the web interface")
47     # doing Role stuff through the web - make sure Admin can
48     p = security.addPermission(name="Web Roles",
49         description="User may manipulate user Roles through the web")
50     security.addPermissionToRole('Admin', p)
52 class Client:
53     '''
54     A note about login
55     ------------------
57     If the user has no login cookie, then they are anonymous. There
58     are two levels of anonymous use. If there is no 'anonymous' user, there
59     is no login at all and the database is opened in read-only mode. If the
60     'anonymous' user exists, the user is logged in using that user (though
61     there is no cookie). This allows them to modify the database, and all
62     modifications are attributed to the 'anonymous' user.
64     Once a user logs in, they are assigned a session. The Client instance
65     keeps the nodeid of the session as the "session" attribute.
66     '''
68     def __init__(self, instance, request, env, form=None):
69         hyperdb.traceMark()
70         self.instance = instance
71         self.request = request
72         self.env = env
73         self.path = env['PATH_INFO']
74         self.split_path = self.path.split('/')
75         self.instance_path_name = env['INSTANCE_NAME']
76         url = self.env['SCRIPT_NAME'] + '/'
77         machine = self.env['SERVER_NAME']
78         port = self.env['SERVER_PORT']
79         if port != '80': machine = machine + ':' + port
80         self.base = urlparse.urlunparse(('http', env['HTTP_HOST'], url,
81             None, None, None))
83         if form is None:
84             self.form = cgi.FieldStorage(environ=env)
85         else:
86             self.form = form
87         self.headers_done = 0
88         try:
89             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
90         except ValueError:
91             # someone gave us a non-int debug level, turn it off
92             self.debug = 0
94     def getuid(self):
95         try:
96             return self.db.user.lookup(self.user)
97         except KeyError:
98             if self.user is None:
99                 # user is not logged in and username 'anonymous' doesn't
100                 # exist in the database
101                 err = _('anonymous users have read-only access only')
102             else:
103                 err = _("sanity check: unknown user name `%s'")%self.user
104             raise Unauthorised, errmsg
106     def header(self, headers=None, response=200):
107         '''Put up the appropriate header.
108         '''
109         if headers is None:
110             headers = {'Content-Type':'text/html'}
111         if not headers.has_key('Content-Type'):
112             headers['Content-Type'] = 'text/html'
113         self.request.send_response(response)
114         for entry in headers.items():
115             self.request.send_header(*entry)
116         self.request.end_headers()
117         self.headers_done = 1
118         if self.debug:
119             self.headers_sent = headers
121     global_javascript = '''
122 <script language="javascript">
123 submitted = false;
124 function submit_once() {
125     if (submitted) {
126         alert("Your request is being processed.\\nPlease be patient.");
127         return 0;
128     }
129     submitted = true;
130     return 1;
133 function help_window(helpurl, width, height) {
134     HelpWin = window.open('%(base)s%(instance_path_name)s/' + helpurl, 'RoundupHelpWindow', 'scrollbars=yes,resizable=yes,toolbar=no,height='+height+',width='+width);
137 </script>
138 '''
139     def make_index_link(self, name):
140         '''Turn a configuration entry into a hyperlink...
141         '''
142         # get the link label and spec
143         spec = getattr(self.instance, name+'_INDEX')
145         d = {}
146         d[':sort'] = ','.join(map(urllib.quote, spec['SORT']))
147         d[':group'] = ','.join(map(urllib.quote, spec['GROUP']))
148         d[':filter'] = ','.join(map(urllib.quote, spec['FILTER']))
149         d[':columns'] = ','.join(map(urllib.quote, spec['COLUMNS']))
150         d[':pagesize'] = spec.get('PAGESIZE','50')
152         # snarf the filterspec
153         filterspec = spec['FILTERSPEC'].copy()
155         # now format the filterspec
156         for k, l in filterspec.items():
157             # fix up the CURRENT USER if needed (handle None too since that's
158             # the old flag value)
159             if l in (None, 'CURRENT USER'):
160                 if not self.user:
161                     continue
162                 l = [self.db.user.lookup(self.user)]
164             # add
165             d[urllib.quote(k)] = ','.join(map(urllib.quote, l))
167         # finally, format the URL
168         return '<a href="%s?%s">%s</a>'%(spec['CLASS'],
169             '&'.join([k+'='+v for k,v in d.items()]), spec['LABEL'])
172     def pagehead(self, title, message=None):
173         '''Display the page heading, with information about the tracker and
174             links to more information
175         '''
177         # include any important message
178         if message is not None:
179             message = _('<div class="system-msg">%(message)s</div>')%locals()
180         else:
181             message = ''
183         # style sheet (CSS)
184         style = open(os.path.join(self.instance.TEMPLATES, 'style.css')).read()
186         # figure who the user is
187         user_name = self.user
188         userid = self.db.user.lookup(user_name)
189         default_queries = 1
190         links = []
191         if user_name != 'anonymous':
192             try:
193                 default_queries = self.db.user.get(userid, 'defaultqueries')
194             except KeyError:
195                 pass
197         # figure all the header links
198         if default_queries:
199             if hasattr(self.instance, 'HEADER_INDEX_LINKS'):
200                 for name in self.instance.HEADER_INDEX_LINKS:
201                     spec = getattr(self.instance, name + '_INDEX')
202                     # skip if we need to fill in the logged-in user id and
203                     # we're anonymous
204                     if (spec['FILTERSPEC'].has_key('assignedto') and
205                             spec['FILTERSPEC']['assignedto'] in ('CURRENT USER',
206                             None) and user_name == 'anonymous'):
207                         continue
208                     links.append(self.make_index_link(name))
209             else:
210                 # no config spec - hard-code
211                 links = [
212                     _('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>'),
213                     _('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>')
214                 ]
216         user_info = _('<a href="login">Login</a>')
217         add_links = ''
218         if user_name != 'anonymous':
219             # add any personal queries to the menu
220             try:
221                 queries = self.db.getclass('query')
222             except KeyError:
223                 # no query class
224                 queries = self.instance.dbinit.Class(self.db, "query",
225                     klass=hyperdb.String(), name=hyperdb.String(),
226                     url=hyperdb.String())
227                 queries.setkey('name')
228                 #queries.disableJournalling()
229             try:
230                 qids = self.db.getclass('user').get(userid, 'queries')
231             except KeyError, e:
232                 #self.db.getclass('user').addprop(queries=hyperdb.Multilink('query'))
233                 qids = []
234             for qid in qids:
235                 links.append('<a href=%s?%s>%s</a>'%(queries.get(qid, 'klass'),
236                     queries.get(qid, 'url'), queries.get(qid, 'name')))
238             # if they're logged in, include links to their information,
239             # and the ability to add an issue
240             user_info = _('''
241 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
242 ''')%locals()
244         # figure the "add class" links
245         if hasattr(self.instance, 'HEADER_ADD_LINKS'):
246             classes = self.instance.HEADER_ADD_LINKS
247         else:
248             classes = ['issue']
249         l = []
250         for class_name in classes:
251             # make sure the user has permission to add
252             if not self.db.security.hasPermission('Edit', userid, class_name):
253                 continue
254             cap_class = class_name.capitalize()
255             links.append(_('Add <a href="new%(class_name)s">'
256                 '%(cap_class)s</a>')%locals())
258         # if the user can edit everything, include the links
259         admin_links = ''
260         userid = self.db.user.lookup(user_name)
261         if self.db.security.hasPermission('Edit', userid):
262             links.append(_('<a href="list_classes">Class List</a>'))
263             links.append(_('<a href="user?:sort=username&:group=roles">User List</a>'))
264             links.append(_('<a href="newuser">Add User</a>'))
266         # add the search links
267         if hasattr(self.instance, 'HEADER_SEARCH_LINKS'):
268             classes = self.instance.HEADER_SEARCH_LINKS
269         else:
270             classes = ['issue']
271         l = []
272         for class_name in classes:
273             # make sure the user has permission to view
274             if not self.db.security.hasPermission('View', userid, class_name):
275                 continue
276             cap_class = class_name.capitalize()
277             links.append(_('Search <a href="search%(class_name)s">'
278                 '%(cap_class)s</a>')%locals())
280         # now we have all the links, join 'em
281         links = '\n | '.join(links)
283         # include the javascript bit
284         global_javascript = self.global_javascript%self.__dict__
286         # finally, format the header
287         self.write(_('''<html><head>
288 <title>%(title)s</title>
289 <style type="text/css">%(style)s</style>
290 </head>
291 %(global_javascript)s
292 <body bgcolor=#ffffff>
293 %(message)s
294 <table width=100%% border=0 cellspacing=0 cellpadding=2>
295  <tr class="location-bar">
296   <td><big><strong>%(title)s</strong></big></td>
297   <td align=right valign=bottom>%(user_name)s</td>
298  </tr>
299  <tr class="location-bar">
300   <td align=left>%(links)s</td>
301   <td align=right>%(user_info)s</td>
302  </tr>
303 </table><br>
304 ''')%locals())
306     def pagefoot(self):
307         if self.debug:
308             self.write(_('<hr><small><dl><dt><b>Path</b></dt>'))
309             self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
310             keys = self.form.keys()
311             keys.sort()
312             if keys:
313                 self.write(_('<dt><b>Form entries</b></dt>'))
314                 for k in self.form.keys():
315                     v = self.form.getvalue(k, "<empty>")
316                     if type(v) is type([]):
317                         # Multiple username fields specified
318                         v = "|".join(v)
319                     self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
320             keys = self.headers_sent.keys()
321             keys.sort()
322             self.write(_('<dt><b>Sent these HTTP headers</b></dt>'))
323             for k in keys:
324                 v = self.headers_sent[k]
325                 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
326             keys = self.env.keys()
327             keys.sort()
328             self.write(_('<dt><b>CGI environment</b></dt>'))
329             for k in keys:
330                 v = self.env[k]
331                 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
332             self.write('</dl></small>')
333         self.write('</body></html>')
335     def write(self, content):
336         if not self.headers_done:
337             self.header()
338         self.request.wfile.write(content)
340     def index_arg(self, arg):
341         ''' handle the args to index - they might be a list from the form
342             (ie. submitted from a form) or they might be a command-separated
343             single string (ie. manually constructed GET args)
344         '''
345         if self.form.has_key(arg):
346             arg =  self.form[arg]
347             if type(arg) == type([]):
348                 return [arg.value for arg in arg]
349             return arg.value.split(',')
350         return []
352     def index_sort(self):
353         # first try query string / simple form
354         x = self.index_arg(':sort')
355         if x:
356             if self.index_arg(':descending'):
357                 return ['-'+x[0]]
358             return x
359         # nope - get the specs out of the form
360         specs = []
361         for colnm in self.db.getclass(self.classname).getprops().keys():
362             desc = ''
363             try:
364                 spec = self.form[':%s_ss' % colnm]
365             except KeyError:
366                 continue
367             spec = spec.value
368             if spec:
369                 if spec[-1] == '-':
370                     desc='-'
371                     spec = spec[0]
372                 specs.append((int(spec), colnm, desc))
373         specs.sort()
374         x = []
375         for _, colnm, desc in specs:
376             x.append('%s%s' % (desc, colnm))
377         return x
378     
379     def index_filterspec(self, filter, classname=None):
380         ''' pull the index filter spec from the form
382         Links and multilinks want to be lists - the rest are straight
383         strings.
384         '''
385         if classname is None:
386             classname = self.classname
387         klass = self.db.getclass(classname)
388         filterspec = {}
389         props = klass.getprops()
390         for colnm in filter:
391             widget = ':%s_fs' % colnm
392             try:
393                 val = self.form[widget]
394             except KeyError:
395                 try:
396                     val = self.form[colnm]
397                 except KeyError:
398                     # they checked the filter box but didn't enter a value
399                     continue
400             propdescr = props.get(colnm, None)
401             if propdescr is None:
402                 print "huh? %r is in filter & form, but not in Class!" % colnm
403                 raise "butthead programmer"
404             if (isinstance(propdescr, hyperdb.Link) or
405                 isinstance(propdescr, hyperdb.Multilink)):
406                 if type(val) == type([]):
407                     val = [arg.value for arg in val]
408                 else:
409                     val = val.value.split(',')
410                 l = filterspec.get(colnm, [])
411                 l = l + val
412                 filterspec[colnm] = l
413             else:
414                 filterspec[colnm] = val.value
415             
416         return filterspec
417     
418     def customization_widget(self):
419         ''' The customization widget is visible by default. The widget
420             visibility is remembered by show_customization.  Visibility
421             is not toggled if the action value is "Redisplay"
422         '''
423         if not self.form.has_key('show_customization'):
424             visible = 1
425         else:
426             visible = int(self.form['show_customization'].value)
427             if self.form.has_key('action'):
428                 if self.form['action'].value != 'Redisplay':
429                     visible = self.form['action'].value == '+'
430             
431         return visible
433     # TODO: make this go away some day...
434     default_index_sort = ['-activity']
435     default_index_group = ['priority']
436     default_index_filter = ['status']
437     default_index_columns = ['id','activity','title','status','assignedto']
438     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
439     default_pagesize = '50'
441     def _get_customisation_info(self):
442         # see if the web has supplied us with any customisation info
443         for key in ':sort', ':group', ':filter', ':columns', ':pagesize':
444             if self.form.has_key(key):
445                 # make list() extract the info from the CGI environ
446                 self.classname = 'issue'
447                 sort = group = filter = columns = filterspec = pagesize = None
448                 break
449         else:
450             # TODO: look up the session first
451             # try the instance config first
452             if hasattr(self.instance, 'DEFAULT_INDEX'):
453                 d = self.instance.DEFAULT_INDEX
454                 self.classname = d['CLASS']
455                 sort = d['SORT']
456                 group = d['GROUP']
457                 filter = d['FILTER']
458                 columns = d['COLUMNS']
459                 filterspec = d['FILTERSPEC']
460                 pagesize = d.get('PAGESIZE', '50')
461             else:
462                 # nope - fall back on the old way of doing it
463                 self.classname = 'issue'
464                 sort = self.default_index_sort
465                 group = self.default_index_group
466                 filter = self.default_index_filter
467                 columns = self.default_index_columns
468                 filterspec = self.default_index_filterspec
469                 pagesize = self.default_pagesize
470         return columns, filter, group, sort, filterspec, pagesize
472     def index(self):
473         ''' put up an index - no class specified
474         '''
475         columns, filter, group, sort, filterspec, pagesize = \
476             self._get_customisation_info()
477         return self.list(columns=columns, filter=filter, group=group,
478             sort=sort, filterspec=filterspec, pagesize=pagesize)
480     def searchnode(self):
481         columns, filter, group, sort, filterspec, pagesize = \
482             self._get_customisation_info()
483         cn = self.classname
484         self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
485             'classname': cn, 'instancename': self.instance.INSTANCE_NAME})
486         index = htmltemplate.IndexTemplate(self, self.instance.TEMPLATES, cn)
487         self.write('<form onSubmit="return submit_once()" action="%s">\n'%self.classname)
488         all_columns = self.db.getclass(cn).getprops().keys()
489         all_columns.sort()
490         index.filter_section('', filter, columns, group, all_columns, sort,
491                              filterspec, pagesize, 0, 0)
492         self.pagefoot()
494     # XXX deviates from spec - loses the '+' (that's a reserved character
495     # in URLS
496     def list(self, sort=None, group=None, filter=None, columns=None,
497             filterspec=None, show_customization=None, show_nodes=1,
498             pagesize=None):
499         ''' call the template index with the args
501             :sort    - sort by prop name, optionally preceeded with '-'
502                      to give descending or nothing for ascending sorting.
503             :group   - group by prop name, optionally preceeded with '-' or
504                      to sort in descending or nothing for ascending order.
505             :filter  - selects which props should be displayed in the filter
506                      section. Default is all.
507             :columns - selects the columns that should be displayed.
508                      Default is all.
510         '''
511         cn = self.classname
512         cl = self.db.classes[cn]
513         if sort is None: sort = self.index_sort()
514         if group is None: group = self.index_arg(':group')
515         if filter is None: filter = self.index_arg(':filter')
516         if columns is None: columns = self.index_arg(':columns')
517         if filterspec is None: filterspec = self.index_filterspec(filter)
518         if show_customization is None:
519             show_customization = self.customization_widget()
520         if self.form.has_key('search_text'):
521             search_text = self.form['search_text'].value
522         else:
523             search_text = ''
524         if pagesize is None:
525             if self.form.has_key(':pagesize'):
526                 pagesize = self.form[':pagesize'].value
527             else:
528                 pagesize = '50'
529         pagesize = int(pagesize)
530         if self.form.has_key(':startwith'):
531             startwith = int(self.form[':startwith'].value)
532         else:
533             startwith = 0
534         simpleform = 1
535         if self.form.has_key(':advancedsearch'):
536             simpleform = 0
538         if self.form.has_key('Query') and self.form['Query'].value == 'Save':
539             # format a query string
540             qd = {}
541             qd[':sort'] = ','.join(map(urllib.quote, sort))
542             qd[':group'] = ','.join(map(urllib.quote, group))
543             qd[':filter'] = ','.join(map(urllib.quote, filter))
544             qd[':columns'] = ','.join(map(urllib.quote, columns))
545             for k, l in filterspec.items():
546                 qd[urllib.quote(k)] = ','.join(map(urllib.quote, l))
547             url = '&'.join([k+'='+v for k,v in qd.items()])
548             url += '&:pagesize=%s' % pagesize
549             if search_text:
550                 url += '&search_text=%s' % search_text
552             # create a query
553             d = {}
554             d['name'] = nm = self.form[':name'].value
555             if not nm:
556                 d['name'] = nm = 'New Query'
557             d['klass'] = self.form[':classname'].value
558             d['url'] = url
559             qid = self.db.getclass('query').create(**d)
561             # and add it to the user's query multilink
562             uid = self.getuid()
563             usercl = self.db.getclass('user')
564             queries = usercl.get(uid, 'queries')
565             queries.append(qid)
566             usercl.set(uid, queries=queries)
567             
568         self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
569             'classname': cn, 'instancename': self.instance.INSTANCE_NAME})
571         index = htmltemplate.IndexTemplate(self, self.instance.TEMPLATES, cn)
572         try:
573             index.render(filterspec=filterspec, search_text=search_text,
574                 filter=filter, columns=columns, sort=sort, group=group,
575                 show_customization=show_customization, 
576                 show_nodes=show_nodes, pagesize=pagesize, startwith=startwith,
577                 simple_search=simpleform)
578         except htmltemplate.MissingTemplateError:
579             self.basicClassEditPage()
580         self.pagefoot()
582     def basicClassEditPage(self):
583         '''Display a basic edit page that allows simple editing of the
584            nodes of the current class
585         '''
586         userid = self.db.user.lookup(self.user)
587         if not self.db.security.hasPermission('Edit', userid):
588             raise Unauthorised, _("You do not have permission to access"\
589                         " %(action)s.")%{'action': self.classname}
590         w = self.write
591         cn = self.classname
592         cl = self.db.classes[cn]
593         idlessprops = cl.getprops(protected=0).keys()
594         props = ['id'] + idlessprops
596         # get the CSV module
597         try:
598             import csv
599         except ImportError:
600             w(_('Sorry, you need the csv module to use this function.<br>\n'
601                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
602             return
604         # do the edit
605         if self.form.has_key('rows'):
606             rows = self.form['rows'].value.splitlines()
607             p = csv.parser()
608             found = {}
609             line = 0
610             for row in rows:
611                 line += 1
612                 values = p.parse(row)
613                 # not a complete row, keep going
614                 if not values: continue
616                 # extract the nodeid
617                 nodeid, values = values[0], values[1:]
618                 found[nodeid] = 1
620                 # confirm correct weight
621                 if len(idlessprops) != len(values):
622                     w(_('Not enough values on line %(line)s'%{'line':line}))
623                     return
625                 # extract the new values
626                 d = {}
627                 for name, value in zip(idlessprops, values):
628                     value = value.strip()
629                     # only add the property if it has a value
630                     if value:
631                         # if it's a multilink, split it
632                         if isinstance(cl.properties[name], hyperdb.Multilink):
633                             value = value.split(':')
634                         d[name] = value
636                 # perform the edit
637                 if cl.hasnode(nodeid):
638                     # edit existing
639                     cl.set(nodeid, **d)
640                 else:
641                     # new node
642                     found[cl.create(**d)] = 1
644             # retire the removed entries
645             for nodeid in cl.list():
646                 if not found.has_key(nodeid):
647                     cl.retire(nodeid)
649         w(_('''<p class="form-help">You may edit the contents of the
650         "%(classname)s" class using this form. Commas, newlines and double
651         quotes (") must be handled delicately. You may include commas and
652         newlines by enclosing the values in double-quotes ("). Double
653         quotes themselves must be quoted by doubling ("").</p>
654         <p class="form-help">Multilink properties have their multiple
655         values colon (":") separated (... ,"one:two:three", ...)</p>
656         <p class="form-help">Remove entries by deleting their line. Add
657         new entries by appending
658         them to the table - put an X in the id column.</p>''')%{'classname':cn})
660         l = []
661         for name in props:
662             l.append(name)
663         w('<tt>')
664         w(', '.join(l) + '\n')
665         w('</tt>')
667         w('<form onSubmit="return submit_once()" method="POST">')
668         w('<textarea name="rows" cols=80 rows=15>')
669         p = csv.parser()
670         for nodeid in cl.list():
671             l = []
672             for name in props:
673                 value = cl.get(nodeid, name)
674                 if value is None:
675                     l.append('')
676                 elif isinstance(value, type([])):
677                     l.append(cgi.escape(':'.join(map(str, value))))
678                 else:
679                     l.append(cgi.escape(str(cl.get(nodeid, name))))
680             w(p.join(l) + '\n')
682         w(_('</textarea><br><input type="submit" value="Save Changes"></form>'))
684     def classhelp(self):
685         '''Display a table of class info
686         '''
687         w = self.write
688         cn = self.form['classname'].value
689         cl = self.db.classes[cn]
690         props = self.form['properties'].value.split(',')
691         if cl.labelprop(1) in props:
692             sort = [cl.labelprop(1)]
693         else:
694             sort = props[0]
696         w('<table border=1 cellspacing=0 cellpaddin=2>')
697         w('<tr>')
698         for name in props:
699             w('<th align=left>%s</th>'%name)
700         w('</tr>')
701         for nodeid in cl.filter(None, {}, sort, []):
702             w('<tr>')
703             for name in props:
704                 value = cgi.escape(str(cl.get(nodeid, name)))
705                 w('<td align="left" valign="top">%s</td>'%value)
706             w('</tr>')
707         w('</table>')
709     def shownode(self, message=None, num_re=re.compile('^\d+$')):
710         ''' display an item
711         '''
712         cn = self.classname
713         cl = self.db.classes[cn]
714         keys = self.form.keys()
715         fromremove = 0
716         if self.form.has_key(':multilink'):
717             # is the multilink there because we came from remove()?
718             if self.form.has_key(':target'):
719                 xtra = ''
720                 fromremove = 1
721                 message = _('%s removed' % self.index_arg(":target")[0])
722             else:
723                 link = self.form[':multilink'].value
724                 designator, linkprop = link.split(':')
725                 xtra = ' for <a href="%s">%s</a>' % (designator, designator)
726         else:
727             xtra = ''
728         
729         # possibly perform an edit
730         # don't try to set properties if the user has just logged in
731         if keys and not fromremove and not self.form.has_key('__login_name'):
732             try:
733                 userid = self.db.user.lookup(self.user)
734                 if not self.db.security.hasPermission('Edit', userid, cn):
735                     message = _('You do not have permission to edit %s' %cn)
736                 else:
737                     props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
738                     # make changes to the node
739                     props = self._changenode(props)
740                     # handle linked nodes 
741                     self._post_editnode(self.nodeid)
742                     # and some nice feedback for the user
743                     if props:
744                         message = _('%(changes)s edited ok')%{'changes':
745                             ', '.join(props.keys())}
746                     elif self.form.has_key('__note') and self.form['__note'].value:
747                         message = _('note added')
748                     elif (self.form.has_key('__file') and
749                             self.form['__file'].filename):
750                         message = _('file added')
751                     else:
752                         message = _('nothing changed')
753             except:
754                 self.db.rollback()
755                 s = StringIO.StringIO()
756                 traceback.print_exc(None, s)
757                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
759         # now the display
760         id = self.nodeid
761         if cl.getkey():
762             id = cl.get(id, cl.getkey())
763         self.pagehead('%s: %s %s'%(self.classname.capitalize(), id, xtra),
764             message)
766         nodeid = self.nodeid
768         # use the template to display the item
769         item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES,
770             self.classname)
771         item.render(nodeid)
773         self.pagefoot()
774     showissue = shownode
775     showmsg = shownode
776     searchissue = searchnode
778     def showquery(self):
779         queries = self.db.getclass(self.classname)
780         if self.form.keys():
781             sort = self.index_sort()
782             group = self.index_arg(':group')
783             filter = self.index_arg(':filter')
784             columns = self.index_arg(':columns')
785             filterspec = self.index_filterspec(filter, queries.get(self.nodeid, 'klass'))
786             if self.form.has_key('search_text'):
787                 search_text = self.form['search_text'].value
788                 search_text = urllib.quote(search_text)
789             else:
790                 search_text = ''
791             if self.form.has_key(':pagesize'):
792                 pagesize = int(self.form[':pagesize'].value)
793             else:
794                 pagesize = 50
795             # format a query string
796             qd = {}
797             qd[':sort'] = ','.join(map(urllib.quote, sort))
798             qd[':group'] = ','.join(map(urllib.quote, group))
799             qd[':filter'] = ','.join(map(urllib.quote, filter))
800             qd[':columns'] = ','.join(map(urllib.quote, columns))
801             for k, l in filterspec.items():
802                 qd[urllib.quote(k)] = ','.join(map(urllib.quote, l))
803             url = '&'.join([k+'='+v for k,v in qd.items()])
804             url += '&:pagesize=%s' % pagesize
805             if search_text:
806                 url += '&search_text=%s' % search_text
807             if url != queries.get(self.nodeid, 'url'):
808                 queries.set(self.nodeid, url=url)
809                 message = _('url edited ok')
810             else:
811                 message = _('nothing changed')
812         else:
813             message = None
814         nm = queries.get(self.nodeid, 'name')
815         self.pagehead('%s: %s'%(self.classname.capitalize(), nm), message)
817         # use the template to display the item
818         item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES,
819             self.classname)
820         item.render(self.nodeid)
821         self.pagefoot()
822         
823     def _changenode(self, props):
824         ''' change the node based on the contents of the form
825         '''
826         cl = self.db.classes[self.classname]
828         # create the message
829         message, files = self._handle_message()
830         if message:
831             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
832         if files:
833             props['files'] = cl.get(self.nodeid, 'files') + files
835         # make the changes
836         return cl.set(self.nodeid, **props)
838     def _createnode(self):
839         ''' create a node based on the contents of the form
840         '''
841         cl = self.db.classes[self.classname]
842         props = parsePropsFromForm(self.db, cl, self.form)
844         # check for messages and files
845         message, files = self._handle_message()
846         if message:
847             props['messages'] = [message]
848         if files:
849             props['files'] = files
850         # create the node and return it's id
851         return cl.create(**props)
853     def _handle_message(self):
854         ''' generate an edit message
855         '''
856         # handle file attachments 
857         files = []
858         if self.form.has_key('__file'):
859             file = self.form['__file']
860             if file.filename:
861                 filename = file.filename.split('\\')[-1]
862                 mime_type = mimetypes.guess_type(filename)[0]
863                 if not mime_type:
864                     mime_type = "application/octet-stream"
865                 # create the new file entry
866                 files.append(self.db.file.create(type=mime_type,
867                     name=filename, content=file.file.read()))
869         # we don't want to do a message if none of the following is true...
870         cn = self.classname
871         cl = self.db.classes[self.classname]
872         props = cl.getprops()
873         note = None
874         # in a nutshell, don't do anything if there's no note or there's no
875         # NOSY
876         if self.form.has_key('__note'):
877             note = self.form['__note'].value.strip()
878         if not note:
879             return None, files
880         if not props.has_key('messages'):
881             return None, files
882         if not isinstance(props['messages'], hyperdb.Multilink):
883             return None, files
884         if not props['messages'].classname == 'msg':
885             return None, files
886         if not (self.form.has_key('nosy') or note):
887             return None, files
889         # handle the note
890         if '\n' in note:
891             summary = re.split(r'\n\r?', note)[0]
892         else:
893             summary = note
894         m = ['%s\n'%note]
896         # handle the messageid
897         # TODO: handle inreplyto
898         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
899             self.classname, self.instance.MAIL_DOMAIN)
901         # now create the message, attaching the files
902         content = '\n'.join(m)
903         message_id = self.db.msg.create(author=self.getuid(),
904             recipients=[], date=date.Date('.'), summary=summary,
905             content=content, files=files, messageid=messageid)
907         # update the messages property
908         return message_id, files
910     def _post_editnode(self, nid):
911         '''Do the linking part of the node creation.
913            If a form element has :link or :multilink appended to it, its
914            value specifies a node designator and the property on that node
915            to add _this_ node to as a link or multilink.
917            This is typically used on, eg. the file upload page to indicated
918            which issue to link the file to.
920            TODO: I suspect that this and newfile will go away now that
921            there's the ability to upload a file using the issue __file form
922            element!
923         '''
924         cn = self.classname
925         cl = self.db.classes[cn]
926         # link if necessary
927         keys = self.form.keys()
928         for key in keys:
929             if key == ':multilink':
930                 value = self.form[key].value
931                 if type(value) != type([]): value = [value]
932                 for value in value:
933                     designator, property = value.split(':')
934                     link, nodeid = hyperdb.splitDesignator(designator)
935                     link = self.db.classes[link]
936                     # take a dupe of the list so we're not changing the cache
937                     value = link.get(nodeid, property)[:]
938                     value.append(nid)
939                     link.set(nodeid, **{property: value})
940             elif key == ':link':
941                 value = self.form[key].value
942                 if type(value) != type([]): value = [value]
943                 for value in value:
944                     designator, property = value.split(':')
945                     link, nodeid = hyperdb.splitDesignator(designator)
946                     link = self.db.classes[link]
947                     link.set(nodeid, **{property: nid})
949     def newnode(self, message=None):
950         ''' Add a new node to the database.
951         
952         The form works in two modes: blank form and submission (that is,
953         the submission goes to the same URL). **Eventually this means that
954         the form will have previously entered information in it if
955         submission fails.
957         The new node will be created with the properties specified in the
958         form submission. For multilinks, multiple form entries are handled,
959         as are prop=value,value,value. You can't mix them though.
961         If the new node is to be referenced from somewhere else immediately
962         (ie. the new node is a file that is to be attached to a support
963         issue) then supply one of these arguments in addition to the usual
964         form entries:
965             :link=designator:property
966             :multilink=designator:property
967         ... which means that once the new node is created, the "property"
968         on the node given by "designator" should now reference the new
969         node's id. The node id will be appended to the multilink.
970         '''
971         cn = self.classname
972         userid = self.db.user.lookup(self.user)
973         if not self.db.security.hasPermission('View', userid, cn):
974             raise Unauthorised, _("You do not have permission to access"\
975                         " %(action)s.")%{'action': self.classname}
976         cl = self.db.classes[cn]
977         if self.form.has_key(':multilink'):
978             link = self.form[':multilink'].value
979             designator, linkprop = link.split(':')
980             xtra = ' for <a href="%s">%s</a>' % (designator, designator)
981         else:
982             xtra = ''
984         # possibly perform a create
985         keys = self.form.keys()
986         if [i for i in keys if i[0] != ':']:
987             # no dice if you can't edit!
988             if not self.db.security.hasPermission('Edit', userid, cn):
989                 raise Unauthorised, _("You do not have permission to access"\
990                             " %(action)s.")%{'action': 'new'+self.classname}
991             props = {}
992             try:
993                 nid = self._createnode()
994                 # handle linked nodes 
995                 self._post_editnode(nid)
996                 # and some nice feedback for the user
997                 message = _('%(classname)s created ok')%{'classname': cn}
999                 # render the newly created issue
1000                 self.db.commit()
1001                 self.nodeid = nid
1002                 self.pagehead('%s: %s'%(self.classname.capitalize(), nid),
1003                     message)
1004                 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 
1005                     self.classname)
1006                 item.render(nid)
1007                 self.pagefoot()
1008                 return
1009             except:
1010                 self.db.rollback()
1011                 s = StringIO.StringIO()
1012                 traceback.print_exc(None, s)
1013                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
1014         self.pagehead(_('New %(classname)s %(xtra)s')%{
1015                 'classname': self.classname.capitalize(),
1016                 'xtra': xtra }, message)
1018         # call the template
1019         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
1020             self.classname)
1021         newitem.render(self.form)
1023         self.pagefoot()
1024     newissue = newnode
1026     def newuser(self, message=None):
1027         ''' Add a new user to the database.
1029             Don't do any of the message or file handling, just create the node.
1030         '''
1031         userid = self.db.user.lookup(self.user)
1032         if not self.db.security.hasPermission('Edit', userid, 'user'):
1033             raise Unauthorised, _("You do not have permission to access"\
1034                         " %(action)s.")%{'action': 'newuser'}
1036         cn = self.classname
1037         cl = self.db.classes[cn]
1039         # possibly perform a create
1040         keys = self.form.keys()
1041         if [i for i in keys if i[0] != ':']:
1042             try:
1043                 props = parsePropsFromForm(self.db, cl, self.form)
1044                 nid = cl.create(**props)
1045                 # handle linked nodes 
1046                 self._post_editnode(nid)
1047                 # and some nice feedback for the user
1048                 message = _('%(classname)s created ok')%{'classname': cn}
1049             except:
1050                 self.db.rollback()
1051                 s = StringIO.StringIO()
1052                 traceback.print_exc(None, s)
1053                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
1054         self.pagehead(_('New %(classname)s')%{'classname':
1055              self.classname.capitalize()}, message)
1057         # call the template
1058         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
1059             self.classname)
1060         newitem.render(self.form)
1062         self.pagefoot()
1064     def newfile(self, message=None):
1065         ''' Add a new file to the database.
1066         
1067         This form works very much the same way as newnode - it just has a
1068         file upload.
1069         '''
1070         userid = self.db.user.lookup(self.user)
1071         if not self.db.security.hasPermission('Edit', userid, 'file'):
1072             raise Unauthorised, _("You do not have permission to access"\
1073                         " %(action)s.")%{'action': 'newfile'}
1074         cn = self.classname
1075         cl = self.db.classes[cn]
1076         props = parsePropsFromForm(self.db, cl, self.form)
1077         if self.form.has_key(':multilink'):
1078             link = self.form[':multilink'].value
1079             designator, linkprop = link.split(':')
1080             xtra = ' for <a href="%s">%s</a>' % (designator, designator)
1081         else:
1082             xtra = ''
1084         # possibly perform a create
1085         keys = self.form.keys()
1086         if [i for i in keys if i[0] != ':']:
1087             try:
1088                 file = self.form['content']
1089                 mime_type = mimetypes.guess_type(file.filename)[0]
1090                 if not mime_type:
1091                     mime_type = "application/octet-stream"
1092                 # save the file
1093                 props['type'] = mime_type
1094                 props['name'] = file.filename
1095                 props['content'] = file.file.read()
1096                 nid = cl.create(**props)
1097                 # handle linked nodes
1098                 self._post_editnode(nid)
1099                 # and some nice feedback for the user
1100                 message = _('%(classname)s created ok')%{'classname': cn}
1101             except:
1102                 self.db.rollback()
1103                 s = StringIO.StringIO()
1104                 traceback.print_exc(None, s)
1105                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
1107         self.pagehead(_('New %(classname)s %(xtra)s')%{
1108                 'classname': self.classname.capitalize(),
1109                 'xtra': xtra }, message)
1110         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
1111             self.classname)
1112         newitem.render(self.form)
1113         self.pagefoot()
1115     def showuser(self, message=None, num_re=re.compile('^\d+$')):
1116         '''Display a user page for editing. Make sure the user is allowed
1117            to edit this node, and also check for password changes.
1119            Note: permission checks for this node are handled in the template.
1120         '''
1121         user = self.db.user
1123         # get the username of the node being edited
1124         try:
1125             node_user = user.get(self.nodeid, 'username')
1126         except IndexError:
1127             raise NotFound, 'user%s'%self.nodeid
1129         #
1130         # perform any editing
1131         #
1132         keys = self.form.keys()
1133         if keys:
1134             try:
1135                 props = parsePropsFromForm(self.db, user, self.form,
1136                     self.nodeid)
1137                 set_cookie = 0
1138                 if props.has_key('password'):
1139                     password = self.form['password'].value.strip()
1140                     if not password:
1141                         # no password was supplied - don't change it
1142                         del props['password']
1143                     elif self.nodeid == self.getuid():
1144                         # this is the logged-in user's password
1145                         set_cookie = password
1146                 user.set(self.nodeid, **props)
1147                 # and some feedback for the user
1148                 message = _('%(changes)s edited ok')%{'changes':
1149                     ', '.join(props.keys())}
1150             except:
1151                 self.db.rollback()
1152                 s = StringIO.StringIO()
1153                 traceback.print_exc(None, s)
1154                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
1155         else:
1156             set_cookie = 0
1158         # fix the cookie if the password has changed
1159         if set_cookie:
1160             self.set_cookie(self.user, set_cookie)
1162         #
1163         # now the display
1164         #
1165         self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
1167         # use the template to display the item
1168         item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 'user')
1169         item.render(self.nodeid)
1170         self.pagefoot()
1172     def showfile(self):
1173         ''' display a file
1174         '''
1175         # nothing in xtrapath - edit the file's metadata
1176         if self.xtrapath is None:
1177             return self.shownode()
1179         # something in xtrapath - download the file    
1180         nodeid = self.nodeid
1181         cl = self.db.classes[self.classname]
1182         try:
1183             mime_type = cl.get(nodeid, 'type')
1184         except IndexError:
1185             raise NotFound, 'file%s'%nodeid
1186         if mime_type == 'message/rfc822':
1187             mime_type = 'text/plain'
1188         self.header(headers={'Content-Type': mime_type})
1189         self.write(cl.get(nodeid, 'content'))
1190         
1191     def permission(self):
1192         '''
1193         '''
1195     def classes(self, message=None):
1196         ''' display a list of all the classes in the database
1197         '''
1198         userid = self.db.user.lookup(self.user)
1199         if not self.db.security.hasPermission('Edit', userid):
1200             raise Unauthorised, _("You do not have permission to access"\
1201                         " %(action)s.")%{'action': 'all classes'}
1203         self.pagehead(_('Table of classes'), message)
1204         classnames = self.db.classes.keys()
1205         classnames.sort()
1206         self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
1207         for cn in classnames:
1208             cl = self.db.getclass(cn)
1209             self.write('<tr class="list-header"><th colspan=2 align=left>'
1210                 '<a href="%s">%s</a></th></tr>'%(cn, cn.capitalize()))
1211             for key, value in cl.properties.items():
1212                 if value is None: value = ''
1213                 else: value = str(value)
1214                 self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
1215                     key, cgi.escape(value)))
1216         self.write('</table>')
1217         self.pagefoot()
1219     def unauthorised(self, message):
1220         ''' The user is not authorised to do something. If they're
1221             anonymous, throw up a login box. If not, just tell them they
1222             can't do whatever it was they were trying to do.
1224             Bot cases print up the message, which is most likely the
1225             argument to the Unauthorised exception.
1226         '''
1227         self.header(response=403)
1228         if self.desired_action is None or self.desired_action == 'login':
1229             if not message:
1230                 message=_("You do not have permission.")
1231             action = 'index'
1232         else:
1233             if not message:
1234                 message=_("You do not have permission to access"\
1235                     " %(action)s.")%{'action': self.desired_action}
1236             action = self.desired_action
1237         if self.user == 'anonymous':
1238             self.login(action=action, message=message)
1239         else:
1240             self.pagehead(_('Not Authorised'))
1241             self.write('<p class="system-msg">%s</p>'%message)
1242             self.pagefoot()
1244     def login(self, message=None, newuser_form=None, action='index'):
1245         '''Display a login page.
1246         '''
1247         self.pagehead(_('Login to roundup'))
1248         if message:
1249             self.write('<p class="system-msg">%s</p>'%message)
1250         self.write(_('''
1251 <table>
1252 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
1253 <form onSubmit="return submit_once()" action="login_action" method=POST>
1254 <input type="hidden" name="__destination_url" value="%(action)s">
1255 <tr><td align=right>Login name: </td>
1256     <td><input name="__login_name"></td></tr>
1257 <tr><td align=right>Password: </td>
1258     <td><input type="password" name="__login_password"></td></tr>
1259 <tr><td></td>
1260     <td><input type="submit" value="Log In"></td></tr>
1261 </form>
1262 ''')%locals())
1263         userid = self.db.user.lookup(self.user)
1264         if not self.db.security.hasPermission('Web Registration', userid):
1265             self.write('</table>')
1266             self.pagefoot()
1267             return
1268         values = {'realname': '', 'organisation': '', 'address': '',
1269             'phone': '', 'username': '', 'password': '', 'confirm': '',
1270             'action': action, 'alternate_addresses': ''}
1271         if newuser_form is not None:
1272             for key in newuser_form.keys():
1273                 values[key] = newuser_form[key].value
1274         self.write(_('''
1275 <p>
1276 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
1277 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
1278 <form onSubmit="return submit_once()" action="newuser_action" method=POST>
1279 <input type="hidden" name="__destination_url" value="%(action)s">
1280 <tr><td align=right><em>Name: </em></td>
1281     <td><input name="realname" value="%(realname)s" size=40></td></tr>
1282 <tr><td align=right><em>Organisation: </em></td>
1283     <td><input name="organisation" value="%(organisation)s" size=40></td></tr>
1284 <tr><td align=right>E-Mail Address: </td>
1285     <td><input name="address" value="%(address)s" size=40></td></tr>
1286 <tr><td align=right><em>Alternate E-mail Addresses: </em></td>
1287     <td><textarea name="alternate_addresses" rows=5 cols=40>%(alternate_addresses)s</textarea></td></tr>
1288 <tr><td align=right><em>Phone: </em></td>
1289     <td><input name="phone" value="%(phone)s"></td></tr>
1290 <tr><td align=right>Preferred Login name: </td>
1291     <td><input name="username" value="%(username)s"></td></tr>
1292 <tr><td align=right>Password: </td>
1293     <td><input type="password" name="password" value="%(password)s"></td></tr>
1294 <tr><td align=right>Password Again: </td>
1295     <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
1296 <tr><td></td>
1297     <td><input type="submit" value="Register"></td></tr>
1298 </form>
1299 </table>
1300 ''')%values)
1301         self.pagefoot()
1303     def login_action(self, message=None):
1304         '''Attempt to log a user in and set the cookie
1306         returns 0 if a page is generated as a result of this call, and
1307         1 if not (ie. the login is successful
1308         '''
1309         if not self.form.has_key('__login_name'):
1310             self.login(message=_('Username required'))
1311             return 0
1312         self.user = self.form['__login_name'].value
1313         # re-open the database for real, using the user
1314         self.opendb(self.user)
1315         if self.form.has_key('__login_password'):
1316             password = self.form['__login_password'].value
1317         else:
1318             password = ''
1319         # make sure the user exists
1320         try:
1321             uid = self.db.user.lookup(self.user)
1322         except KeyError:
1323             name = self.user
1324             self.make_user_anonymous()
1325             action = self.form['__destination_url'].value
1326             self.login(message=_('No such user "%(name)s"')%locals(),
1327                 action=action)
1328             return 0
1330         # and that the password is correct
1331         pw = self.db.user.get(uid, 'password')
1332         if password != pw:
1333             self.make_user_anonymous()
1334             action = self.form['__destination_url'].value
1335             self.login(message=_('Incorrect password'), action=action)
1336             return 0
1338         self.set_cookie(self.user, password)
1339         return 1
1341     def newuser_action(self, message=None):
1342         '''Attempt to create a new user based on the contents of the form
1343         and then set the cookie.
1345         return 1 on successful login
1346         '''
1347         # make sure we're allowed to register
1348         userid = self.db.user.lookup(self.user)
1349         if not self.db.security.hasPermission('Web Registration', userid):
1350             raise Unauthorised, _("You do not have permission to access"\
1351                         " %(action)s.")%{'action': 'registration'}
1353         # re-open the database as "admin"
1354         if self.user != 'admin':
1355             self.opendb('admin')
1356             
1357         # create the new user
1358         cl = self.db.user
1359         try:
1360             props = parsePropsFromForm(self.db, cl, self.form)
1361             props['roles'] = self.instance.NEW_WEB_USER_ROLES
1362             uid = cl.create(**props)
1363             self.db.commit()
1364         except ValueError, message:
1365             action = self.form['__destination_url'].value
1366             self.login(message, action=action)
1367             return 0
1369         # log the new user in
1370         self.user = cl.get(uid, 'username')
1371         # re-open the database for real, using the user
1372         self.opendb(self.user)
1373         password = cl.get(uid, 'password')
1374         self.set_cookie(self.user, password)
1375         return 1
1377     def set_cookie(self, user, password):
1378         # TODO generate a much, much stronger session key ;)
1379         self.session = binascii.b2a_base64(repr(time.time())).strip()
1381         # clean up the base64
1382         if self.session[-1] == '=':
1383             if self.session[-2] == '=':
1384                 self.session = self.session[:-2]
1385             else:
1386                 self.session = self.session[:-1]
1388         # insert the session in the sessiondb
1389         self.db.sessions.set(self.session, user=user, last_use=time.time())
1391         # and commit immediately
1392         self.db.sessions.commit()
1394         # expire us in a long, long time
1395         expire = Cookie._getdate(86400*365)
1397         # generate the cookie path - make sure it has a trailing '/'
1398         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
1399             ''))
1400         self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;'%(
1401             self.session, expire, path)})
1403     def make_user_anonymous(self):
1404         ''' Make us anonymous
1406             This method used to handle non-existence of the 'anonymous'
1407             user, but that user is mandatory now.
1408         '''
1409         self.db.user.lookup('anonymous')
1410         self.user = 'anonymous'
1412     def logout(self, message=None):
1413         ''' Make us really anonymous - nuke the cookie too
1414         '''
1415         self.make_user_anonymous()
1417         # construct the logout cookie
1418         now = Cookie._getdate()
1419         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
1420             ''))
1421         self.header({'Set-Cookie':
1422             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
1423             path)})
1424         self.login()
1426     def opendb(self, user):
1427         ''' Open the database - but include the definition of the sessions db.
1428         '''
1429         # open the db if the user has changed
1430         if not hasattr(self, 'db') or user != self.db.journaltag:
1431             self.db = self.instance.open(user)
1433     def main(self):
1434         ''' Wrap the request and handle unauthorised requests
1435         '''
1436         self.desired_action = None
1437         try:
1438             self.main_action()
1439         except Unauthorised, message:
1440             self.unauthorised(message)
1442     def main_action(self):
1443         '''Wrap the database accesses so we can close the database cleanly
1444         '''
1445         # determine the uid to use
1446         self.opendb('admin')
1448         # make sure we have the session Class
1449         sessions = self.db.sessions
1451         # age sessions, remove when they haven't been used for a week
1452         # TODO: this shouldn't be done every access
1453         week = 60*60*24*7
1454         now = time.time()
1455         for sessid in sessions.list():
1456             interval = now - sessions.get(sessid, 'last_use')
1457             if interval > week:
1458                 sessions.destroy(sessid)
1460         # look up the user session cookie
1461         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
1462         user = 'anonymous'
1464         if (cookie.has_key('roundup_user') and
1465                 cookie['roundup_user'].value != 'deleted'):
1467             # get the session key from the cookie
1468             self.session = cookie['roundup_user'].value
1469             # get the user from the session
1470             try:
1471                 # update the lifetime datestamp
1472                 sessions.set(self.session, last_use=time.time())
1473                 sessions.commit()
1474                 user = sessions.get(self.session, 'user')
1475             except KeyError:
1476                 user = 'anonymous'
1478         # sanity check on the user still being valid
1479         try:
1480             self.db.user.lookup(user)
1481         except (KeyError, TypeError):
1482             user = 'anonymous'
1484         # make sure the anonymous user is valid if we're using it
1485         if user == 'anonymous':
1486             self.make_user_anonymous()
1487         else:
1488             self.user = user
1490         # now figure which function to call
1491         path = self.split_path
1492         self.xtrapath = None
1494         # default action to index if the path has no information in it
1495         if not path or path[0] in ('', 'index'):
1496             action = 'index'
1497         else:
1498             action = path[0]
1499             if len(path) > 1:
1500                 self.xtrapath = path[1:]
1501         self.desired_action = action
1503         # everyone is allowed to try to log in
1504         if action == 'login_action':
1505             # try to login
1506             if not self.login_action():
1507                 return
1508             # figure the resulting page
1509             action = self.form['__destination_url'].value
1511         # allow anonymous people to register
1512         elif action == 'newuser_action':
1513             # try to add the user
1514             if not self.newuser_action():
1515                 return
1516             # figure the resulting page
1517             action = self.form['__destination_url'].value
1519         # ok, now we have figured out who the user is, make sure the user
1520         # has permission to use this interface
1521         userid = self.db.user.lookup(self.user)
1522         if not self.db.security.hasPermission('Web Access', userid):
1523             raise Unauthorised, \
1524                 _("You do not have permission to access this interface.")
1526         # re-open the database for real, using the user
1527         self.opendb(self.user)
1529         # make sure we have a sane action
1530         if not action:
1531             action = 'index'
1533         # just a regular action
1534         try:
1535             self.do_action(action)
1536         except Unauthorised, message:
1537             # if unauth is raised here, then a page header will have 
1538             # been displayed
1539             self.write('<p class="system-msg">%s</p>'%message)
1540         else:
1541             # commit all changes to the database
1542             self.db.commit()
1544     def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
1545             nre=re.compile(r'new(\w+)'), sre=re.compile(r'search(\w+)')):
1546         '''Figure the user's action and do it.
1547         '''
1548         # here be the "normal" functionality
1549         if action == 'index':
1550             self.index()
1551             return
1552         if action == 'list_classes':
1553             self.classes()
1554             return
1555         if action == 'classhelp':
1556             self.classhelp()
1557             return
1558         if action == 'login':
1559             self.login()
1560             return
1561         if action == 'logout':
1562             self.logout()
1563             return
1564         if action == 'remove':
1565             self.remove()
1566             return
1568         # see if we're to display an existing node
1569         m = dre.match(action)
1570         if m:
1571             self.classname = m.group(1)
1572             self.nodeid = m.group(2)
1573             try:
1574                 cl = self.db.classes[self.classname]
1575             except KeyError:
1576                 raise NotFound, self.classname
1577             try:
1578                 cl.get(self.nodeid, 'id')
1579             except IndexError:
1580                 raise NotFound, self.nodeid
1581             try:
1582                 func = getattr(self, 'show%s'%self.classname)
1583             except AttributeError:
1584                 raise NotFound, 'show%s'%self.classname
1585             func()
1586             return
1588         # see if we're to put up the new node page
1589         m = nre.match(action)
1590         if m:
1591             self.classname = m.group(1)
1592             try:
1593                 func = getattr(self, 'new%s'%self.classname)
1594             except AttributeError:
1595                 raise NotFound, 'new%s'%self.classname
1596             func()
1597             return
1599         # see if we're to put up the new node page
1600         m = sre.match(action)
1601         if m:
1602             self.classname = m.group(1)
1603             try:
1604                 func = getattr(self, 'search%s'%self.classname)
1605             except AttributeError:
1606                 raise NotFound
1607             func()
1608             return
1610         # otherwise, display the named class
1611         self.classname = action
1612         try:
1613             self.db.getclass(self.classname)
1614         except KeyError:
1615             raise NotFound, self.classname
1616         self.list()
1618     def remove(self,  dre=re.compile(r'([^\d]+)(\d+)')):
1619         target = self.index_arg(':target')[0]
1620         m = dre.match(target)
1621         if m:
1622             classname = m.group(1)
1623             nodeid = m.group(2)
1624             cl = self.db.getclass(classname)
1625             cl.retire(nodeid)
1626             # now take care of the reference
1627             parentref =  self.index_arg(':multilink')[0]
1628             parent, prop = parentref.split(':')
1629             m = dre.match(parent)
1630             if m:
1631                 self.classname = m.group(1)
1632                 self.nodeid = m.group(2)
1633                 cl = self.db.getclass(self.classname)
1634                 value = cl.get(self.nodeid, prop)
1635                 value.remove(nodeid)
1636                 cl.set(self.nodeid, **{prop:value})
1637                 func = getattr(self, 'show%s'%self.classname)
1638                 return func()
1639             else:
1640                 raise NotFound, parent
1641         else:
1642             raise NotFound, target
1644 class ExtendedClient(Client): 
1645     '''Includes pages and page heading information that relate to the
1646        extended schema.
1647     ''' 
1648     showsupport = Client.shownode
1649     showtimelog = Client.shownode
1650     newsupport = Client.newnode
1651     newtimelog = Client.newnode
1652     searchsupport = Client.searchnode
1654     default_index_sort = ['-activity']
1655     default_index_group = ['priority']
1656     default_index_filter = ['status']
1657     default_index_columns = ['activity','status','title','assignedto']
1658     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
1659     default_pagesize = '50'
1661 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1662     '''Pull properties for the given class out of the form.
1663     '''
1664     props = {}
1665     keys = form.keys()
1666     for key in keys:
1667         if not cl.properties.has_key(key):
1668             continue
1669         proptype = cl.properties[key]
1670         if isinstance(proptype, hyperdb.String):
1671             value = form[key].value.strip()
1672         elif isinstance(proptype, hyperdb.Password):
1673             value = password.Password(form[key].value.strip())
1674         elif isinstance(proptype, hyperdb.Date):
1675             value = form[key].value.strip()
1676             if value:
1677                 value = date.Date(form[key].value.strip())
1678             else:
1679                 value = None
1680         elif isinstance(proptype, hyperdb.Interval):
1681             value = form[key].value.strip()
1682             if value:
1683                 value = date.Interval(form[key].value.strip())
1684             else:
1685                 value = None
1686         elif isinstance(proptype, hyperdb.Link):
1687             value = form[key].value.strip()
1688             # see if it's the "no selection" choice
1689             if value == '-1':
1690                 value = None
1691             else:
1692                 # handle key values
1693                 link = cl.properties[key].classname
1694                 if not num_re.match(value):
1695                     try:
1696                         value = db.classes[link].lookup(value)
1697                     except KeyError:
1698                         raise ValueError, _('property "%(propname)s": '
1699                             '%(value)s not a %(classname)s')%{'propname':key, 
1700                             'value': value, 'classname': link}
1701         elif isinstance(proptype, hyperdb.Multilink):
1702             value = form[key]
1703             if hasattr(value, 'value'):
1704                 # Quite likely to be a FormItem instance
1705                 value = value.value
1706             if not isinstance(value, type([])):
1707                 value = [i.strip() for i in value.split(',')]
1708             else:
1709                 value = [i.strip() for i in value]
1710             link = cl.properties[key].classname
1711             l = []
1712             for entry in map(str, value):
1713                 if entry == '': continue
1714                 if not num_re.match(entry):
1715                     try:
1716                         entry = db.classes[link].lookup(entry)
1717                     except KeyError:
1718                         raise ValueError, _('property "%(propname)s": '
1719                             '"%(value)s" not an entry of %(classname)s')%{
1720                             'propname':key, 'value': entry, 'classname': link}
1721                 l.append(entry)
1722             l.sort()
1723             value = l
1724         elif isinstance(proptype, hyperdb.Boolean):
1725             value = form[key].value.strip()
1726             props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1727         elif isinstance(proptype, hyperdb.Number):
1728             value = form[key].value.strip()
1729             props[key] = value = int(value)
1731         # get the old value
1732         if nodeid:
1733             try:
1734                 existing = cl.get(nodeid, key)
1735             except KeyError:
1736                 # this might be a new property for which there is no existing
1737                 # value
1738                 if not cl.properties.has_key(key): raise
1740             # if changed, set it
1741             if value != existing:
1742                 props[key] = value
1743         else:
1744             props[key] = value
1745     return props
1748 # $Log: not supported by cvs2svn $
1749 # Revision 1.158  2002/08/15 00:40:10  richard
1750 # cleanup
1752 # Revision 1.157  2002/08/13 20:16:09  gmcm
1753 # Use a real parser for templates.
1754 # Rewrite htmltemplate to use the parser (hack, hack).
1755 # Move the "do_XXX" methods to template_funcs.py.
1756 # Redo the funcion tests (but not Template tests - they're hopeless).
1757 # Simplified query form in cgi_client.
1758 # Ability to delete msgs, files, queries.
1759 # Ability to edit the metadata on files.
1761 # Revision 1.156  2002/08/01 15:06:06  gmcm
1762 # Use same regex to split search terms as used to index text.
1763 # Fix to back_metakit for not changing journaltag on reopen.
1764 # Fix htmltemplate's do_link so [No <whatever>] strings are href'd.
1765 # Fix bogus "nosy edited ok" msg - the **d syntax does NOT share d between caller and callee.
1767 # Revision 1.155  2002/08/01 00:56:22  richard
1768 # Added the web access and email access permissions, so people can restrict
1769 # access to users who register through the email interface (for example).
1770 # Also added "security" command to the roundup-admin interface to display the
1771 # Role/Permission config for an instance.
1773 # Revision 1.154  2002/07/31 23:57:36  richard
1774 #  . web forms may now unset Link values (like assignedto)
1776 # Revision 1.153  2002/07/31 22:40:50  gmcm
1777 # Fixes to the search form and saving queries.
1778 # Fixes to  sorting in back_metakit.py.
1780 # Revision 1.152  2002/07/31 22:04:14  richard
1781 # cleanup
1783 # Revision 1.151  2002/07/30 21:37:43  richard
1784 # oops, thanks Duncan Booth for spotting this one
1786 # Revision 1.150  2002/07/30 20:43:18  gmcm
1787 # Oops, fix the permission check!
1789 # Revision 1.149  2002/07/30 20:04:38  gmcm
1790 # Adapt metakit backend to new security scheme.
1791 # Put some more permission checks in cgi_client.
1793 # Revision 1.148  2002/07/30 16:09:11  gmcm
1794 # Simple optimization.
1796 # Revision 1.147  2002/07/30 08:22:38  richard
1797 # Session storage in the hyperdb was horribly, horribly inefficient. We use
1798 # a simple anydbm wrapper now - which could be overridden by the metakit
1799 # backend or RDB backend if necessary.
1800 # Much, much better.
1802 # Revision 1.146  2002/07/30 05:27:30  richard
1803 # nicer error messages, and a bugfix
1805 # Revision 1.145  2002/07/26 08:26:59  richard
1806 # Very close now. The cgi and mailgw now use the new security API. The two
1807 # templates have been migrated to that setup. Lots of unit tests. Still some
1808 # issue in the web form for editing Roles assigned to users.
1810 # Revision 1.144  2002/07/25 07:14:05  richard
1811 # Bugger it. Here's the current shape of the new security implementation.
1812 # Still to do:
1813 #  . call the security funcs from cgi and mailgw
1814 #  . change shipped templates to include correct initialisation and remove
1815 #    the old config vars
1816 # ... that seems like a lot. The bulk of the work has been done though. Honest :)
1818 # Revision 1.143  2002/07/20 19:29:10  gmcm
1819 # Fixes/improvements to the search form & saved queries.
1821 # Revision 1.142  2002/07/18 11:17:30  gmcm
1822 # Add Number and Boolean types to hyperdb.
1823 # Add conversion cases to web, mail & admin interfaces.
1824 # Add storage/serialization cases to back_anydbm & back_metakit.
1826 # Revision 1.141  2002/07/17 12:39:10  gmcm
1827 # Saving, running & editing queries.
1829 # Revision 1.140  2002/07/14 23:17:15  richard
1830 # cleaned up structure
1832 # Revision 1.139  2002/07/14 06:14:40  richard
1833 # Some more TODOs
1835 # Revision 1.138  2002/07/14 04:03:13  richard
1836 # Implemented a switch to disable journalling for a Class. CGI session
1837 # database now uses it.
1839 # Revision 1.137  2002/07/10 07:00:30  richard
1840 # removed debugging
1842 # Revision 1.136  2002/07/10 06:51:08  richard
1843 # . #576241 ] MultiLink problems in parsePropsFromForm
1845 # Revision 1.135  2002/07/10 00:22:34  richard
1846 #  . switched to using a session-based web login
1848 # Revision 1.134  2002/07/09 04:19:09  richard
1849 # Added reindex command to roundup-admin.
1850 # Fixed reindex on first access.
1851 # Also fixed reindexing of entries that change.
1853 # Revision 1.133  2002/07/08 15:32:05  gmcm
1854 # Pagination of index pages.
1855 # New search form.
1857 # Revision 1.132  2002/07/08 07:26:14  richard
1858 # ehem
1860 # Revision 1.131  2002/07/08 06:53:57  richard
1861 # Not sure why the cgi_client had an indexer argument.
1863 # Revision 1.130  2002/06/27 12:01:53  gmcm
1864 # If the form has a :multilink, put a back href in the pageheader (back to the linked-to node).
1865 # Some minor optimizations (only compile regexes once).
1867 # Revision 1.129  2002/06/20 23:52:11  richard
1868 # Better handling of unauth attempt to edit stuff
1870 # Revision 1.128  2002/06/12 21:28:25  gmcm
1871 # Allow form to set user-properties on a Fileclass.
1872 # Don't assume that a Fileclass is named "files".
1874 # Revision 1.127  2002/06/11 06:38:24  richard
1875 #  . #565996 ] The "Attach a File to this Issue" fails
1877 # Revision 1.126  2002/05/29 01:16:17  richard
1878 # Sorry about this huge checkin! It's fixing a lot of related stuff in one go
1879 # though.
1881 # . #541941 ] changing multilink properties by mail
1882 # . #526730 ] search for messages capability
1883 # . #505180 ] split MailGW.handle_Message
1884 #   - also changed cgi client since it was duplicating the functionality
1885 # . build htmlbase if tests are run using CVS checkout (removed note from
1886 #   installation.txt)
1887 # . don't create an empty message on email issue creation if the email is empty
1889 # Revision 1.125  2002/05/25 07:16:24  rochecompaan
1890 # Merged search_indexing-branch with HEAD
1892 # Revision 1.124  2002/05/24 02:09:24  richard
1893 # Nothing like a live demo to show up the bugs ;)
1895 # Revision 1.123  2002/05/22 05:04:13  richard
1896 # Oops
1898 # Revision 1.122  2002/05/22 04:12:05  richard
1899 #  . applied patch #558876 ] cgi client customization
1900 #    ... with significant additions and modifications ;)
1901 #    - extended handling of ML assignedto to all places it's handled
1902 #    - added more NotFound info
1904 # Revision 1.121  2002/05/21 06:08:10  richard
1905 # Handle migration
1907 # Revision 1.120  2002/05/21 06:05:53  richard
1908 #  . #551483 ] assignedto in Client.make_index_link
1910 # Revision 1.119  2002/05/15 06:21:21  richard
1911 #  . node caching now works, and gives a small boost in performance
1913 # As a part of this, I cleaned up the DEBUG output and implemented TRACE
1914 # output (HYPERDBTRACE='file to trace to') with checkpoints at the start of
1915 # CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff
1916 # (using if __debug__ which is compiled out with -O)
1918 # Revision 1.118  2002/05/12 23:46:33  richard
1919 # ehem, part 2
1921 # Revision 1.117  2002/05/12 23:42:29  richard
1922 # ehem
1924 # Revision 1.116  2002/05/02 08:07:49  richard
1925 # Added the ADD_AUTHOR_TO_NOSY handling to the CGI interface.
1927 # Revision 1.115  2002/04/02 01:56:10  richard
1928 #  . stop sending blank (whitespace-only) notes
1930 # Revision 1.114.2.4  2002/05/02 11:49:18  rochecompaan
1931 # Allow customization of the search filters that should be displayed
1932 # on the search page.
1934 # Revision 1.114.2.3  2002/04/20 13:23:31  rochecompaan
1935 # We now have a separate search page for nodes.  Search links for
1936 # different classes can be customized in instance_config similar to
1937 # index links.
1939 # Revision 1.114.2.2  2002/04/19 19:54:42  rochecompaan
1940 # cgi_client.py
1941 #     removed search link for the time being
1942 #     moved rendering of matches to htmltemplate
1943 # hyperdb.py
1944 #     filtering of nodes on full text search incorporated in filter method
1945 # roundupdb.py
1946 #     added paramater to call of filter method
1947 # roundup_indexer.py
1948 #     added search method to RoundupIndexer class
1950 # Revision 1.114.2.1  2002/04/03 11:55:57  rochecompaan
1951 #  . Added feature #526730 - search for messages capability
1953 # Revision 1.114  2002/03/17 23:06:05  richard
1954 # oops
1956 # Revision 1.113  2002/03/14 23:59:24  richard
1957 #  . #517734 ] web header customisation is obscure
1959 # Revision 1.112  2002/03/12 22:52:26  richard
1960 # more pychecker warnings removed
1962 # Revision 1.111  2002/02/25 04:32:21  richard
1963 # ahem
1965 # Revision 1.110  2002/02/21 07:19:08  richard
1966 # ... and label, width and height control for extra flavour!
1968 # Revision 1.109  2002/02/21 07:08:19  richard
1969 # oops
1971 # Revision 1.108  2002/02/21 07:02:54  richard
1972 # The correct var is "HTTP_HOST"
1974 # Revision 1.107  2002/02/21 06:57:38  richard
1975 #  . Added popup help for classes using the classhelp html template function.
1976 #    - add <display call="classhelp('priority', 'id,name,description')">
1977 #      to an item page, and it generates a link to a popup window which displays
1978 #      the id, name and description for the priority class. The description
1979 #      field won't exist in most installations, but it will be added to the
1980 #      default templates.
1982 # Revision 1.106  2002/02/21 06:23:00  richard
1983 # *** empty log message ***
1985 # Revision 1.105  2002/02/20 05:52:10  richard
1986 # better error handling
1988 # Revision 1.104  2002/02/20 05:45:17  richard
1989 # Use the csv module for generating the form entry so it's correct.
1990 # [also noted the sf.net feature request id in the change log]
1992 # Revision 1.103  2002/02/20 05:05:28  richard
1993 #  . Added simple editing for classes that don't define a templated interface.
1994 #    - access using the admin "class list" interface
1995 #    - limited to admin-only
1996 #    - requires the csv module from object-craft (url given if it's missing)
1998 # Revision 1.102  2002/02/15 07:08:44  richard
1999 #  . Alternate email addresses are now available for users. See the MIGRATION
2000 #    file for info on how to activate the feature.
2002 # Revision 1.101  2002/02/14 23:39:18  richard
2003 # . All forms now have "double-submit" protection when Javascript is enabled
2004 #   on the client-side.
2006 # Revision 1.100  2002/01/16 07:02:57  richard
2007 #  . lots of date/interval related changes:
2008 #    - more relaxed date format for input
2010 # Revision 1.99  2002/01/16 03:02:42  richard
2011 # #503793 ] changing assignedto resets nosy list
2013 # Revision 1.98  2002/01/14 02:20:14  richard
2014 #  . changed all config accesses so they access either the instance or the
2015 #    config attriubute on the db. This means that all config is obtained from
2016 #    instance_config instead of the mish-mash of classes. This will make
2017 #    switching to a ConfigParser setup easier too, I hope.
2019 # At a minimum, this makes migration a _little_ easier (a lot easier in the
2020 # 0.5.0 switch, I hope!)
2022 # Revision 1.97  2002/01/11 23:22:29  richard
2023 #  . #502437 ] rogue reactor and unittest
2024 #    in short, the nosy reactor was modifying the nosy list. That code had
2025 #    been there for a long time, and I suspsect it was there because we
2026 #    weren't generating the nosy list correctly in other places of the code.
2027 #    We're now doing that, so the nosy-modifying code can go away from the
2028 #    nosy reactor.
2030 # Revision 1.96  2002/01/10 05:26:10  richard
2031 # missed a parsePropsFromForm in last update
2033 # Revision 1.95  2002/01/10 03:39:45  richard
2034 #  . fixed some problems with web editing and change detection
2036 # Revision 1.94  2002/01/09 13:54:21  grubert
2037 # _add_assignedto_to_nosy did set nosy to assignedto only, no adding.
2039 # Revision 1.93  2002/01/08 11:57:12  richard
2040 # crying out for real configuration handling... :(
2042 # Revision 1.92  2002/01/08 04:12:05  richard
2043 # Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
2045 # Revision 1.91  2002/01/08 04:03:47  richard
2046 # I mucked the intent of the code up.
2048 # Revision 1.90  2002/01/08 03:56:55  richard
2049 # Oops, missed this before the beta:
2050 #  . #495392 ] empty nosy -patch
2052 # Revision 1.89  2002/01/07 20:24:45  richard
2053 # *mutter* stupid cutnpaste
2055 # Revision 1.88  2002/01/02 02:31:38  richard
2056 # Sorry for the huge checkin message - I was only intending to implement #496356
2057 # but I found a number of places where things had been broken by transactions:
2058 #  . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
2059 #    for _all_ roundup-generated smtp messages to be sent to.
2060 #  . the transaction cache had broken the roundupdb.Class set() reactors
2061 #  . newly-created author users in the mailgw weren't being committed to the db
2063 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
2064 # on when I found that stuff :):
2065 #  . #496356 ] Use threading in messages
2066 #  . detectors were being registered multiple times
2067 #  . added tests for mailgw
2068 #  . much better attaching of erroneous messages in the mail gateway
2070 # Revision 1.87  2001/12/23 23:18:49  richard
2071 # We already had an admin-specific section of the web heading, no need to add
2072 # another one :)
2074 # Revision 1.86  2001/12/20 15:43:01  rochecompaan
2075 # Features added:
2076 #  .  Multilink properties are now displayed as comma separated values in
2077 #     a textbox
2078 #  .  The add user link is now only visible to the admin user
2079 #  .  Modified the mail gateway to reject submissions from unknown
2080 #     addresses if ANONYMOUS_ACCESS is denied
2082 # Revision 1.85  2001/12/20 06:13:24  rochecompaan
2083 # Bugs fixed:
2084 #   . Exception handling in hyperdb for strings-that-look-like numbers got
2085 #     lost somewhere
2086 #   . Internet Explorer submits full path for filename - we now strip away
2087 #     the path
2088 # Features added:
2089 #   . Link and multilink properties are now displayed sorted in the cgi
2090 #     interface
2092 # Revision 1.84  2001/12/18 15:30:30  rochecompaan
2093 # Fixed bugs:
2094 #  .  Fixed file creation and retrieval in same transaction in anydbm
2095 #     backend
2096 #  .  Cgi interface now renders new issue after issue creation
2097 #  .  Could not set issue status to resolved through cgi interface
2098 #  .  Mail gateway was changing status back to 'chatting' if status was
2099 #     omitted as an argument
2101 # Revision 1.83  2001/12/15 23:51:01  richard
2102 # Tested the changes and fixed a few problems:
2103 #  . files are now attached to the issue as well as the message
2104 #  . newuser is a real method now since we don't want to do the message/file
2105 #    stuff for it
2106 #  . added some documentation
2107 # The really big changes in the diff are a result of me moving some code
2108 # around to keep like methods together a bit better.
2110 # Revision 1.82  2001/12/15 19:24:39  rochecompaan
2111 #  . Modified cgi interface to change properties only once all changes are
2112 #    collected, files created and messages generated.
2113 #  . Moved generation of change note to nosyreactors.
2114 #  . We now check for changes to "assignedto" to ensure it's added to the
2115 #    nosy list.
2117 # Revision 1.81  2001/12/12 23:55:00  richard
2118 # Fixed some problems with user editing
2120 # Revision 1.80  2001/12/12 23:27:14  richard
2121 # Added a Zope frontend for roundup.
2123 # Revision 1.79  2001/12/10 22:20:01  richard
2124 # Enabled transaction support in the bsddb backend. It uses the anydbm code
2125 # where possible, only replacing methods where the db is opened (it uses the
2126 # btree opener specifically.)
2127 # Also cleaned up some change note generation.
2128 # Made the backends package work with pydoc too.
2130 # Revision 1.78  2001/12/07 05:59:27  rochecompaan
2131 # Fixed small bug that prevented adding issues through the web.
2133 # Revision 1.77  2001/12/06 22:48:29  richard
2134 # files multilink was being nuked in post_edit_node
2136 # Revision 1.76  2001/12/05 14:26:44  rochecompaan
2137 # Removed generation of change note from "sendmessage" in roundupdb.py.
2138 # The change note is now generated when the message is created.
2140 # Revision 1.75  2001/12/04 01:25:08  richard
2141 # Added some rollbacks where we were catching exceptions that would otherwise
2142 # have stopped committing.
2144 # Revision 1.74  2001/12/02 05:06:16  richard
2145 # . We now use weakrefs in the Classes to keep the database reference, so
2146 #   the close() method on the database is no longer needed.
2147 #   I bumped the minimum python requirement up to 2.1 accordingly.
2148 # . #487480 ] roundup-server
2149 # . #487476 ] INSTALL.txt
2151 # I also cleaned up the change message / post-edit stuff in the cgi client.
2152 # There's now a clearly marked "TODO: append the change note" where I believe
2153 # the change note should be added there. The "changes" list will obviously
2154 # have to be modified to be a dict of the changes, or somesuch.
2156 # More testing needed.
2158 # Revision 1.73  2001/12/01 07:17:50  richard
2159 # . We now have basic transaction support! Information is only written to
2160 #   the database when the commit() method is called. Only the anydbm
2161 #   backend is modified in this way - neither of the bsddb backends have been.
2162 #   The mail, admin and cgi interfaces all use commit (except the admin tool
2163 #   doesn't have a commit command, so interactive users can't commit...)
2164 # . Fixed login/registration forwarding the user to the right page (or not,
2165 #   on a failure)
2167 # Revision 1.72  2001/11/30 20:47:58  rochecompaan
2168 # Links in page header are now consistent with default sort order.
2170 # Fixed bugs:
2171 #     - When login failed the list of issues were still rendered.
2172 #     - User was redirected to index page and not to his destination url
2173 #       if his first login attempt failed.
2175 # Revision 1.71  2001/11/30 20:28:10  rochecompaan
2176 # Property changes are now completely traceable, whether changes are
2177 # made through the web or by email
2179 # Revision 1.70  2001/11/30 00:06:29  richard
2180 # Converted roundup/cgi_client.py to use _()
2181 # Added the status file, I18N_PROGRESS.txt
2183 # Revision 1.69  2001/11/29 23:19:51  richard
2184 # Removed the "This issue has been edited through the web" when a valid
2185 # change note is supplied.
2187 # Revision 1.68  2001/11/29 04:57:23  richard
2188 # a little comment
2190 # Revision 1.67  2001/11/28 21:55:35  richard
2191 #  . login_action and newuser_action return values were being ignored
2192 #  . Woohoo! Found that bloody re-login bug that was killing the mail
2193 #    gateway.
2194 #  (also a minor cleanup in hyperdb)
2196 # Revision 1.66  2001/11/27 03:00:50  richard
2197 # couple of bugfixes from latest patch integration
2199 # Revision 1.65  2001/11/26 23:00:53  richard
2200 # This config stuff is getting to be a real mess...
2202 # Revision 1.64  2001/11/26 22:56:35  richard
2203 # typo
2205 # Revision 1.63  2001/11/26 22:55:56  richard
2206 # Feature:
2207 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
2208 #    the instance.
2209 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
2210 #    signature info in e-mails.
2211 #  . Some more flexibility in the mail gateway and more error handling.
2212 #  . Login now takes you to the page you back to the were denied access to.
2214 # Fixed:
2215 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
2217 # Revision 1.62  2001/11/24 00:45:42  jhermann
2218 # typeof() instead of type(): avoid clash with database field(?) "type"
2220 # Fixes this traceback:
2222 # Traceback (most recent call last):
2223 #   File "roundup\cgi_client.py", line 535, in newnode
2224 #     self._post_editnode(nid)
2225 #   File "roundup\cgi_client.py", line 415, in _post_editnode
2226 #     if type(value) != type([]): value = [value]
2227 # UnboundLocalError: local variable 'type' referenced before assignment
2229 # Revision 1.61  2001/11/22 15:46:42  jhermann
2230 # Added module docstrings to all modules.
2232 # Revision 1.60  2001/11/21 22:57:28  jhermann
2233 # Added dummy hooks for I18N and some preliminary (test) markup of
2234 # translatable messages
2236 # Revision 1.59  2001/11/21 03:21:13  richard
2237 # oops
2239 # Revision 1.58  2001/11/21 03:11:28  richard
2240 # Better handling of new properties.
2242 # Revision 1.57  2001/11/15 10:24:27  richard
2243 # handle the case where there is no file attached
2245 # Revision 1.56  2001/11/14 21:35:21  richard
2246 #  . users may attach files to issues (and support in ext) through the web now
2248 # Revision 1.55  2001/11/07 02:34:06  jhermann
2249 # Handling of damaged login cookies
2251 # Revision 1.54  2001/11/07 01:16:12  richard
2252 # Remove the '=' padding from cookie value so quoting isn't an issue.
2254 # Revision 1.53  2001/11/06 23:22:05  jhermann
2255 # More IE fixes: it does not like quotes around cookie values; in the
2256 # hope this does not break anything for other browser; if it does, we
2257 # need to check HTTP_USER_AGENT
2259 # Revision 1.52  2001/11/06 23:11:22  jhermann
2260 # Fixed debug output in page footer; added expiry date to the login cookie
2261 # (expires 1 year in the future) to prevent probs with certain versions
2262 # of IE
2264 # Revision 1.51  2001/11/06 22:00:34  jhermann
2265 # Get debug level from ROUNDUP_DEBUG env var
2267 # Revision 1.50  2001/11/05 23:45:40  richard
2268 # Fixed newuser_action so it sets the cookie with the unencrypted password.
2269 # Also made it present nicer error messages (not tracebacks).
2271 # Revision 1.49  2001/11/04 03:07:12  richard
2272 # Fixed various cookie-related bugs:
2273 #  . bug #477685 ] base64.decodestring breaks
2274 #  . bug #477837 ] lynx does not like the cookie
2275 #  . bug #477892 ] Password edit doesn't fix login cookie
2276 # Also closed a security hole - a logged-in user could edit another user's
2277 # details.
2279 # Revision 1.48  2001/11/03 01:30:18  richard
2280 # Oops. uses pagefoot now.
2282 # Revision 1.47  2001/11/03 01:29:28  richard
2283 # Login page didn't have all close tags.
2285 # Revision 1.46  2001/11/03 01:26:55  richard
2286 # possibly fix truncated base64'ed user:pass
2288 # Revision 1.45  2001/11/01 22:04:37  richard
2289 # Started work on supporting a pop3-fetching server
2290 # Fixed bugs:
2291 #  . bug #477104 ] HTML tag error in roundup-server
2292 #  . bug #477107 ] HTTP header problem
2294 # Revision 1.44  2001/10/28 23:03:08  richard
2295 # Added more useful header to the classic schema.
2297 # Revision 1.43  2001/10/24 00:01:42  richard
2298 # More fixes to lockout logic.
2300 # Revision 1.42  2001/10/23 23:56:03  richard
2301 # HTML typo
2303 # Revision 1.41  2001/10/23 23:52:35  richard
2304 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
2306 # Revision 1.40  2001/10/23 23:06:39  richard
2307 # Some cleanup.
2309 # Revision 1.39  2001/10/23 01:00:18  richard
2310 # Re-enabled login and registration access after lopping them off via
2311 # disabling access for anonymous users.
2312 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
2313 # a couple of bugs while I was there. Probably introduced a couple, but
2314 # things seem to work OK at the moment.
2316 # Revision 1.38  2001/10/22 03:25:01  richard
2317 # Added configuration for:
2318 #  . anonymous user access and registration (deny/allow)
2319 #  . filter "widget" location on index page (top, bottom, both)
2320 # Updated some documentation.
2322 # Revision 1.37  2001/10/21 07:26:35  richard
2323 # feature #473127: Filenames. I modified the file.index and htmltemplate
2324 #  source so that the filename is used in the link and the creation
2325 #  information is displayed.
2327 # Revision 1.36  2001/10/21 04:44:50  richard
2328 # bug #473124: UI inconsistency with Link fields.
2329 #    This also prompted me to fix a fairly long-standing usability issue -
2330 #    that of being able to turn off certain filters.
2332 # Revision 1.35  2001/10/21 00:17:54  richard
2333 # CGI interface view customisation section may now be hidden (patch from
2334 #  Roch'e Compaan.)
2336 # Revision 1.34  2001/10/20 11:58:48  richard
2337 # Catch errors in login - no username or password supplied.
2338 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
2340 # Revision 1.33  2001/10/17 00:18:41  richard
2341 # Manually constructing cookie headers now.
2343 # Revision 1.32  2001/10/16 03:36:21  richard
2344 # CGI interface wasn't handling checkboxes at all.
2346 # Revision 1.31  2001/10/14 10:55:00  richard
2347 # Handle empty strings in HTML template Link function
2349 # Revision 1.30  2001/10/09 07:38:58  richard
2350 # Pushed the base code for the extended schema CGI interface back into the
2351 # code cgi_client module so that future updates will be less painful.
2352 # Also removed a debugging print statement from cgi_client.
2354 # Revision 1.29  2001/10/09 07:25:59  richard
2355 # Added the Password property type. See "pydoc roundup.password" for
2356 # implementation details. Have updated some of the documentation too.
2358 # Revision 1.28  2001/10/08 00:34:31  richard
2359 # Change message was stuffing up for multilinks with no key property.
2361 # Revision 1.27  2001/10/05 02:23:24  richard
2362 #  . roundup-admin create now prompts for property info if none is supplied
2363 #    on the command-line.
2364 #  . hyperdb Class getprops() method may now return only the mutable
2365 #    properties.
2366 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
2367 #    now support anonymous user access (read-only, unless there's an
2368 #    "anonymous" user, in which case write access is permitted). Login
2369 #    handling has been moved into cgi_client.Client.main()
2370 #  . The "extended" schema is now the default in roundup init.
2371 #  . The schemas have had their page headings modified to cope with the new
2372 #    login handling. Existing installations should copy the interfaces.py
2373 #    file from the roundup lib directory to their instance home.
2374 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
2375 #    Ping - has been removed.
2376 #  . Fixed a whole bunch of places in the CGI interface where we should have
2377 #    been returning Not Found instead of throwing an exception.
2378 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
2379 #    an item now throws an exception.
2381 # Revision 1.26  2001/09/12 08:31:42  richard
2382 # handle cases where mime type is not guessable
2384 # Revision 1.25  2001/08/29 05:30:49  richard
2385 # change messages weren't being saved when there was no-one on the nosy list.
2387 # Revision 1.24  2001/08/29 04:49:39  richard
2388 # didn't clean up fully after debugging :(
2390 # Revision 1.23  2001/08/29 04:47:18  richard
2391 # Fixed CGI client change messages so they actually include the properties
2392 # changed (again).
2394 # Revision 1.22  2001/08/17 00:08:10  richard
2395 # reverted back to sending messages always regardless of who is doing the web
2396 # edit. change notes weren't being saved. bleah. hackish.
2398 # Revision 1.21  2001/08/15 23:43:18  richard
2399 # Fixed some isFooTypes that I missed.
2400 # Refactored some code in the CGI code.
2402 # Revision 1.20  2001/08/12 06:32:36  richard
2403 # using isinstance(blah, Foo) now instead of isFooType
2405 # Revision 1.19  2001/08/07 00:24:42  richard
2406 # stupid typo
2408 # Revision 1.18  2001/08/07 00:15:51  richard
2409 # Added the copyright/license notice to (nearly) all files at request of
2410 # Bizar Software.
2412 # Revision 1.17  2001/08/02 06:38:17  richard
2413 # Roundupdb now appends "mailing list" information to its messages which
2414 # include the e-mail address and web interface address. Templates may
2415 # override this in their db classes to include specific information (support
2416 # instructions, etc).
2418 # Revision 1.16  2001/08/02 05:55:25  richard
2419 # Web edit messages aren't sent to the person who did the edit any more. No
2420 # message is generated if they are the only person on the nosy list.
2422 # Revision 1.15  2001/08/02 00:34:10  richard
2423 # bleah syntax error
2425 # Revision 1.14  2001/08/02 00:26:16  richard
2426 # Changed the order of the information in the message generated by web edits.
2428 # Revision 1.13  2001/07/30 08:12:17  richard
2429 # Added time logging and file uploading to the templates.
2431 # Revision 1.12  2001/07/30 06:26:31  richard
2432 # Added some documentation on how the newblah works.
2434 # Revision 1.11  2001/07/30 06:17:45  richard
2435 # Features:
2436 #  . Added ability for cgi newblah forms to indicate that the new node
2437 #    should be linked somewhere.
2438 # Fixed:
2439 #  . Fixed the agument handling for the roundup-admin find command.
2440 #  . Fixed handling of summary when no note supplied for newblah. Again.
2441 #  . Fixed detection of no form in htmltemplate Field display.
2443 # Revision 1.10  2001/07/30 02:37:34  richard
2444 # Temporary measure until we have decent schema migration...
2446 # Revision 1.9  2001/07/30 01:25:07  richard
2447 # Default implementation is now "classic" rather than "extended" as one would
2448 # expect.
2450 # Revision 1.8  2001/07/29 08:27:40  richard
2451 # Fixed handling of passed-in values in form elements (ie. during a
2452 # drill-down)
2454 # Revision 1.7  2001/07/29 07:01:39  richard
2455 # Added vim command to all source so that we don't get no steenkin' tabs :)
2457 # Revision 1.6  2001/07/29 04:04:00  richard
2458 # Moved some code around allowing for subclassing to change behaviour.
2460 # Revision 1.5  2001/07/28 08:16:52  richard
2461 # New issue form handles lack of note better now.
2463 # Revision 1.4  2001/07/28 00:34:34  richard
2464 # Fixed some non-string node ids.
2466 # Revision 1.3  2001/07/23 03:56:30  richard
2467 # oops, missed a config removal
2469 # Revision 1.2  2001/07/22 12:09:32  richard
2470 # Final commit of Grande Splite
2472 # Revision 1.1  2001/07/22 11:58:35  richard
2473 # More Grande Splite
2476 # vim: set filetype=python ts=4 sw=4 et si