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