Code

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