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.157 2002-08-13 20:16:09 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;
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, search_text, filter, columns, sort,
574 group, show_customization=show_customization,
575 show_nodes=show_nodes, pagesize=pagesize, startwith=startwith,
576 simple_search=simpleform)
577 except htmltemplate.MissingTemplateError:
578 self.basicClassEditPage()
579 self.pagefoot()
581 def basicClassEditPage(self):
582 '''Display a basic edit page that allows simple editing of the
583 nodes of the current class
584 '''
585 userid = self.db.user.lookup(self.user)
586 if not self.db.security.hasPermission('Edit', userid):
587 raise Unauthorised, _("You do not have permission to access"\
588 " %(action)s.")%{'action': self.classname}
589 w = self.write
590 cn = self.classname
591 cl = self.db.classes[cn]
592 idlessprops = cl.getprops(protected=0).keys()
593 props = ['id'] + idlessprops
595 # get the CSV module
596 try:
597 import csv
598 except ImportError:
599 w(_('Sorry, you need the csv module to use this function.<br>\n'
600 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
601 return
603 # do the edit
604 if self.form.has_key('rows'):
605 rows = self.form['rows'].value.splitlines()
606 p = csv.parser()
607 found = {}
608 line = 0
609 for row in rows:
610 line += 1
611 values = p.parse(row)
612 # not a complete row, keep going
613 if not values: continue
615 # extract the nodeid
616 nodeid, values = values[0], values[1:]
617 found[nodeid] = 1
619 # confirm correct weight
620 if len(idlessprops) != len(values):
621 w(_('Not enough values on line %(line)s'%{'line':line}))
622 return
624 # extract the new values
625 d = {}
626 for name, value in zip(idlessprops, values):
627 value = value.strip()
628 # only add the property if it has a value
629 if value:
630 # if it's a multilink, split it
631 if isinstance(cl.properties[name], hyperdb.Multilink):
632 value = value.split(':')
633 d[name] = value
635 # perform the edit
636 if cl.hasnode(nodeid):
637 # edit existing
638 cl.set(nodeid, **d)
639 else:
640 # new node
641 found[cl.create(**d)] = 1
643 # retire the removed entries
644 for nodeid in cl.list():
645 if not found.has_key(nodeid):
646 cl.retire(nodeid)
648 w(_('''<p class="form-help">You may edit the contents of the
649 "%(classname)s" class using this form. Commas, newlines and double
650 quotes (") must be handled delicately. You may include commas and
651 newlines by enclosing the values in double-quotes ("). Double
652 quotes themselves must be quoted by doubling ("").</p>
653 <p class="form-help">Multilink properties have their multiple
654 values colon (":") separated (... ,"one:two:three", ...)</p>
655 <p class="form-help">Remove entries by deleting their line. Add
656 new entries by appending
657 them to the table - put an X in the id column.</p>''')%{'classname':cn})
659 l = []
660 for name in props:
661 l.append(name)
662 w('<tt>')
663 w(', '.join(l) + '\n')
664 w('</tt>')
666 w('<form onSubmit="return submit_once()" method="POST">')
667 w('<textarea name="rows" cols=80 rows=15>')
668 p = csv.parser()
669 for nodeid in cl.list():
670 l = []
671 for name in props:
672 value = cl.get(nodeid, name)
673 if value is None:
674 l.append('')
675 elif isinstance(value, type([])):
676 l.append(cgi.escape(':'.join(map(str, value))))
677 else:
678 l.append(cgi.escape(str(cl.get(nodeid, name))))
679 w(p.join(l) + '\n')
681 w(_('</textarea><br><input type="submit" value="Save Changes"></form>'))
683 def classhelp(self):
684 '''Display a table of class info
685 '''
686 w = self.write
687 cn = self.form['classname'].value
688 cl = self.db.classes[cn]
689 props = self.form['properties'].value.split(',')
690 if cl.labelprop(1) in props:
691 sort = [cl.labelprop(1)]
692 else:
693 sort = props[0]
695 w('<table border=1 cellspacing=0 cellpaddin=2>')
696 w('<tr>')
697 for name in props:
698 w('<th align=left>%s</th>'%name)
699 w('</tr>')
700 for nodeid in cl.filter(None, {}, sort, []):
701 w('<tr>')
702 for name in props:
703 value = cgi.escape(str(cl.get(nodeid, name)))
704 w('<td align="left" valign="top">%s</td>'%value)
705 w('</tr>')
706 w('</table>')
708 def shownode(self, message=None, num_re=re.compile('^\d+$')):
709 ''' display an item
710 '''
711 cn = self.classname
712 cl = self.db.classes[cn]
713 keys = self.form.keys()
714 fromremove = 0
715 if self.form.has_key(':multilink'):
716 # is the multilink there because we came from remove()?
717 if self.form.has_key(':target'):
718 xtra = ''
719 fromremove = 1
720 message = _('%s removed' % self.index_arg(":target")[0])
721 else:
722 link = self.form[':multilink'].value
723 designator, linkprop = link.split(':')
724 xtra = ' for <a href="%s">%s</a>' % (designator, designator)
725 else:
726 xtra = ''
728 # possibly perform an edit
729 # don't try to set properties if the user has just logged in
730 if keys and not fromremove and not self.form.has_key('__login_name'):
731 try:
732 userid = self.db.user.lookup(self.user)
733 if not self.db.security.hasPermission('Edit', userid, cn):
734 message = _('You do not have permission to edit %s' %cn)
735 else:
736 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
737 # make changes to the node
738 props = self._changenode(props)
739 # handle linked nodes
740 self._post_editnode(self.nodeid)
741 # and some nice feedback for the user
742 if props:
743 message = _('%(changes)s edited ok')%{'changes':
744 ', '.join(props.keys())}
745 elif self.form.has_key('__note') and self.form['__note'].value:
746 message = _('note added')
747 elif (self.form.has_key('__file') and
748 self.form['__file'].filename):
749 message = _('file added')
750 else:
751 message = _('nothing changed')
752 except:
753 self.db.rollback()
754 s = StringIO.StringIO()
755 traceback.print_exc(None, s)
756 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
758 # now the display
759 id = self.nodeid
760 if cl.getkey():
761 id = cl.get(id, cl.getkey())
762 self.pagehead('%s: %s %s'%(self.classname.capitalize(), id, xtra),
763 message)
765 nodeid = self.nodeid
767 # use the template to display the item
768 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES,
769 self.classname)
770 item.render(nodeid)
772 self.pagefoot()
773 showissue = shownode
774 showmsg = shownode
775 searchissue = searchnode
777 def showquery(self):
778 queries = self.db.getclass(self.classname)
779 if self.form.keys():
780 sort = self.index_sort()
781 group = self.index_arg(':group')
782 filter = self.index_arg(':filter')
783 columns = self.index_arg(':columns')
784 filterspec = self.index_filterspec(filter, queries.get(self.nodeid, 'klass'))
785 if self.form.has_key('search_text'):
786 search_text = self.form['search_text'].value
787 search_text = urllib.quote(search_text)
788 else:
789 search_text = ''
790 if self.form.has_key(':pagesize'):
791 pagesize = int(self.form[':pagesize'].value)
792 else:
793 pagesize = 50
794 # format a query string
795 qd = {}
796 qd[':sort'] = ','.join(map(urllib.quote, sort))
797 qd[':group'] = ','.join(map(urllib.quote, group))
798 qd[':filter'] = ','.join(map(urllib.quote, filter))
799 qd[':columns'] = ','.join(map(urllib.quote, columns))
800 for k, l in filterspec.items():
801 qd[urllib.quote(k)] = ','.join(map(urllib.quote, l))
802 url = '&'.join([k+'='+v for k,v in qd.items()])
803 url += '&:pagesize=%s' % pagesize
804 if search_text:
805 url += '&search_text=%s' % search_text
806 if url != queries.get(self.nodeid, 'url'):
807 queries.set(self.nodeid, url=url)
808 message = _('url edited ok')
809 else:
810 message = _('nothing changed')
811 else:
812 message = None
813 nm = queries.get(self.nodeid, 'name')
814 self.pagehead('%s: %s'%(self.classname.capitalize(), nm), message)
816 # use the template to display the item
817 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES,
818 self.classname)
819 item.render(self.nodeid)
820 self.pagefoot()
822 def _changenode(self, props):
823 ''' change the node based on the contents of the form
824 '''
825 cl = self.db.classes[self.classname]
827 # create the message
828 message, files = self._handle_message()
829 if message:
830 props['messages'] = cl.get(self.nodeid, 'messages') + [message]
831 if files:
832 props['files'] = cl.get(self.nodeid, 'files') + files
834 # make the changes
835 return cl.set(self.nodeid, **props)
837 def _createnode(self):
838 ''' create a node based on the contents of the form
839 '''
840 cl = self.db.classes[self.classname]
841 props = parsePropsFromForm(self.db, cl, self.form)
843 # check for messages and files
844 message, files = self._handle_message()
845 if message:
846 props['messages'] = [message]
847 if files:
848 props['files'] = files
849 # create the node and return it's id
850 return cl.create(**props)
852 def _handle_message(self):
853 ''' generate an edit message
854 '''
855 # handle file attachments
856 files = []
857 if self.form.has_key('__file'):
858 file = self.form['__file']
859 if file.filename:
860 filename = file.filename.split('\\')[-1]
861 mime_type = mimetypes.guess_type(filename)[0]
862 if not mime_type:
863 mime_type = "application/octet-stream"
864 # create the new file entry
865 files.append(self.db.file.create(type=mime_type,
866 name=filename, content=file.file.read()))
868 # we don't want to do a message if none of the following is true...
869 cn = self.classname
870 cl = self.db.classes[self.classname]
871 props = cl.getprops()
872 note = None
873 # in a nutshell, don't do anything if there's no note or there's no
874 # NOSY
875 if self.form.has_key('__note'):
876 note = self.form['__note'].value.strip()
877 if not note:
878 return None, files
879 if not props.has_key('messages'):
880 return None, files
881 if not isinstance(props['messages'], hyperdb.Multilink):
882 return None, files
883 if not props['messages'].classname == 'msg':
884 return None, files
885 if not (self.form.has_key('nosy') or note):
886 return None, files
888 # handle the note
889 if '\n' in note:
890 summary = re.split(r'\n\r?', note)[0]
891 else:
892 summary = note
893 m = ['%s\n'%note]
895 # handle the messageid
896 # TODO: handle inreplyto
897 messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
898 self.classname, self.instance.MAIL_DOMAIN)
900 # now create the message, attaching the files
901 content = '\n'.join(m)
902 message_id = self.db.msg.create(author=self.getuid(),
903 recipients=[], date=date.Date('.'), summary=summary,
904 content=content, files=files, messageid=messageid)
906 # update the messages property
907 return message_id, files
909 def _post_editnode(self, nid):
910 '''Do the linking part of the node creation.
912 If a form element has :link or :multilink appended to it, its
913 value specifies a node designator and the property on that node
914 to add _this_ node to as a link or multilink.
916 This is typically used on, eg. the file upload page to indicated
917 which issue to link the file to.
919 TODO: I suspect that this and newfile will go away now that
920 there's the ability to upload a file using the issue __file form
921 element!
922 '''
923 cn = self.classname
924 cl = self.db.classes[cn]
925 # link if necessary
926 keys = self.form.keys()
927 for key in keys:
928 if key == ':multilink':
929 value = self.form[key].value
930 if type(value) != type([]): value = [value]
931 for value in value:
932 designator, property = value.split(':')
933 link, nodeid = hyperdb.splitDesignator(designator)
934 link = self.db.classes[link]
935 # take a dupe of the list so we're not changing the cache
936 value = link.get(nodeid, property)[:]
937 value.append(nid)
938 link.set(nodeid, **{property: value})
939 elif key == ':link':
940 value = self.form[key].value
941 if type(value) != type([]): value = [value]
942 for value in value:
943 designator, property = value.split(':')
944 link, nodeid = hyperdb.splitDesignator(designator)
945 link = self.db.classes[link]
946 link.set(nodeid, **{property: nid})
948 def newnode(self, message=None):
949 ''' Add a new node to the database.
951 The form works in two modes: blank form and submission (that is,
952 the submission goes to the same URL). **Eventually this means that
953 the form will have previously entered information in it if
954 submission fails.
956 The new node will be created with the properties specified in the
957 form submission. For multilinks, multiple form entries are handled,
958 as are prop=value,value,value. You can't mix them though.
960 If the new node is to be referenced from somewhere else immediately
961 (ie. the new node is a file that is to be attached to a support
962 issue) then supply one of these arguments in addition to the usual
963 form entries:
964 :link=designator:property
965 :multilink=designator:property
966 ... which means that once the new node is created, the "property"
967 on the node given by "designator" should now reference the new
968 node's id. The node id will be appended to the multilink.
969 '''
970 cn = self.classname
971 userid = self.db.user.lookup(self.user)
972 if not self.db.security.hasPermission('View', userid, cn):
973 raise Unauthorised, _("You do not have permission to access"\
974 " %(action)s.")%{'action': self.classname}
975 cl = self.db.classes[cn]
976 if self.form.has_key(':multilink'):
977 link = self.form[':multilink'].value
978 designator, linkprop = link.split(':')
979 xtra = ' for <a href="%s">%s</a>' % (designator, designator)
980 else:
981 xtra = ''
983 # possibly perform a create
984 keys = self.form.keys()
985 if [i for i in keys if i[0] != ':']:
986 # no dice if you can't edit!
987 if not self.db.security.hasPermission('Edit', userid, cn):
988 raise Unauthorised, _("You do not have permission to access"\
989 " %(action)s.")%{'action': 'new'+self.classname}
990 props = {}
991 try:
992 nid = self._createnode()
993 # handle linked nodes
994 self._post_editnode(nid)
995 # and some nice feedback for the user
996 message = _('%(classname)s created ok')%{'classname': cn}
998 # render the newly created issue
999 self.db.commit()
1000 self.nodeid = nid
1001 self.pagehead('%s: %s'%(self.classname.capitalize(), nid),
1002 message)
1003 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES,
1004 self.classname)
1005 item.render(nid)
1006 self.pagefoot()
1007 return
1008 except:
1009 self.db.rollback()
1010 s = StringIO.StringIO()
1011 traceback.print_exc(None, s)
1012 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
1013 self.pagehead(_('New %(classname)s %(xtra)s')%{
1014 'classname': self.classname.capitalize(),
1015 'xtra': xtra }, message)
1017 # call the template
1018 newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
1019 self.classname)
1020 newitem.render(self.form)
1022 self.pagefoot()
1023 newissue = newnode
1025 def newuser(self, message=None):
1026 ''' Add a new user to the database.
1028 Don't do any of the message or file handling, just create the node.
1029 '''
1030 userid = self.db.user.lookup(self.user)
1031 if not self.db.security.hasPermission('Edit', userid, 'user'):
1032 raise Unauthorised, _("You do not have permission to access"\
1033 " %(action)s.")%{'action': 'newuser'}
1035 cn = self.classname
1036 cl = self.db.classes[cn]
1038 # possibly perform a create
1039 keys = self.form.keys()
1040 if [i for i in keys if i[0] != ':']:
1041 try:
1042 props = parsePropsFromForm(self.db, cl, self.form)
1043 nid = cl.create(**props)
1044 # handle linked nodes
1045 self._post_editnode(nid)
1046 # and some nice feedback for the user
1047 message = _('%(classname)s created ok')%{'classname': cn}
1048 except:
1049 self.db.rollback()
1050 s = StringIO.StringIO()
1051 traceback.print_exc(None, s)
1052 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
1053 self.pagehead(_('New %(classname)s')%{'classname':
1054 self.classname.capitalize()}, message)
1056 # call the template
1057 newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
1058 self.classname)
1059 newitem.render(self.form)
1061 self.pagefoot()
1063 def newfile(self, message=None):
1064 ''' Add a new file to the database.
1066 This form works very much the same way as newnode - it just has a
1067 file upload.
1068 '''
1069 userid = self.db.user.lookup(self.user)
1070 if not self.db.security.hasPermission('Edit', userid, 'file'):
1071 raise Unauthorised, _("You do not have permission to access"\
1072 " %(action)s.")%{'action': 'newfile'}
1073 cn = self.classname
1074 cl = self.db.classes[cn]
1075 props = parsePropsFromForm(self.db, cl, self.form)
1076 if self.form.has_key(':multilink'):
1077 link = self.form[':multilink'].value
1078 designator, linkprop = link.split(':')
1079 xtra = ' for <a href="%s">%s</a>' % (designator, designator)
1080 else:
1081 xtra = ''
1083 # possibly perform a create
1084 keys = self.form.keys()
1085 if [i for i in keys if i[0] != ':']:
1086 try:
1087 file = self.form['content']
1088 mime_type = mimetypes.guess_type(file.filename)[0]
1089 if not mime_type:
1090 mime_type = "application/octet-stream"
1091 # save the file
1092 props['type'] = mime_type
1093 props['name'] = file.filename
1094 props['content'] = file.file.read()
1095 nid = cl.create(**props)
1096 # handle linked nodes
1097 self._post_editnode(nid)
1098 # and some nice feedback for the user
1099 message = _('%(classname)s created ok')%{'classname': cn}
1100 except:
1101 self.db.rollback()
1102 s = StringIO.StringIO()
1103 traceback.print_exc(None, s)
1104 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
1106 self.pagehead(_('New %(classname)s %(xtra)s')%{
1107 'classname': self.classname.capitalize(),
1108 'xtra': xtra }, message)
1109 newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
1110 self.classname)
1111 newitem.render(self.form)
1112 self.pagefoot()
1114 def showuser(self, message=None, num_re=re.compile('^\d+$')):
1115 '''Display a user page for editing. Make sure the user is allowed
1116 to edit this node, and also check for password changes.
1117 '''
1118 user = self.db.user
1120 # get the username of the node being edited
1121 try:
1122 node_user = user.get(self.nodeid, 'username')
1123 except IndexError:
1124 raise NotFound, 'user%s'%self.nodeid
1126 # ok, so we need to be able to edit everything, or be this node's
1127 # user
1128 userid = self.db.user.lookup(self.user)
1129 # removed check on user's permissions - this needs to be done
1130 # through require tags in user.item
1131 #
1132 # perform any editing
1133 #
1134 keys = self.form.keys()
1135 if keys:
1136 try:
1137 props = parsePropsFromForm(self.db, user, self.form,
1138 self.nodeid)
1139 set_cookie = 0
1140 if props.has_key('password'):
1141 password = self.form['password'].value.strip()
1142 if not password:
1143 # no password was supplied - don't change it
1144 del props['password']
1145 elif self.nodeid == self.getuid():
1146 # this is the logged-in user's password
1147 set_cookie = password
1148 user.set(self.nodeid, **props)
1149 # and some feedback for the user
1150 message = _('%(changes)s edited ok')%{'changes':
1151 ', '.join(props.keys())}
1152 except:
1153 self.db.rollback()
1154 s = StringIO.StringIO()
1155 traceback.print_exc(None, s)
1156 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
1157 else:
1158 set_cookie = 0
1160 # fix the cookie if the password has changed
1161 if set_cookie:
1162 self.set_cookie(self.user, set_cookie)
1164 #
1165 # now the display
1166 #
1167 self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
1169 # use the template to display the item
1170 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 'user')
1171 item.render(self.nodeid)
1172 self.pagefoot()
1174 def showfile(self):
1175 ''' display a file
1176 '''
1177 # nothing in xtrapath - edit the file's metadata
1178 if self.xtrapath is None:
1179 return self.shownode()
1181 # something in xtrapath - download the file
1182 nodeid = self.nodeid
1183 cl = self.db.classes[self.classname]
1184 try:
1185 mime_type = cl.get(nodeid, 'type')
1186 except IndexError:
1187 raise NotFound, 'file%s'%nodeid
1188 if mime_type == 'message/rfc822':
1189 mime_type = 'text/plain'
1190 self.header(headers={'Content-Type': mime_type})
1191 self.write(cl.get(nodeid, 'content'))
1193 def permission(self):
1194 '''
1195 '''
1197 def classes(self, message=None):
1198 ''' display a list of all the classes in the database
1199 '''
1200 userid = self.db.user.lookup(self.user)
1201 if not self.db.security.hasPermission('Edit', userid):
1202 raise Unauthorised, _("You do not have permission to access"\
1203 " %(action)s.")%{'action': 'all classes'}
1205 self.pagehead(_('Table of classes'), message)
1206 classnames = self.db.classes.keys()
1207 classnames.sort()
1208 self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
1209 for cn in classnames:
1210 cl = self.db.getclass(cn)
1211 self.write('<tr class="list-header"><th colspan=2 align=left>'
1212 '<a href="%s">%s</a></th></tr>'%(cn, cn.capitalize()))
1213 for key, value in cl.properties.items():
1214 if value is None: value = ''
1215 else: value = str(value)
1216 self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
1217 key, cgi.escape(value)))
1218 self.write('</table>')
1219 self.pagefoot()
1221 def unauthorised(self, message):
1222 ''' The user is not authorised to do something. If they're
1223 anonymous, throw up a login box. If not, just tell them they
1224 can't do whatever it was they were trying to do.
1226 Bot cases print up the message, which is most likely the
1227 argument to the Unauthorised exception.
1228 '''
1229 self.header(response=403)
1230 if self.desired_action is None or self.desired_action == 'login':
1231 if not message:
1232 message=_("You do not have permission.")
1233 action = 'index'
1234 else:
1235 if not message:
1236 message=_("You do not have permission to access"\
1237 " %(action)s.")%{'action': self.desired_action}
1238 action = self.desired_action
1239 if self.user == 'anonymous':
1240 self.login(action=action, message=message)
1241 else:
1242 self.pagehead(_('Not Authorised'))
1243 self.write('<p class="system-msg">%s</p>'%message)
1244 self.pagefoot()
1246 def login(self, message=None, newuser_form=None, action='index'):
1247 '''Display a login page.
1248 '''
1249 self.pagehead(_('Login to roundup'))
1250 if message:
1251 self.write('<p class="system-msg">%s</p>'%message)
1252 self.write(_('''
1253 <table>
1254 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
1255 <form onSubmit="return submit_once()" action="login_action" method=POST>
1256 <input type="hidden" name="__destination_url" value="%(action)s">
1257 <tr><td align=right>Login name: </td>
1258 <td><input name="__login_name"></td></tr>
1259 <tr><td align=right>Password: </td>
1260 <td><input type="password" name="__login_password"></td></tr>
1261 <tr><td></td>
1262 <td><input type="submit" value="Log In"></td></tr>
1263 </form>
1264 ''')%locals())
1265 userid = self.db.user.lookup(self.user)
1266 if not self.db.security.hasPermission('Web Registration', userid):
1267 self.write('</table>')
1268 self.pagefoot()
1269 return
1270 values = {'realname': '', 'organisation': '', 'address': '',
1271 'phone': '', 'username': '', 'password': '', 'confirm': '',
1272 'action': action, 'alternate_addresses': ''}
1273 if newuser_form is not None:
1274 for key in newuser_form.keys():
1275 values[key] = newuser_form[key].value
1276 self.write(_('''
1277 <p>
1278 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
1279 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
1280 <form onSubmit="return submit_once()" action="newuser_action" method=POST>
1281 <input type="hidden" name="__destination_url" value="%(action)s">
1282 <tr><td align=right><em>Name: </em></td>
1283 <td><input name="realname" value="%(realname)s" size=40></td></tr>
1284 <tr><td align=right><em>Organisation: </em></td>
1285 <td><input name="organisation" value="%(organisation)s" size=40></td></tr>
1286 <tr><td align=right>E-Mail Address: </td>
1287 <td><input name="address" value="%(address)s" size=40></td></tr>
1288 <tr><td align=right><em>Alternate E-mail Addresses: </em></td>
1289 <td><textarea name="alternate_addresses" rows=5 cols=40>%(alternate_addresses)s</textarea></td></tr>
1290 <tr><td align=right><em>Phone: </em></td>
1291 <td><input name="phone" value="%(phone)s"></td></tr>
1292 <tr><td align=right>Preferred Login name: </td>
1293 <td><input name="username" value="%(username)s"></td></tr>
1294 <tr><td align=right>Password: </td>
1295 <td><input type="password" name="password" value="%(password)s"></td></tr>
1296 <tr><td align=right>Password Again: </td>
1297 <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
1298 <tr><td></td>
1299 <td><input type="submit" value="Register"></td></tr>
1300 </form>
1301 </table>
1302 ''')%values)
1303 self.pagefoot()
1305 def login_action(self, message=None):
1306 '''Attempt to log a user in and set the cookie
1308 returns 0 if a page is generated as a result of this call, and
1309 1 if not (ie. the login is successful
1310 '''
1311 if not self.form.has_key('__login_name'):
1312 self.login(message=_('Username required'))
1313 return 0
1314 self.user = self.form['__login_name'].value
1315 # re-open the database for real, using the user
1316 self.opendb(self.user)
1317 if self.form.has_key('__login_password'):
1318 password = self.form['__login_password'].value
1319 else:
1320 password = ''
1321 # make sure the user exists
1322 try:
1323 uid = self.db.user.lookup(self.user)
1324 except KeyError:
1325 name = self.user
1326 self.make_user_anonymous()
1327 action = self.form['__destination_url'].value
1328 self.login(message=_('No such user "%(name)s"')%locals(),
1329 action=action)
1330 return 0
1332 # and that the password is correct
1333 pw = self.db.user.get(uid, 'password')
1334 if password != pw:
1335 self.make_user_anonymous()
1336 action = self.form['__destination_url'].value
1337 self.login(message=_('Incorrect password'), action=action)
1338 return 0
1340 self.set_cookie(self.user, password)
1341 return 1
1343 def newuser_action(self, message=None):
1344 '''Attempt to create a new user based on the contents of the form
1345 and then set the cookie.
1347 return 1 on successful login
1348 '''
1349 # make sure we're allowed to register
1350 userid = self.db.user.lookup(self.user)
1351 if not self.db.security.hasPermission('Web Registration', userid):
1352 raise Unauthorised, _("You do not have permission to access"\
1353 " %(action)s.")%{'action': 'registration'}
1355 # re-open the database as "admin"
1356 if self.user != 'admin':
1357 self.opendb('admin')
1359 # create the new user
1360 cl = self.db.user
1361 try:
1362 props = parsePropsFromForm(self.db, cl, self.form)
1363 props['roles'] = self.instance.NEW_WEB_USER_ROLES
1364 uid = cl.create(**props)
1365 self.db.commit()
1366 except ValueError, message:
1367 action = self.form['__destination_url'].value
1368 self.login(message, action=action)
1369 return 0
1371 # log the new user in
1372 self.user = cl.get(uid, 'username')
1373 # re-open the database for real, using the user
1374 self.opendb(self.user)
1375 password = cl.get(uid, 'password')
1376 self.set_cookie(self.user, password)
1377 return 1
1379 def set_cookie(self, user, password):
1380 # TODO generate a much, much stronger session key ;)
1381 self.session = binascii.b2a_base64(repr(time.time())).strip()
1383 # clean up the base64
1384 if self.session[-1] == '=':
1385 if self.session[-2] == '=':
1386 self.session = self.session[:-2]
1387 else:
1388 self.session = self.session[:-1]
1390 # insert the session in the sessiondb
1391 self.db.sessions.set(self.session, user=user, last_use=time.time())
1393 # and commit immediately
1394 self.db.sessions.commit()
1396 # expire us in a long, long time
1397 expire = Cookie._getdate(86400*365)
1399 # generate the cookie path - make sure it has a trailing '/'
1400 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
1401 ''))
1402 self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;'%(
1403 self.session, expire, path)})
1405 def make_user_anonymous(self):
1406 ''' Make us anonymous
1408 This method used to handle non-existence of the 'anonymous'
1409 user, but that user is mandatory now.
1410 '''
1411 self.db.user.lookup('anonymous')
1412 self.user = 'anonymous'
1414 def logout(self, message=None):
1415 ''' Make us really anonymous - nuke the cookie too
1416 '''
1417 self.make_user_anonymous()
1419 # construct the logout cookie
1420 now = Cookie._getdate()
1421 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
1422 ''))
1423 self.header({'Set-Cookie':
1424 'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
1425 path)})
1426 self.login()
1428 def opendb(self, user):
1429 ''' Open the database - but include the definition of the sessions db.
1430 '''
1431 # open the db if the user has changed
1432 if not hasattr(self, 'db') or user != self.db.journaltag:
1433 self.db = self.instance.open(user)
1435 def main(self):
1436 ''' Wrap the request and handle unauthorised requests
1437 '''
1438 self.desired_action = None
1439 try:
1440 self.main_action()
1441 except Unauthorised, message:
1442 self.unauthorised(message)
1444 def main_action(self):
1445 '''Wrap the database accesses so we can close the database cleanly
1446 '''
1447 # determine the uid to use
1448 self.opendb('admin')
1450 # make sure we have the session Class
1451 sessions = self.db.sessions
1453 # age sessions, remove when they haven't been used for a week
1454 # TODO: this shouldn't be done every access
1455 week = 60*60*24*7
1456 now = time.time()
1457 for sessid in sessions.list():
1458 interval = now - sessions.get(sessid, 'last_use')
1459 if interval > week:
1460 sessions.destroy(sessid)
1462 # look up the user session cookie
1463 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
1464 user = 'anonymous'
1466 if (cookie.has_key('roundup_user') and
1467 cookie['roundup_user'].value != 'deleted'):
1469 # get the session key from the cookie
1470 self.session = cookie['roundup_user'].value
1471 # get the user from the session
1472 try:
1473 # update the lifetime datestamp
1474 sessions.set(self.session, last_use=time.time())
1475 sessions.commit()
1476 user = sessions.get(self.session, 'user')
1477 except KeyError:
1478 user = 'anonymous'
1480 # sanity check on the user still being valid
1481 try:
1482 self.db.user.lookup(user)
1483 except (KeyError, TypeError):
1484 user = 'anonymous'
1486 # make sure the anonymous user is valid if we're using it
1487 if user == 'anonymous':
1488 self.make_user_anonymous()
1489 else:
1490 self.user = user
1492 # now figure which function to call
1493 path = self.split_path
1494 self.xtrapath = None
1496 # default action to index if the path has no information in it
1497 if not path or path[0] in ('', 'index'):
1498 action = 'index'
1499 else:
1500 action = path[0]
1501 if len(path) > 1:
1502 self.xtrapath = path[1:]
1503 self.desired_action = action
1505 # everyone is allowed to try to log in
1506 if action == 'login_action':
1507 # try to login
1508 if not self.login_action():
1509 return
1510 # figure the resulting page
1511 action = self.form['__destination_url'].value
1513 # allow anonymous people to register
1514 elif action == 'newuser_action':
1515 # try to add the user
1516 if not self.newuser_action():
1517 return
1518 # figure the resulting page
1519 action = self.form['__destination_url'].value
1521 # ok, now we have figured out who the user is, make sure the user
1522 # has permission to use this interface
1523 userid = self.db.user.lookup(self.user)
1524 if not self.db.security.hasPermission('Web Access', userid):
1525 raise Unauthorised, \
1526 _("You do not have permission to access this interface.")
1528 # re-open the database for real, using the user
1529 self.opendb(self.user)
1531 # make sure we have a sane action
1532 if not action:
1533 action = 'index'
1535 # just a regular action
1536 try:
1537 self.do_action(action)
1538 except Unauthorised, message:
1539 # if unauth is raised here, then a page header will have
1540 # been displayed
1541 self.write('<p class="system-msg">%s</p>'%message)
1542 else:
1543 # commit all changes to the database
1544 self.db.commit()
1546 def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
1547 nre=re.compile(r'new(\w+)'), sre=re.compile(r'search(\w+)')):
1548 '''Figure the user's action and do it.
1549 '''
1550 # here be the "normal" functionality
1551 if action == 'index':
1552 self.index()
1553 return
1554 if action == 'list_classes':
1555 self.classes()
1556 return
1557 if action == 'classhelp':
1558 self.classhelp()
1559 return
1560 if action == 'login':
1561 self.login()
1562 return
1563 if action == 'logout':
1564 self.logout()
1565 return
1566 if action == 'remove':
1567 self.remove()
1568 return
1570 # see if we're to display an existing node
1571 m = dre.match(action)
1572 if m:
1573 self.classname = m.group(1)
1574 self.nodeid = m.group(2)
1575 try:
1576 cl = self.db.classes[self.classname]
1577 except KeyError:
1578 raise NotFound, self.classname
1579 try:
1580 cl.get(self.nodeid, 'id')
1581 except IndexError:
1582 raise NotFound, self.nodeid
1583 try:
1584 func = getattr(self, 'show%s'%self.classname)
1585 except AttributeError:
1586 raise NotFound, 'show%s'%self.classname
1587 func()
1588 return
1590 # see if we're to put up the new node page
1591 m = nre.match(action)
1592 if m:
1593 self.classname = m.group(1)
1594 try:
1595 func = getattr(self, 'new%s'%self.classname)
1596 except AttributeError:
1597 raise NotFound, 'new%s'%self.classname
1598 func()
1599 return
1601 # see if we're to put up the new node page
1602 m = sre.match(action)
1603 if m:
1604 self.classname = m.group(1)
1605 try:
1606 func = getattr(self, 'search%s'%self.classname)
1607 except AttributeError:
1608 raise NotFound
1609 func()
1610 return
1612 # otherwise, display the named class
1613 self.classname = action
1614 try:
1615 self.db.getclass(self.classname)
1616 except KeyError:
1617 raise NotFound, self.classname
1618 self.list()
1620 def remove(self, dre=re.compile(r'([^\d]+)(\d+)')):
1621 target = self.index_arg(':target')[0]
1622 m = dre.match(target)
1623 if m:
1624 classname = m.group(1)
1625 nodeid = m.group(2)
1626 cl = self.db.getclass(classname)
1627 cl.retire(nodeid)
1628 # now take care of the reference
1629 parentref = self.index_arg(':multilink')[0]
1630 parent, prop = parentref.split(':')
1631 m = dre.match(parent)
1632 if m:
1633 self.classname = m.group(1)
1634 self.nodeid = m.group(2)
1635 cl = self.db.getclass(self.classname)
1636 value = cl.get(self.nodeid, prop)
1637 value.remove(nodeid)
1638 cl.set(self.nodeid, **{prop:value})
1639 func = getattr(self, 'show%s'%self.classname)
1640 return func()
1641 else:
1642 raise NotFound, parent
1643 else:
1644 raise NotFound, target
1646 class ExtendedClient(Client):
1647 '''Includes pages and page heading information that relate to the
1648 extended schema.
1649 '''
1650 showsupport = Client.shownode
1651 showtimelog = Client.shownode
1652 newsupport = Client.newnode
1653 newtimelog = Client.newnode
1654 searchsupport = Client.searchnode
1656 default_index_sort = ['-activity']
1657 default_index_group = ['priority']
1658 default_index_filter = ['status']
1659 default_index_columns = ['activity','status','title','assignedto']
1660 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
1661 default_pagesize = '50'
1663 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1664 '''Pull properties for the given class out of the form.
1665 '''
1666 props = {}
1667 keys = form.keys()
1668 for key in keys:
1669 if not cl.properties.has_key(key):
1670 continue
1671 proptype = cl.properties[key]
1672 if isinstance(proptype, hyperdb.String):
1673 value = form[key].value.strip()
1674 elif isinstance(proptype, hyperdb.Password):
1675 value = password.Password(form[key].value.strip())
1676 elif isinstance(proptype, hyperdb.Date):
1677 value = form[key].value.strip()
1678 if value:
1679 value = date.Date(form[key].value.strip())
1680 else:
1681 value = None
1682 elif isinstance(proptype, hyperdb.Interval):
1683 value = form[key].value.strip()
1684 if value:
1685 value = date.Interval(form[key].value.strip())
1686 else:
1687 value = None
1688 elif isinstance(proptype, hyperdb.Link):
1689 value = form[key].value.strip()
1690 # see if it's the "no selection" choice
1691 if value == '-1':
1692 value = None
1693 else:
1694 # handle key values
1695 link = cl.properties[key].classname
1696 if not num_re.match(value):
1697 try:
1698 value = db.classes[link].lookup(value)
1699 except KeyError:
1700 raise ValueError, _('property "%(propname)s": '
1701 '%(value)s not a %(classname)s')%{'propname':key,
1702 'value': value, 'classname': link}
1703 elif isinstance(proptype, hyperdb.Multilink):
1704 value = form[key]
1705 if hasattr(value, 'value'):
1706 # Quite likely to be a FormItem instance
1707 value = value.value
1708 if not isinstance(value, type([])):
1709 value = [i.strip() for i in value.split(',')]
1710 else:
1711 value = [i.strip() for i in value]
1712 link = cl.properties[key].classname
1713 l = []
1714 for entry in map(str, value):
1715 if entry == '': continue
1716 if not num_re.match(entry):
1717 try:
1718 entry = db.classes[link].lookup(entry)
1719 except KeyError:
1720 raise ValueError, _('property "%(propname)s": '
1721 '"%(value)s" not an entry of %(classname)s')%{
1722 'propname':key, 'value': entry, 'classname': link}
1723 l.append(entry)
1724 l.sort()
1725 value = l
1726 elif isinstance(proptype, hyperdb.Boolean):
1727 value = form[key].value.strip()
1728 props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1729 elif isinstance(proptype, hyperdb.Number):
1730 value = form[key].value.strip()
1731 props[key] = value = int(value)
1733 # get the old value
1734 if nodeid:
1735 try:
1736 existing = cl.get(nodeid, key)
1737 except KeyError:
1738 # this might be a new property for which there is no existing
1739 # value
1740 if not cl.properties.has_key(key): raise
1742 # if changed, set it
1743 if value != existing:
1744 props[key] = value
1745 else:
1746 props[key] = value
1747 return props
1749 #
1750 # $Log: not supported by cvs2svn $
1751 # Revision 1.156 2002/08/01 15:06:06 gmcm
1752 # Use same regex to split search terms as used to index text.
1753 # Fix to back_metakit for not changing journaltag on reopen.
1754 # Fix htmltemplate's do_link so [No <whatever>] strings are href'd.
1755 # Fix bogus "nosy edited ok" msg - the **d syntax does NOT share d between caller and callee.
1756 #
1757 # Revision 1.155 2002/08/01 00:56:22 richard
1758 # Added the web access and email access permissions, so people can restrict
1759 # access to users who register through the email interface (for example).
1760 # Also added "security" command to the roundup-admin interface to display the
1761 # Role/Permission config for an instance.
1762 #
1763 # Revision 1.154 2002/07/31 23:57:36 richard
1764 # . web forms may now unset Link values (like assignedto)
1765 #
1766 # Revision 1.153 2002/07/31 22:40:50 gmcm
1767 # Fixes to the search form and saving queries.
1768 # Fixes to sorting in back_metakit.py.
1769 #
1770 # Revision 1.152 2002/07/31 22:04:14 richard
1771 # cleanup
1772 #
1773 # Revision 1.151 2002/07/30 21:37:43 richard
1774 # oops, thanks Duncan Booth for spotting this one
1775 #
1776 # Revision 1.150 2002/07/30 20:43:18 gmcm
1777 # Oops, fix the permission check!
1778 #
1779 # Revision 1.149 2002/07/30 20:04:38 gmcm
1780 # Adapt metakit backend to new security scheme.
1781 # Put some more permission checks in cgi_client.
1782 #
1783 # Revision 1.148 2002/07/30 16:09:11 gmcm
1784 # Simple optimization.
1785 #
1786 # Revision 1.147 2002/07/30 08:22:38 richard
1787 # Session storage in the hyperdb was horribly, horribly inefficient. We use
1788 # a simple anydbm wrapper now - which could be overridden by the metakit
1789 # backend or RDB backend if necessary.
1790 # Much, much better.
1791 #
1792 # Revision 1.146 2002/07/30 05:27:30 richard
1793 # nicer error messages, and a bugfix
1794 #
1795 # Revision 1.145 2002/07/26 08:26:59 richard
1796 # Very close now. The cgi and mailgw now use the new security API. The two
1797 # templates have been migrated to that setup. Lots of unit tests. Still some
1798 # issue in the web form for editing Roles assigned to users.
1799 #
1800 # Revision 1.144 2002/07/25 07:14:05 richard
1801 # Bugger it. Here's the current shape of the new security implementation.
1802 # Still to do:
1803 # . call the security funcs from cgi and mailgw
1804 # . change shipped templates to include correct initialisation and remove
1805 # the old config vars
1806 # ... that seems like a lot. The bulk of the work has been done though. Honest :)
1807 #
1808 # Revision 1.143 2002/07/20 19:29:10 gmcm
1809 # Fixes/improvements to the search form & saved queries.
1810 #
1811 # Revision 1.142 2002/07/18 11:17:30 gmcm
1812 # Add Number and Boolean types to hyperdb.
1813 # Add conversion cases to web, mail & admin interfaces.
1814 # Add storage/serialization cases to back_anydbm & back_metakit.
1815 #
1816 # Revision 1.141 2002/07/17 12:39:10 gmcm
1817 # Saving, running & editing queries.
1818 #
1819 # Revision 1.140 2002/07/14 23:17:15 richard
1820 # cleaned up structure
1821 #
1822 # Revision 1.139 2002/07/14 06:14:40 richard
1823 # Some more TODOs
1824 #
1825 # Revision 1.138 2002/07/14 04:03:13 richard
1826 # Implemented a switch to disable journalling for a Class. CGI session
1827 # database now uses it.
1828 #
1829 # Revision 1.137 2002/07/10 07:00:30 richard
1830 # removed debugging
1831 #
1832 # Revision 1.136 2002/07/10 06:51:08 richard
1833 # . #576241 ] MultiLink problems in parsePropsFromForm
1834 #
1835 # Revision 1.135 2002/07/10 00:22:34 richard
1836 # . switched to using a session-based web login
1837 #
1838 # Revision 1.134 2002/07/09 04:19:09 richard
1839 # Added reindex command to roundup-admin.
1840 # Fixed reindex on first access.
1841 # Also fixed reindexing of entries that change.
1842 #
1843 # Revision 1.133 2002/07/08 15:32:05 gmcm
1844 # Pagination of index pages.
1845 # New search form.
1846 #
1847 # Revision 1.132 2002/07/08 07:26:14 richard
1848 # ehem
1849 #
1850 # Revision 1.131 2002/07/08 06:53:57 richard
1851 # Not sure why the cgi_client had an indexer argument.
1852 #
1853 # Revision 1.130 2002/06/27 12:01:53 gmcm
1854 # If the form has a :multilink, put a back href in the pageheader (back to the linked-to node).
1855 # Some minor optimizations (only compile regexes once).
1856 #
1857 # Revision 1.129 2002/06/20 23:52:11 richard
1858 # Better handling of unauth attempt to edit stuff
1859 #
1860 # Revision 1.128 2002/06/12 21:28:25 gmcm
1861 # Allow form to set user-properties on a Fileclass.
1862 # Don't assume that a Fileclass is named "files".
1863 #
1864 # Revision 1.127 2002/06/11 06:38:24 richard
1865 # . #565996 ] The "Attach a File to this Issue" fails
1866 #
1867 # Revision 1.126 2002/05/29 01:16:17 richard
1868 # Sorry about this huge checkin! It's fixing a lot of related stuff in one go
1869 # though.
1870 #
1871 # . #541941 ] changing multilink properties by mail
1872 # . #526730 ] search for messages capability
1873 # . #505180 ] split MailGW.handle_Message
1874 # - also changed cgi client since it was duplicating the functionality
1875 # . build htmlbase if tests are run using CVS checkout (removed note from
1876 # installation.txt)
1877 # . don't create an empty message on email issue creation if the email is empty
1878 #
1879 # Revision 1.125 2002/05/25 07:16:24 rochecompaan
1880 # Merged search_indexing-branch with HEAD
1881 #
1882 # Revision 1.124 2002/05/24 02:09:24 richard
1883 # Nothing like a live demo to show up the bugs ;)
1884 #
1885 # Revision 1.123 2002/05/22 05:04:13 richard
1886 # Oops
1887 #
1888 # Revision 1.122 2002/05/22 04:12:05 richard
1889 # . applied patch #558876 ] cgi client customization
1890 # ... with significant additions and modifications ;)
1891 # - extended handling of ML assignedto to all places it's handled
1892 # - added more NotFound info
1893 #
1894 # Revision 1.121 2002/05/21 06:08:10 richard
1895 # Handle migration
1896 #
1897 # Revision 1.120 2002/05/21 06:05:53 richard
1898 # . #551483 ] assignedto in Client.make_index_link
1899 #
1900 # Revision 1.119 2002/05/15 06:21:21 richard
1901 # . node caching now works, and gives a small boost in performance
1902 #
1903 # As a part of this, I cleaned up the DEBUG output and implemented TRACE
1904 # output (HYPERDBTRACE='file to trace to') with checkpoints at the start of
1905 # CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff
1906 # (using if __debug__ which is compiled out with -O)
1907 #
1908 # Revision 1.118 2002/05/12 23:46:33 richard
1909 # ehem, part 2
1910 #
1911 # Revision 1.117 2002/05/12 23:42:29 richard
1912 # ehem
1913 #
1914 # Revision 1.116 2002/05/02 08:07:49 richard
1915 # Added the ADD_AUTHOR_TO_NOSY handling to the CGI interface.
1916 #
1917 # Revision 1.115 2002/04/02 01:56:10 richard
1918 # . stop sending blank (whitespace-only) notes
1919 #
1920 # Revision 1.114.2.4 2002/05/02 11:49:18 rochecompaan
1921 # Allow customization of the search filters that should be displayed
1922 # on the search page.
1923 #
1924 # Revision 1.114.2.3 2002/04/20 13:23:31 rochecompaan
1925 # We now have a separate search page for nodes. Search links for
1926 # different classes can be customized in instance_config similar to
1927 # index links.
1928 #
1929 # Revision 1.114.2.2 2002/04/19 19:54:42 rochecompaan
1930 # cgi_client.py
1931 # removed search link for the time being
1932 # moved rendering of matches to htmltemplate
1933 # hyperdb.py
1934 # filtering of nodes on full text search incorporated in filter method
1935 # roundupdb.py
1936 # added paramater to call of filter method
1937 # roundup_indexer.py
1938 # added search method to RoundupIndexer class
1939 #
1940 # Revision 1.114.2.1 2002/04/03 11:55:57 rochecompaan
1941 # . Added feature #526730 - search for messages capability
1942 #
1943 # Revision 1.114 2002/03/17 23:06:05 richard
1944 # oops
1945 #
1946 # Revision 1.113 2002/03/14 23:59:24 richard
1947 # . #517734 ] web header customisation is obscure
1948 #
1949 # Revision 1.112 2002/03/12 22:52:26 richard
1950 # more pychecker warnings removed
1951 #
1952 # Revision 1.111 2002/02/25 04:32:21 richard
1953 # ahem
1954 #
1955 # Revision 1.110 2002/02/21 07:19:08 richard
1956 # ... and label, width and height control for extra flavour!
1957 #
1958 # Revision 1.109 2002/02/21 07:08:19 richard
1959 # oops
1960 #
1961 # Revision 1.108 2002/02/21 07:02:54 richard
1962 # The correct var is "HTTP_HOST"
1963 #
1964 # Revision 1.107 2002/02/21 06:57:38 richard
1965 # . Added popup help for classes using the classhelp html template function.
1966 # - add <display call="classhelp('priority', 'id,name,description')">
1967 # to an item page, and it generates a link to a popup window which displays
1968 # the id, name and description for the priority class. The description
1969 # field won't exist in most installations, but it will be added to the
1970 # default templates.
1971 #
1972 # Revision 1.106 2002/02/21 06:23:00 richard
1973 # *** empty log message ***
1974 #
1975 # Revision 1.105 2002/02/20 05:52:10 richard
1976 # better error handling
1977 #
1978 # Revision 1.104 2002/02/20 05:45:17 richard
1979 # Use the csv module for generating the form entry so it's correct.
1980 # [also noted the sf.net feature request id in the change log]
1981 #
1982 # Revision 1.103 2002/02/20 05:05:28 richard
1983 # . Added simple editing for classes that don't define a templated interface.
1984 # - access using the admin "class list" interface
1985 # - limited to admin-only
1986 # - requires the csv module from object-craft (url given if it's missing)
1987 #
1988 # Revision 1.102 2002/02/15 07:08:44 richard
1989 # . Alternate email addresses are now available for users. See the MIGRATION
1990 # file for info on how to activate the feature.
1991 #
1992 # Revision 1.101 2002/02/14 23:39:18 richard
1993 # . All forms now have "double-submit" protection when Javascript is enabled
1994 # on the client-side.
1995 #
1996 # Revision 1.100 2002/01/16 07:02:57 richard
1997 # . lots of date/interval related changes:
1998 # - more relaxed date format for input
1999 #
2000 # Revision 1.99 2002/01/16 03:02:42 richard
2001 # #503793 ] changing assignedto resets nosy list
2002 #
2003 # Revision 1.98 2002/01/14 02:20:14 richard
2004 # . changed all config accesses so they access either the instance or the
2005 # config attriubute on the db. This means that all config is obtained from
2006 # instance_config instead of the mish-mash of classes. This will make
2007 # switching to a ConfigParser setup easier too, I hope.
2008 #
2009 # At a minimum, this makes migration a _little_ easier (a lot easier in the
2010 # 0.5.0 switch, I hope!)
2011 #
2012 # Revision 1.97 2002/01/11 23:22:29 richard
2013 # . #502437 ] rogue reactor and unittest
2014 # in short, the nosy reactor was modifying the nosy list. That code had
2015 # been there for a long time, and I suspsect it was there because we
2016 # weren't generating the nosy list correctly in other places of the code.
2017 # We're now doing that, so the nosy-modifying code can go away from the
2018 # nosy reactor.
2019 #
2020 # Revision 1.96 2002/01/10 05:26:10 richard
2021 # missed a parsePropsFromForm in last update
2022 #
2023 # Revision 1.95 2002/01/10 03:39:45 richard
2024 # . fixed some problems with web editing and change detection
2025 #
2026 # Revision 1.94 2002/01/09 13:54:21 grubert
2027 # _add_assignedto_to_nosy did set nosy to assignedto only, no adding.
2028 #
2029 # Revision 1.93 2002/01/08 11:57:12 richard
2030 # crying out for real configuration handling... :(
2031 #
2032 # Revision 1.92 2002/01/08 04:12:05 richard
2033 # Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
2034 #
2035 # Revision 1.91 2002/01/08 04:03:47 richard
2036 # I mucked the intent of the code up.
2037 #
2038 # Revision 1.90 2002/01/08 03:56:55 richard
2039 # Oops, missed this before the beta:
2040 # . #495392 ] empty nosy -patch
2041 #
2042 # Revision 1.89 2002/01/07 20:24:45 richard
2043 # *mutter* stupid cutnpaste
2044 #
2045 # Revision 1.88 2002/01/02 02:31:38 richard
2046 # Sorry for the huge checkin message - I was only intending to implement #496356
2047 # but I found a number of places where things had been broken by transactions:
2048 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
2049 # for _all_ roundup-generated smtp messages to be sent to.
2050 # . the transaction cache had broken the roundupdb.Class set() reactors
2051 # . newly-created author users in the mailgw weren't being committed to the db
2052 #
2053 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
2054 # on when I found that stuff :):
2055 # . #496356 ] Use threading in messages
2056 # . detectors were being registered multiple times
2057 # . added tests for mailgw
2058 # . much better attaching of erroneous messages in the mail gateway
2059 #
2060 # Revision 1.87 2001/12/23 23:18:49 richard
2061 # We already had an admin-specific section of the web heading, no need to add
2062 # another one :)
2063 #
2064 # Revision 1.86 2001/12/20 15:43:01 rochecompaan
2065 # Features added:
2066 # . Multilink properties are now displayed as comma separated values in
2067 # a textbox
2068 # . The add user link is now only visible to the admin user
2069 # . Modified the mail gateway to reject submissions from unknown
2070 # addresses if ANONYMOUS_ACCESS is denied
2071 #
2072 # Revision 1.85 2001/12/20 06:13:24 rochecompaan
2073 # Bugs fixed:
2074 # . Exception handling in hyperdb for strings-that-look-like numbers got
2075 # lost somewhere
2076 # . Internet Explorer submits full path for filename - we now strip away
2077 # the path
2078 # Features added:
2079 # . Link and multilink properties are now displayed sorted in the cgi
2080 # interface
2081 #
2082 # Revision 1.84 2001/12/18 15:30:30 rochecompaan
2083 # Fixed bugs:
2084 # . Fixed file creation and retrieval in same transaction in anydbm
2085 # backend
2086 # . Cgi interface now renders new issue after issue creation
2087 # . Could not set issue status to resolved through cgi interface
2088 # . Mail gateway was changing status back to 'chatting' if status was
2089 # omitted as an argument
2090 #
2091 # Revision 1.83 2001/12/15 23:51:01 richard
2092 # Tested the changes and fixed a few problems:
2093 # . files are now attached to the issue as well as the message
2094 # . newuser is a real method now since we don't want to do the message/file
2095 # stuff for it
2096 # . added some documentation
2097 # The really big changes in the diff are a result of me moving some code
2098 # around to keep like methods together a bit better.
2099 #
2100 # Revision 1.82 2001/12/15 19:24:39 rochecompaan
2101 # . Modified cgi interface to change properties only once all changes are
2102 # collected, files created and messages generated.
2103 # . Moved generation of change note to nosyreactors.
2104 # . We now check for changes to "assignedto" to ensure it's added to the
2105 # nosy list.
2106 #
2107 # Revision 1.81 2001/12/12 23:55:00 richard
2108 # Fixed some problems with user editing
2109 #
2110 # Revision 1.80 2001/12/12 23:27:14 richard
2111 # Added a Zope frontend for roundup.
2112 #
2113 # Revision 1.79 2001/12/10 22:20:01 richard
2114 # Enabled transaction support in the bsddb backend. It uses the anydbm code
2115 # where possible, only replacing methods where the db is opened (it uses the
2116 # btree opener specifically.)
2117 # Also cleaned up some change note generation.
2118 # Made the backends package work with pydoc too.
2119 #
2120 # Revision 1.78 2001/12/07 05:59:27 rochecompaan
2121 # Fixed small bug that prevented adding issues through the web.
2122 #
2123 # Revision 1.77 2001/12/06 22:48:29 richard
2124 # files multilink was being nuked in post_edit_node
2125 #
2126 # Revision 1.76 2001/12/05 14:26:44 rochecompaan
2127 # Removed generation of change note from "sendmessage" in roundupdb.py.
2128 # The change note is now generated when the message is created.
2129 #
2130 # Revision 1.75 2001/12/04 01:25:08 richard
2131 # Added some rollbacks where we were catching exceptions that would otherwise
2132 # have stopped committing.
2133 #
2134 # Revision 1.74 2001/12/02 05:06:16 richard
2135 # . We now use weakrefs in the Classes to keep the database reference, so
2136 # the close() method on the database is no longer needed.
2137 # I bumped the minimum python requirement up to 2.1 accordingly.
2138 # . #487480 ] roundup-server
2139 # . #487476 ] INSTALL.txt
2140 #
2141 # I also cleaned up the change message / post-edit stuff in the cgi client.
2142 # There's now a clearly marked "TODO: append the change note" where I believe
2143 # the change note should be added there. The "changes" list will obviously
2144 # have to be modified to be a dict of the changes, or somesuch.
2145 #
2146 # More testing needed.
2147 #
2148 # Revision 1.73 2001/12/01 07:17:50 richard
2149 # . We now have basic transaction support! Information is only written to
2150 # the database when the commit() method is called. Only the anydbm
2151 # backend is modified in this way - neither of the bsddb backends have been.
2152 # The mail, admin and cgi interfaces all use commit (except the admin tool
2153 # doesn't have a commit command, so interactive users can't commit...)
2154 # . Fixed login/registration forwarding the user to the right page (or not,
2155 # on a failure)
2156 #
2157 # Revision 1.72 2001/11/30 20:47:58 rochecompaan
2158 # Links in page header are now consistent with default sort order.
2159 #
2160 # Fixed bugs:
2161 # - When login failed the list of issues were still rendered.
2162 # - User was redirected to index page and not to his destination url
2163 # if his first login attempt failed.
2164 #
2165 # Revision 1.71 2001/11/30 20:28:10 rochecompaan
2166 # Property changes are now completely traceable, whether changes are
2167 # made through the web or by email
2168 #
2169 # Revision 1.70 2001/11/30 00:06:29 richard
2170 # Converted roundup/cgi_client.py to use _()
2171 # Added the status file, I18N_PROGRESS.txt
2172 #
2173 # Revision 1.69 2001/11/29 23:19:51 richard
2174 # Removed the "This issue has been edited through the web" when a valid
2175 # change note is supplied.
2176 #
2177 # Revision 1.68 2001/11/29 04:57:23 richard
2178 # a little comment
2179 #
2180 # Revision 1.67 2001/11/28 21:55:35 richard
2181 # . login_action and newuser_action return values were being ignored
2182 # . Woohoo! Found that bloody re-login bug that was killing the mail
2183 # gateway.
2184 # (also a minor cleanup in hyperdb)
2185 #
2186 # Revision 1.66 2001/11/27 03:00:50 richard
2187 # couple of bugfixes from latest patch integration
2188 #
2189 # Revision 1.65 2001/11/26 23:00:53 richard
2190 # This config stuff is getting to be a real mess...
2191 #
2192 # Revision 1.64 2001/11/26 22:56:35 richard
2193 # typo
2194 #
2195 # Revision 1.63 2001/11/26 22:55:56 richard
2196 # Feature:
2197 # . Added INSTANCE_NAME to configuration - used in web and email to identify
2198 # the instance.
2199 # . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
2200 # signature info in e-mails.
2201 # . Some more flexibility in the mail gateway and more error handling.
2202 # . Login now takes you to the page you back to the were denied access to.
2203 #
2204 # Fixed:
2205 # . Lots of bugs, thanks Roché and others on the devel mailing list!
2206 #
2207 # Revision 1.62 2001/11/24 00:45:42 jhermann
2208 # typeof() instead of type(): avoid clash with database field(?) "type"
2209 #
2210 # Fixes this traceback:
2211 #
2212 # Traceback (most recent call last):
2213 # File "roundup\cgi_client.py", line 535, in newnode
2214 # self._post_editnode(nid)
2215 # File "roundup\cgi_client.py", line 415, in _post_editnode
2216 # if type(value) != type([]): value = [value]
2217 # UnboundLocalError: local variable 'type' referenced before assignment
2218 #
2219 # Revision 1.61 2001/11/22 15:46:42 jhermann
2220 # Added module docstrings to all modules.
2221 #
2222 # Revision 1.60 2001/11/21 22:57:28 jhermann
2223 # Added dummy hooks for I18N and some preliminary (test) markup of
2224 # translatable messages
2225 #
2226 # Revision 1.59 2001/11/21 03:21:13 richard
2227 # oops
2228 #
2229 # Revision 1.58 2001/11/21 03:11:28 richard
2230 # Better handling of new properties.
2231 #
2232 # Revision 1.57 2001/11/15 10:24:27 richard
2233 # handle the case where there is no file attached
2234 #
2235 # Revision 1.56 2001/11/14 21:35:21 richard
2236 # . users may attach files to issues (and support in ext) through the web now
2237 #
2238 # Revision 1.55 2001/11/07 02:34:06 jhermann
2239 # Handling of damaged login cookies
2240 #
2241 # Revision 1.54 2001/11/07 01:16:12 richard
2242 # Remove the '=' padding from cookie value so quoting isn't an issue.
2243 #
2244 # Revision 1.53 2001/11/06 23:22:05 jhermann
2245 # More IE fixes: it does not like quotes around cookie values; in the
2246 # hope this does not break anything for other browser; if it does, we
2247 # need to check HTTP_USER_AGENT
2248 #
2249 # Revision 1.52 2001/11/06 23:11:22 jhermann
2250 # Fixed debug output in page footer; added expiry date to the login cookie
2251 # (expires 1 year in the future) to prevent probs with certain versions
2252 # of IE
2253 #
2254 # Revision 1.51 2001/11/06 22:00:34 jhermann
2255 # Get debug level from ROUNDUP_DEBUG env var
2256 #
2257 # Revision 1.50 2001/11/05 23:45:40 richard
2258 # Fixed newuser_action so it sets the cookie with the unencrypted password.
2259 # Also made it present nicer error messages (not tracebacks).
2260 #
2261 # Revision 1.49 2001/11/04 03:07:12 richard
2262 # Fixed various cookie-related bugs:
2263 # . bug #477685 ] base64.decodestring breaks
2264 # . bug #477837 ] lynx does not like the cookie
2265 # . bug #477892 ] Password edit doesn't fix login cookie
2266 # Also closed a security hole - a logged-in user could edit another user's
2267 # details.
2268 #
2269 # Revision 1.48 2001/11/03 01:30:18 richard
2270 # Oops. uses pagefoot now.
2271 #
2272 # Revision 1.47 2001/11/03 01:29:28 richard
2273 # Login page didn't have all close tags.
2274 #
2275 # Revision 1.46 2001/11/03 01:26:55 richard
2276 # possibly fix truncated base64'ed user:pass
2277 #
2278 # Revision 1.45 2001/11/01 22:04:37 richard
2279 # Started work on supporting a pop3-fetching server
2280 # Fixed bugs:
2281 # . bug #477104 ] HTML tag error in roundup-server
2282 # . bug #477107 ] HTTP header problem
2283 #
2284 # Revision 1.44 2001/10/28 23:03:08 richard
2285 # Added more useful header to the classic schema.
2286 #
2287 # Revision 1.43 2001/10/24 00:01:42 richard
2288 # More fixes to lockout logic.
2289 #
2290 # Revision 1.42 2001/10/23 23:56:03 richard
2291 # HTML typo
2292 #
2293 # Revision 1.41 2001/10/23 23:52:35 richard
2294 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
2295 #
2296 # Revision 1.40 2001/10/23 23:06:39 richard
2297 # Some cleanup.
2298 #
2299 # Revision 1.39 2001/10/23 01:00:18 richard
2300 # Re-enabled login and registration access after lopping them off via
2301 # disabling access for anonymous users.
2302 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
2303 # a couple of bugs while I was there. Probably introduced a couple, but
2304 # things seem to work OK at the moment.
2305 #
2306 # Revision 1.38 2001/10/22 03:25:01 richard
2307 # Added configuration for:
2308 # . anonymous user access and registration (deny/allow)
2309 # . filter "widget" location on index page (top, bottom, both)
2310 # Updated some documentation.
2311 #
2312 # Revision 1.37 2001/10/21 07:26:35 richard
2313 # feature #473127: Filenames. I modified the file.index and htmltemplate
2314 # source so that the filename is used in the link and the creation
2315 # information is displayed.
2316 #
2317 # Revision 1.36 2001/10/21 04:44:50 richard
2318 # bug #473124: UI inconsistency with Link fields.
2319 # This also prompted me to fix a fairly long-standing usability issue -
2320 # that of being able to turn off certain filters.
2321 #
2322 # Revision 1.35 2001/10/21 00:17:54 richard
2323 # CGI interface view customisation section may now be hidden (patch from
2324 # Roch'e Compaan.)
2325 #
2326 # Revision 1.34 2001/10/20 11:58:48 richard
2327 # Catch errors in login - no username or password supplied.
2328 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
2329 #
2330 # Revision 1.33 2001/10/17 00:18:41 richard
2331 # Manually constructing cookie headers now.
2332 #
2333 # Revision 1.32 2001/10/16 03:36:21 richard
2334 # CGI interface wasn't handling checkboxes at all.
2335 #
2336 # Revision 1.31 2001/10/14 10:55:00 richard
2337 # Handle empty strings in HTML template Link function
2338 #
2339 # Revision 1.30 2001/10/09 07:38:58 richard
2340 # Pushed the base code for the extended schema CGI interface back into the
2341 # code cgi_client module so that future updates will be less painful.
2342 # Also removed a debugging print statement from cgi_client.
2343 #
2344 # Revision 1.29 2001/10/09 07:25:59 richard
2345 # Added the Password property type. See "pydoc roundup.password" for
2346 # implementation details. Have updated some of the documentation too.
2347 #
2348 # Revision 1.28 2001/10/08 00:34:31 richard
2349 # Change message was stuffing up for multilinks with no key property.
2350 #
2351 # Revision 1.27 2001/10/05 02:23:24 richard
2352 # . roundup-admin create now prompts for property info if none is supplied
2353 # on the command-line.
2354 # . hyperdb Class getprops() method may now return only the mutable
2355 # properties.
2356 # . Login now uses cookies, which makes it a whole lot more flexible. We can
2357 # now support anonymous user access (read-only, unless there's an
2358 # "anonymous" user, in which case write access is permitted). Login
2359 # handling has been moved into cgi_client.Client.main()
2360 # . The "extended" schema is now the default in roundup init.
2361 # . The schemas have had their page headings modified to cope with the new
2362 # login handling. Existing installations should copy the interfaces.py
2363 # file from the roundup lib directory to their instance home.
2364 # . Incorrectly had a Bizar Software copyright on the cgitb.py module from
2365 # Ping - has been removed.
2366 # . Fixed a whole bunch of places in the CGI interface where we should have
2367 # been returning Not Found instead of throwing an exception.
2368 # . Fixed a deviation from the spec: trying to modify the 'id' property of
2369 # an item now throws an exception.
2370 #
2371 # Revision 1.26 2001/09/12 08:31:42 richard
2372 # handle cases where mime type is not guessable
2373 #
2374 # Revision 1.25 2001/08/29 05:30:49 richard
2375 # change messages weren't being saved when there was no-one on the nosy list.
2376 #
2377 # Revision 1.24 2001/08/29 04:49:39 richard
2378 # didn't clean up fully after debugging :(
2379 #
2380 # Revision 1.23 2001/08/29 04:47:18 richard
2381 # Fixed CGI client change messages so they actually include the properties
2382 # changed (again).
2383 #
2384 # Revision 1.22 2001/08/17 00:08:10 richard
2385 # reverted back to sending messages always regardless of who is doing the web
2386 # edit. change notes weren't being saved. bleah. hackish.
2387 #
2388 # Revision 1.21 2001/08/15 23:43:18 richard
2389 # Fixed some isFooTypes that I missed.
2390 # Refactored some code in the CGI code.
2391 #
2392 # Revision 1.20 2001/08/12 06:32:36 richard
2393 # using isinstance(blah, Foo) now instead of isFooType
2394 #
2395 # Revision 1.19 2001/08/07 00:24:42 richard
2396 # stupid typo
2397 #
2398 # Revision 1.18 2001/08/07 00:15:51 richard
2399 # Added the copyright/license notice to (nearly) all files at request of
2400 # Bizar Software.
2401 #
2402 # Revision 1.17 2001/08/02 06:38:17 richard
2403 # Roundupdb now appends "mailing list" information to its messages which
2404 # include the e-mail address and web interface address. Templates may
2405 # override this in their db classes to include specific information (support
2406 # instructions, etc).
2407 #
2408 # Revision 1.16 2001/08/02 05:55:25 richard
2409 # Web edit messages aren't sent to the person who did the edit any more. No
2410 # message is generated if they are the only person on the nosy list.
2411 #
2412 # Revision 1.15 2001/08/02 00:34:10 richard
2413 # bleah syntax error
2414 #
2415 # Revision 1.14 2001/08/02 00:26:16 richard
2416 # Changed the order of the information in the message generated by web edits.
2417 #
2418 # Revision 1.13 2001/07/30 08:12:17 richard
2419 # Added time logging and file uploading to the templates.
2420 #
2421 # Revision 1.12 2001/07/30 06:26:31 richard
2422 # Added some documentation on how the newblah works.
2423 #
2424 # Revision 1.11 2001/07/30 06:17:45 richard
2425 # Features:
2426 # . Added ability for cgi newblah forms to indicate that the new node
2427 # should be linked somewhere.
2428 # Fixed:
2429 # . Fixed the agument handling for the roundup-admin find command.
2430 # . Fixed handling of summary when no note supplied for newblah. Again.
2431 # . Fixed detection of no form in htmltemplate Field display.
2432 #
2433 # Revision 1.10 2001/07/30 02:37:34 richard
2434 # Temporary measure until we have decent schema migration...
2435 #
2436 # Revision 1.9 2001/07/30 01:25:07 richard
2437 # Default implementation is now "classic" rather than "extended" as one would
2438 # expect.
2439 #
2440 # Revision 1.8 2001/07/29 08:27:40 richard
2441 # Fixed handling of passed-in values in form elements (ie. during a
2442 # drill-down)
2443 #
2444 # Revision 1.7 2001/07/29 07:01:39 richard
2445 # Added vim command to all source so that we don't get no steenkin' tabs :)
2446 #
2447 # Revision 1.6 2001/07/29 04:04:00 richard
2448 # Moved some code around allowing for subclassing to change behaviour.
2449 #
2450 # Revision 1.5 2001/07/28 08:16:52 richard
2451 # New issue form handles lack of note better now.
2452 #
2453 # Revision 1.4 2001/07/28 00:34:34 richard
2454 # Fixed some non-string node ids.
2455 #
2456 # Revision 1.3 2001/07/23 03:56:30 richard
2457 # oops, missed a config removal
2458 #
2459 # Revision 1.2 2001/07/22 12:09:32 richard
2460 # Final commit of Grande Splite
2461 #
2462 # Revision 1.1 2001/07/22 11:58:35 richard
2463 # More Grande Splite
2464 #
2465 #
2466 # vim: set filetype=python ts=4 sw=4 et si