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