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.158 2002-08-15 00:40:10 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, 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.
1118 Note: permission checks for this node are handled in the template.
1119 '''
1120 user = self.db.user
1122 # get the username of the node being edited
1123 try:
1124 node_user = user.get(self.nodeid, 'username')
1125 except IndexError:
1126 raise NotFound, 'user%s'%self.nodeid
1128 #
1129 # perform any editing
1130 #
1131 keys = self.form.keys()
1132 if keys:
1133 try:
1134 props = parsePropsFromForm(self.db, user, self.form,
1135 self.nodeid)
1136 set_cookie = 0
1137 if props.has_key('password'):
1138 password = self.form['password'].value.strip()
1139 if not password:
1140 # no password was supplied - don't change it
1141 del props['password']
1142 elif self.nodeid == self.getuid():
1143 # this is the logged-in user's password
1144 set_cookie = password
1145 user.set(self.nodeid, **props)
1146 # and some feedback for the user
1147 message = _('%(changes)s edited ok')%{'changes':
1148 ', '.join(props.keys())}
1149 except:
1150 self.db.rollback()
1151 s = StringIO.StringIO()
1152 traceback.print_exc(None, s)
1153 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
1154 else:
1155 set_cookie = 0
1157 # fix the cookie if the password has changed
1158 if set_cookie:
1159 self.set_cookie(self.user, set_cookie)
1161 #
1162 # now the display
1163 #
1164 self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
1166 # use the template to display the item
1167 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 'user')
1168 item.render(self.nodeid)
1169 self.pagefoot()
1171 def showfile(self):
1172 ''' display a file
1173 '''
1174 # nothing in xtrapath - edit the file's metadata
1175 if self.xtrapath is None:
1176 return self.shownode()
1178 # something in xtrapath - download the file
1179 nodeid = self.nodeid
1180 cl = self.db.classes[self.classname]
1181 try:
1182 mime_type = cl.get(nodeid, 'type')
1183 except IndexError:
1184 raise NotFound, 'file%s'%nodeid
1185 if mime_type == 'message/rfc822':
1186 mime_type = 'text/plain'
1187 self.header(headers={'Content-Type': mime_type})
1188 self.write(cl.get(nodeid, 'content'))
1190 def permission(self):
1191 '''
1192 '''
1194 def classes(self, message=None):
1195 ''' display a list of all the classes in the database
1196 '''
1197 userid = self.db.user.lookup(self.user)
1198 if not self.db.security.hasPermission('Edit', userid):
1199 raise Unauthorised, _("You do not have permission to access"\
1200 " %(action)s.")%{'action': 'all classes'}
1202 self.pagehead(_('Table of classes'), message)
1203 classnames = self.db.classes.keys()
1204 classnames.sort()
1205 self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
1206 for cn in classnames:
1207 cl = self.db.getclass(cn)
1208 self.write('<tr class="list-header"><th colspan=2 align=left>'
1209 '<a href="%s">%s</a></th></tr>'%(cn, cn.capitalize()))
1210 for key, value in cl.properties.items():
1211 if value is None: value = ''
1212 else: value = str(value)
1213 self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
1214 key, cgi.escape(value)))
1215 self.write('</table>')
1216 self.pagefoot()
1218 def unauthorised(self, message):
1219 ''' The user is not authorised to do something. If they're
1220 anonymous, throw up a login box. If not, just tell them they
1221 can't do whatever it was they were trying to do.
1223 Bot cases print up the message, which is most likely the
1224 argument to the Unauthorised exception.
1225 '''
1226 self.header(response=403)
1227 if self.desired_action is None or self.desired_action == 'login':
1228 if not message:
1229 message=_("You do not have permission.")
1230 action = 'index'
1231 else:
1232 if not message:
1233 message=_("You do not have permission to access"\
1234 " %(action)s.")%{'action': self.desired_action}
1235 action = self.desired_action
1236 if self.user == 'anonymous':
1237 self.login(action=action, message=message)
1238 else:
1239 self.pagehead(_('Not Authorised'))
1240 self.write('<p class="system-msg">%s</p>'%message)
1241 self.pagefoot()
1243 def login(self, message=None, newuser_form=None, action='index'):
1244 '''Display a login page.
1245 '''
1246 self.pagehead(_('Login to roundup'))
1247 if message:
1248 self.write('<p class="system-msg">%s</p>'%message)
1249 self.write(_('''
1250 <table>
1251 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
1252 <form onSubmit="return submit_once()" action="login_action" method=POST>
1253 <input type="hidden" name="__destination_url" value="%(action)s">
1254 <tr><td align=right>Login name: </td>
1255 <td><input name="__login_name"></td></tr>
1256 <tr><td align=right>Password: </td>
1257 <td><input type="password" name="__login_password"></td></tr>
1258 <tr><td></td>
1259 <td><input type="submit" value="Log In"></td></tr>
1260 </form>
1261 ''')%locals())
1262 userid = self.db.user.lookup(self.user)
1263 if not self.db.security.hasPermission('Web Registration', userid):
1264 self.write('</table>')
1265 self.pagefoot()
1266 return
1267 values = {'realname': '', 'organisation': '', 'address': '',
1268 'phone': '', 'username': '', 'password': '', 'confirm': '',
1269 'action': action, 'alternate_addresses': ''}
1270 if newuser_form is not None:
1271 for key in newuser_form.keys():
1272 values[key] = newuser_form[key].value
1273 self.write(_('''
1274 <p>
1275 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
1276 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
1277 <form onSubmit="return submit_once()" action="newuser_action" method=POST>
1278 <input type="hidden" name="__destination_url" value="%(action)s">
1279 <tr><td align=right><em>Name: </em></td>
1280 <td><input name="realname" value="%(realname)s" size=40></td></tr>
1281 <tr><td align=right><em>Organisation: </em></td>
1282 <td><input name="organisation" value="%(organisation)s" size=40></td></tr>
1283 <tr><td align=right>E-Mail Address: </td>
1284 <td><input name="address" value="%(address)s" size=40></td></tr>
1285 <tr><td align=right><em>Alternate E-mail Addresses: </em></td>
1286 <td><textarea name="alternate_addresses" rows=5 cols=40>%(alternate_addresses)s</textarea></td></tr>
1287 <tr><td align=right><em>Phone: </em></td>
1288 <td><input name="phone" value="%(phone)s"></td></tr>
1289 <tr><td align=right>Preferred Login name: </td>
1290 <td><input name="username" value="%(username)s"></td></tr>
1291 <tr><td align=right>Password: </td>
1292 <td><input type="password" name="password" value="%(password)s"></td></tr>
1293 <tr><td align=right>Password Again: </td>
1294 <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
1295 <tr><td></td>
1296 <td><input type="submit" value="Register"></td></tr>
1297 </form>
1298 </table>
1299 ''')%values)
1300 self.pagefoot()
1302 def login_action(self, message=None):
1303 '''Attempt to log a user in and set the cookie
1305 returns 0 if a page is generated as a result of this call, and
1306 1 if not (ie. the login is successful
1307 '''
1308 if not self.form.has_key('__login_name'):
1309 self.login(message=_('Username required'))
1310 return 0
1311 self.user = self.form['__login_name'].value
1312 # re-open the database for real, using the user
1313 self.opendb(self.user)
1314 if self.form.has_key('__login_password'):
1315 password = self.form['__login_password'].value
1316 else:
1317 password = ''
1318 # make sure the user exists
1319 try:
1320 uid = self.db.user.lookup(self.user)
1321 except KeyError:
1322 name = self.user
1323 self.make_user_anonymous()
1324 action = self.form['__destination_url'].value
1325 self.login(message=_('No such user "%(name)s"')%locals(),
1326 action=action)
1327 return 0
1329 # and that the password is correct
1330 pw = self.db.user.get(uid, 'password')
1331 if password != pw:
1332 self.make_user_anonymous()
1333 action = self.form['__destination_url'].value
1334 self.login(message=_('Incorrect password'), action=action)
1335 return 0
1337 self.set_cookie(self.user, password)
1338 return 1
1340 def newuser_action(self, message=None):
1341 '''Attempt to create a new user based on the contents of the form
1342 and then set the cookie.
1344 return 1 on successful login
1345 '''
1346 # make sure we're allowed to register
1347 userid = self.db.user.lookup(self.user)
1348 if not self.db.security.hasPermission('Web Registration', userid):
1349 raise Unauthorised, _("You do not have permission to access"\
1350 " %(action)s.")%{'action': 'registration'}
1352 # re-open the database as "admin"
1353 if self.user != 'admin':
1354 self.opendb('admin')
1356 # create the new user
1357 cl = self.db.user
1358 try:
1359 props = parsePropsFromForm(self.db, cl, self.form)
1360 props['roles'] = self.instance.NEW_WEB_USER_ROLES
1361 uid = cl.create(**props)
1362 self.db.commit()
1363 except ValueError, message:
1364 action = self.form['__destination_url'].value
1365 self.login(message, action=action)
1366 return 0
1368 # log the new user in
1369 self.user = cl.get(uid, 'username')
1370 # re-open the database for real, using the user
1371 self.opendb(self.user)
1372 password = cl.get(uid, 'password')
1373 self.set_cookie(self.user, password)
1374 return 1
1376 def set_cookie(self, user, password):
1377 # TODO generate a much, much stronger session key ;)
1378 self.session = binascii.b2a_base64(repr(time.time())).strip()
1380 # clean up the base64
1381 if self.session[-1] == '=':
1382 if self.session[-2] == '=':
1383 self.session = self.session[:-2]
1384 else:
1385 self.session = self.session[:-1]
1387 # insert the session in the sessiondb
1388 self.db.sessions.set(self.session, user=user, last_use=time.time())
1390 # and commit immediately
1391 self.db.sessions.commit()
1393 # expire us in a long, long time
1394 expire = Cookie._getdate(86400*365)
1396 # generate the cookie path - make sure it has a trailing '/'
1397 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
1398 ''))
1399 self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;'%(
1400 self.session, expire, path)})
1402 def make_user_anonymous(self):
1403 ''' Make us anonymous
1405 This method used to handle non-existence of the 'anonymous'
1406 user, but that user is mandatory now.
1407 '''
1408 self.db.user.lookup('anonymous')
1409 self.user = 'anonymous'
1411 def logout(self, message=None):
1412 ''' Make us really anonymous - nuke the cookie too
1413 '''
1414 self.make_user_anonymous()
1416 # construct the logout cookie
1417 now = Cookie._getdate()
1418 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
1419 ''))
1420 self.header({'Set-Cookie':
1421 'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
1422 path)})
1423 self.login()
1425 def opendb(self, user):
1426 ''' Open the database - but include the definition of the sessions db.
1427 '''
1428 # open the db if the user has changed
1429 if not hasattr(self, 'db') or user != self.db.journaltag:
1430 self.db = self.instance.open(user)
1432 def main(self):
1433 ''' Wrap the request and handle unauthorised requests
1434 '''
1435 self.desired_action = None
1436 try:
1437 self.main_action()
1438 except Unauthorised, message:
1439 self.unauthorised(message)
1441 def main_action(self):
1442 '''Wrap the database accesses so we can close the database cleanly
1443 '''
1444 # determine the uid to use
1445 self.opendb('admin')
1447 # make sure we have the session Class
1448 sessions = self.db.sessions
1450 # age sessions, remove when they haven't been used for a week
1451 # TODO: this shouldn't be done every access
1452 week = 60*60*24*7
1453 now = time.time()
1454 for sessid in sessions.list():
1455 interval = now - sessions.get(sessid, 'last_use')
1456 if interval > week:
1457 sessions.destroy(sessid)
1459 # look up the user session cookie
1460 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
1461 user = 'anonymous'
1463 if (cookie.has_key('roundup_user') and
1464 cookie['roundup_user'].value != 'deleted'):
1466 # get the session key from the cookie
1467 self.session = cookie['roundup_user'].value
1468 # get the user from the session
1469 try:
1470 # update the lifetime datestamp
1471 sessions.set(self.session, last_use=time.time())
1472 sessions.commit()
1473 user = sessions.get(self.session, 'user')
1474 except KeyError:
1475 user = 'anonymous'
1477 # sanity check on the user still being valid
1478 try:
1479 self.db.user.lookup(user)
1480 except (KeyError, TypeError):
1481 user = 'anonymous'
1483 # make sure the anonymous user is valid if we're using it
1484 if user == 'anonymous':
1485 self.make_user_anonymous()
1486 else:
1487 self.user = user
1489 # now figure which function to call
1490 path = self.split_path
1491 self.xtrapath = None
1493 # default action to index if the path has no information in it
1494 if not path or path[0] in ('', 'index'):
1495 action = 'index'
1496 else:
1497 action = path[0]
1498 if len(path) > 1:
1499 self.xtrapath = path[1:]
1500 self.desired_action = action
1502 # everyone is allowed to try to log in
1503 if action == 'login_action':
1504 # try to login
1505 if not self.login_action():
1506 return
1507 # figure the resulting page
1508 action = self.form['__destination_url'].value
1510 # allow anonymous people to register
1511 elif action == 'newuser_action':
1512 # try to add the user
1513 if not self.newuser_action():
1514 return
1515 # figure the resulting page
1516 action = self.form['__destination_url'].value
1518 # ok, now we have figured out who the user is, make sure the user
1519 # has permission to use this interface
1520 userid = self.db.user.lookup(self.user)
1521 if not self.db.security.hasPermission('Web Access', userid):
1522 raise Unauthorised, \
1523 _("You do not have permission to access this interface.")
1525 # re-open the database for real, using the user
1526 self.opendb(self.user)
1528 # make sure we have a sane action
1529 if not action:
1530 action = 'index'
1532 # just a regular action
1533 try:
1534 self.do_action(action)
1535 except Unauthorised, message:
1536 # if unauth is raised here, then a page header will have
1537 # been displayed
1538 self.write('<p class="system-msg">%s</p>'%message)
1539 else:
1540 # commit all changes to the database
1541 self.db.commit()
1543 def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
1544 nre=re.compile(r'new(\w+)'), sre=re.compile(r'search(\w+)')):
1545 '''Figure the user's action and do it.
1546 '''
1547 # here be the "normal" functionality
1548 if action == 'index':
1549 self.index()
1550 return
1551 if action == 'list_classes':
1552 self.classes()
1553 return
1554 if action == 'classhelp':
1555 self.classhelp()
1556 return
1557 if action == 'login':
1558 self.login()
1559 return
1560 if action == 'logout':
1561 self.logout()
1562 return
1563 if action == 'remove':
1564 self.remove()
1565 return
1567 # see if we're to display an existing node
1568 m = dre.match(action)
1569 if m:
1570 self.classname = m.group(1)
1571 self.nodeid = m.group(2)
1572 try:
1573 cl = self.db.classes[self.classname]
1574 except KeyError:
1575 raise NotFound, self.classname
1576 try:
1577 cl.get(self.nodeid, 'id')
1578 except IndexError:
1579 raise NotFound, self.nodeid
1580 try:
1581 func = getattr(self, 'show%s'%self.classname)
1582 except AttributeError:
1583 raise NotFound, 'show%s'%self.classname
1584 func()
1585 return
1587 # see if we're to put up the new node page
1588 m = nre.match(action)
1589 if m:
1590 self.classname = m.group(1)
1591 try:
1592 func = getattr(self, 'new%s'%self.classname)
1593 except AttributeError:
1594 raise NotFound, 'new%s'%self.classname
1595 func()
1596 return
1598 # see if we're to put up the new node page
1599 m = sre.match(action)
1600 if m:
1601 self.classname = m.group(1)
1602 try:
1603 func = getattr(self, 'search%s'%self.classname)
1604 except AttributeError:
1605 raise NotFound
1606 func()
1607 return
1609 # otherwise, display the named class
1610 self.classname = action
1611 try:
1612 self.db.getclass(self.classname)
1613 except KeyError:
1614 raise NotFound, self.classname
1615 self.list()
1617 def remove(self, dre=re.compile(r'([^\d]+)(\d+)')):
1618 target = self.index_arg(':target')[0]
1619 m = dre.match(target)
1620 if m:
1621 classname = m.group(1)
1622 nodeid = m.group(2)
1623 cl = self.db.getclass(classname)
1624 cl.retire(nodeid)
1625 # now take care of the reference
1626 parentref = self.index_arg(':multilink')[0]
1627 parent, prop = parentref.split(':')
1628 m = dre.match(parent)
1629 if m:
1630 self.classname = m.group(1)
1631 self.nodeid = m.group(2)
1632 cl = self.db.getclass(self.classname)
1633 value = cl.get(self.nodeid, prop)
1634 value.remove(nodeid)
1635 cl.set(self.nodeid, **{prop:value})
1636 func = getattr(self, 'show%s'%self.classname)
1637 return func()
1638 else:
1639 raise NotFound, parent
1640 else:
1641 raise NotFound, target
1643 class ExtendedClient(Client):
1644 '''Includes pages and page heading information that relate to the
1645 extended schema.
1646 '''
1647 showsupport = Client.shownode
1648 showtimelog = Client.shownode
1649 newsupport = Client.newnode
1650 newtimelog = Client.newnode
1651 searchsupport = Client.searchnode
1653 default_index_sort = ['-activity']
1654 default_index_group = ['priority']
1655 default_index_filter = ['status']
1656 default_index_columns = ['activity','status','title','assignedto']
1657 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
1658 default_pagesize = '50'
1660 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1661 '''Pull properties for the given class out of the form.
1662 '''
1663 props = {}
1664 keys = form.keys()
1665 for key in keys:
1666 if not cl.properties.has_key(key):
1667 continue
1668 proptype = cl.properties[key]
1669 if isinstance(proptype, hyperdb.String):
1670 value = form[key].value.strip()
1671 elif isinstance(proptype, hyperdb.Password):
1672 value = password.Password(form[key].value.strip())
1673 elif isinstance(proptype, hyperdb.Date):
1674 value = form[key].value.strip()
1675 if value:
1676 value = date.Date(form[key].value.strip())
1677 else:
1678 value = None
1679 elif isinstance(proptype, hyperdb.Interval):
1680 value = form[key].value.strip()
1681 if value:
1682 value = date.Interval(form[key].value.strip())
1683 else:
1684 value = None
1685 elif isinstance(proptype, hyperdb.Link):
1686 value = form[key].value.strip()
1687 # see if it's the "no selection" choice
1688 if value == '-1':
1689 value = None
1690 else:
1691 # handle key values
1692 link = cl.properties[key].classname
1693 if not num_re.match(value):
1694 try:
1695 value = db.classes[link].lookup(value)
1696 except KeyError:
1697 raise ValueError, _('property "%(propname)s": '
1698 '%(value)s not a %(classname)s')%{'propname':key,
1699 'value': value, 'classname': link}
1700 elif isinstance(proptype, hyperdb.Multilink):
1701 value = form[key]
1702 if hasattr(value, 'value'):
1703 # Quite likely to be a FormItem instance
1704 value = value.value
1705 if not isinstance(value, type([])):
1706 value = [i.strip() for i in value.split(',')]
1707 else:
1708 value = [i.strip() for i in value]
1709 link = cl.properties[key].classname
1710 l = []
1711 for entry in map(str, value):
1712 if entry == '': continue
1713 if not num_re.match(entry):
1714 try:
1715 entry = db.classes[link].lookup(entry)
1716 except KeyError:
1717 raise ValueError, _('property "%(propname)s": '
1718 '"%(value)s" not an entry of %(classname)s')%{
1719 'propname':key, 'value': entry, 'classname': link}
1720 l.append(entry)
1721 l.sort()
1722 value = l
1723 elif isinstance(proptype, hyperdb.Boolean):
1724 value = form[key].value.strip()
1725 props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1726 elif isinstance(proptype, hyperdb.Number):
1727 value = form[key].value.strip()
1728 props[key] = value = int(value)
1730 # get the old value
1731 if nodeid:
1732 try:
1733 existing = cl.get(nodeid, key)
1734 except KeyError:
1735 # this might be a new property for which there is no existing
1736 # value
1737 if not cl.properties.has_key(key): raise
1739 # if changed, set it
1740 if value != existing:
1741 props[key] = value
1742 else:
1743 props[key] = value
1744 return props
1746 #
1747 # $Log: not supported by cvs2svn $
1748 # Revision 1.157 2002/08/13 20:16:09 gmcm
1749 # Use a real parser for templates.
1750 # Rewrite htmltemplate to use the parser (hack, hack).
1751 # Move the "do_XXX" methods to template_funcs.py.
1752 # Redo the funcion tests (but not Template tests - they're hopeless).
1753 # Simplified query form in cgi_client.
1754 # Ability to delete msgs, files, queries.
1755 # Ability to edit the metadata on files.
1756 #
1757 # Revision 1.156 2002/08/01 15:06:06 gmcm
1758 # Use same regex to split search terms as used to index text.
1759 # Fix to back_metakit for not changing journaltag on reopen.
1760 # Fix htmltemplate's do_link so [No <whatever>] strings are href'd.
1761 # Fix bogus "nosy edited ok" msg - the **d syntax does NOT share d between caller and callee.
1762 #
1763 # Revision 1.155 2002/08/01 00:56:22 richard
1764 # Added the web access and email access permissions, so people can restrict
1765 # access to users who register through the email interface (for example).
1766 # Also added "security" command to the roundup-admin interface to display the
1767 # Role/Permission config for an instance.
1768 #
1769 # Revision 1.154 2002/07/31 23:57:36 richard
1770 # . web forms may now unset Link values (like assignedto)
1771 #
1772 # Revision 1.153 2002/07/31 22:40:50 gmcm
1773 # Fixes to the search form and saving queries.
1774 # Fixes to sorting in back_metakit.py.
1775 #
1776 # Revision 1.152 2002/07/31 22:04:14 richard
1777 # cleanup
1778 #
1779 # Revision 1.151 2002/07/30 21:37:43 richard
1780 # oops, thanks Duncan Booth for spotting this one
1781 #
1782 # Revision 1.150 2002/07/30 20:43:18 gmcm
1783 # Oops, fix the permission check!
1784 #
1785 # Revision 1.149 2002/07/30 20:04:38 gmcm
1786 # Adapt metakit backend to new security scheme.
1787 # Put some more permission checks in cgi_client.
1788 #
1789 # Revision 1.148 2002/07/30 16:09:11 gmcm
1790 # Simple optimization.
1791 #
1792 # Revision 1.147 2002/07/30 08:22:38 richard
1793 # Session storage in the hyperdb was horribly, horribly inefficient. We use
1794 # a simple anydbm wrapper now - which could be overridden by the metakit
1795 # backend or RDB backend if necessary.
1796 # Much, much better.
1797 #
1798 # Revision 1.146 2002/07/30 05:27:30 richard
1799 # nicer error messages, and a bugfix
1800 #
1801 # Revision 1.145 2002/07/26 08:26:59 richard
1802 # Very close now. The cgi and mailgw now use the new security API. The two
1803 # templates have been migrated to that setup. Lots of unit tests. Still some
1804 # issue in the web form for editing Roles assigned to users.
1805 #
1806 # Revision 1.144 2002/07/25 07:14:05 richard
1807 # Bugger it. Here's the current shape of the new security implementation.
1808 # Still to do:
1809 # . call the security funcs from cgi and mailgw
1810 # . change shipped templates to include correct initialisation and remove
1811 # the old config vars
1812 # ... that seems like a lot. The bulk of the work has been done though. Honest :)
1813 #
1814 # Revision 1.143 2002/07/20 19:29:10 gmcm
1815 # Fixes/improvements to the search form & saved queries.
1816 #
1817 # Revision 1.142 2002/07/18 11:17:30 gmcm
1818 # Add Number and Boolean types to hyperdb.
1819 # Add conversion cases to web, mail & admin interfaces.
1820 # Add storage/serialization cases to back_anydbm & back_metakit.
1821 #
1822 # Revision 1.141 2002/07/17 12:39:10 gmcm
1823 # Saving, running & editing queries.
1824 #
1825 # Revision 1.140 2002/07/14 23:17:15 richard
1826 # cleaned up structure
1827 #
1828 # Revision 1.139 2002/07/14 06:14:40 richard
1829 # Some more TODOs
1830 #
1831 # Revision 1.138 2002/07/14 04:03:13 richard
1832 # Implemented a switch to disable journalling for a Class. CGI session
1833 # database now uses it.
1834 #
1835 # Revision 1.137 2002/07/10 07:00:30 richard
1836 # removed debugging
1837 #
1838 # Revision 1.136 2002/07/10 06:51:08 richard
1839 # . #576241 ] MultiLink problems in parsePropsFromForm
1840 #
1841 # Revision 1.135 2002/07/10 00:22:34 richard
1842 # . switched to using a session-based web login
1843 #
1844 # Revision 1.134 2002/07/09 04:19:09 richard
1845 # Added reindex command to roundup-admin.
1846 # Fixed reindex on first access.
1847 # Also fixed reindexing of entries that change.
1848 #
1849 # Revision 1.133 2002/07/08 15:32:05 gmcm
1850 # Pagination of index pages.
1851 # New search form.
1852 #
1853 # Revision 1.132 2002/07/08 07:26:14 richard
1854 # ehem
1855 #
1856 # Revision 1.131 2002/07/08 06:53:57 richard
1857 # Not sure why the cgi_client had an indexer argument.
1858 #
1859 # Revision 1.130 2002/06/27 12:01:53 gmcm
1860 # If the form has a :multilink, put a back href in the pageheader (back to the linked-to node).
1861 # Some minor optimizations (only compile regexes once).
1862 #
1863 # Revision 1.129 2002/06/20 23:52:11 richard
1864 # Better handling of unauth attempt to edit stuff
1865 #
1866 # Revision 1.128 2002/06/12 21:28:25 gmcm
1867 # Allow form to set user-properties on a Fileclass.
1868 # Don't assume that a Fileclass is named "files".
1869 #
1870 # Revision 1.127 2002/06/11 06:38:24 richard
1871 # . #565996 ] The "Attach a File to this Issue" fails
1872 #
1873 # Revision 1.126 2002/05/29 01:16:17 richard
1874 # Sorry about this huge checkin! It's fixing a lot of related stuff in one go
1875 # though.
1876 #
1877 # . #541941 ] changing multilink properties by mail
1878 # . #526730 ] search for messages capability
1879 # . #505180 ] split MailGW.handle_Message
1880 # - also changed cgi client since it was duplicating the functionality
1881 # . build htmlbase if tests are run using CVS checkout (removed note from
1882 # installation.txt)
1883 # . don't create an empty message on email issue creation if the email is empty
1884 #
1885 # Revision 1.125 2002/05/25 07:16:24 rochecompaan
1886 # Merged search_indexing-branch with HEAD
1887 #
1888 # Revision 1.124 2002/05/24 02:09:24 richard
1889 # Nothing like a live demo to show up the bugs ;)
1890 #
1891 # Revision 1.123 2002/05/22 05:04:13 richard
1892 # Oops
1893 #
1894 # Revision 1.122 2002/05/22 04:12:05 richard
1895 # . applied patch #558876 ] cgi client customization
1896 # ... with significant additions and modifications ;)
1897 # - extended handling of ML assignedto to all places it's handled
1898 # - added more NotFound info
1899 #
1900 # Revision 1.121 2002/05/21 06:08:10 richard
1901 # Handle migration
1902 #
1903 # Revision 1.120 2002/05/21 06:05:53 richard
1904 # . #551483 ] assignedto in Client.make_index_link
1905 #
1906 # Revision 1.119 2002/05/15 06:21:21 richard
1907 # . node caching now works, and gives a small boost in performance
1908 #
1909 # As a part of this, I cleaned up the DEBUG output and implemented TRACE
1910 # output (HYPERDBTRACE='file to trace to') with checkpoints at the start of
1911 # CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff
1912 # (using if __debug__ which is compiled out with -O)
1913 #
1914 # Revision 1.118 2002/05/12 23:46:33 richard
1915 # ehem, part 2
1916 #
1917 # Revision 1.117 2002/05/12 23:42:29 richard
1918 # ehem
1919 #
1920 # Revision 1.116 2002/05/02 08:07:49 richard
1921 # Added the ADD_AUTHOR_TO_NOSY handling to the CGI interface.
1922 #
1923 # Revision 1.115 2002/04/02 01:56:10 richard
1924 # . stop sending blank (whitespace-only) notes
1925 #
1926 # Revision 1.114.2.4 2002/05/02 11:49:18 rochecompaan
1927 # Allow customization of the search filters that should be displayed
1928 # on the search page.
1929 #
1930 # Revision 1.114.2.3 2002/04/20 13:23:31 rochecompaan
1931 # We now have a separate search page for nodes. Search links for
1932 # different classes can be customized in instance_config similar to
1933 # index links.
1934 #
1935 # Revision 1.114.2.2 2002/04/19 19:54:42 rochecompaan
1936 # cgi_client.py
1937 # removed search link for the time being
1938 # moved rendering of matches to htmltemplate
1939 # hyperdb.py
1940 # filtering of nodes on full text search incorporated in filter method
1941 # roundupdb.py
1942 # added paramater to call of filter method
1943 # roundup_indexer.py
1944 # added search method to RoundupIndexer class
1945 #
1946 # Revision 1.114.2.1 2002/04/03 11:55:57 rochecompaan
1947 # . Added feature #526730 - search for messages capability
1948 #
1949 # Revision 1.114 2002/03/17 23:06:05 richard
1950 # oops
1951 #
1952 # Revision 1.113 2002/03/14 23:59:24 richard
1953 # . #517734 ] web header customisation is obscure
1954 #
1955 # Revision 1.112 2002/03/12 22:52:26 richard
1956 # more pychecker warnings removed
1957 #
1958 # Revision 1.111 2002/02/25 04:32:21 richard
1959 # ahem
1960 #
1961 # Revision 1.110 2002/02/21 07:19:08 richard
1962 # ... and label, width and height control for extra flavour!
1963 #
1964 # Revision 1.109 2002/02/21 07:08:19 richard
1965 # oops
1966 #
1967 # Revision 1.108 2002/02/21 07:02:54 richard
1968 # The correct var is "HTTP_HOST"
1969 #
1970 # Revision 1.107 2002/02/21 06:57:38 richard
1971 # . Added popup help for classes using the classhelp html template function.
1972 # - add <display call="classhelp('priority', 'id,name,description')">
1973 # to an item page, and it generates a link to a popup window which displays
1974 # the id, name and description for the priority class. The description
1975 # field won't exist in most installations, but it will be added to the
1976 # default templates.
1977 #
1978 # Revision 1.106 2002/02/21 06:23:00 richard
1979 # *** empty log message ***
1980 #
1981 # Revision 1.105 2002/02/20 05:52:10 richard
1982 # better error handling
1983 #
1984 # Revision 1.104 2002/02/20 05:45:17 richard
1985 # Use the csv module for generating the form entry so it's correct.
1986 # [also noted the sf.net feature request id in the change log]
1987 #
1988 # Revision 1.103 2002/02/20 05:05:28 richard
1989 # . Added simple editing for classes that don't define a templated interface.
1990 # - access using the admin "class list" interface
1991 # - limited to admin-only
1992 # - requires the csv module from object-craft (url given if it's missing)
1993 #
1994 # Revision 1.102 2002/02/15 07:08:44 richard
1995 # . Alternate email addresses are now available for users. See the MIGRATION
1996 # file for info on how to activate the feature.
1997 #
1998 # Revision 1.101 2002/02/14 23:39:18 richard
1999 # . All forms now have "double-submit" protection when Javascript is enabled
2000 # on the client-side.
2001 #
2002 # Revision 1.100 2002/01/16 07:02:57 richard
2003 # . lots of date/interval related changes:
2004 # - more relaxed date format for input
2005 #
2006 # Revision 1.99 2002/01/16 03:02:42 richard
2007 # #503793 ] changing assignedto resets nosy list
2008 #
2009 # Revision 1.98 2002/01/14 02:20:14 richard
2010 # . changed all config accesses so they access either the instance or the
2011 # config attriubute on the db. This means that all config is obtained from
2012 # instance_config instead of the mish-mash of classes. This will make
2013 # switching to a ConfigParser setup easier too, I hope.
2014 #
2015 # At a minimum, this makes migration a _little_ easier (a lot easier in the
2016 # 0.5.0 switch, I hope!)
2017 #
2018 # Revision 1.97 2002/01/11 23:22:29 richard
2019 # . #502437 ] rogue reactor and unittest
2020 # in short, the nosy reactor was modifying the nosy list. That code had
2021 # been there for a long time, and I suspsect it was there because we
2022 # weren't generating the nosy list correctly in other places of the code.
2023 # We're now doing that, so the nosy-modifying code can go away from the
2024 # nosy reactor.
2025 #
2026 # Revision 1.96 2002/01/10 05:26:10 richard
2027 # missed a parsePropsFromForm in last update
2028 #
2029 # Revision 1.95 2002/01/10 03:39:45 richard
2030 # . fixed some problems with web editing and change detection
2031 #
2032 # Revision 1.94 2002/01/09 13:54:21 grubert
2033 # _add_assignedto_to_nosy did set nosy to assignedto only, no adding.
2034 #
2035 # Revision 1.93 2002/01/08 11:57:12 richard
2036 # crying out for real configuration handling... :(
2037 #
2038 # Revision 1.92 2002/01/08 04:12:05 richard
2039 # Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
2040 #
2041 # Revision 1.91 2002/01/08 04:03:47 richard
2042 # I mucked the intent of the code up.
2043 #
2044 # Revision 1.90 2002/01/08 03:56:55 richard
2045 # Oops, missed this before the beta:
2046 # . #495392 ] empty nosy -patch
2047 #
2048 # Revision 1.89 2002/01/07 20:24:45 richard
2049 # *mutter* stupid cutnpaste
2050 #
2051 # Revision 1.88 2002/01/02 02:31:38 richard
2052 # Sorry for the huge checkin message - I was only intending to implement #496356
2053 # but I found a number of places where things had been broken by transactions:
2054 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
2055 # for _all_ roundup-generated smtp messages to be sent to.
2056 # . the transaction cache had broken the roundupdb.Class set() reactors
2057 # . newly-created author users in the mailgw weren't being committed to the db
2058 #
2059 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
2060 # on when I found that stuff :):
2061 # . #496356 ] Use threading in messages
2062 # . detectors were being registered multiple times
2063 # . added tests for mailgw
2064 # . much better attaching of erroneous messages in the mail gateway
2065 #
2066 # Revision 1.87 2001/12/23 23:18:49 richard
2067 # We already had an admin-specific section of the web heading, no need to add
2068 # another one :)
2069 #
2070 # Revision 1.86 2001/12/20 15:43:01 rochecompaan
2071 # Features added:
2072 # . Multilink properties are now displayed as comma separated values in
2073 # a textbox
2074 # . The add user link is now only visible to the admin user
2075 # . Modified the mail gateway to reject submissions from unknown
2076 # addresses if ANONYMOUS_ACCESS is denied
2077 #
2078 # Revision 1.85 2001/12/20 06:13:24 rochecompaan
2079 # Bugs fixed:
2080 # . Exception handling in hyperdb for strings-that-look-like numbers got
2081 # lost somewhere
2082 # . Internet Explorer submits full path for filename - we now strip away
2083 # the path
2084 # Features added:
2085 # . Link and multilink properties are now displayed sorted in the cgi
2086 # interface
2087 #
2088 # Revision 1.84 2001/12/18 15:30:30 rochecompaan
2089 # Fixed bugs:
2090 # . Fixed file creation and retrieval in same transaction in anydbm
2091 # backend
2092 # . Cgi interface now renders new issue after issue creation
2093 # . Could not set issue status to resolved through cgi interface
2094 # . Mail gateway was changing status back to 'chatting' if status was
2095 # omitted as an argument
2096 #
2097 # Revision 1.83 2001/12/15 23:51:01 richard
2098 # Tested the changes and fixed a few problems:
2099 # . files are now attached to the issue as well as the message
2100 # . newuser is a real method now since we don't want to do the message/file
2101 # stuff for it
2102 # . added some documentation
2103 # The really big changes in the diff are a result of me moving some code
2104 # around to keep like methods together a bit better.
2105 #
2106 # Revision 1.82 2001/12/15 19:24:39 rochecompaan
2107 # . Modified cgi interface to change properties only once all changes are
2108 # collected, files created and messages generated.
2109 # . Moved generation of change note to nosyreactors.
2110 # . We now check for changes to "assignedto" to ensure it's added to the
2111 # nosy list.
2112 #
2113 # Revision 1.81 2001/12/12 23:55:00 richard
2114 # Fixed some problems with user editing
2115 #
2116 # Revision 1.80 2001/12/12 23:27:14 richard
2117 # Added a Zope frontend for roundup.
2118 #
2119 # Revision 1.79 2001/12/10 22:20:01 richard
2120 # Enabled transaction support in the bsddb backend. It uses the anydbm code
2121 # where possible, only replacing methods where the db is opened (it uses the
2122 # btree opener specifically.)
2123 # Also cleaned up some change note generation.
2124 # Made the backends package work with pydoc too.
2125 #
2126 # Revision 1.78 2001/12/07 05:59:27 rochecompaan
2127 # Fixed small bug that prevented adding issues through the web.
2128 #
2129 # Revision 1.77 2001/12/06 22:48:29 richard
2130 # files multilink was being nuked in post_edit_node
2131 #
2132 # Revision 1.76 2001/12/05 14:26:44 rochecompaan
2133 # Removed generation of change note from "sendmessage" in roundupdb.py.
2134 # The change note is now generated when the message is created.
2135 #
2136 # Revision 1.75 2001/12/04 01:25:08 richard
2137 # Added some rollbacks where we were catching exceptions that would otherwise
2138 # have stopped committing.
2139 #
2140 # Revision 1.74 2001/12/02 05:06:16 richard
2141 # . We now use weakrefs in the Classes to keep the database reference, so
2142 # the close() method on the database is no longer needed.
2143 # I bumped the minimum python requirement up to 2.1 accordingly.
2144 # . #487480 ] roundup-server
2145 # . #487476 ] INSTALL.txt
2146 #
2147 # I also cleaned up the change message / post-edit stuff in the cgi client.
2148 # There's now a clearly marked "TODO: append the change note" where I believe
2149 # the change note should be added there. The "changes" list will obviously
2150 # have to be modified to be a dict of the changes, or somesuch.
2151 #
2152 # More testing needed.
2153 #
2154 # Revision 1.73 2001/12/01 07:17:50 richard
2155 # . We now have basic transaction support! Information is only written to
2156 # the database when the commit() method is called. Only the anydbm
2157 # backend is modified in this way - neither of the bsddb backends have been.
2158 # The mail, admin and cgi interfaces all use commit (except the admin tool
2159 # doesn't have a commit command, so interactive users can't commit...)
2160 # . Fixed login/registration forwarding the user to the right page (or not,
2161 # on a failure)
2162 #
2163 # Revision 1.72 2001/11/30 20:47:58 rochecompaan
2164 # Links in page header are now consistent with default sort order.
2165 #
2166 # Fixed bugs:
2167 # - When login failed the list of issues were still rendered.
2168 # - User was redirected to index page and not to his destination url
2169 # if his first login attempt failed.
2170 #
2171 # Revision 1.71 2001/11/30 20:28:10 rochecompaan
2172 # Property changes are now completely traceable, whether changes are
2173 # made through the web or by email
2174 #
2175 # Revision 1.70 2001/11/30 00:06:29 richard
2176 # Converted roundup/cgi_client.py to use _()
2177 # Added the status file, I18N_PROGRESS.txt
2178 #
2179 # Revision 1.69 2001/11/29 23:19:51 richard
2180 # Removed the "This issue has been edited through the web" when a valid
2181 # change note is supplied.
2182 #
2183 # Revision 1.68 2001/11/29 04:57:23 richard
2184 # a little comment
2185 #
2186 # Revision 1.67 2001/11/28 21:55:35 richard
2187 # . login_action and newuser_action return values were being ignored
2188 # . Woohoo! Found that bloody re-login bug that was killing the mail
2189 # gateway.
2190 # (also a minor cleanup in hyperdb)
2191 #
2192 # Revision 1.66 2001/11/27 03:00:50 richard
2193 # couple of bugfixes from latest patch integration
2194 #
2195 # Revision 1.65 2001/11/26 23:00:53 richard
2196 # This config stuff is getting to be a real mess...
2197 #
2198 # Revision 1.64 2001/11/26 22:56:35 richard
2199 # typo
2200 #
2201 # Revision 1.63 2001/11/26 22:55:56 richard
2202 # Feature:
2203 # . Added INSTANCE_NAME to configuration - used in web and email to identify
2204 # the instance.
2205 # . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
2206 # signature info in e-mails.
2207 # . Some more flexibility in the mail gateway and more error handling.
2208 # . Login now takes you to the page you back to the were denied access to.
2209 #
2210 # Fixed:
2211 # . Lots of bugs, thanks Roché and others on the devel mailing list!
2212 #
2213 # Revision 1.62 2001/11/24 00:45:42 jhermann
2214 # typeof() instead of type(): avoid clash with database field(?) "type"
2215 #
2216 # Fixes this traceback:
2217 #
2218 # Traceback (most recent call last):
2219 # File "roundup\cgi_client.py", line 535, in newnode
2220 # self._post_editnode(nid)
2221 # File "roundup\cgi_client.py", line 415, in _post_editnode
2222 # if type(value) != type([]): value = [value]
2223 # UnboundLocalError: local variable 'type' referenced before assignment
2224 #
2225 # Revision 1.61 2001/11/22 15:46:42 jhermann
2226 # Added module docstrings to all modules.
2227 #
2228 # Revision 1.60 2001/11/21 22:57:28 jhermann
2229 # Added dummy hooks for I18N and some preliminary (test) markup of
2230 # translatable messages
2231 #
2232 # Revision 1.59 2001/11/21 03:21:13 richard
2233 # oops
2234 #
2235 # Revision 1.58 2001/11/21 03:11:28 richard
2236 # Better handling of new properties.
2237 #
2238 # Revision 1.57 2001/11/15 10:24:27 richard
2239 # handle the case where there is no file attached
2240 #
2241 # Revision 1.56 2001/11/14 21:35:21 richard
2242 # . users may attach files to issues (and support in ext) through the web now
2243 #
2244 # Revision 1.55 2001/11/07 02:34:06 jhermann
2245 # Handling of damaged login cookies
2246 #
2247 # Revision 1.54 2001/11/07 01:16:12 richard
2248 # Remove the '=' padding from cookie value so quoting isn't an issue.
2249 #
2250 # Revision 1.53 2001/11/06 23:22:05 jhermann
2251 # More IE fixes: it does not like quotes around cookie values; in the
2252 # hope this does not break anything for other browser; if it does, we
2253 # need to check HTTP_USER_AGENT
2254 #
2255 # Revision 1.52 2001/11/06 23:11:22 jhermann
2256 # Fixed debug output in page footer; added expiry date to the login cookie
2257 # (expires 1 year in the future) to prevent probs with certain versions
2258 # of IE
2259 #
2260 # Revision 1.51 2001/11/06 22:00:34 jhermann
2261 # Get debug level from ROUNDUP_DEBUG env var
2262 #
2263 # Revision 1.50 2001/11/05 23:45:40 richard
2264 # Fixed newuser_action so it sets the cookie with the unencrypted password.
2265 # Also made it present nicer error messages (not tracebacks).
2266 #
2267 # Revision 1.49 2001/11/04 03:07:12 richard
2268 # Fixed various cookie-related bugs:
2269 # . bug #477685 ] base64.decodestring breaks
2270 # . bug #477837 ] lynx does not like the cookie
2271 # . bug #477892 ] Password edit doesn't fix login cookie
2272 # Also closed a security hole - a logged-in user could edit another user's
2273 # details.
2274 #
2275 # Revision 1.48 2001/11/03 01:30:18 richard
2276 # Oops. uses pagefoot now.
2277 #
2278 # Revision 1.47 2001/11/03 01:29:28 richard
2279 # Login page didn't have all close tags.
2280 #
2281 # Revision 1.46 2001/11/03 01:26:55 richard
2282 # possibly fix truncated base64'ed user:pass
2283 #
2284 # Revision 1.45 2001/11/01 22:04:37 richard
2285 # Started work on supporting a pop3-fetching server
2286 # Fixed bugs:
2287 # . bug #477104 ] HTML tag error in roundup-server
2288 # . bug #477107 ] HTTP header problem
2289 #
2290 # Revision 1.44 2001/10/28 23:03:08 richard
2291 # Added more useful header to the classic schema.
2292 #
2293 # Revision 1.43 2001/10/24 00:01:42 richard
2294 # More fixes to lockout logic.
2295 #
2296 # Revision 1.42 2001/10/23 23:56:03 richard
2297 # HTML typo
2298 #
2299 # Revision 1.41 2001/10/23 23:52:35 richard
2300 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
2301 #
2302 # Revision 1.40 2001/10/23 23:06:39 richard
2303 # Some cleanup.
2304 #
2305 # Revision 1.39 2001/10/23 01:00:18 richard
2306 # Re-enabled login and registration access after lopping them off via
2307 # disabling access for anonymous users.
2308 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
2309 # a couple of bugs while I was there. Probably introduced a couple, but
2310 # things seem to work OK at the moment.
2311 #
2312 # Revision 1.38 2001/10/22 03:25:01 richard
2313 # Added configuration for:
2314 # . anonymous user access and registration (deny/allow)
2315 # . filter "widget" location on index page (top, bottom, both)
2316 # Updated some documentation.
2317 #
2318 # Revision 1.37 2001/10/21 07:26:35 richard
2319 # feature #473127: Filenames. I modified the file.index and htmltemplate
2320 # source so that the filename is used in the link and the creation
2321 # information is displayed.
2322 #
2323 # Revision 1.36 2001/10/21 04:44:50 richard
2324 # bug #473124: UI inconsistency with Link fields.
2325 # This also prompted me to fix a fairly long-standing usability issue -
2326 # that of being able to turn off certain filters.
2327 #
2328 # Revision 1.35 2001/10/21 00:17:54 richard
2329 # CGI interface view customisation section may now be hidden (patch from
2330 # Roch'e Compaan.)
2331 #
2332 # Revision 1.34 2001/10/20 11:58:48 richard
2333 # Catch errors in login - no username or password supplied.
2334 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
2335 #
2336 # Revision 1.33 2001/10/17 00:18:41 richard
2337 # Manually constructing cookie headers now.
2338 #
2339 # Revision 1.32 2001/10/16 03:36:21 richard
2340 # CGI interface wasn't handling checkboxes at all.
2341 #
2342 # Revision 1.31 2001/10/14 10:55:00 richard
2343 # Handle empty strings in HTML template Link function
2344 #
2345 # Revision 1.30 2001/10/09 07:38:58 richard
2346 # Pushed the base code for the extended schema CGI interface back into the
2347 # code cgi_client module so that future updates will be less painful.
2348 # Also removed a debugging print statement from cgi_client.
2349 #
2350 # Revision 1.29 2001/10/09 07:25:59 richard
2351 # Added the Password property type. See "pydoc roundup.password" for
2352 # implementation details. Have updated some of the documentation too.
2353 #
2354 # Revision 1.28 2001/10/08 00:34:31 richard
2355 # Change message was stuffing up for multilinks with no key property.
2356 #
2357 # Revision 1.27 2001/10/05 02:23:24 richard
2358 # . roundup-admin create now prompts for property info if none is supplied
2359 # on the command-line.
2360 # . hyperdb Class getprops() method may now return only the mutable
2361 # properties.
2362 # . Login now uses cookies, which makes it a whole lot more flexible. We can
2363 # now support anonymous user access (read-only, unless there's an
2364 # "anonymous" user, in which case write access is permitted). Login
2365 # handling has been moved into cgi_client.Client.main()
2366 # . The "extended" schema is now the default in roundup init.
2367 # . The schemas have had their page headings modified to cope with the new
2368 # login handling. Existing installations should copy the interfaces.py
2369 # file from the roundup lib directory to their instance home.
2370 # . Incorrectly had a Bizar Software copyright on the cgitb.py module from
2371 # Ping - has been removed.
2372 # . Fixed a whole bunch of places in the CGI interface where we should have
2373 # been returning Not Found instead of throwing an exception.
2374 # . Fixed a deviation from the spec: trying to modify the 'id' property of
2375 # an item now throws an exception.
2376 #
2377 # Revision 1.26 2001/09/12 08:31:42 richard
2378 # handle cases where mime type is not guessable
2379 #
2380 # Revision 1.25 2001/08/29 05:30:49 richard
2381 # change messages weren't being saved when there was no-one on the nosy list.
2382 #
2383 # Revision 1.24 2001/08/29 04:49:39 richard
2384 # didn't clean up fully after debugging :(
2385 #
2386 # Revision 1.23 2001/08/29 04:47:18 richard
2387 # Fixed CGI client change messages so they actually include the properties
2388 # changed (again).
2389 #
2390 # Revision 1.22 2001/08/17 00:08:10 richard
2391 # reverted back to sending messages always regardless of who is doing the web
2392 # edit. change notes weren't being saved. bleah. hackish.
2393 #
2394 # Revision 1.21 2001/08/15 23:43:18 richard
2395 # Fixed some isFooTypes that I missed.
2396 # Refactored some code in the CGI code.
2397 #
2398 # Revision 1.20 2001/08/12 06:32:36 richard
2399 # using isinstance(blah, Foo) now instead of isFooType
2400 #
2401 # Revision 1.19 2001/08/07 00:24:42 richard
2402 # stupid typo
2403 #
2404 # Revision 1.18 2001/08/07 00:15:51 richard
2405 # Added the copyright/license notice to (nearly) all files at request of
2406 # Bizar Software.
2407 #
2408 # Revision 1.17 2001/08/02 06:38:17 richard
2409 # Roundupdb now appends "mailing list" information to its messages which
2410 # include the e-mail address and web interface address. Templates may
2411 # override this in their db classes to include specific information (support
2412 # instructions, etc).
2413 #
2414 # Revision 1.16 2001/08/02 05:55:25 richard
2415 # Web edit messages aren't sent to the person who did the edit any more. No
2416 # message is generated if they are the only person on the nosy list.
2417 #
2418 # Revision 1.15 2001/08/02 00:34:10 richard
2419 # bleah syntax error
2420 #
2421 # Revision 1.14 2001/08/02 00:26:16 richard
2422 # Changed the order of the information in the message generated by web edits.
2423 #
2424 # Revision 1.13 2001/07/30 08:12:17 richard
2425 # Added time logging and file uploading to the templates.
2426 #
2427 # Revision 1.12 2001/07/30 06:26:31 richard
2428 # Added some documentation on how the newblah works.
2429 #
2430 # Revision 1.11 2001/07/30 06:17:45 richard
2431 # Features:
2432 # . Added ability for cgi newblah forms to indicate that the new node
2433 # should be linked somewhere.
2434 # Fixed:
2435 # . Fixed the agument handling for the roundup-admin find command.
2436 # . Fixed handling of summary when no note supplied for newblah. Again.
2437 # . Fixed detection of no form in htmltemplate Field display.
2438 #
2439 # Revision 1.10 2001/07/30 02:37:34 richard
2440 # Temporary measure until we have decent schema migration...
2441 #
2442 # Revision 1.9 2001/07/30 01:25:07 richard
2443 # Default implementation is now "classic" rather than "extended" as one would
2444 # expect.
2445 #
2446 # Revision 1.8 2001/07/29 08:27:40 richard
2447 # Fixed handling of passed-in values in form elements (ie. during a
2448 # drill-down)
2449 #
2450 # Revision 1.7 2001/07/29 07:01:39 richard
2451 # Added vim command to all source so that we don't get no steenkin' tabs :)
2452 #
2453 # Revision 1.6 2001/07/29 04:04:00 richard
2454 # Moved some code around allowing for subclassing to change behaviour.
2455 #
2456 # Revision 1.5 2001/07/28 08:16:52 richard
2457 # New issue form handles lack of note better now.
2458 #
2459 # Revision 1.4 2001/07/28 00:34:34 richard
2460 # Fixed some non-string node ids.
2461 #
2462 # Revision 1.3 2001/07/23 03:56:30 richard
2463 # oops, missed a config removal
2464 #
2465 # Revision 1.2 2001/07/22 12:09:32 richard
2466 # Final commit of Grande Splite
2467 #
2468 # Revision 1.1 2001/07/22 11:58:35 richard
2469 # More Grande Splite
2470 #
2471 #
2472 # vim: set filetype=python ts=4 sw=4 et si