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.124 2002-05-24 02:09:24 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 class Client:
37 '''
38 A note about login
39 ------------------
41 If the user has no login cookie, then they are anonymous. There
42 are two levels of anonymous use. If there is no 'anonymous' user, there
43 is no login at all and the database is opened in read-only mode. If the
44 'anonymous' user exists, the user is logged in using that user (though
45 there is no cookie). This allows them to modify the database, and all
46 modifications are attributed to the 'anonymous' user.
47 '''
49 def __init__(self, instance, request, env, form=None):
50 hyperdb.traceMark()
51 self.instance = instance
52 self.request = request
53 self.env = env
54 self.path = env['PATH_INFO']
55 self.split_path = self.path.split('/')
56 self.instance_path_name = env['INSTANCE_NAME']
57 url = self.env['SCRIPT_NAME'] + '/'
58 machine = self.env['SERVER_NAME']
59 port = self.env['SERVER_PORT']
60 if port != '80': machine = machine + ':' + port
61 self.base = urlparse.urlunparse(('http', env['HTTP_HOST'], url,
62 None, None, None))
64 if form is None:
65 self.form = cgi.FieldStorage(environ=env)
66 else:
67 self.form = form
68 self.headers_done = 0
69 try:
70 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
71 except ValueError:
72 # someone gave us a non-int debug level, turn it off
73 self.debug = 0
75 def getuid(self):
76 return self.db.user.lookup(self.user)
78 def header(self, headers=None):
79 '''Put up the appropriate header.
80 '''
81 if headers is None:
82 headers = {'Content-Type':'text/html'}
83 if not headers.has_key('Content-Type'):
84 headers['Content-Type'] = 'text/html'
85 self.request.send_response(200)
86 for entry in headers.items():
87 self.request.send_header(*entry)
88 self.request.end_headers()
89 self.headers_done = 1
90 if self.debug:
91 self.headers_sent = headers
93 global_javascript = '''
94 <script language="javascript">
95 submitted = false;
96 function submit_once() {
97 if (submitted) {
98 alert("Your request is being processed.\\nPlease be patient.");
99 return 0;
100 }
101 submitted = true;
102 return 1;
103 }
105 function help_window(helpurl, width, height) {
106 HelpWin = window.open('%(base)s%(instance_path_name)s/' + helpurl, 'HelpWindow', 'scrollbars=yes,resizable=yes,toolbar=no,height='+height+',width='+width);
107 }
109 </script>
110 '''
111 def make_index_link(self, name):
112 '''Turn a configuration entry into a hyperlink...
113 '''
114 # get the link label and spec
115 spec = getattr(self.instance, name+'_INDEX')
117 d = {}
118 d[':sort'] = ','.join(map(urllib.quote, spec['SORT']))
119 d[':group'] = ','.join(map(urllib.quote, spec['GROUP']))
120 d[':filter'] = ','.join(map(urllib.quote, spec['FILTER']))
121 d[':columns'] = ','.join(map(urllib.quote, spec['COLUMNS']))
123 # snarf the filterspec
124 filterspec = spec['FILTERSPEC'].copy()
126 # now format the filterspec
127 for k, l in filterspec.items():
128 # fix up the CURRENT USER if needed (handle None too since that's
129 # the old flag value)
130 if l in (None, 'CURRENT USER'):
131 if not self.user:
132 continue
133 l = [self.db.user.lookup(self.user)]
135 # add
136 d[urllib.quote(k)] = ','.join(map(urllib.quote, l))
138 # finally, format the URL
139 return '<a href="%s?%s">%s</a>'%(spec['CLASS'],
140 '&'.join([k+'='+v for k,v in d.items()]), spec['LABEL'])
143 def pagehead(self, title, message=None):
144 '''Display the page heading, with information about the tracker and
145 links to more information
146 '''
148 # include any important message
149 if message is not None:
150 message = _('<div class="system-msg">%(message)s</div>')%locals()
151 else:
152 message = ''
154 # style sheet (CSS)
155 style = open(os.path.join(self.instance.TEMPLATES, 'style.css')).read()
157 # figure who the user is
158 user_name = self.user or ''
159 if user_name not in ('', 'anonymous'):
160 userid = self.db.user.lookup(self.user)
161 else:
162 userid = None
164 # figure all the header links
165 if hasattr(self.instance, 'HEADER_INDEX_LINKS'):
166 links = []
167 for name in self.instance.HEADER_INDEX_LINKS:
168 spec = getattr(self.instance, name + '_INDEX')
169 # skip if we need to fill in the logged-in user id there's
170 # no user logged in
171 if (spec['FILTERSPEC'].has_key('assignedto') and
172 spec['FILTERSPEC']['assignedto'] in ('CURRENT USER',
173 None) and userid is None):
174 continue
175 links.append(self.make_index_link(name))
176 else:
177 # no config spec - hard-code
178 links = [
179 _('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>'),
180 _('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>')
181 ]
183 # if they're logged in, include links to their information, and the
184 # ability to add an issue
185 if user_name not in ('', 'anonymous'):
186 user_info = _('''
187 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
188 ''')%locals()
190 # figure the "add class" links
191 if hasattr(self.instance, 'HEADER_ADD_LINKS'):
192 classes = self.instance.HEADER_ADD_LINKS
193 else:
194 classes = ['issue']
195 l = []
196 for class_name in classes:
197 cap_class = class_name.capitalize()
198 links.append(_('Add <a href="new%(class_name)s">'
199 '%(cap_class)s</a>')%locals())
201 # if there's no config header link spec, force a user link here
202 if not hasattr(self.instance, 'HEADER_INDEX_LINKS'):
203 links.append(_('<a href="issue?assignedto=%(userid)s&status=-1,unread,chatting,open,pending&:filter=status,resolution,assignedto&:sort=-activity&:columns=id,activity,status,resolution,title,creator&:group=type&show_customization=1">My Issues</a>')%locals())
204 else:
205 user_info = _('<a href="login">Login</a>')
206 add_links = ''
208 # if the user is admin, include admin links
209 admin_links = ''
210 if user_name == 'admin':
211 links.append(_('<a href="list_classes">Class List</a>'))
212 links.append(_('<a href="user">User List</a>'))
213 links.append(_('<a href="newuser">Add User</a>'))
215 # now we have all the links, join 'em
216 links = '\n | '.join(links)
218 # include the javascript bit
219 global_javascript = self.global_javascript%self.__dict__
221 # finally, format the header
222 self.write(_('''<html><head>
223 <title>%(title)s</title>
224 <style type="text/css">%(style)s</style>
225 </head>
226 %(global_javascript)s
227 <body bgcolor=#ffffff>
228 %(message)s
229 <table width=100%% border=0 cellspacing=0 cellpadding=2>
230 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
231 <td align=right valign=bottom>%(user_name)s</td></tr>
232 <tr class="location-bar">
233 <td align=left>%(links)s</td>
234 <td align=right>%(user_info)s</td>
235 </table>
236 ''')%locals())
238 def pagefoot(self):
239 if self.debug:
240 self.write(_('<hr><small><dl><dt><b>Path</b></dt>'))
241 self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
242 keys = self.form.keys()
243 keys.sort()
244 if keys:
245 self.write(_('<dt><b>Form entries</b></dt>'))
246 for k in self.form.keys():
247 v = self.form.getvalue(k, "<empty>")
248 if type(v) is type([]):
249 # Multiple username fields specified
250 v = "|".join(v)
251 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
252 keys = self.headers_sent.keys()
253 keys.sort()
254 self.write(_('<dt><b>Sent these HTTP headers</b></dt>'))
255 for k in keys:
256 v = self.headers_sent[k]
257 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
258 keys = self.env.keys()
259 keys.sort()
260 self.write(_('<dt><b>CGI environment</b></dt>'))
261 for k in keys:
262 v = self.env[k]
263 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
264 self.write('</dl></small>')
265 self.write('</body></html>')
267 def write(self, content):
268 if not self.headers_done:
269 self.header()
270 self.request.wfile.write(content)
272 def index_arg(self, arg):
273 ''' handle the args to index - they might be a list from the form
274 (ie. submitted from a form) or they might be a command-separated
275 single string (ie. manually constructed GET args)
276 '''
277 if self.form.has_key(arg):
278 arg = self.form[arg]
279 if type(arg) == type([]):
280 return [arg.value for arg in arg]
281 return arg.value.split(',')
282 return []
284 def index_filterspec(self, filter):
285 ''' pull the index filter spec from the form
287 Links and multilinks want to be lists - the rest are straight
288 strings.
289 '''
290 props = self.db.classes[self.classname].getprops()
291 # all the form args not starting with ':' are filters
292 filterspec = {}
293 for key in self.form.keys():
294 if key[0] == ':': continue
295 if not props.has_key(key): continue
296 if key not in filter: continue
297 prop = props[key]
298 value = self.form[key]
299 if (isinstance(prop, hyperdb.Link) or
300 isinstance(prop, hyperdb.Multilink)):
301 if type(value) == type([]):
302 value = [arg.value for arg in value]
303 else:
304 value = value.value.split(',')
305 l = filterspec.get(key, [])
306 l = l + value
307 filterspec[key] = l
308 else:
309 filterspec[key] = value.value
310 return filterspec
312 def customization_widget(self):
313 ''' The customization widget is visible by default. The widget
314 visibility is remembered by show_customization. Visibility
315 is not toggled if the action value is "Redisplay"
316 '''
317 if not self.form.has_key('show_customization'):
318 visible = 1
319 else:
320 visible = int(self.form['show_customization'].value)
321 if self.form.has_key('action'):
322 if self.form['action'].value != 'Redisplay':
323 visible = self.form['action'].value == '+'
325 return visible
327 # TODO: make this go away some day...
328 default_index_sort = ['-activity']
329 default_index_group = ['priority']
330 default_index_filter = ['status']
331 default_index_columns = ['id','activity','title','status','assignedto']
332 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
334 def index(self):
335 ''' put up an index - no class specified
336 '''
337 # see if the web has supplied us with any customisation info
338 defaults = 1
339 for key in ':sort', ':group', ':filter', ':columns':
340 if self.form.has_key(key):
341 defaults = 0
342 break
343 if defaults:
344 # try the instance config first
345 if hasattr(self.instance, 'DEFAULT_INDEX'):
346 d = self.instance.DEFAULT_INDEX
347 self.classname = d['CLASS']
348 sort = d['SORT']
349 group = d['GROUP']
350 filter = d['FILTER']
351 columns = d['COLUMNS']
352 filterspec = d['FILTERSPEC']
354 else:
355 # nope - fall back on the old way of doing it
356 self.classname = 'issue'
357 sort = self.default_index_sort
358 group = self.default_index_group
359 filter = self.default_index_filter
360 columns = self.default_index_columns
361 filterspec = self.default_index_filterspec
362 else:
363 # make list() extract the info from the CGI environ
364 self.classname = 'issue'
365 sort = group = filter = columns = filterspec = None
366 return self.list(columns=columns, filter=filter, group=group,
367 sort=sort, filterspec=filterspec)
369 # XXX deviates from spec - loses the '+' (that's a reserved character
370 # in URLS
371 def list(self, sort=None, group=None, filter=None, columns=None,
372 filterspec=None, show_customization=None):
373 ''' call the template index with the args
375 :sort - sort by prop name, optionally preceeded with '-'
376 to give descending or nothing for ascending sorting.
377 :group - group by prop name, optionally preceeded with '-' or
378 to sort in descending or nothing for ascending order.
379 :filter - selects which props should be displayed in the filter
380 section. Default is all.
381 :columns - selects the columns that should be displayed.
382 Default is all.
384 '''
385 cn = self.classname
386 cl = self.db.classes[cn]
387 self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
388 'classname': cn, 'instancename': self.instance.INSTANCE_NAME})
389 if sort is None: sort = self.index_arg(':sort')
390 if group is None: group = self.index_arg(':group')
391 if filter is None: filter = self.index_arg(':filter')
392 if columns is None: columns = self.index_arg(':columns')
393 if filterspec is None: filterspec = self.index_filterspec(filter)
394 if show_customization is None:
395 show_customization = self.customization_widget()
397 index = htmltemplate.IndexTemplate(self, self.instance.TEMPLATES, cn)
398 try:
399 index.render(filterspec, filter, columns, sort, group,
400 show_customization=show_customization)
401 except htmltemplate.MissingTemplateError:
402 self.basicClassEditPage()
403 self.pagefoot()
405 def basicClassEditPage(self):
406 '''Display a basic edit page that allows simple editing of the
407 nodes of the current class
408 '''
409 if self.user != 'admin':
410 raise Unauthorised
411 w = self.write
412 cn = self.classname
413 cl = self.db.classes[cn]
414 idlessprops = cl.getprops(protected=0).keys()
415 props = ['id'] + idlessprops
418 # get the CSV module
419 try:
420 import csv
421 except ImportError:
422 w(_('Sorry, you need the csv module to use this function.<br>\n'
423 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
424 return
426 # do the edit
427 if self.form.has_key('rows'):
428 rows = self.form['rows'].value.splitlines()
429 p = csv.parser()
430 found = {}
431 line = 0
432 for row in rows:
433 line += 1
434 values = p.parse(row)
435 # not a complete row, keep going
436 if not values: continue
438 # extract the nodeid
439 nodeid, values = values[0], values[1:]
440 found[nodeid] = 1
442 # confirm correct weight
443 if len(idlessprops) != len(values):
444 w(_('Not enough values on line %(line)s'%{'line':line}))
445 return
447 # extract the new values
448 d = {}
449 for name, value in zip(idlessprops, values):
450 d[name] = value.strip()
452 # perform the edit
453 if cl.hasnode(nodeid):
454 # edit existing
455 cl.set(nodeid, **d)
456 else:
457 # new node
458 found[cl.create(**d)] = 1
460 # retire the removed entries
461 for nodeid in cl.list():
462 if not found.has_key(nodeid):
463 cl.retire(nodeid)
465 w(_('''<p class="form-help">You may edit the contents of the
466 "%(classname)s" class using this form. The lines are full-featured
467 Comma-Separated-Value lines, so you may include commas and even
468 newlines by enclosing the values in double-quotes ("). Double
469 quotes themselves must be quoted by doubling ("").</p>
470 <p class="form-help">Remove entries by deleting their line. Add
471 new entries by appending
472 them to the table - put an X in the id column.</p>''')%{'classname':cn})
474 l = []
475 for name in props:
476 l.append(name)
477 w('<tt>')
478 w(', '.join(l) + '\n')
479 w('</tt>')
481 w('<form onSubmit="return submit_once()" method="POST">')
482 w('<textarea name="rows" cols=80 rows=15>')
483 p = csv.parser()
484 for nodeid in cl.list():
485 l = []
486 for name in props:
487 l.append(cgi.escape(str(cl.get(nodeid, name))))
488 w(p.join(l) + '\n')
490 w(_('</textarea><br><input type="submit" value="Save Changes"></form>'))
492 def classhelp(self):
493 '''Display a table of class info
494 '''
495 w = self.write
496 cn = self.form['classname'].value
497 cl = self.db.classes[cn]
498 props = self.form['properties'].value.split(',')
500 w('<table border=1 cellspacing=0 cellpaddin=2>')
501 w('<tr>')
502 for name in props:
503 w('<th align=left>%s</th>'%name)
504 w('</tr>')
505 for nodeid in cl.list():
506 w('<tr>')
507 for name in props:
508 value = cgi.escape(str(cl.get(nodeid, name)))
509 w('<td align="left" valign="top">%s</td>'%value)
510 w('</tr>')
511 w('</table>')
513 def shownode(self, message=None):
514 ''' display an item
515 '''
516 cn = self.classname
517 cl = self.db.classes[cn]
519 # possibly perform an edit
520 keys = self.form.keys()
521 num_re = re.compile('^\d+$')
522 # don't try to set properties if the user has just logged in
523 if keys and not self.form.has_key('__login_name'):
524 try:
525 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
526 # make changes to the node
527 self._changenode(props)
528 # handle linked nodes
529 self._post_editnode(self.nodeid)
530 # and some nice feedback for the user
531 if props:
532 message = _('%(changes)s edited ok')%{'changes':
533 ', '.join(props.keys())}
534 elif self.form.has_key('__note') and self.form['__note'].value:
535 message = _('note added')
536 elif (self.form.has_key('__file') and
537 self.form['__file'].filename):
538 message = _('file added')
539 else:
540 message = _('nothing changed')
541 except:
542 self.db.rollback()
543 s = StringIO.StringIO()
544 traceback.print_exc(None, s)
545 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
547 # now the display
548 id = self.nodeid
549 if cl.getkey():
550 id = cl.get(id, cl.getkey())
551 self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
553 nodeid = self.nodeid
555 # use the template to display the item
556 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES,
557 self.classname)
558 item.render(nodeid)
560 self.pagefoot()
561 showissue = shownode
562 showmsg = shownode
564 def _add_author_to_nosy(self, props):
565 ''' add the author value from the props to the nosy list
566 '''
567 if not props.has_key('author'):
568 return
569 author_id = props['author']
570 if not props.has_key('nosy'):
571 # load current nosy
572 if self.nodeid:
573 cl = self.db.classes[self.classname]
574 l = cl.get(self.nodeid, 'nosy')
575 if author_id in l:
576 return
577 props['nosy'] = l
578 else:
579 props['nosy'] = []
580 if author_id not in props['nosy']:
581 props['nosy'].append(author_id)
583 def _add_assignedto_to_nosy(self, props):
584 ''' add the assignedto value from the props to the nosy list
585 '''
586 # get the properties definition and make some checks
587 if not props.has_key('assignedto'):
588 return
589 cl = self.db.classes[self.classname]
590 propdef = cl.getprops()
591 if not propdef.has_key('assignedto') or not propdef.has_key('nosy'):
592 return
594 # get the assignedto(s)
595 if isinstance(propdef['assignedto'], hyperdb.Link):
596 assignedto_ids = [props['assignedto']]
597 elif isinstance(propdef['assignedto'], hyperdb.Multilink):
598 assignedto_ids = props['assignedto']
599 else:
600 return
602 # ok, now get the nosy list value
603 if not props.has_key('nosy'):
604 # load current nosy
605 if self.nodeid:
606 props['nosy'] = cl.get(self.nodeid, 'nosy')
607 else:
608 props['nosy'] = []
610 # and update for assignedto id(s)
611 for assignedto_id in assignedto_ids:
612 if assignedto_id not in props['nosy']:
613 props['nosy'].append(assignedto_id)
615 def _changenode(self, props):
616 ''' change the node based on the contents of the form
617 '''
618 cl = self.db.classes[self.classname]
619 # set status to chatting if 'unread' or 'resolved'
620 try:
621 # determine the id of 'unread','resolved' and 'chatting'
622 unread_id = self.db.status.lookup('unread')
623 resolved_id = self.db.status.lookup('resolved')
624 chatting_id = self.db.status.lookup('chatting')
625 current_status = cl.get(self.nodeid, 'status')
626 if props.has_key('status'):
627 new_status = props['status']
628 else:
629 # apparently there's a chance that some browsers don't
630 # send status...
631 new_status = current_status
632 except KeyError:
633 pass
634 else:
635 if new_status == unread_id or (new_status == resolved_id
636 and current_status == resolved_id):
637 props['status'] = chatting_id
639 self._add_assignedto_to_nosy(props)
641 # possibly add the author of the change to the nosy list
642 if self.db.config.ADD_AUTHOR_TO_NOSY == 'yes':
643 self._add_author_to_nosy(props)
645 # create the message
646 message, files = self._handle_message()
647 if message:
648 props['messages'] = cl.get(self.nodeid, 'messages') + [message]
649 if files:
650 props['files'] = cl.get(self.nodeid, 'files') + files
652 # make the changes
653 cl.set(self.nodeid, **props)
655 def _createnode(self):
656 ''' create a node based on the contents of the form
657 '''
658 cl = self.db.classes[self.classname]
659 props = parsePropsFromForm(self.db, cl, self.form)
661 # set status to 'unread' if not specified - a status of '- no
662 # selection -' doesn't make sense
663 if not props.has_key('status') and cl.getprops().has_key('status'):
664 try:
665 unread_id = self.db.status.lookup('unread')
666 except KeyError:
667 pass
668 else:
669 props['status'] = unread_id
671 self._add_assignedto_to_nosy(props)
673 # possibly add the author of the new node to the nosy list
674 if self.db.config.ADD_AUTHOR_TO_NOSY in ('new', 'yes'):
675 self._add_author_to_nosy(props)
677 # check for messages and files
678 message, files = self._handle_message()
679 if message:
680 props['messages'] = [message]
681 if files:
682 props['files'] = files
683 # create the node and return it's id
684 return cl.create(**props)
686 def _handle_message(self):
687 ''' generate an edit message
688 '''
689 # handle file attachments
690 files = []
691 if self.form.has_key('__file'):
692 file = self.form['__file']
693 if file.filename:
694 filename = file.filename.split('\\')[-1]
695 mime_type = mimetypes.guess_type(filename)[0]
696 if not mime_type:
697 mime_type = "application/octet-stream"
698 # create the new file entry
699 files.append(self.db.file.create(type=mime_type,
700 name=filename, content=file.file.read()))
702 # we don't want to do a message if none of the following is true...
703 cn = self.classname
704 cl = self.db.classes[self.classname]
705 props = cl.getprops()
706 note = None
707 # in a nutshell, don't do anything if there's no note or there's no
708 # NOSY
709 if self.form.has_key('__note'):
710 note = self.form['__note'].value.strip()
711 if not note:
712 return None, files
713 if not props.has_key('messages'):
714 return None, files
715 if not isinstance(props['messages'], hyperdb.Multilink):
716 return None, files
717 if not props['messages'].classname == 'msg':
718 return None, files
719 if not (self.form.has_key('nosy') or note):
720 return None, files
722 # handle the note
723 if '\n' in note:
724 summary = re.split(r'\n\r?', note)[0]
725 else:
726 summary = note
727 m = ['%s\n'%note]
729 # handle the messageid
730 # TODO: handle inreplyto
731 messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
732 self.classname, self.instance.MAIL_DOMAIN)
734 # now create the message, attaching the files
735 content = '\n'.join(m)
736 message_id = self.db.msg.create(author=self.getuid(),
737 recipients=[], date=date.Date('.'), summary=summary,
738 content=content, files=files, messageid=messageid)
740 # update the messages property
741 return message_id, files
743 def _post_editnode(self, nid):
744 '''Do the linking part of the node creation.
746 If a form element has :link or :multilink appended to it, its
747 value specifies a node designator and the property on that node
748 to add _this_ node to as a link or multilink.
750 This is typically used on, eg. the file upload page to indicated
751 which issue to link the file to.
753 TODO: I suspect that this and newfile will go away now that
754 there's the ability to upload a file using the issue __file form
755 element!
756 '''
757 cn = self.classname
758 cl = self.db.classes[cn]
759 # link if necessary
760 keys = self.form.keys()
761 for key in keys:
762 if key == ':multilink':
763 value = self.form[key].value
764 if type(value) != type([]): value = [value]
765 for value in value:
766 designator, property = value.split(':')
767 link, nodeid = roundupdb.splitDesignator(designator)
768 link = self.db.classes[link]
769 value = link.get(nodeid, property)
770 value.append(nid)
771 link.set(nodeid, **{property: value})
772 elif key == ':link':
773 value = self.form[key].value
774 if type(value) != type([]): value = [value]
775 for value in value:
776 designator, property = value.split(':')
777 link, nodeid = roundupdb.splitDesignator(designator)
778 link = self.db.classes[link]
779 link.set(nodeid, **{property: nid})
781 def newnode(self, message=None):
782 ''' Add a new node to the database.
784 The form works in two modes: blank form and submission (that is,
785 the submission goes to the same URL). **Eventually this means that
786 the form will have previously entered information in it if
787 submission fails.
789 The new node will be created with the properties specified in the
790 form submission. For multilinks, multiple form entries are handled,
791 as are prop=value,value,value. You can't mix them though.
793 If the new node is to be referenced from somewhere else immediately
794 (ie. the new node is a file that is to be attached to a support
795 issue) then supply one of these arguments in addition to the usual
796 form entries:
797 :link=designator:property
798 :multilink=designator:property
799 ... which means that once the new node is created, the "property"
800 on the node given by "designator" should now reference the new
801 node's id. The node id will be appended to the multilink.
802 '''
803 cn = self.classname
804 cl = self.db.classes[cn]
806 # possibly perform a create
807 keys = self.form.keys()
808 if [i for i in keys if i[0] != ':']:
809 props = {}
810 try:
811 nid = self._createnode()
812 # handle linked nodes
813 self._post_editnode(nid)
814 # and some nice feedback for the user
815 message = _('%(classname)s created ok')%{'classname': cn}
817 # render the newly created issue
818 self.db.commit()
819 self.nodeid = nid
820 self.pagehead('%s: %s'%(self.classname.capitalize(), nid),
821 message)
822 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES,
823 self.classname)
824 item.render(nid)
825 self.pagefoot()
826 return
827 except:
828 self.db.rollback()
829 s = StringIO.StringIO()
830 traceback.print_exc(None, s)
831 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
832 self.pagehead(_('New %(classname)s')%{'classname':
833 self.classname.capitalize()}, message)
835 # call the template
836 newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
837 self.classname)
838 newitem.render(self.form)
840 self.pagefoot()
841 newissue = newnode
843 def newuser(self, message=None):
844 ''' Add a new user to the database.
846 Don't do any of the message or file handling, just create the node.
847 '''
848 cn = self.classname
849 cl = self.db.classes[cn]
851 # possibly perform a create
852 keys = self.form.keys()
853 if [i for i in keys if i[0] != ':']:
854 try:
855 props = parsePropsFromForm(self.db, cl, self.form)
856 nid = cl.create(**props)
857 # handle linked nodes
858 self._post_editnode(nid)
859 # and some nice feedback for the user
860 message = _('%(classname)s created ok')%{'classname': cn}
861 except:
862 self.db.rollback()
863 s = StringIO.StringIO()
864 traceback.print_exc(None, s)
865 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
866 self.pagehead(_('New %(classname)s')%{'classname':
867 self.classname.capitalize()}, message)
869 # call the template
870 newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
871 self.classname)
872 newitem.render(self.form)
874 self.pagefoot()
876 def newfile(self, message=None):
877 ''' Add a new file to the database.
879 This form works very much the same way as newnode - it just has a
880 file upload.
881 '''
882 cn = self.classname
883 cl = self.db.classes[cn]
885 # possibly perform a create
886 keys = self.form.keys()
887 if [i for i in keys if i[0] != ':']:
888 try:
889 file = self.form['content']
890 mime_type = mimetypes.guess_type(file.filename)[0]
891 if not mime_type:
892 mime_type = "application/octet-stream"
893 # save the file
894 nid = cl.create(content=file.file.read(), type=mime_type,
895 name=file.filename)
896 # handle linked nodes
897 self._post_editnode(nid)
898 # and some nice feedback for the user
899 message = _('%(classname)s created ok')%{'classname': cn}
900 except:
901 self.db.rollback()
902 s = StringIO.StringIO()
903 traceback.print_exc(None, s)
904 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
906 self.pagehead(_('New %(classname)s')%{'classname':
907 self.classname.capitalize()}, message)
908 newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
909 self.classname)
910 newitem.render(self.form)
911 self.pagefoot()
913 def showuser(self, message=None):
914 '''Display a user page for editing. Make sure the user is allowed
915 to edit this node, and also check for password changes.
916 '''
917 if self.user == 'anonymous':
918 raise Unauthorised
920 user = self.db.user
922 # get the username of the node being edited
923 node_user = user.get(self.nodeid, 'username')
925 if self.user not in ('admin', node_user):
926 raise Unauthorised
928 #
929 # perform any editing
930 #
931 keys = self.form.keys()
932 num_re = re.compile('^\d+$')
933 if keys:
934 try:
935 props = parsePropsFromForm(self.db, user, self.form,
936 self.nodeid)
937 set_cookie = 0
938 if props.has_key('password'):
939 password = self.form['password'].value.strip()
940 if not password:
941 # no password was supplied - don't change it
942 del props['password']
943 elif self.nodeid == self.getuid():
944 # this is the logged-in user's password
945 set_cookie = password
946 user.set(self.nodeid, **props)
947 # and some feedback for the user
948 message = _('%(changes)s edited ok')%{'changes':
949 ', '.join(props.keys())}
950 except:
951 self.db.rollback()
952 s = StringIO.StringIO()
953 traceback.print_exc(None, s)
954 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
955 else:
956 set_cookie = 0
958 # fix the cookie if the password has changed
959 if set_cookie:
960 self.set_cookie(self.user, set_cookie)
962 #
963 # now the display
964 #
965 self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
967 # use the template to display the item
968 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 'user')
969 item.render(self.nodeid)
970 self.pagefoot()
972 def showfile(self):
973 ''' display a file
974 '''
975 nodeid = self.nodeid
976 cl = self.db.file
977 mime_type = cl.get(nodeid, 'type')
978 if mime_type == 'message/rfc822':
979 mime_type = 'text/plain'
980 self.header(headers={'Content-Type': mime_type})
981 self.write(cl.get(nodeid, 'content'))
983 def classes(self, message=None):
984 ''' display a list of all the classes in the database
985 '''
986 if self.user == 'admin':
987 self.pagehead(_('Table of classes'), message)
988 classnames = self.db.classes.keys()
989 classnames.sort()
990 self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
991 for cn in classnames:
992 cl = self.db.getclass(cn)
993 self.write('<tr class="list-header"><th colspan=2 align=left>'
994 '<a href="%s">%s</a></th></tr>'%(cn, cn.capitalize()))
995 for key, value in cl.properties.items():
996 if value is None: value = ''
997 else: value = str(value)
998 self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
999 key, cgi.escape(value)))
1000 self.write('</table>')
1001 self.pagefoot()
1002 else:
1003 raise Unauthorised
1005 def login(self, message=None, newuser_form=None, action='index'):
1006 '''Display a login page.
1007 '''
1008 self.pagehead(_('Login to roundup'), message)
1009 self.write(_('''
1010 <table>
1011 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
1012 <form onSubmit="return submit_once()" action="login_action" method=POST>
1013 <input type="hidden" name="__destination_url" value="%(action)s">
1014 <tr><td align=right>Login name: </td>
1015 <td><input name="__login_name"></td></tr>
1016 <tr><td align=right>Password: </td>
1017 <td><input type="password" name="__login_password"></td></tr>
1018 <tr><td></td>
1019 <td><input type="submit" value="Log In"></td></tr>
1020 </form>
1021 ''')%locals())
1022 if self.user is None and self.instance.ANONYMOUS_REGISTER == 'deny':
1023 self.write('</table>')
1024 self.pagefoot()
1025 return
1026 values = {'realname': '', 'organisation': '', 'address': '',
1027 'phone': '', 'username': '', 'password': '', 'confirm': '',
1028 'action': action, 'alternate_addresses': ''}
1029 if newuser_form is not None:
1030 for key in newuser_form.keys():
1031 values[key] = newuser_form[key].value
1032 self.write(_('''
1033 <p>
1034 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
1035 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
1036 <form onSubmit="return submit_once()" action="newuser_action" method=POST>
1037 <input type="hidden" name="__destination_url" value="%(action)s">
1038 <tr><td align=right><em>Name: </em></td>
1039 <td><input name="realname" value="%(realname)s" size=40></td></tr>
1040 <tr><td align=right><em>Organisation: </em></td>
1041 <td><input name="organisation" value="%(organisation)s" size=40></td></tr>
1042 <tr><td align=right>E-Mail Address: </td>
1043 <td><input name="address" value="%(address)s" size=40></td></tr>
1044 <tr><td align=right><em>Alternate E-mail Addresses: </em></td>
1045 <td><textarea name="alternate_addresses" rows=5 cols=40>%(alternate_addresses)s</textarea></td></tr>
1046 <tr><td align=right><em>Phone: </em></td>
1047 <td><input name="phone" value="%(phone)s"></td></tr>
1048 <tr><td align=right>Preferred Login name: </td>
1049 <td><input name="username" value="%(username)s"></td></tr>
1050 <tr><td align=right>Password: </td>
1051 <td><input type="password" name="password" value="%(password)s"></td></tr>
1052 <tr><td align=right>Password Again: </td>
1053 <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
1054 <tr><td></td>
1055 <td><input type="submit" value="Register"></td></tr>
1056 </form>
1057 </table>
1058 ''')%values)
1059 self.pagefoot()
1061 def login_action(self, message=None):
1062 '''Attempt to log a user in and set the cookie
1064 returns 0 if a page is generated as a result of this call, and
1065 1 if not (ie. the login is successful
1066 '''
1067 if not self.form.has_key('__login_name'):
1068 self.login(message=_('Username required'))
1069 return 0
1070 self.user = self.form['__login_name'].value
1071 if self.form.has_key('__login_password'):
1072 password = self.form['__login_password'].value
1073 else:
1074 password = ''
1075 # make sure the user exists
1076 try:
1077 uid = self.db.user.lookup(self.user)
1078 except KeyError:
1079 name = self.user
1080 self.make_user_anonymous()
1081 action = self.form['__destination_url'].value
1082 self.login(message=_('No such user "%(name)s"')%locals(),
1083 action=action)
1084 return 0
1086 # and that the password is correct
1087 pw = self.db.user.get(uid, 'password')
1088 if password != pw:
1089 self.make_user_anonymous()
1090 action = self.form['__destination_url'].value
1091 self.login(message=_('Incorrect password'), action=action)
1092 return 0
1094 self.set_cookie(self.user, password)
1095 return 1
1097 def newuser_action(self, message=None):
1098 '''Attempt to create a new user based on the contents of the form
1099 and then set the cookie.
1101 return 1 on successful login
1102 '''
1103 # re-open the database as "admin"
1104 self.db = self.instance.open('admin')
1106 # TODO: pre-check the required fields and username key property
1107 cl = self.db.user
1108 try:
1109 props = parsePropsFromForm(self.db, cl, self.form)
1110 uid = cl.create(**props)
1111 except ValueError, message:
1112 action = self.form['__destination_url'].value
1113 self.login(message, action=action)
1114 return 0
1115 self.user = cl.get(uid, 'username')
1116 password = cl.get(uid, 'password')
1117 self.set_cookie(self.user, self.form['password'].value)
1118 return 1
1120 def set_cookie(self, user, password):
1121 # construct the cookie
1122 user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
1123 if user[-1] == '=':
1124 if user[-2] == '=':
1125 user = user[:-2]
1126 else:
1127 user = user[:-1]
1128 expire = Cookie._getdate(86400*365)
1129 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
1130 self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
1131 user, expire, path)})
1133 def make_user_anonymous(self):
1134 # make us anonymous if we can
1135 try:
1136 self.db.user.lookup('anonymous')
1137 self.user = 'anonymous'
1138 except KeyError:
1139 self.user = None
1141 def logout(self, message=None):
1142 self.make_user_anonymous()
1143 # construct the logout cookie
1144 now = Cookie._getdate()
1145 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
1146 self.header({'Set-Cookie':
1147 'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
1148 path)})
1149 self.login()
1151 def main(self):
1152 '''Wrap the database accesses so we can close the database cleanly
1153 '''
1154 # determine the uid to use
1155 self.db = self.instance.open('admin')
1156 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
1157 user = 'anonymous'
1158 if (cookie.has_key('roundup_user') and
1159 cookie['roundup_user'].value != 'deleted'):
1160 cookie = cookie['roundup_user'].value
1161 if len(cookie)%4:
1162 cookie = cookie + '='*(4-len(cookie)%4)
1163 try:
1164 user, password = binascii.a2b_base64(cookie).split(':')
1165 except (TypeError, binascii.Error, binascii.Incomplete):
1166 # damaged cookie!
1167 user, password = 'anonymous', ''
1169 # make sure the user exists
1170 try:
1171 uid = self.db.user.lookup(user)
1172 # now validate the password
1173 if password != self.db.user.get(uid, 'password'):
1174 user = 'anonymous'
1175 except KeyError:
1176 user = 'anonymous'
1178 # make sure the anonymous user is valid if we're using it
1179 if user == 'anonymous':
1180 self.make_user_anonymous()
1181 else:
1182 self.user = user
1184 # re-open the database for real, using the user
1185 self.db = self.instance.open(self.user)
1187 # now figure which function to call
1188 path = self.split_path
1190 # default action to index if the path has no information in it
1191 if not path or path[0] in ('', 'index'):
1192 action = 'index'
1193 else:
1194 action = path[0]
1196 # Everthing ignores path[1:]
1197 # - The file download link generator actually relies on this - it
1198 # appends the name of the file to the URL so the download file name
1199 # is correct, but doesn't actually use it.
1201 # everyone is allowed to try to log in
1202 if action == 'login_action':
1203 # try to login
1204 if not self.login_action():
1205 return
1206 # figure the resulting page
1207 action = self.form['__destination_url'].value
1208 if not action:
1209 action = 'index'
1210 self.do_action(action)
1211 return
1213 # allow anonymous people to register
1214 if action == 'newuser_action':
1215 # if we don't have a login and anonymous people aren't allowed to
1216 # register, then spit up the login form
1217 if self.instance.ANONYMOUS_REGISTER == 'deny' and self.user is None:
1218 if action == 'login':
1219 self.login() # go to the index after login
1220 else:
1221 self.login(action=action)
1222 return
1223 # try to add the user
1224 if not self.newuser_action():
1225 return
1226 # figure the resulting page
1227 action = self.form['__destination_url'].value
1228 if not action:
1229 action = 'index'
1231 # no login or registration, make sure totally anonymous access is OK
1232 elif self.instance.ANONYMOUS_ACCESS == 'deny' and self.user is None:
1233 if action == 'login':
1234 self.login() # go to the index after login
1235 else:
1236 self.login(action=action)
1237 return
1239 # just a regular action
1240 self.do_action(action)
1242 # commit all changes to the database
1243 self.db.commit()
1245 def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
1246 nre=re.compile(r'new(\w+)')):
1247 '''Figure the user's action and do it.
1248 '''
1249 # here be the "normal" functionality
1250 if action == 'index':
1251 self.index()
1252 return
1253 if action == 'list_classes':
1254 self.classes()
1255 return
1256 if action == 'classhelp':
1257 self.classhelp()
1258 return
1259 if action == 'login':
1260 self.login()
1261 return
1262 if action == 'logout':
1263 self.logout()
1264 return
1266 # see if we're to display an existing node
1267 m = dre.match(action)
1268 if m:
1269 self.classname = m.group(1)
1270 self.nodeid = m.group(2)
1271 try:
1272 cl = self.db.classes[self.classname]
1273 except KeyError:
1274 raise NotFound, self.classname
1275 try:
1276 cl.get(self.nodeid, 'id')
1277 except IndexError:
1278 raise NotFound, self.nodeid
1279 try:
1280 func = getattr(self, 'show%s'%self.classname)
1281 except AttributeError:
1282 raise NotFound, 'show%s'%self.classname
1283 func()
1284 return
1286 # see if we're to put up the new node page
1287 m = nre.match(action)
1288 if m:
1289 self.classname = m.group(1)
1290 try:
1291 func = getattr(self, 'new%s'%self.classname)
1292 except AttributeError:
1293 raise NotFound, 'new%s'%self.classname
1294 func()
1295 return
1297 # otherwise, display the named class
1298 self.classname = action
1299 try:
1300 self.db.getclass(self.classname)
1301 except KeyError:
1302 raise NotFound, self.classname
1303 self.list()
1306 class ExtendedClient(Client):
1307 '''Includes pages and page heading information that relate to the
1308 extended schema.
1309 '''
1310 showsupport = Client.shownode
1311 showtimelog = Client.shownode
1312 newsupport = Client.newnode
1313 newtimelog = Client.newnode
1315 default_index_sort = ['-activity']
1316 default_index_group = ['priority']
1317 default_index_filter = ['status']
1318 default_index_columns = ['activity','status','title','assignedto']
1319 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
1321 def parsePropsFromForm(db, cl, form, nodeid=0):
1322 '''Pull properties for the given class out of the form.
1323 '''
1324 props = {}
1325 keys = form.keys()
1326 num_re = re.compile('^\d+$')
1327 for key in keys:
1328 if not cl.properties.has_key(key):
1329 continue
1330 proptype = cl.properties[key]
1331 if isinstance(proptype, hyperdb.String):
1332 value = form[key].value.strip()
1333 elif isinstance(proptype, hyperdb.Password):
1334 value = password.Password(form[key].value.strip())
1335 elif isinstance(proptype, hyperdb.Date):
1336 value = form[key].value.strip()
1337 if value:
1338 value = date.Date(form[key].value.strip())
1339 else:
1340 value = None
1341 elif isinstance(proptype, hyperdb.Interval):
1342 value = form[key].value.strip()
1343 if value:
1344 value = date.Interval(form[key].value.strip())
1345 else:
1346 value = None
1347 elif isinstance(proptype, hyperdb.Link):
1348 value = form[key].value.strip()
1349 # see if it's the "no selection" choice
1350 if value == '-1':
1351 # don't set this property
1352 continue
1353 else:
1354 # handle key values
1355 link = cl.properties[key].classname
1356 if not num_re.match(value):
1357 try:
1358 value = db.classes[link].lookup(value)
1359 except KeyError:
1360 raise ValueError, _('property "%(propname)s": '
1361 '%(value)s not a %(classname)s')%{'propname':key,
1362 'value': value, 'classname': link}
1363 elif isinstance(proptype, hyperdb.Multilink):
1364 value = form[key]
1365 if type(value) != type([]):
1366 value = [i.strip() for i in value.value.split(',')]
1367 else:
1368 value = [i.value.strip() for i in value]
1369 link = cl.properties[key].classname
1370 l = []
1371 for entry in map(str, value):
1372 if entry == '': continue
1373 if not num_re.match(entry):
1374 try:
1375 entry = db.classes[link].lookup(entry)
1376 except KeyError:
1377 raise ValueError, _('property "%(propname)s": '
1378 '"%(value)s" not an entry of %(classname)s')%{
1379 'propname':key, 'value': entry, 'classname': link}
1380 l.append(entry)
1381 l.sort()
1382 value = l
1384 # get the old value
1385 if nodeid:
1386 try:
1387 existing = cl.get(nodeid, key)
1388 except KeyError:
1389 # this might be a new property for which there is no existing
1390 # value
1391 if not cl.properties.has_key(key): raise
1393 # if changed, set it
1394 if value != existing:
1395 props[key] = value
1396 else:
1397 props[key] = value
1398 return props
1400 #
1401 # $Log: not supported by cvs2svn $
1402 # Revision 1.123 2002/05/22 05:04:13 richard
1403 # Oops
1404 #
1405 # Revision 1.122 2002/05/22 04:12:05 richard
1406 # . applied patch #558876 ] cgi client customization
1407 # ... with significant additions and modifications ;)
1408 # - extended handling of ML assignedto to all places it's handled
1409 # - added more NotFound info
1410 #
1411 # Revision 1.121 2002/05/21 06:08:10 richard
1412 # Handle migration
1413 #
1414 # Revision 1.120 2002/05/21 06:05:53 richard
1415 # . #551483 ] assignedto in Client.make_index_link
1416 #
1417 # Revision 1.119 2002/05/15 06:21:21 richard
1418 # . node caching now works, and gives a small boost in performance
1419 #
1420 # As a part of this, I cleaned up the DEBUG output and implemented TRACE
1421 # output (HYPERDBTRACE='file to trace to') with checkpoints at the start of
1422 # CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff
1423 # (using if __debug__ which is compiled out with -O)
1424 #
1425 # Revision 1.118 2002/05/12 23:46:33 richard
1426 # ehem, part 2
1427 #
1428 # Revision 1.117 2002/05/12 23:42:29 richard
1429 # ehem
1430 #
1431 # Revision 1.116 2002/05/02 08:07:49 richard
1432 # Added the ADD_AUTHOR_TO_NOSY handling to the CGI interface.
1433 #
1434 # Revision 1.115 2002/04/02 01:56:10 richard
1435 # . stop sending blank (whitespace-only) notes
1436 #
1437 # Revision 1.114 2002/03/17 23:06:05 richard
1438 # oops
1439 #
1440 # Revision 1.113 2002/03/14 23:59:24 richard
1441 # . #517734 ] web header customisation is obscure
1442 #
1443 # Revision 1.112 2002/03/12 22:52:26 richard
1444 # more pychecker warnings removed
1445 #
1446 # Revision 1.111 2002/02/25 04:32:21 richard
1447 # ahem
1448 #
1449 # Revision 1.110 2002/02/21 07:19:08 richard
1450 # ... and label, width and height control for extra flavour!
1451 #
1452 # Revision 1.109 2002/02/21 07:08:19 richard
1453 # oops
1454 #
1455 # Revision 1.108 2002/02/21 07:02:54 richard
1456 # The correct var is "HTTP_HOST"
1457 #
1458 # Revision 1.107 2002/02/21 06:57:38 richard
1459 # . Added popup help for classes using the classhelp html template function.
1460 # - add <display call="classhelp('priority', 'id,name,description')">
1461 # to an item page, and it generates a link to a popup window which displays
1462 # the id, name and description for the priority class. The description
1463 # field won't exist in most installations, but it will be added to the
1464 # default templates.
1465 #
1466 # Revision 1.106 2002/02/21 06:23:00 richard
1467 # *** empty log message ***
1468 #
1469 # Revision 1.105 2002/02/20 05:52:10 richard
1470 # better error handling
1471 #
1472 # Revision 1.104 2002/02/20 05:45:17 richard
1473 # Use the csv module for generating the form entry so it's correct.
1474 # [also noted the sf.net feature request id in the change log]
1475 #
1476 # Revision 1.103 2002/02/20 05:05:28 richard
1477 # . Added simple editing for classes that don't define a templated interface.
1478 # - access using the admin "class list" interface
1479 # - limited to admin-only
1480 # - requires the csv module from object-craft (url given if it's missing)
1481 #
1482 # Revision 1.102 2002/02/15 07:08:44 richard
1483 # . Alternate email addresses are now available for users. See the MIGRATION
1484 # file for info on how to activate the feature.
1485 #
1486 # Revision 1.101 2002/02/14 23:39:18 richard
1487 # . All forms now have "double-submit" protection when Javascript is enabled
1488 # on the client-side.
1489 #
1490 # Revision 1.100 2002/01/16 07:02:57 richard
1491 # . lots of date/interval related changes:
1492 # - more relaxed date format for input
1493 #
1494 # Revision 1.99 2002/01/16 03:02:42 richard
1495 # #503793 ] changing assignedto resets nosy list
1496 #
1497 # Revision 1.98 2002/01/14 02:20:14 richard
1498 # . changed all config accesses so they access either the instance or the
1499 # config attriubute on the db. This means that all config is obtained from
1500 # instance_config instead of the mish-mash of classes. This will make
1501 # switching to a ConfigParser setup easier too, I hope.
1502 #
1503 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1504 # 0.5.0 switch, I hope!)
1505 #
1506 # Revision 1.97 2002/01/11 23:22:29 richard
1507 # . #502437 ] rogue reactor and unittest
1508 # in short, the nosy reactor was modifying the nosy list. That code had
1509 # been there for a long time, and I suspsect it was there because we
1510 # weren't generating the nosy list correctly in other places of the code.
1511 # We're now doing that, so the nosy-modifying code can go away from the
1512 # nosy reactor.
1513 #
1514 # Revision 1.96 2002/01/10 05:26:10 richard
1515 # missed a parsePropsFromForm in last update
1516 #
1517 # Revision 1.95 2002/01/10 03:39:45 richard
1518 # . fixed some problems with web editing and change detection
1519 #
1520 # Revision 1.94 2002/01/09 13:54:21 grubert
1521 # _add_assignedto_to_nosy did set nosy to assignedto only, no adding.
1522 #
1523 # Revision 1.93 2002/01/08 11:57:12 richard
1524 # crying out for real configuration handling... :(
1525 #
1526 # Revision 1.92 2002/01/08 04:12:05 richard
1527 # Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
1528 #
1529 # Revision 1.91 2002/01/08 04:03:47 richard
1530 # I mucked the intent of the code up.
1531 #
1532 # Revision 1.90 2002/01/08 03:56:55 richard
1533 # Oops, missed this before the beta:
1534 # . #495392 ] empty nosy -patch
1535 #
1536 # Revision 1.89 2002/01/07 20:24:45 richard
1537 # *mutter* stupid cutnpaste
1538 #
1539 # Revision 1.88 2002/01/02 02:31:38 richard
1540 # Sorry for the huge checkin message - I was only intending to implement #496356
1541 # but I found a number of places where things had been broken by transactions:
1542 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1543 # for _all_ roundup-generated smtp messages to be sent to.
1544 # . the transaction cache had broken the roundupdb.Class set() reactors
1545 # . newly-created author users in the mailgw weren't being committed to the db
1546 #
1547 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1548 # on when I found that stuff :):
1549 # . #496356 ] Use threading in messages
1550 # . detectors were being registered multiple times
1551 # . added tests for mailgw
1552 # . much better attaching of erroneous messages in the mail gateway
1553 #
1554 # Revision 1.87 2001/12/23 23:18:49 richard
1555 # We already had an admin-specific section of the web heading, no need to add
1556 # another one :)
1557 #
1558 # Revision 1.86 2001/12/20 15:43:01 rochecompaan
1559 # Features added:
1560 # . Multilink properties are now displayed as comma separated values in
1561 # a textbox
1562 # . The add user link is now only visible to the admin user
1563 # . Modified the mail gateway to reject submissions from unknown
1564 # addresses if ANONYMOUS_ACCESS is denied
1565 #
1566 # Revision 1.85 2001/12/20 06:13:24 rochecompaan
1567 # Bugs fixed:
1568 # . Exception handling in hyperdb for strings-that-look-like numbers got
1569 # lost somewhere
1570 # . Internet Explorer submits full path for filename - we now strip away
1571 # the path
1572 # Features added:
1573 # . Link and multilink properties are now displayed sorted in the cgi
1574 # interface
1575 #
1576 # Revision 1.84 2001/12/18 15:30:30 rochecompaan
1577 # Fixed bugs:
1578 # . Fixed file creation and retrieval in same transaction in anydbm
1579 # backend
1580 # . Cgi interface now renders new issue after issue creation
1581 # . Could not set issue status to resolved through cgi interface
1582 # . Mail gateway was changing status back to 'chatting' if status was
1583 # omitted as an argument
1584 #
1585 # Revision 1.83 2001/12/15 23:51:01 richard
1586 # Tested the changes and fixed a few problems:
1587 # . files are now attached to the issue as well as the message
1588 # . newuser is a real method now since we don't want to do the message/file
1589 # stuff for it
1590 # . added some documentation
1591 # The really big changes in the diff are a result of me moving some code
1592 # around to keep like methods together a bit better.
1593 #
1594 # Revision 1.82 2001/12/15 19:24:39 rochecompaan
1595 # . Modified cgi interface to change properties only once all changes are
1596 # collected, files created and messages generated.
1597 # . Moved generation of change note to nosyreactors.
1598 # . We now check for changes to "assignedto" to ensure it's added to the
1599 # nosy list.
1600 #
1601 # Revision 1.81 2001/12/12 23:55:00 richard
1602 # Fixed some problems with user editing
1603 #
1604 # Revision 1.80 2001/12/12 23:27:14 richard
1605 # Added a Zope frontend for roundup.
1606 #
1607 # Revision 1.79 2001/12/10 22:20:01 richard
1608 # Enabled transaction support in the bsddb backend. It uses the anydbm code
1609 # where possible, only replacing methods where the db is opened (it uses the
1610 # btree opener specifically.)
1611 # Also cleaned up some change note generation.
1612 # Made the backends package work with pydoc too.
1613 #
1614 # Revision 1.78 2001/12/07 05:59:27 rochecompaan
1615 # Fixed small bug that prevented adding issues through the web.
1616 #
1617 # Revision 1.77 2001/12/06 22:48:29 richard
1618 # files multilink was being nuked in post_edit_node
1619 #
1620 # Revision 1.76 2001/12/05 14:26:44 rochecompaan
1621 # Removed generation of change note from "sendmessage" in roundupdb.py.
1622 # The change note is now generated when the message is created.
1623 #
1624 # Revision 1.75 2001/12/04 01:25:08 richard
1625 # Added some rollbacks where we were catching exceptions that would otherwise
1626 # have stopped committing.
1627 #
1628 # Revision 1.74 2001/12/02 05:06:16 richard
1629 # . We now use weakrefs in the Classes to keep the database reference, so
1630 # the close() method on the database is no longer needed.
1631 # I bumped the minimum python requirement up to 2.1 accordingly.
1632 # . #487480 ] roundup-server
1633 # . #487476 ] INSTALL.txt
1634 #
1635 # I also cleaned up the change message / post-edit stuff in the cgi client.
1636 # There's now a clearly marked "TODO: append the change note" where I believe
1637 # the change note should be added there. The "changes" list will obviously
1638 # have to be modified to be a dict of the changes, or somesuch.
1639 #
1640 # More testing needed.
1641 #
1642 # Revision 1.73 2001/12/01 07:17:50 richard
1643 # . We now have basic transaction support! Information is only written to
1644 # the database when the commit() method is called. Only the anydbm
1645 # backend is modified in this way - neither of the bsddb backends have been.
1646 # The mail, admin and cgi interfaces all use commit (except the admin tool
1647 # doesn't have a commit command, so interactive users can't commit...)
1648 # . Fixed login/registration forwarding the user to the right page (or not,
1649 # on a failure)
1650 #
1651 # Revision 1.72 2001/11/30 20:47:58 rochecompaan
1652 # Links in page header are now consistent with default sort order.
1653 #
1654 # Fixed bugs:
1655 # - When login failed the list of issues were still rendered.
1656 # - User was redirected to index page and not to his destination url
1657 # if his first login attempt failed.
1658 #
1659 # Revision 1.71 2001/11/30 20:28:10 rochecompaan
1660 # Property changes are now completely traceable, whether changes are
1661 # made through the web or by email
1662 #
1663 # Revision 1.70 2001/11/30 00:06:29 richard
1664 # Converted roundup/cgi_client.py to use _()
1665 # Added the status file, I18N_PROGRESS.txt
1666 #
1667 # Revision 1.69 2001/11/29 23:19:51 richard
1668 # Removed the "This issue has been edited through the web" when a valid
1669 # change note is supplied.
1670 #
1671 # Revision 1.68 2001/11/29 04:57:23 richard
1672 # a little comment
1673 #
1674 # Revision 1.67 2001/11/28 21:55:35 richard
1675 # . login_action and newuser_action return values were being ignored
1676 # . Woohoo! Found that bloody re-login bug that was killing the mail
1677 # gateway.
1678 # (also a minor cleanup in hyperdb)
1679 #
1680 # Revision 1.66 2001/11/27 03:00:50 richard
1681 # couple of bugfixes from latest patch integration
1682 #
1683 # Revision 1.65 2001/11/26 23:00:53 richard
1684 # This config stuff is getting to be a real mess...
1685 #
1686 # Revision 1.64 2001/11/26 22:56:35 richard
1687 # typo
1688 #
1689 # Revision 1.63 2001/11/26 22:55:56 richard
1690 # Feature:
1691 # . Added INSTANCE_NAME to configuration - used in web and email to identify
1692 # the instance.
1693 # . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1694 # signature info in e-mails.
1695 # . Some more flexibility in the mail gateway and more error handling.
1696 # . Login now takes you to the page you back to the were denied access to.
1697 #
1698 # Fixed:
1699 # . Lots of bugs, thanks Roché and others on the devel mailing list!
1700 #
1701 # Revision 1.62 2001/11/24 00:45:42 jhermann
1702 # typeof() instead of type(): avoid clash with database field(?) "type"
1703 #
1704 # Fixes this traceback:
1705 #
1706 # Traceback (most recent call last):
1707 # File "roundup\cgi_client.py", line 535, in newnode
1708 # self._post_editnode(nid)
1709 # File "roundup\cgi_client.py", line 415, in _post_editnode
1710 # if type(value) != type([]): value = [value]
1711 # UnboundLocalError: local variable 'type' referenced before assignment
1712 #
1713 # Revision 1.61 2001/11/22 15:46:42 jhermann
1714 # Added module docstrings to all modules.
1715 #
1716 # Revision 1.60 2001/11/21 22:57:28 jhermann
1717 # Added dummy hooks for I18N and some preliminary (test) markup of
1718 # translatable messages
1719 #
1720 # Revision 1.59 2001/11/21 03:21:13 richard
1721 # oops
1722 #
1723 # Revision 1.58 2001/11/21 03:11:28 richard
1724 # Better handling of new properties.
1725 #
1726 # Revision 1.57 2001/11/15 10:24:27 richard
1727 # handle the case where there is no file attached
1728 #
1729 # Revision 1.56 2001/11/14 21:35:21 richard
1730 # . users may attach files to issues (and support in ext) through the web now
1731 #
1732 # Revision 1.55 2001/11/07 02:34:06 jhermann
1733 # Handling of damaged login cookies
1734 #
1735 # Revision 1.54 2001/11/07 01:16:12 richard
1736 # Remove the '=' padding from cookie value so quoting isn't an issue.
1737 #
1738 # Revision 1.53 2001/11/06 23:22:05 jhermann
1739 # More IE fixes: it does not like quotes around cookie values; in the
1740 # hope this does not break anything for other browser; if it does, we
1741 # need to check HTTP_USER_AGENT
1742 #
1743 # Revision 1.52 2001/11/06 23:11:22 jhermann
1744 # Fixed debug output in page footer; added expiry date to the login cookie
1745 # (expires 1 year in the future) to prevent probs with certain versions
1746 # of IE
1747 #
1748 # Revision 1.51 2001/11/06 22:00:34 jhermann
1749 # Get debug level from ROUNDUP_DEBUG env var
1750 #
1751 # Revision 1.50 2001/11/05 23:45:40 richard
1752 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1753 # Also made it present nicer error messages (not tracebacks).
1754 #
1755 # Revision 1.49 2001/11/04 03:07:12 richard
1756 # Fixed various cookie-related bugs:
1757 # . bug #477685 ] base64.decodestring breaks
1758 # . bug #477837 ] lynx does not like the cookie
1759 # . bug #477892 ] Password edit doesn't fix login cookie
1760 # Also closed a security hole - a logged-in user could edit another user's
1761 # details.
1762 #
1763 # Revision 1.48 2001/11/03 01:30:18 richard
1764 # Oops. uses pagefoot now.
1765 #
1766 # Revision 1.47 2001/11/03 01:29:28 richard
1767 # Login page didn't have all close tags.
1768 #
1769 # Revision 1.46 2001/11/03 01:26:55 richard
1770 # possibly fix truncated base64'ed user:pass
1771 #
1772 # Revision 1.45 2001/11/01 22:04:37 richard
1773 # Started work on supporting a pop3-fetching server
1774 # Fixed bugs:
1775 # . bug #477104 ] HTML tag error in roundup-server
1776 # . bug #477107 ] HTTP header problem
1777 #
1778 # Revision 1.44 2001/10/28 23:03:08 richard
1779 # Added more useful header to the classic schema.
1780 #
1781 # Revision 1.43 2001/10/24 00:01:42 richard
1782 # More fixes to lockout logic.
1783 #
1784 # Revision 1.42 2001/10/23 23:56:03 richard
1785 # HTML typo
1786 #
1787 # Revision 1.41 2001/10/23 23:52:35 richard
1788 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1789 #
1790 # Revision 1.40 2001/10/23 23:06:39 richard
1791 # Some cleanup.
1792 #
1793 # Revision 1.39 2001/10/23 01:00:18 richard
1794 # Re-enabled login and registration access after lopping them off via
1795 # disabling access for anonymous users.
1796 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1797 # a couple of bugs while I was there. Probably introduced a couple, but
1798 # things seem to work OK at the moment.
1799 #
1800 # Revision 1.38 2001/10/22 03:25:01 richard
1801 # Added configuration for:
1802 # . anonymous user access and registration (deny/allow)
1803 # . filter "widget" location on index page (top, bottom, both)
1804 # Updated some documentation.
1805 #
1806 # Revision 1.37 2001/10/21 07:26:35 richard
1807 # feature #473127: Filenames. I modified the file.index and htmltemplate
1808 # source so that the filename is used in the link and the creation
1809 # information is displayed.
1810 #
1811 # Revision 1.36 2001/10/21 04:44:50 richard
1812 # bug #473124: UI inconsistency with Link fields.
1813 # This also prompted me to fix a fairly long-standing usability issue -
1814 # that of being able to turn off certain filters.
1815 #
1816 # Revision 1.35 2001/10/21 00:17:54 richard
1817 # CGI interface view customisation section may now be hidden (patch from
1818 # Roch'e Compaan.)
1819 #
1820 # Revision 1.34 2001/10/20 11:58:48 richard
1821 # Catch errors in login - no username or password supplied.
1822 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1823 #
1824 # Revision 1.33 2001/10/17 00:18:41 richard
1825 # Manually constructing cookie headers now.
1826 #
1827 # Revision 1.32 2001/10/16 03:36:21 richard
1828 # CGI interface wasn't handling checkboxes at all.
1829 #
1830 # Revision 1.31 2001/10/14 10:55:00 richard
1831 # Handle empty strings in HTML template Link function
1832 #
1833 # Revision 1.30 2001/10/09 07:38:58 richard
1834 # Pushed the base code for the extended schema CGI interface back into the
1835 # code cgi_client module so that future updates will be less painful.
1836 # Also removed a debugging print statement from cgi_client.
1837 #
1838 # Revision 1.29 2001/10/09 07:25:59 richard
1839 # Added the Password property type. See "pydoc roundup.password" for
1840 # implementation details. Have updated some of the documentation too.
1841 #
1842 # Revision 1.28 2001/10/08 00:34:31 richard
1843 # Change message was stuffing up for multilinks with no key property.
1844 #
1845 # Revision 1.27 2001/10/05 02:23:24 richard
1846 # . roundup-admin create now prompts for property info if none is supplied
1847 # on the command-line.
1848 # . hyperdb Class getprops() method may now return only the mutable
1849 # properties.
1850 # . Login now uses cookies, which makes it a whole lot more flexible. We can
1851 # now support anonymous user access (read-only, unless there's an
1852 # "anonymous" user, in which case write access is permitted). Login
1853 # handling has been moved into cgi_client.Client.main()
1854 # . The "extended" schema is now the default in roundup init.
1855 # . The schemas have had their page headings modified to cope with the new
1856 # login handling. Existing installations should copy the interfaces.py
1857 # file from the roundup lib directory to their instance home.
1858 # . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1859 # Ping - has been removed.
1860 # . Fixed a whole bunch of places in the CGI interface where we should have
1861 # been returning Not Found instead of throwing an exception.
1862 # . Fixed a deviation from the spec: trying to modify the 'id' property of
1863 # an item now throws an exception.
1864 #
1865 # Revision 1.26 2001/09/12 08:31:42 richard
1866 # handle cases where mime type is not guessable
1867 #
1868 # Revision 1.25 2001/08/29 05:30:49 richard
1869 # change messages weren't being saved when there was no-one on the nosy list.
1870 #
1871 # Revision 1.24 2001/08/29 04:49:39 richard
1872 # didn't clean up fully after debugging :(
1873 #
1874 # Revision 1.23 2001/08/29 04:47:18 richard
1875 # Fixed CGI client change messages so they actually include the properties
1876 # changed (again).
1877 #
1878 # Revision 1.22 2001/08/17 00:08:10 richard
1879 # reverted back to sending messages always regardless of who is doing the web
1880 # edit. change notes weren't being saved. bleah. hackish.
1881 #
1882 # Revision 1.21 2001/08/15 23:43:18 richard
1883 # Fixed some isFooTypes that I missed.
1884 # Refactored some code in the CGI code.
1885 #
1886 # Revision 1.20 2001/08/12 06:32:36 richard
1887 # using isinstance(blah, Foo) now instead of isFooType
1888 #
1889 # Revision 1.19 2001/08/07 00:24:42 richard
1890 # stupid typo
1891 #
1892 # Revision 1.18 2001/08/07 00:15:51 richard
1893 # Added the copyright/license notice to (nearly) all files at request of
1894 # Bizar Software.
1895 #
1896 # Revision 1.17 2001/08/02 06:38:17 richard
1897 # Roundupdb now appends "mailing list" information to its messages which
1898 # include the e-mail address and web interface address. Templates may
1899 # override this in their db classes to include specific information (support
1900 # instructions, etc).
1901 #
1902 # Revision 1.16 2001/08/02 05:55:25 richard
1903 # Web edit messages aren't sent to the person who did the edit any more. No
1904 # message is generated if they are the only person on the nosy list.
1905 #
1906 # Revision 1.15 2001/08/02 00:34:10 richard
1907 # bleah syntax error
1908 #
1909 # Revision 1.14 2001/08/02 00:26:16 richard
1910 # Changed the order of the information in the message generated by web edits.
1911 #
1912 # Revision 1.13 2001/07/30 08:12:17 richard
1913 # Added time logging and file uploading to the templates.
1914 #
1915 # Revision 1.12 2001/07/30 06:26:31 richard
1916 # Added some documentation on how the newblah works.
1917 #
1918 # Revision 1.11 2001/07/30 06:17:45 richard
1919 # Features:
1920 # . Added ability for cgi newblah forms to indicate that the new node
1921 # should be linked somewhere.
1922 # Fixed:
1923 # . Fixed the agument handling for the roundup-admin find command.
1924 # . Fixed handling of summary when no note supplied for newblah. Again.
1925 # . Fixed detection of no form in htmltemplate Field display.
1926 #
1927 # Revision 1.10 2001/07/30 02:37:34 richard
1928 # Temporary measure until we have decent schema migration...
1929 #
1930 # Revision 1.9 2001/07/30 01:25:07 richard
1931 # Default implementation is now "classic" rather than "extended" as one would
1932 # expect.
1933 #
1934 # Revision 1.8 2001/07/29 08:27:40 richard
1935 # Fixed handling of passed-in values in form elements (ie. during a
1936 # drill-down)
1937 #
1938 # Revision 1.7 2001/07/29 07:01:39 richard
1939 # Added vim command to all source so that we don't get no steenkin' tabs :)
1940 #
1941 # Revision 1.6 2001/07/29 04:04:00 richard
1942 # Moved some code around allowing for subclassing to change behaviour.
1943 #
1944 # Revision 1.5 2001/07/28 08:16:52 richard
1945 # New issue form handles lack of note better now.
1946 #
1947 # Revision 1.4 2001/07/28 00:34:34 richard
1948 # Fixed some non-string node ids.
1949 #
1950 # Revision 1.3 2001/07/23 03:56:30 richard
1951 # oops, missed a config removal
1952 #
1953 # Revision 1.2 2001/07/22 12:09:32 richard
1954 # Final commit of Grande Splite
1955 #
1956 # Revision 1.1 2001/07/22 11:58:35 richard
1957 # More Grande Splite
1958 #
1959 #
1960 # vim: set filetype=python ts=4 sw=4 et si