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