Code

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