66c923a21586cd0b0df3c06df33ae7241437808a
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.72 2001-11-30 20:47:58 rochecompaan Exp $
20 __doc__ = """
21 WWW request handler (also used in the stand-alone server).
22 """
24 import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes
25 import binascii, Cookie, time
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.
49 Customisation
50 -------------
51 FILTER_POSITION - one of 'top', 'bottom', 'top and bottom'
52 ANONYMOUS_ACCESS - one of 'deny', 'allow'
53 ANONYMOUS_REGISTER - one of 'deny', 'allow'
55 from the roundup class:
56 INSTANCE_NAME - defaults to 'Roundup issue tracker'
58 '''
59 FILTER_POSITION = 'bottom' # one of 'top', 'bottom', 'top and bottom'
60 ANONYMOUS_ACCESS = 'deny' # one of 'deny', 'allow'
61 ANONYMOUS_REGISTER = 'deny' # one of 'deny', 'allow'
63 def __init__(self, instance, request, env):
64 self.instance = instance
65 self.request = request
66 self.env = env
67 self.path = env['PATH_INFO']
68 self.split_path = self.path.split('/')
70 self.form = cgi.FieldStorage(environ=env)
71 self.headers_done = 0
72 try:
73 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
74 except ValueError:
75 # someone gave us a non-int debug level, turn it off
76 self.debug = 0
78 def getuid(self):
79 return self.db.user.lookup(self.user)
81 def header(self, headers={'Content-Type':'text/html'}):
82 '''Put up the appropriate header.
83 '''
84 if not headers.has_key('Content-Type'):
85 headers['Content-Type'] = 'text/html'
86 self.request.send_response(200)
87 for entry in headers.items():
88 self.request.send_header(*entry)
89 self.request.end_headers()
90 self.headers_done = 1
91 if self.debug:
92 self.headers_sent = headers
94 def pagehead(self, title, message=None):
95 url = self.env['SCRIPT_NAME'] + '/'
96 machine = self.env['SERVER_NAME']
97 port = self.env['SERVER_PORT']
98 if port != '80': machine = machine + ':' + port
99 base = urlparse.urlunparse(('http', machine, url, None, None, None))
100 if message is not None:
101 message = _('<div class="system-msg">%(message)s</div>')%locals()
102 else:
103 message = ''
104 style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
105 user_name = self.user or ''
106 if self.user == 'admin':
107 admin_links = _(' | <a href="list_classes">Class List</a>' \
108 ' | <a href="user">User List</a>')
109 else:
110 admin_links = ''
111 if self.user not in (None, 'anonymous'):
112 userid = self.db.user.lookup(self.user)
113 user_info = _('''
114 <a href="issue?assignedto=%(userid)s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=-activity&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">My Issues</a> |
115 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
116 ''')%locals()
117 else:
118 user_info = _('<a href="login">Login</a>')
119 if self.user is not None:
120 add_links = _('''
121 | Add
122 <a href="newissue">Issue</a>,
123 <a href="newuser">User</a>
124 ''')
125 else:
126 add_links = ''
127 self.write(_('''<html><head>
128 <title>%(title)s</title>
129 <style type="text/css">%(style)s</style>
130 </head>
131 <body bgcolor=#ffffff>
132 %(message)s
133 <table width=100%% border=0 cellspacing=0 cellpadding=2>
134 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
135 <td align=right valign=bottom>%(user_name)s</td></tr>
136 <tr class="location-bar">
137 <td align=left>All
138 <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>
139 | Unassigned
140 <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>
141 %(add_links)s
142 %(admin_links)s</td>
143 <td align=right>%(user_info)s</td>
144 </table>
145 ''')%locals())
147 def pagefoot(self):
148 if self.debug:
149 self.write(_('<hr><small><dl><dt><b>Path</b></dt>'))
150 self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
151 keys = self.form.keys()
152 keys.sort()
153 if keys:
154 self.write(_('<dt><b>Form entries</b></dt>'))
155 for k in self.form.keys():
156 v = self.form.getvalue(k, "<empty>")
157 if type(v) is type([]):
158 # Multiple username fields specified
159 v = "|".join(v)
160 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
161 keys = self.headers_sent.keys()
162 keys.sort()
163 self.write(_('<dt><b>Sent these HTTP headers</b></dt>'))
164 for k in keys:
165 v = self.headers_sent[k]
166 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
167 keys = self.env.keys()
168 keys.sort()
169 self.write(_('<dt><b>CGI environment</b></dt>'))
170 for k in keys:
171 v = self.env[k]
172 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
173 self.write('</dl></small>')
174 self.write('</body></html>')
176 def write(self, content):
177 if not self.headers_done:
178 self.header()
179 self.request.wfile.write(content)
181 def index_arg(self, arg):
182 ''' handle the args to index - they might be a list from the form
183 (ie. submitted from a form) or they might be a command-separated
184 single string (ie. manually constructed GET args)
185 '''
186 if self.form.has_key(arg):
187 arg = self.form[arg]
188 if type(arg) == type([]):
189 return [arg.value for arg in arg]
190 return arg.value.split(',')
191 return []
193 def index_filterspec(self, filter):
194 ''' pull the index filter spec from the form
196 Links and multilinks want to be lists - the rest are straight
197 strings.
198 '''
199 props = self.db.classes[self.classname].getprops()
200 # all the form args not starting with ':' are filters
201 filterspec = {}
202 for key in self.form.keys():
203 if key[0] == ':': continue
204 if not props.has_key(key): continue
205 if key not in filter: continue
206 prop = props[key]
207 value = self.form[key]
208 if (isinstance(prop, hyperdb.Link) or
209 isinstance(prop, hyperdb.Multilink)):
210 if type(value) == type([]):
211 value = [arg.value for arg in value]
212 else:
213 value = value.value.split(',')
214 l = filterspec.get(key, [])
215 l = l + value
216 filterspec[key] = l
217 else:
218 filterspec[key] = value.value
219 return filterspec
221 def customization_widget(self):
222 ''' The customization widget is visible by default. The widget
223 visibility is remembered by show_customization. Visibility
224 is not toggled if the action value is "Redisplay"
225 '''
226 if not self.form.has_key('show_customization'):
227 visible = 1
228 else:
229 visible = int(self.form['show_customization'].value)
230 if self.form.has_key('action'):
231 if self.form['action'].value != 'Redisplay':
232 visible = self.form['action'].value == '+'
234 return visible
236 default_index_sort = ['-activity']
237 default_index_group = ['priority']
238 default_index_filter = ['status']
239 default_index_columns = ['id','activity','title','status','assignedto']
240 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
241 def index(self):
242 ''' put up an index
243 '''
244 self.classname = 'issue'
245 # see if the web has supplied us with any customisation info
246 defaults = 1
247 for key in ':sort', ':group', ':filter', ':columns':
248 if self.form.has_key(key):
249 defaults = 0
250 break
251 if defaults:
252 # no info supplied - use the defaults
253 sort = self.default_index_sort
254 group = self.default_index_group
255 filter = self.default_index_filter
256 columns = self.default_index_columns
257 filterspec = self.default_index_filterspec
258 else:
259 sort = self.index_arg(':sort')
260 group = self.index_arg(':group')
261 filter = self.index_arg(':filter')
262 columns = self.index_arg(':columns')
263 filterspec = self.index_filterspec(filter)
264 return self.list(columns=columns, filter=filter, group=group,
265 sort=sort, filterspec=filterspec)
267 # XXX deviates from spec - loses the '+' (that's a reserved character
268 # in URLS
269 def list(self, sort=None, group=None, filter=None, columns=None,
270 filterspec=None, show_customization=None):
271 ''' call the template index with the args
273 :sort - sort by prop name, optionally preceeded with '-'
274 to give descending or nothing for ascending sorting.
275 :group - group by prop name, optionally preceeded with '-' or
276 to sort in descending or nothing for ascending order.
277 :filter - selects which props should be displayed in the filter
278 section. Default is all.
279 :columns - selects the columns that should be displayed.
280 Default is all.
282 '''
283 cn = self.classname
284 cl = self.db.classes[cn]
285 self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
286 'classname': cn, 'instancename': self.INSTANCE_NAME})
287 if sort is None: sort = self.index_arg(':sort')
288 if group is None: group = self.index_arg(':group')
289 if filter is None: filter = self.index_arg(':filter')
290 if columns is None: columns = self.index_arg(':columns')
291 if filterspec is None: filterspec = self.index_filterspec(filter)
292 if show_customization is None:
293 show_customization = self.customization_widget()
295 index = htmltemplate.IndexTemplate(self, self.TEMPLATES, cn)
296 index.render(filterspec, filter, columns, sort, group,
297 show_customization=show_customization)
298 self.pagefoot()
300 def shownode(self, message=None):
301 ''' display an item
302 '''
303 cn = self.classname
304 cl = self.db.classes[cn]
306 # possibly perform an edit
307 keys = self.form.keys()
308 num_re = re.compile('^\d+$')
309 # don't try to set properties if the user has just logged in
310 if keys and not self.form.has_key('__login_name'):
311 try:
312 props, changed = parsePropsFromForm(self.db, cl, self.form,
313 self.nodeid)
315 # set status to chatting if 'unread' or 'resolved'
316 if 'status' not in changed:
317 try:
318 # determine the id of 'unread','resolved' and 'chatting'
319 unread_id = self.db.status.lookup('unread')
320 resolved_id = self.db.status.lookup('resolved')
321 chatting_id = self.db.status.lookup('chatting')
322 except KeyError:
323 pass
324 else:
325 if (not props.has_key('status') or
326 props['status'] == unread_id or
327 props['status'] == resolved_id):
328 props['status'] = chatting_id
329 changed.append('status')
330 note = None
331 if self.form.has_key('__note'):
332 note = self.form['__note']
333 note = note.value
334 if changed or note:
335 p = self._post_editnode(self.nodeid, changed)
336 props['messages'] = p['messages']
337 props['files'] = p['files']
338 cl.set(self.nodeid, **props)
339 # and some nice feedback for the user
340 message = _('%(changes)s edited ok')%{'changes':
341 ', '.join(changed)}
342 else:
343 message = _('nothing changed')
344 except:
345 s = StringIO.StringIO()
346 traceback.print_exc(None, s)
347 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
349 # now the display
350 id = self.nodeid
351 if cl.getkey():
352 id = cl.get(id, cl.getkey())
353 self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
355 nodeid = self.nodeid
357 # use the template to display the item
358 item = htmltemplate.ItemTemplate(self, self.TEMPLATES, self.classname)
359 item.render(nodeid)
361 self.pagefoot()
362 showissue = shownode
363 showmsg = shownode
365 def showuser(self, message=None):
366 '''Display a user page for editing. Make sure the user is allowed
367 to edit this node, and also check for password changes.
368 '''
369 if self.user == 'anonymous':
370 raise Unauthorised
372 user = self.db.user
374 # get the username of the node being edited
375 node_user = user.get(self.nodeid, 'username')
377 if self.user not in ('admin', node_user):
378 raise Unauthorised
380 #
381 # perform any editing
382 #
383 keys = self.form.keys()
384 num_re = re.compile('^\d+$')
385 if keys:
386 try:
387 props, changed = parsePropsFromForm(self.db, user, self.form,
388 self.nodeid)
389 set_cookie = 0
390 if self.nodeid == self.getuid() and 'password' in changed:
391 password = self.form['password'].value.strip()
392 if password:
393 set_cookie = password
394 else:
395 del props['password']
396 del changed[changed.index('password')]
397 user.set(self.nodeid, **props)
398 self._post_editnode(self.nodeid, changed)
399 # and some feedback for the user
400 message = _('%(changes)s edited ok')%{'changes':
401 ', '.join(changed)}
402 except:
403 s = StringIO.StringIO()
404 traceback.print_exc(None, s)
405 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
406 else:
407 set_cookie = 0
409 # fix the cookie if the password has changed
410 if set_cookie:
411 self.set_cookie(self.user, set_cookie)
413 #
414 # now the display
415 #
416 self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
418 # use the template to display the item
419 item = htmltemplate.ItemTemplate(self, self.TEMPLATES, 'user')
420 item.render(self.nodeid)
421 self.pagefoot()
423 def showfile(self):
424 ''' display a file
425 '''
426 nodeid = self.nodeid
427 cl = self.db.file
428 mime_type = cl.get(nodeid, 'type')
429 if mime_type == 'message/rfc822':
430 mime_type = 'text/plain'
431 self.header(headers={'Content-Type': mime_type})
432 self.write(cl.get(nodeid, 'content'))
434 def _createnode(self):
435 ''' create a node based on the contents of the form
436 '''
437 cl = self.db.classes[self.classname]
438 props, dummy = parsePropsFromForm(self.db, cl, self.form)
439 return cl.create(**props)
441 def _post_editnode(self, nid, changes=None):
442 ''' do the linking and message sending part of the node creation
443 '''
444 cn = self.classname
445 cl = self.db.classes[cn]
446 # link if necessary
447 keys = self.form.keys()
448 for key in keys:
449 if key == ':multilink':
450 value = self.form[key].value
451 if type(value) != type([]): value = [value]
452 for value in value:
453 designator, property = value.split(':')
454 link, nodeid = roundupdb.splitDesignator(designator)
455 link = self.db.classes[link]
456 value = link.get(nodeid, property)
457 value.append(nid)
458 link.set(nodeid, **{property: value})
459 elif key == ':link':
460 value = self.form[key].value
461 if type(value) != type([]): value = [value]
462 for value in value:
463 designator, property = value.split(':')
464 link, nodeid = roundupdb.splitDesignator(designator)
465 link = self.db.classes[link]
466 link.set(nodeid, **{property: nid})
468 # handle file attachments
469 files = []
470 if self.form.has_key('__file'):
471 file = self.form['__file']
472 if file.filename:
473 mime_type = mimetypes.guess_type(file.filename)[0]
474 if not mime_type:
475 mime_type = "application/octet-stream"
476 # create the new file entry
477 files.append(self.db.file.create(type=mime_type,
478 name=file.filename, content=file.file.read()))
480 # generate an edit message
481 # don't bother if there's no messages or nosy list
482 props = cl.getprops()
483 note = None
484 if self.form.has_key('__note'):
485 note = self.form['__note']
486 note = note.value
487 send = len(cl.get(nid, 'nosy', [])) or note
488 if (send and props.has_key('messages') and
489 isinstance(props['messages'], hyperdb.Multilink) and
490 props['messages'].classname == 'msg'):
492 # handle the note
493 if note:
494 if '\n' in note:
495 summary = re.split(r'\n\r?', note)[0]
496 else:
497 summary = note
498 m = ['%s\n'%note]
499 else:
500 summary = _('This %(classname)s has been edited through'
501 ' the web.\n')%{'classname': cn}
502 m = [summary]
504 # now create the message
505 content = '\n'.join(m)
506 message_id = self.db.msg.create(author=self.getuid(),
507 recipients=[], date=date.Date('.'), summary=summary,
508 content=content, files=files)
509 messages = cl.get(nid, 'messages')
510 messages.append(message_id)
511 props = {'messages': messages, 'files': files}
512 return props
514 def newnode(self, message=None):
515 ''' Add a new node to the database.
517 The form works in two modes: blank form and submission (that is,
518 the submission goes to the same URL). **Eventually this means that
519 the form will have previously entered information in it if
520 submission fails.
522 The new node will be created with the properties specified in the
523 form submission. For multilinks, multiple form entries are handled,
524 as are prop=value,value,value. You can't mix them though.
526 If the new node is to be referenced from somewhere else immediately
527 (ie. the new node is a file that is to be attached to a support
528 issue) then supply one of these arguments in addition to the usual
529 form entries:
530 :link=designator:property
531 :multilink=designator:property
532 ... which means that once the new node is created, the "property"
533 on the node given by "designator" should now reference the new
534 node's id. The node id will be appended to the multilink.
535 '''
536 cn = self.classname
537 cl = self.db.classes[cn]
539 # possibly perform a create
540 keys = self.form.keys()
541 if [i for i in keys if i[0] != ':']:
542 props = {}
543 try:
544 nid = self._createnode()
545 props = self._post_editnode(nid)
546 cl.set(nid, **props)
547 # and some nice feedback for the user
548 message = _('%(classname)s created ok')%{'classname': cn}
549 except:
550 s = StringIO.StringIO()
551 traceback.print_exc(None, s)
552 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
553 self.pagehead(_('New %(classname)s')%{'classname':
554 self.classname.capitalize()}, message)
556 # call the template
557 newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
558 self.classname)
559 newitem.render(self.form)
561 self.pagefoot()
562 newissue = newnode
563 newuser = newnode
565 def newfile(self, message=None):
566 ''' Add a new file to the database.
568 This form works very much the same way as newnode - it just has a
569 file upload.
570 '''
571 cn = self.classname
572 cl = self.db.classes[cn]
574 # possibly perform a create
575 keys = self.form.keys()
576 if [i for i in keys if i[0] != ':']:
577 try:
578 file = self.form['content']
579 mime_type = mimetypes.guess_type(file.filename)[0]
580 if not mime_type:
581 mime_type = "application/octet-stream"
582 self._post_editnode(cl.create(content=file.file.read(),
583 type=mime_type, name=file.filename))
584 # and some nice feedback for the user
585 message = _('%(classname)s created ok')%{'classname': cn}
586 except:
587 s = StringIO.StringIO()
588 traceback.print_exc(None, s)
589 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
591 self.pagehead(_('New %(classname)s')%{'classname':
592 self.classname.capitalize()}, message)
593 newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
594 self.classname)
595 newitem.render(self.form)
596 self.pagefoot()
598 def classes(self, message=None):
599 ''' display a list of all the classes in the database
600 '''
601 if self.user == 'admin':
602 self.pagehead(_('Table of classes'), message)
603 classnames = self.db.classes.keys()
604 classnames.sort()
605 self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
606 for cn in classnames:
607 cl = self.db.getclass(cn)
608 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
609 for key, value in cl.properties.items():
610 if value is None: value = ''
611 else: value = str(value)
612 self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
613 key, cgi.escape(value)))
614 self.write('</table>')
615 self.pagefoot()
616 else:
617 raise Unauthorised
619 def login(self, message=None, newuser_form=None, action='index'):
620 self.pagehead(_('Login to roundup'), message)
621 self.write(_('''
622 <table>
623 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
624 <form action="login_action" method=POST>
625 <input type="hidden" name="__destination_url" value="%(action)s">
626 <tr><td align=right>Login name: </td>
627 <td><input name="__login_name"></td></tr>
628 <tr><td align=right>Password: </td>
629 <td><input type="password" name="__login_password"></td></tr>
630 <tr><td></td>
631 <td><input type="submit" value="Log In"></td></tr>
632 </form>
633 ''')%locals())
634 if self.user is None and self.ANONYMOUS_REGISTER == 'deny':
635 self.write('</table>')
636 self.pagefoot()
637 return 1
638 values = {'realname': '', 'organisation': '', 'address': '',
639 'phone': '', 'username': '', 'password': '', 'confirm': ''}
640 if newuser_form is not None:
641 for key in newuser_form.keys():
642 values[key] = newuser_form[key].value
643 self.write(_('''
644 <p>
645 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
646 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
647 <form action="newuser_action" method=POST>
648 <tr><td align=right><em>Name: </em></td>
649 <td><input name="realname" value="%(realname)s"></td></tr>
650 <tr><td align=right><em>Organisation: </em></td>
651 <td><input name="organisation" value="%(organisation)s"></td></tr>
652 <tr><td align=right>E-Mail Address: </td>
653 <td><input name="address" value="%(address)s"></td></tr>
654 <tr><td align=right><em>Phone: </em></td>
655 <td><input name="phone" value="%(phone)s"></td></tr>
656 <tr><td align=right>Preferred Login name: </td>
657 <td><input name="username" value="%(username)s"></td></tr>
658 <tr><td align=right>Password: </td>
659 <td><input type="password" name="password" value="%(password)s"></td></tr>
660 <tr><td align=right>Password Again: </td>
661 <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
662 <tr><td></td>
663 <td><input type="submit" value="Register"></td></tr>
664 </form>
665 </table>
666 ''')%values)
667 self.pagefoot()
669 def login_action(self, message=None):
670 if not self.form.has_key('__login_name'):
671 return self.login(message=_('Username required'))
672 self.user = self.form['__login_name'].value
673 if self.form.has_key('__login_password'):
674 password = self.form['__login_password'].value
675 else:
676 password = ''
677 # make sure the user exists
678 try:
679 uid = self.db.user.lookup(self.user)
680 except KeyError:
681 name = self.user
682 self.make_user_anonymous()
683 action = self.form['__destination_url'].value
684 return self.login(message=_('No such user "%(name)s"')%locals(),
685 action=action)
687 # and that the password is correct
688 pw = self.db.user.get(uid, 'password')
689 if password != self.db.user.get(uid, 'password'):
690 self.make_user_anonymous()
691 action = self.form['__destination_url'].value
692 return self.login(message=_('Incorrect password'), action=action)
694 self.set_cookie(self.user, password)
695 return None # make it explicit
697 def set_cookie(self, user, password):
698 # construct the cookie
699 user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
700 if user[-1] == '=':
701 if user[-2] == '=':
702 user = user[:-2]
703 else:
704 user = user[:-1]
705 expire = Cookie._getdate(86400*365)
706 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
707 self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
708 user, expire, path)})
710 def make_user_anonymous(self):
711 # make us anonymous if we can
712 try:
713 self.db.user.lookup('anonymous')
714 self.user = 'anonymous'
715 except KeyError:
716 self.user = None
718 def logout(self, message=None):
719 self.make_user_anonymous()
720 # construct the logout cookie
721 now = Cookie._getdate()
722 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
723 self.header({'Set-Cookie':
724 'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
725 path)})
726 return self.login()
728 def newuser_action(self, message=None):
729 ''' create a new user based on the contents of the form and then
730 set the cookie
731 '''
732 # re-open the database as "admin"
733 self.db.close()
734 self.db = self.instance.open('admin')
736 # TODO: pre-check the required fields and username key property
737 cl = self.db.user
738 try:
739 props, dummy = parsePropsFromForm(self.db, cl, self.form)
740 uid = cl.create(**props)
741 except ValueError, message:
742 return self.login(message, newuser_form=self.form)
743 self.user = cl.get(uid, 'username')
744 password = cl.get(uid, 'password')
745 self.set_cookie(self.user, self.form['password'].value)
746 return None # make the None explicit
748 def main(self):
749 # determine the uid to use
750 self.db = self.instance.open('admin')
751 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
752 user = 'anonymous'
753 if (cookie.has_key('roundup_user') and
754 cookie['roundup_user'].value != 'deleted'):
755 cookie = cookie['roundup_user'].value
756 if len(cookie)%4:
757 cookie = cookie + '='*(4-len(cookie)%4)
758 try:
759 user, password = binascii.a2b_base64(cookie).split(':')
760 except (TypeError, binascii.Error, binascii.Incomplete):
761 # damaged cookie!
762 user, password = 'anonymous', ''
764 # make sure the user exists
765 try:
766 uid = self.db.user.lookup(user)
767 # now validate the password
768 if password != self.db.user.get(uid, 'password'):
769 user = 'anonymous'
770 except KeyError:
771 user = 'anonymous'
773 # make sure the anonymous user is valid if we're using it
774 if user == 'anonymous':
775 self.make_user_anonymous()
776 else:
777 self.user = user
778 self.db.close()
780 # re-open the database for real, using the user
781 self.db = self.instance.open(self.user)
783 # now figure which function to call
784 path = self.split_path
786 # default action to index if the path has no information in it
787 if not path or path[0] in ('', 'index'):
788 action = 'index'
789 else:
790 action = path[0]
792 # Everthing ignores path[1:]
793 # - The file download link generator actually relies on this - it
794 # appends the name of the file to the URL so the download file name
795 # is correct, but doesn't actually use it.
797 # everyone is allowed to try to log in
798 if action == 'login_action':
799 # do the login
800 ret = self.login_action()
801 if ret is not None:
802 return ret
803 # figure the resulting page
804 action = self.form['__destination_url'].value
805 if not action:
806 action = 'index'
807 return self.do_action(action)
809 # allow anonymous people to register
810 if action == 'newuser_action':
811 # if we don't have a login and anonymous people aren't allowed to
812 # register, then spit up the login form
813 if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
814 if action == 'login':
815 return self.login() # go to the index after login
816 else:
817 return self.login(action=action)
818 # add the user
819 ret = self.newuser_action()
820 if ret is not None:
821 return ret
822 # figure the resulting page
823 action = self.form['__destination_url'].value
824 if not action:
825 action = 'index'
826 return self.do_action(action)
828 # no login or registration, make sure totally anonymous access is OK
829 if self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
830 if action == 'login':
831 return self.login() # go to the index after login
832 else:
833 return self.login(action=action)
835 # just a regular action
836 return self.do_action(action)
838 def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
839 nre=re.compile(r'new(\w+)')):
840 # here be the "normal" functionality
841 if action == 'index':
842 return self.index()
843 if action == 'list_classes':
844 return self.classes()
845 if action == 'login':
846 return self.login()
847 if action == 'logout':
848 return self.logout()
849 m = dre.match(action)
850 if m:
851 self.classname = m.group(1)
852 self.nodeid = m.group(2)
853 try:
854 cl = self.db.classes[self.classname]
855 except KeyError:
856 raise NotFound
857 try:
858 cl.get(self.nodeid, 'id')
859 except IndexError:
860 raise NotFound
861 try:
862 func = getattr(self, 'show%s'%self.classname)
863 except AttributeError:
864 raise NotFound
865 return func()
866 m = nre.match(action)
867 if m:
868 self.classname = m.group(1)
869 try:
870 func = getattr(self, 'new%s'%self.classname)
871 except AttributeError:
872 raise NotFound
873 return func()
874 self.classname = action
875 try:
876 self.db.getclass(self.classname)
877 except KeyError:
878 raise NotFound
879 return self.list()
881 def __del__(self):
882 self.db.close()
885 class ExtendedClient(Client):
886 '''Includes pages and page heading information that relate to the
887 extended schema.
888 '''
889 showsupport = Client.shownode
890 showtimelog = Client.shownode
891 newsupport = Client.newnode
892 newtimelog = Client.newnode
894 default_index_sort = ['-activity']
895 default_index_group = ['priority']
896 default_index_filter = ['status']
897 default_index_columns = ['activity','status','title','assignedto']
898 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
900 def pagehead(self, title, message=None):
901 url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
902 machine = self.env['SERVER_NAME']
903 port = self.env['SERVER_PORT']
904 if port != '80': machine = machine + ':' + port
905 base = urlparse.urlunparse(('http', machine, url, None, None, None))
906 if message is not None:
907 message = _('<div class="system-msg">%(message)s</div>')%locals()
908 else:
909 message = ''
910 style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
911 user_name = self.user or ''
912 if self.user == 'admin':
913 admin_links = _(' | <a href="list_classes">Class List</a>' \
914 ' | <a href="user">User List</a>')
915 else:
916 admin_links = ''
917 if self.user not in (None, 'anonymous'):
918 userid = self.db.user.lookup(self.user)
919 user_info = _('''
920 <a href="issue?assignedto=%(userid)s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=-activity&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">My Issues</a> |
921 <a href="support?assignedto=%(userid)s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=-activity&:columns=id,activity,status,title,assignedto&:group=customername&show_customization=1">My Support</a> |
922 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
923 ''')%locals()
924 else:
925 user_info = _('<a href="login">Login</a>')
926 if self.user is not None:
927 add_links = _('''
928 | Add
929 <a href="newissue">Issue</a>,
930 <a href="newsupport">Support</a>,
931 <a href="newuser">User</a>
932 ''')
933 else:
934 add_links = ''
935 self.write(_('''<html><head>
936 <title>%(title)s</title>
937 <style type="text/css">%(style)s</style>
938 </head>
939 <body bgcolor=#ffffff>
940 %(message)s
941 <table width=100%% border=0 cellspacing=0 cellpadding=2>
942 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
943 <td align=right valign=bottom>%(user_name)s</td></tr>
944 <tr class="location-bar">
945 <td align=left>All
946 <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>,
947 <a href="support?status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status&:columns=id,activity,status,title,assignedto&:group=customername&show_customization=1">Support</a>
948 | Unassigned
949 <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>,
950 <a href="support?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=customername&show_customization=1">Support</a>
951 %(add_links)s
952 %(admin_links)s</td>
953 <td align=right>%(user_info)s</td>
954 </table>
955 ''')%locals())
957 def parsePropsFromForm(db, cl, form, nodeid=0):
958 '''Pull properties for the given class out of the form.
959 '''
960 props = {}
961 changed = []
962 keys = form.keys()
963 num_re = re.compile('^\d+$')
964 for key in keys:
965 if not cl.properties.has_key(key):
966 continue
967 proptype = cl.properties[key]
968 if isinstance(proptype, hyperdb.String):
969 value = form[key].value.strip()
970 elif isinstance(proptype, hyperdb.Password):
971 value = password.Password(form[key].value.strip())
972 elif isinstance(proptype, hyperdb.Date):
973 value = date.Date(form[key].value.strip())
974 elif isinstance(proptype, hyperdb.Interval):
975 value = date.Interval(form[key].value.strip())
976 elif isinstance(proptype, hyperdb.Link):
977 value = form[key].value.strip()
978 # see if it's the "no selection" choice
979 if value == '-1':
980 # don't set this property
981 continue
982 else:
983 # handle key values
984 link = cl.properties[key].classname
985 if not num_re.match(value):
986 try:
987 value = db.classes[link].lookup(value)
988 except KeyError:
989 raise ValueError, _('property "%(propname)s": '
990 '%(value)s not a %(classname)s')%{'propname':key,
991 'value': value, 'classname': link}
992 elif isinstance(proptype, hyperdb.Multilink):
993 value = form[key]
994 if type(value) != type([]):
995 value = [i.strip() for i in value.value.split(',')]
996 else:
997 value = [i.value.strip() for i in value]
998 link = cl.properties[key].classname
999 l = []
1000 for entry in map(str, value):
1001 if not num_re.match(entry):
1002 try:
1003 entry = db.classes[link].lookup(entry)
1004 except KeyError:
1005 raise ValueError, _('property "%(propname)s": '
1006 '"%(value)s" not an entry of %(classname)s')%{
1007 'propname':key, 'value': entry, 'classname': link}
1008 l.append(entry)
1009 l.sort()
1010 value = l
1011 props[key] = value
1013 # get the old value
1014 if nodeid:
1015 try:
1016 existing = cl.get(nodeid, key)
1017 except KeyError:
1018 # this might be a new property for which there is no existing
1019 # value
1020 if not cl.properties.has_key(key): raise
1022 # if changed, set it
1023 if nodeid and value != existing:
1024 changed.append(key)
1025 props[key] = value
1026 return props, changed
1028 #
1029 # $Log: not supported by cvs2svn $
1030 # Revision 1.71 2001/11/30 20:28:10 rochecompaan
1031 # Property changes are now completely traceable, whether changes are
1032 # made through the web or by email
1033 #
1034 # Revision 1.70 2001/11/30 00:06:29 richard
1035 # Converted roundup/cgi_client.py to use _()
1036 # Added the status file, I18N_PROGRESS.txt
1037 #
1038 # Revision 1.69 2001/11/29 23:19:51 richard
1039 # Removed the "This issue has been edited through the web" when a valid
1040 # change note is supplied.
1041 #
1042 # Revision 1.68 2001/11/29 04:57:23 richard
1043 # a little comment
1044 #
1045 # Revision 1.67 2001/11/28 21:55:35 richard
1046 # . login_action and newuser_action return values were being ignored
1047 # . Woohoo! Found that bloody re-login bug that was killing the mail
1048 # gateway.
1049 # (also a minor cleanup in hyperdb)
1050 #
1051 # Revision 1.66 2001/11/27 03:00:50 richard
1052 # couple of bugfixes from latest patch integration
1053 #
1054 # Revision 1.65 2001/11/26 23:00:53 richard
1055 # This config stuff is getting to be a real mess...
1056 #
1057 # Revision 1.64 2001/11/26 22:56:35 richard
1058 # typo
1059 #
1060 # Revision 1.63 2001/11/26 22:55:56 richard
1061 # Feature:
1062 # . Added INSTANCE_NAME to configuration - used in web and email to identify
1063 # the instance.
1064 # . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1065 # signature info in e-mails.
1066 # . Some more flexibility in the mail gateway and more error handling.
1067 # . Login now takes you to the page you back to the were denied access to.
1068 #
1069 # Fixed:
1070 # . Lots of bugs, thanks Roché and others on the devel mailing list!
1071 #
1072 # Revision 1.62 2001/11/24 00:45:42 jhermann
1073 # typeof() instead of type(): avoid clash with database field(?) "type"
1074 #
1075 # Fixes this traceback:
1076 #
1077 # Traceback (most recent call last):
1078 # File "roundup\cgi_client.py", line 535, in newnode
1079 # self._post_editnode(nid)
1080 # File "roundup\cgi_client.py", line 415, in _post_editnode
1081 # if type(value) != type([]): value = [value]
1082 # UnboundLocalError: local variable 'type' referenced before assignment
1083 #
1084 # Revision 1.61 2001/11/22 15:46:42 jhermann
1085 # Added module docstrings to all modules.
1086 #
1087 # Revision 1.60 2001/11/21 22:57:28 jhermann
1088 # Added dummy hooks for I18N and some preliminary (test) markup of
1089 # translatable messages
1090 #
1091 # Revision 1.59 2001/11/21 03:21:13 richard
1092 # oops
1093 #
1094 # Revision 1.58 2001/11/21 03:11:28 richard
1095 # Better handling of new properties.
1096 #
1097 # Revision 1.57 2001/11/15 10:24:27 richard
1098 # handle the case where there is no file attached
1099 #
1100 # Revision 1.56 2001/11/14 21:35:21 richard
1101 # . users may attach files to issues (and support in ext) through the web now
1102 #
1103 # Revision 1.55 2001/11/07 02:34:06 jhermann
1104 # Handling of damaged login cookies
1105 #
1106 # Revision 1.54 2001/11/07 01:16:12 richard
1107 # Remove the '=' padding from cookie value so quoting isn't an issue.
1108 #
1109 # Revision 1.53 2001/11/06 23:22:05 jhermann
1110 # More IE fixes: it does not like quotes around cookie values; in the
1111 # hope this does not break anything for other browser; if it does, we
1112 # need to check HTTP_USER_AGENT
1113 #
1114 # Revision 1.52 2001/11/06 23:11:22 jhermann
1115 # Fixed debug output in page footer; added expiry date to the login cookie
1116 # (expires 1 year in the future) to prevent probs with certain versions
1117 # of IE
1118 #
1119 # Revision 1.51 2001/11/06 22:00:34 jhermann
1120 # Get debug level from ROUNDUP_DEBUG env var
1121 #
1122 # Revision 1.50 2001/11/05 23:45:40 richard
1123 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1124 # Also made it present nicer error messages (not tracebacks).
1125 #
1126 # Revision 1.49 2001/11/04 03:07:12 richard
1127 # Fixed various cookie-related bugs:
1128 # . bug #477685 ] base64.decodestring breaks
1129 # . bug #477837 ] lynx does not like the cookie
1130 # . bug #477892 ] Password edit doesn't fix login cookie
1131 # Also closed a security hole - a logged-in user could edit another user's
1132 # details.
1133 #
1134 # Revision 1.48 2001/11/03 01:30:18 richard
1135 # Oops. uses pagefoot now.
1136 #
1137 # Revision 1.47 2001/11/03 01:29:28 richard
1138 # Login page didn't have all close tags.
1139 #
1140 # Revision 1.46 2001/11/03 01:26:55 richard
1141 # possibly fix truncated base64'ed user:pass
1142 #
1143 # Revision 1.45 2001/11/01 22:04:37 richard
1144 # Started work on supporting a pop3-fetching server
1145 # Fixed bugs:
1146 # . bug #477104 ] HTML tag error in roundup-server
1147 # . bug #477107 ] HTTP header problem
1148 #
1149 # Revision 1.44 2001/10/28 23:03:08 richard
1150 # Added more useful header to the classic schema.
1151 #
1152 # Revision 1.43 2001/10/24 00:01:42 richard
1153 # More fixes to lockout logic.
1154 #
1155 # Revision 1.42 2001/10/23 23:56:03 richard
1156 # HTML typo
1157 #
1158 # Revision 1.41 2001/10/23 23:52:35 richard
1159 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1160 #
1161 # Revision 1.40 2001/10/23 23:06:39 richard
1162 # Some cleanup.
1163 #
1164 # Revision 1.39 2001/10/23 01:00:18 richard
1165 # Re-enabled login and registration access after lopping them off via
1166 # disabling access for anonymous users.
1167 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1168 # a couple of bugs while I was there. Probably introduced a couple, but
1169 # things seem to work OK at the moment.
1170 #
1171 # Revision 1.38 2001/10/22 03:25:01 richard
1172 # Added configuration for:
1173 # . anonymous user access and registration (deny/allow)
1174 # . filter "widget" location on index page (top, bottom, both)
1175 # Updated some documentation.
1176 #
1177 # Revision 1.37 2001/10/21 07:26:35 richard
1178 # feature #473127: Filenames. I modified the file.index and htmltemplate
1179 # source so that the filename is used in the link and the creation
1180 # information is displayed.
1181 #
1182 # Revision 1.36 2001/10/21 04:44:50 richard
1183 # bug #473124: UI inconsistency with Link fields.
1184 # This also prompted me to fix a fairly long-standing usability issue -
1185 # that of being able to turn off certain filters.
1186 #
1187 # Revision 1.35 2001/10/21 00:17:54 richard
1188 # CGI interface view customisation section may now be hidden (patch from
1189 # Roch'e Compaan.)
1190 #
1191 # Revision 1.34 2001/10/20 11:58:48 richard
1192 # Catch errors in login - no username or password supplied.
1193 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1194 #
1195 # Revision 1.33 2001/10/17 00:18:41 richard
1196 # Manually constructing cookie headers now.
1197 #
1198 # Revision 1.32 2001/10/16 03:36:21 richard
1199 # CGI interface wasn't handling checkboxes at all.
1200 #
1201 # Revision 1.31 2001/10/14 10:55:00 richard
1202 # Handle empty strings in HTML template Link function
1203 #
1204 # Revision 1.30 2001/10/09 07:38:58 richard
1205 # Pushed the base code for the extended schema CGI interface back into the
1206 # code cgi_client module so that future updates will be less painful.
1207 # Also removed a debugging print statement from cgi_client.
1208 #
1209 # Revision 1.29 2001/10/09 07:25:59 richard
1210 # Added the Password property type. See "pydoc roundup.password" for
1211 # implementation details. Have updated some of the documentation too.
1212 #
1213 # Revision 1.28 2001/10/08 00:34:31 richard
1214 # Change message was stuffing up for multilinks with no key property.
1215 #
1216 # Revision 1.27 2001/10/05 02:23:24 richard
1217 # . roundup-admin create now prompts for property info if none is supplied
1218 # on the command-line.
1219 # . hyperdb Class getprops() method may now return only the mutable
1220 # properties.
1221 # . Login now uses cookies, which makes it a whole lot more flexible. We can
1222 # now support anonymous user access (read-only, unless there's an
1223 # "anonymous" user, in which case write access is permitted). Login
1224 # handling has been moved into cgi_client.Client.main()
1225 # . The "extended" schema is now the default in roundup init.
1226 # . The schemas have had their page headings modified to cope with the new
1227 # login handling. Existing installations should copy the interfaces.py
1228 # file from the roundup lib directory to their instance home.
1229 # . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1230 # Ping - has been removed.
1231 # . Fixed a whole bunch of places in the CGI interface where we should have
1232 # been returning Not Found instead of throwing an exception.
1233 # . Fixed a deviation from the spec: trying to modify the 'id' property of
1234 # an item now throws an exception.
1235 #
1236 # Revision 1.26 2001/09/12 08:31:42 richard
1237 # handle cases where mime type is not guessable
1238 #
1239 # Revision 1.25 2001/08/29 05:30:49 richard
1240 # change messages weren't being saved when there was no-one on the nosy list.
1241 #
1242 # Revision 1.24 2001/08/29 04:49:39 richard
1243 # didn't clean up fully after debugging :(
1244 #
1245 # Revision 1.23 2001/08/29 04:47:18 richard
1246 # Fixed CGI client change messages so they actually include the properties
1247 # changed (again).
1248 #
1249 # Revision 1.22 2001/08/17 00:08:10 richard
1250 # reverted back to sending messages always regardless of who is doing the web
1251 # edit. change notes weren't being saved. bleah. hackish.
1252 #
1253 # Revision 1.21 2001/08/15 23:43:18 richard
1254 # Fixed some isFooTypes that I missed.
1255 # Refactored some code in the CGI code.
1256 #
1257 # Revision 1.20 2001/08/12 06:32:36 richard
1258 # using isinstance(blah, Foo) now instead of isFooType
1259 #
1260 # Revision 1.19 2001/08/07 00:24:42 richard
1261 # stupid typo
1262 #
1263 # Revision 1.18 2001/08/07 00:15:51 richard
1264 # Added the copyright/license notice to (nearly) all files at request of
1265 # Bizar Software.
1266 #
1267 # Revision 1.17 2001/08/02 06:38:17 richard
1268 # Roundupdb now appends "mailing list" information to its messages which
1269 # include the e-mail address and web interface address. Templates may
1270 # override this in their db classes to include specific information (support
1271 # instructions, etc).
1272 #
1273 # Revision 1.16 2001/08/02 05:55:25 richard
1274 # Web edit messages aren't sent to the person who did the edit any more. No
1275 # message is generated if they are the only person on the nosy list.
1276 #
1277 # Revision 1.15 2001/08/02 00:34:10 richard
1278 # bleah syntax error
1279 #
1280 # Revision 1.14 2001/08/02 00:26:16 richard
1281 # Changed the order of the information in the message generated by web edits.
1282 #
1283 # Revision 1.13 2001/07/30 08:12:17 richard
1284 # Added time logging and file uploading to the templates.
1285 #
1286 # Revision 1.12 2001/07/30 06:26:31 richard
1287 # Added some documentation on how the newblah works.
1288 #
1289 # Revision 1.11 2001/07/30 06:17:45 richard
1290 # Features:
1291 # . Added ability for cgi newblah forms to indicate that the new node
1292 # should be linked somewhere.
1293 # Fixed:
1294 # . Fixed the agument handling for the roundup-admin find command.
1295 # . Fixed handling of summary when no note supplied for newblah. Again.
1296 # . Fixed detection of no form in htmltemplate Field display.
1297 #
1298 # Revision 1.10 2001/07/30 02:37:34 richard
1299 # Temporary measure until we have decent schema migration...
1300 #
1301 # Revision 1.9 2001/07/30 01:25:07 richard
1302 # Default implementation is now "classic" rather than "extended" as one would
1303 # expect.
1304 #
1305 # Revision 1.8 2001/07/29 08:27:40 richard
1306 # Fixed handling of passed-in values in form elements (ie. during a
1307 # drill-down)
1308 #
1309 # Revision 1.7 2001/07/29 07:01:39 richard
1310 # Added vim command to all source so that we don't get no steenkin' tabs :)
1311 #
1312 # Revision 1.6 2001/07/29 04:04:00 richard
1313 # Moved some code around allowing for subclassing to change behaviour.
1314 #
1315 # Revision 1.5 2001/07/28 08:16:52 richard
1316 # New issue form handles lack of note better now.
1317 #
1318 # Revision 1.4 2001/07/28 00:34:34 richard
1319 # Fixed some non-string node ids.
1320 #
1321 # Revision 1.3 2001/07/23 03:56:30 richard
1322 # oops, missed a config removal
1323 #
1324 # Revision 1.2 2001/07/22 12:09:32 richard
1325 # Final commit of Grande Splite
1326 #
1327 # Revision 1.1 2001/07/22 11:58:35 richard
1328 # More Grande Splite
1329 #
1330 #
1331 # vim: set filetype=python ts=4 sw=4 et si