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.90 2002-01-08 03:56:55 richard 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, random
27 import roundupdb, htmltemplate, date, hyperdb, password
28 from roundup.i18n import _
30 class Unauthorised(ValueError):
31 pass
33 class NotFound(ValueError):
34 pass
36 class Client:
37 '''
38 A note about login
39 ------------------
41 If the user has no login cookie, then they are anonymous. There
42 are two levels of anonymous use. If there is no 'anonymous' user, there
43 is no login at all and the database is opened in read-only mode. If the
44 'anonymous' user exists, the user is logged in using that user (though
45 there is no cookie). This allows them to modify the database, and all
46 modifications are attributed to the 'anonymous' user.
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, form=None):
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 if form is None:
71 self.form = cgi.FieldStorage(environ=env)
72 else:
73 self.form = form
74 self.headers_done = 0
75 try:
76 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
77 except ValueError:
78 # someone gave us a non-int debug level, turn it off
79 self.debug = 0
81 def getuid(self):
82 return self.db.user.lookup(self.user)
84 def header(self, headers={'Content-Type':'text/html'}):
85 '''Put up the appropriate header.
86 '''
87 if not headers.has_key('Content-Type'):
88 headers['Content-Type'] = 'text/html'
89 self.request.send_response(200)
90 for entry in headers.items():
91 self.request.send_header(*entry)
92 self.request.end_headers()
93 self.headers_done = 1
94 if self.debug:
95 self.headers_sent = headers
97 def pagehead(self, title, message=None):
98 url = self.env['SCRIPT_NAME'] + '/'
99 machine = self.env['SERVER_NAME']
100 port = self.env['SERVER_PORT']
101 if port != '80': machine = machine + ':' + port
102 base = urlparse.urlunparse(('http', machine, url, None, None, None))
103 if message is not None:
104 message = _('<div class="system-msg">%(message)s</div>')%locals()
105 else:
106 message = ''
107 style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
108 user_name = self.user or ''
109 if self.user == 'admin':
110 admin_links = _(' | <a href="list_classes">Class List</a>' \
111 ' | <a href="user">User List</a>' \
112 ' | <a href="newuser">Add User</a>')
113 else:
114 admin_links = ''
115 if self.user not in (None, 'anonymous'):
116 userid = self.db.user.lookup(self.user)
117 user_info = _('''
118 <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> |
119 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
120 ''')%locals()
121 else:
122 user_info = _('<a href="login">Login</a>')
123 if self.user is not None:
124 add_links = _('''
125 | Add
126 <a href="newissue">Issue</a>
127 ''')
128 else:
129 add_links = ''
130 self.write(_('''<html><head>
131 <title>%(title)s</title>
132 <style type="text/css">%(style)s</style>
133 </head>
134 <body bgcolor=#ffffff>
135 %(message)s
136 <table width=100%% border=0 cellspacing=0 cellpadding=2>
137 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
138 <td align=right valign=bottom>%(user_name)s</td></tr>
139 <tr class="location-bar">
140 <td align=left>All
141 <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>
142 | Unassigned
143 <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>
144 %(add_links)s
145 %(admin_links)s</td>
146 <td align=right>%(user_info)s</td>
147 </table>
148 ''')%locals())
150 def pagefoot(self):
151 if self.debug:
152 self.write(_('<hr><small><dl><dt><b>Path</b></dt>'))
153 self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
154 keys = self.form.keys()
155 keys.sort()
156 if keys:
157 self.write(_('<dt><b>Form entries</b></dt>'))
158 for k in self.form.keys():
159 v = self.form.getvalue(k, "<empty>")
160 if type(v) is type([]):
161 # Multiple username fields specified
162 v = "|".join(v)
163 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
164 keys = self.headers_sent.keys()
165 keys.sort()
166 self.write(_('<dt><b>Sent these HTTP headers</b></dt>'))
167 for k in keys:
168 v = self.headers_sent[k]
169 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
170 keys = self.env.keys()
171 keys.sort()
172 self.write(_('<dt><b>CGI environment</b></dt>'))
173 for k in keys:
174 v = self.env[k]
175 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
176 self.write('</dl></small>')
177 self.write('</body></html>')
179 def write(self, content):
180 if not self.headers_done:
181 self.header()
182 self.request.wfile.write(content)
184 def index_arg(self, arg):
185 ''' handle the args to index - they might be a list from the form
186 (ie. submitted from a form) or they might be a command-separated
187 single string (ie. manually constructed GET args)
188 '''
189 if self.form.has_key(arg):
190 arg = self.form[arg]
191 if type(arg) == type([]):
192 return [arg.value for arg in arg]
193 return arg.value.split(',')
194 return []
196 def index_filterspec(self, filter):
197 ''' pull the index filter spec from the form
199 Links and multilinks want to be lists - the rest are straight
200 strings.
201 '''
202 props = self.db.classes[self.classname].getprops()
203 # all the form args not starting with ':' are filters
204 filterspec = {}
205 for key in self.form.keys():
206 if key[0] == ':': continue
207 if not props.has_key(key): continue
208 if key not in filter: continue
209 prop = props[key]
210 value = self.form[key]
211 if (isinstance(prop, hyperdb.Link) or
212 isinstance(prop, hyperdb.Multilink)):
213 if type(value) == type([]):
214 value = [arg.value for arg in value]
215 else:
216 value = value.value.split(',')
217 l = filterspec.get(key, [])
218 l = l + value
219 filterspec[key] = l
220 else:
221 filterspec[key] = value.value
222 return filterspec
224 def customization_widget(self):
225 ''' The customization widget is visible by default. The widget
226 visibility is remembered by show_customization. Visibility
227 is not toggled if the action value is "Redisplay"
228 '''
229 if not self.form.has_key('show_customization'):
230 visible = 1
231 else:
232 visible = int(self.form['show_customization'].value)
233 if self.form.has_key('action'):
234 if self.form['action'].value != 'Redisplay':
235 visible = self.form['action'].value == '+'
237 return visible
239 default_index_sort = ['-activity']
240 default_index_group = ['priority']
241 default_index_filter = ['status']
242 default_index_columns = ['id','activity','title','status','assignedto']
243 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
244 def index(self):
245 ''' put up an index
246 '''
247 self.classname = 'issue'
248 # see if the web has supplied us with any customisation info
249 defaults = 1
250 for key in ':sort', ':group', ':filter', ':columns':
251 if self.form.has_key(key):
252 defaults = 0
253 break
254 if defaults:
255 # no info supplied - use the defaults
256 sort = self.default_index_sort
257 group = self.default_index_group
258 filter = self.default_index_filter
259 columns = self.default_index_columns
260 filterspec = self.default_index_filterspec
261 else:
262 sort = self.index_arg(':sort')
263 group = self.index_arg(':group')
264 filter = self.index_arg(':filter')
265 columns = self.index_arg(':columns')
266 filterspec = self.index_filterspec(filter)
267 return self.list(columns=columns, filter=filter, group=group,
268 sort=sort, filterspec=filterspec)
270 # XXX deviates from spec - loses the '+' (that's a reserved character
271 # in URLS
272 def list(self, sort=None, group=None, filter=None, columns=None,
273 filterspec=None, show_customization=None):
274 ''' call the template index with the args
276 :sort - sort by prop name, optionally preceeded with '-'
277 to give descending or nothing for ascending sorting.
278 :group - group by prop name, optionally preceeded with '-' or
279 to sort in descending or nothing for ascending order.
280 :filter - selects which props should be displayed in the filter
281 section. Default is all.
282 :columns - selects the columns that should be displayed.
283 Default is all.
285 '''
286 cn = self.classname
287 cl = self.db.classes[cn]
288 self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
289 'classname': cn, 'instancename': self.INSTANCE_NAME})
290 if sort is None: sort = self.index_arg(':sort')
291 if group is None: group = self.index_arg(':group')
292 if filter is None: filter = self.index_arg(':filter')
293 if columns is None: columns = self.index_arg(':columns')
294 if filterspec is None: filterspec = self.index_filterspec(filter)
295 if show_customization is None:
296 show_customization = self.customization_widget()
298 index = htmltemplate.IndexTemplate(self, self.TEMPLATES, cn)
299 index.render(filterspec, filter, columns, sort, group,
300 show_customization=show_customization)
301 self.pagefoot()
303 def shownode(self, message=None):
304 ''' display an item
305 '''
306 cn = self.classname
307 cl = self.db.classes[cn]
309 # possibly perform an edit
310 keys = self.form.keys()
311 num_re = re.compile('^\d+$')
312 # don't try to set properties if the user has just logged in
313 if keys and not self.form.has_key('__login_name'):
314 try:
315 props, changed = parsePropsFromForm(self.db, cl, self.form,
316 self.nodeid)
317 # make changes to the node
318 self._changenode(props)
319 # handle linked nodes
320 self._post_editnode(self.nodeid)
321 # and some nice feedback for the user
322 if changed:
323 message = _('%(changes)s edited ok')%{'changes':
324 ', '.join(changed.keys())}
325 elif self.form.has_key('__note') and self.form['__note'].value:
326 message = _('note added')
327 elif self.form.has_key('__file'):
328 message = _('file added')
329 else:
330 message = _('nothing changed')
331 except:
332 self.db.rollback()
333 s = StringIO.StringIO()
334 traceback.print_exc(None, s)
335 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
337 # now the display
338 id = self.nodeid
339 if cl.getkey():
340 id = cl.get(id, cl.getkey())
341 self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
343 nodeid = self.nodeid
345 # use the template to display the item
346 item = htmltemplate.ItemTemplate(self, self.TEMPLATES, self.classname)
347 item.render(nodeid)
349 self.pagefoot()
350 showissue = shownode
351 showmsg = shownode
353 def _add_assignedto_to_nosy(self, props):
354 ''' add the assignedto value from the props to the nosy list
355 '''
356 if not props.has_key('assignedto'):
357 return
358 assignedto_id = props['assignedto']
359 if props.has_key('nosy') and assignedto_id not in props['nosy']:
360 props['nosy'].append(assignedto_id)
361 else:
362 props['nosy'] = cl.get(self.nodeid, 'nosy')
363 props['nosy'].append(assignedto_id)
365 def _changenode(self, props):
366 ''' change the node based on the contents of the form
367 '''
368 cl = self.db.classes[self.classname]
369 # set status to chatting if 'unread' or 'resolved'
370 try:
371 # determine the id of 'unread','resolved' and 'chatting'
372 unread_id = self.db.status.lookup('unread')
373 resolved_id = self.db.status.lookup('resolved')
374 chatting_id = self.db.status.lookup('chatting')
375 current_status = cl.get(self.nodeid, 'status')
376 if props.has_key('status'):
377 new_status = props['status']
378 else:
379 # apparently there's a chance that some browsers don't
380 # send status...
381 new_status = current_status
382 except KeyError:
383 pass
384 else:
385 if new_status == unread_id or (new_status == resolved_id
386 and current_status == resolved_id):
387 props['status'] = chatting_id
389 self._add_assignedto_to_nosy(props)
391 # create the message
392 message, files = self._handle_message()
393 if message:
394 props['messages'] = cl.get(self.nodeid, 'messages') + [message]
395 if files:
396 props['files'] = cl.get(self.nodeid, 'files') + files
397 # make the changes
398 cl.set(self.nodeid, **props)
400 def _createnode(self):
401 ''' create a node based on the contents of the form
402 '''
403 cl = self.db.classes[self.classname]
404 props, dummy = parsePropsFromForm(self.db, cl, self.form)
406 # set status to 'unread' if not specified - a status of '- no
407 # selection -' doesn't make sense
408 if not props.has_key('status'):
409 try:
410 unread_id = self.db.status.lookup('unread')
411 except KeyError:
412 pass
413 else:
414 props['status'] = unread_id
416 self._add_assignedto_to_nosy(props)
418 # check for messages and files
419 message, files = self._handle_message()
420 if message:
421 props['messages'] = [message]
422 if files:
423 props['files'] = files
424 # create the node and return it's id
425 return cl.create(**props)
427 def _handle_message(self):
428 ''' generate and edit message
429 '''
430 # handle file attachments
431 files = []
432 if self.form.has_key('__file'):
433 file = self.form['__file']
434 if file.filename:
435 filename = file.filename.split('\\')[-1]
436 mime_type = mimetypes.guess_type(filename)[0]
437 if not mime_type:
438 mime_type = "application/octet-stream"
439 # create the new file entry
440 files.append(self.db.file.create(type=mime_type,
441 name=filename, content=file.file.read()))
443 # we don't want to do a message if none of the following is true...
444 cn = self.classname
445 cl = self.db.classes[self.classname]
446 props = cl.getprops()
447 note = None
448 # in a nutshell, don't do anything if there's no note or there's no
449 # NOSY
450 if self.form.has_key('__note'):
451 note = self.form['__note'].value
452 if not props.has_key('messages'):
453 return None, files
454 if not isinstance(props['messages'], hyperdb.Multilink):
455 return None, files
456 if not props['messages'].classname == 'msg':
457 return None, files
458 if not (self.form.has_key('nosy') or note):
459 return None, files
461 # handle the note
462 if note:
463 if '\n' in note:
464 summary = re.split(r'\n\r?', note)[0]
465 else:
466 summary = note
467 m = ['%s\n'%note]
468 elif not files:
469 # don't generate a useless message
470 return None, files
472 # handle the messageid
473 # TODO: handle inreplyto
474 messageid = "%s.%s.%s-%s"%(time.time(), random.random(),
475 self.classname, self.MAIL_DOMAIN)
477 # now create the message, attaching the files
478 content = '\n'.join(m)
479 message_id = self.db.msg.create(author=self.getuid(),
480 recipients=[], date=date.Date('.'), summary=summary,
481 content=content, files=files, messageid=messageid)
483 # update the messages property
484 return message_id, files
486 def _post_editnode(self, nid):
487 '''Do the linking part of the node creation.
489 If a form element has :link or :multilink appended to it, its
490 value specifies a node designator and the property on that node
491 to add _this_ node to as a link or multilink.
493 This is typically used on, eg. the file upload page to indicated
494 which issue to link the file to.
496 TODO: I suspect that this and newfile will go away now that
497 there's the ability to upload a file using the issue __file form
498 element!
499 '''
500 cn = self.classname
501 cl = self.db.classes[cn]
502 # link if necessary
503 keys = self.form.keys()
504 for key in keys:
505 if key == ':multilink':
506 value = self.form[key].value
507 if type(value) != type([]): value = [value]
508 for value in value:
509 designator, property = value.split(':')
510 link, nodeid = roundupdb.splitDesignator(designator)
511 link = self.db.classes[link]
512 value = link.get(nodeid, property)
513 value.append(nid)
514 link.set(nodeid, **{property: value})
515 elif key == ':link':
516 value = self.form[key].value
517 if type(value) != type([]): value = [value]
518 for value in value:
519 designator, property = value.split(':')
520 link, nodeid = roundupdb.splitDesignator(designator)
521 link = self.db.classes[link]
522 link.set(nodeid, **{property: nid})
524 def newnode(self, message=None):
525 ''' Add a new node to the database.
527 The form works in two modes: blank form and submission (that is,
528 the submission goes to the same URL). **Eventually this means that
529 the form will have previously entered information in it if
530 submission fails.
532 The new node will be created with the properties specified in the
533 form submission. For multilinks, multiple form entries are handled,
534 as are prop=value,value,value. You can't mix them though.
536 If the new node is to be referenced from somewhere else immediately
537 (ie. the new node is a file that is to be attached to a support
538 issue) then supply one of these arguments in addition to the usual
539 form entries:
540 :link=designator:property
541 :multilink=designator:property
542 ... which means that once the new node is created, the "property"
543 on the node given by "designator" should now reference the new
544 node's id. The node id will be appended to the multilink.
545 '''
546 cn = self.classname
547 cl = self.db.classes[cn]
549 # possibly perform a create
550 keys = self.form.keys()
551 if [i for i in keys if i[0] != ':']:
552 props = {}
553 try:
554 nid = self._createnode()
555 # handle linked nodes
556 self._post_editnode(nid)
557 # and some nice feedback for the user
558 message = _('%(classname)s created ok')%{'classname': cn}
560 # render the newly created issue
561 self.db.commit()
562 self.nodeid = nid
563 self.pagehead('%s: %s'%(self.classname.capitalize(), nid),
564 message)
565 item = htmltemplate.ItemTemplate(self, self.TEMPLATES,
566 self.classname)
567 item.render(nid)
568 self.pagefoot()
569 return
570 except:
571 self.db.rollback()
572 s = StringIO.StringIO()
573 traceback.print_exc(None, s)
574 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
575 self.pagehead(_('New %(classname)s')%{'classname':
576 self.classname.capitalize()}, message)
578 # call the template
579 newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
580 self.classname)
581 newitem.render(self.form)
583 self.pagefoot()
584 newissue = newnode
586 def newuser(self, message=None):
587 ''' Add a new user to the database.
589 Don't do any of the message or file handling, just create the node.
590 '''
591 cn = self.classname
592 cl = self.db.classes[cn]
594 # possibly perform a create
595 keys = self.form.keys()
596 if [i for i in keys if i[0] != ':']:
597 try:
598 props, dummy = parsePropsFromForm(self.db, cl, self.form)
599 nid = cl.create(**props)
600 # handle linked nodes
601 self._post_editnode(nid)
602 # and some nice feedback for the user
603 message = _('%(classname)s created ok')%{'classname': cn}
604 except:
605 self.db.rollback()
606 s = StringIO.StringIO()
607 traceback.print_exc(None, s)
608 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
609 self.pagehead(_('New %(classname)s')%{'classname':
610 self.classname.capitalize()}, message)
612 # call the template
613 newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
614 self.classname)
615 newitem.render(self.form)
617 self.pagefoot()
619 def newfile(self, message=None):
620 ''' Add a new file to the database.
622 This form works very much the same way as newnode - it just has a
623 file upload.
624 '''
625 cn = self.classname
626 cl = self.db.classes[cn]
628 # possibly perform a create
629 keys = self.form.keys()
630 if [i for i in keys if i[0] != ':']:
631 try:
632 file = self.form['content']
633 mime_type = mimetypes.guess_type(file.filename)[0]
634 if not mime_type:
635 mime_type = "application/octet-stream"
636 # save the file
637 nid = cl.create(content=file.file.read(), type=mime_type,
638 name=file.filename)
639 # handle linked nodes
640 self._post_editnode(nid)
641 # and some nice feedback for the user
642 message = _('%(classname)s created ok')%{'classname': cn}
643 except:
644 self.db.rollback()
645 s = StringIO.StringIO()
646 traceback.print_exc(None, s)
647 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
649 self.pagehead(_('New %(classname)s')%{'classname':
650 self.classname.capitalize()}, message)
651 newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
652 self.classname)
653 newitem.render(self.form)
654 self.pagefoot()
656 def showuser(self, message=None):
657 '''Display a user page for editing. Make sure the user is allowed
658 to edit this node, and also check for password changes.
659 '''
660 if self.user == 'anonymous':
661 raise Unauthorised
663 user = self.db.user
665 # get the username of the node being edited
666 node_user = user.get(self.nodeid, 'username')
668 if self.user not in ('admin', node_user):
669 raise Unauthorised
671 #
672 # perform any editing
673 #
674 keys = self.form.keys()
675 num_re = re.compile('^\d+$')
676 if keys:
677 try:
678 props, changed = parsePropsFromForm(self.db, user, self.form,
679 self.nodeid)
680 set_cookie = 0
681 if self.nodeid == self.getuid() and changed.has_key('password'):
682 password = self.form['password'].value.strip()
683 if password:
684 set_cookie = password
685 else:
686 # no password was supplied - don't change it
687 del props['password']
688 del changed['password']
689 user.set(self.nodeid, **props)
690 # and some feedback for the user
691 message = _('%(changes)s edited ok')%{'changes':
692 ', '.join(changed.keys())}
693 except:
694 self.db.rollback()
695 s = StringIO.StringIO()
696 traceback.print_exc(None, s)
697 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
698 else:
699 set_cookie = 0
701 # fix the cookie if the password has changed
702 if set_cookie:
703 self.set_cookie(self.user, set_cookie)
705 #
706 # now the display
707 #
708 self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
710 # use the template to display the item
711 item = htmltemplate.ItemTemplate(self, self.TEMPLATES, 'user')
712 item.render(self.nodeid)
713 self.pagefoot()
715 def showfile(self):
716 ''' display a file
717 '''
718 nodeid = self.nodeid
719 cl = self.db.file
720 mime_type = cl.get(nodeid, 'type')
721 if mime_type == 'message/rfc822':
722 mime_type = 'text/plain'
723 self.header(headers={'Content-Type': mime_type})
724 self.write(cl.get(nodeid, 'content'))
726 def classes(self, message=None):
727 ''' display a list of all the classes in the database
728 '''
729 if self.user == 'admin':
730 self.pagehead(_('Table of classes'), message)
731 classnames = self.db.classes.keys()
732 classnames.sort()
733 self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
734 for cn in classnames:
735 cl = self.db.getclass(cn)
736 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
737 for key, value in cl.properties.items():
738 if value is None: value = ''
739 else: value = str(value)
740 self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
741 key, cgi.escape(value)))
742 self.write('</table>')
743 self.pagefoot()
744 else:
745 raise Unauthorised
747 def login(self, message=None, newuser_form=None, action='index'):
748 '''Display a login page.
749 '''
750 self.pagehead(_('Login to roundup'), message)
751 self.write(_('''
752 <table>
753 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
754 <form action="login_action" method=POST>
755 <input type="hidden" name="__destination_url" value="%(action)s">
756 <tr><td align=right>Login name: </td>
757 <td><input name="__login_name"></td></tr>
758 <tr><td align=right>Password: </td>
759 <td><input type="password" name="__login_password"></td></tr>
760 <tr><td></td>
761 <td><input type="submit" value="Log In"></td></tr>
762 </form>
763 ''')%locals())
764 if self.user is None and self.ANONYMOUS_REGISTER == 'deny':
765 self.write('</table>')
766 self.pagefoot()
767 return
768 values = {'realname': '', 'organisation': '', 'address': '',
769 'phone': '', 'username': '', 'password': '', 'confirm': '',
770 'action': action}
771 if newuser_form is not None:
772 for key in newuser_form.keys():
773 values[key] = newuser_form[key].value
774 self.write(_('''
775 <p>
776 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
777 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
778 <form action="newuser_action" method=POST>
779 <input type="hidden" name="__destination_url" value="%(action)s">
780 <tr><td align=right><em>Name: </em></td>
781 <td><input name="realname" value="%(realname)s"></td></tr>
782 <tr><td align=right><em>Organisation: </em></td>
783 <td><input name="organisation" value="%(organisation)s"></td></tr>
784 <tr><td align=right>E-Mail Address: </td>
785 <td><input name="address" value="%(address)s"></td></tr>
786 <tr><td align=right><em>Phone: </em></td>
787 <td><input name="phone" value="%(phone)s"></td></tr>
788 <tr><td align=right>Preferred Login name: </td>
789 <td><input name="username" value="%(username)s"></td></tr>
790 <tr><td align=right>Password: </td>
791 <td><input type="password" name="password" value="%(password)s"></td></tr>
792 <tr><td align=right>Password Again: </td>
793 <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
794 <tr><td></td>
795 <td><input type="submit" value="Register"></td></tr>
796 </form>
797 </table>
798 ''')%values)
799 self.pagefoot()
801 def login_action(self, message=None):
802 '''Attempt to log a user in and set the cookie
804 returns 0 if a page is generated as a result of this call, and
805 1 if not (ie. the login is successful
806 '''
807 if not self.form.has_key('__login_name'):
808 self.login(message=_('Username required'))
809 return 0
810 self.user = self.form['__login_name'].value
811 if self.form.has_key('__login_password'):
812 password = self.form['__login_password'].value
813 else:
814 password = ''
815 # make sure the user exists
816 try:
817 uid = self.db.user.lookup(self.user)
818 except KeyError:
819 name = self.user
820 self.make_user_anonymous()
821 action = self.form['__destination_url'].value
822 self.login(message=_('No such user "%(name)s"')%locals(),
823 action=action)
824 return 0
826 # and that the password is correct
827 pw = self.db.user.get(uid, 'password')
828 if password != pw:
829 self.make_user_anonymous()
830 action = self.form['__destination_url'].value
831 self.login(message=_('Incorrect password'), action=action)
832 return 0
834 self.set_cookie(self.user, password)
835 return 1
837 def newuser_action(self, message=None):
838 '''Attempt to create a new user based on the contents of the form
839 and then set the cookie.
841 return 1 on successful login
842 '''
843 # re-open the database as "admin"
844 self.db = self.instance.open('admin')
846 # TODO: pre-check the required fields and username key property
847 cl = self.db.user
848 try:
849 props, dummy = parsePropsFromForm(self.db, cl, self.form)
850 uid = cl.create(**props)
851 except ValueError, message:
852 action = self.form['__destination_url'].value
853 self.login(message, action=action)
854 return 0
855 self.user = cl.get(uid, 'username')
856 password = cl.get(uid, 'password')
857 self.set_cookie(self.user, self.form['password'].value)
858 return 1
860 def set_cookie(self, user, password):
861 # construct the cookie
862 user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
863 if user[-1] == '=':
864 if user[-2] == '=':
865 user = user[:-2]
866 else:
867 user = user[:-1]
868 expire = Cookie._getdate(86400*365)
869 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
870 self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
871 user, expire, path)})
873 def make_user_anonymous(self):
874 # make us anonymous if we can
875 try:
876 self.db.user.lookup('anonymous')
877 self.user = 'anonymous'
878 except KeyError:
879 self.user = None
881 def logout(self, message=None):
882 self.make_user_anonymous()
883 # construct the logout cookie
884 now = Cookie._getdate()
885 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
886 self.header({'Set-Cookie':
887 'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
888 path)})
889 self.login()
892 def main(self):
893 '''Wrap the database accesses so we can close the database cleanly
894 '''
895 # determine the uid to use
896 self.db = self.instance.open('admin')
897 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
898 user = 'anonymous'
899 if (cookie.has_key('roundup_user') and
900 cookie['roundup_user'].value != 'deleted'):
901 cookie = cookie['roundup_user'].value
902 if len(cookie)%4:
903 cookie = cookie + '='*(4-len(cookie)%4)
904 try:
905 user, password = binascii.a2b_base64(cookie).split(':')
906 except (TypeError, binascii.Error, binascii.Incomplete):
907 # damaged cookie!
908 user, password = 'anonymous', ''
910 # make sure the user exists
911 try:
912 uid = self.db.user.lookup(user)
913 # now validate the password
914 if password != self.db.user.get(uid, 'password'):
915 user = 'anonymous'
916 except KeyError:
917 user = 'anonymous'
919 # make sure the anonymous user is valid if we're using it
920 if user == 'anonymous':
921 self.make_user_anonymous()
922 else:
923 self.user = user
925 # re-open the database for real, using the user
926 self.db = self.instance.open(self.user)
928 # now figure which function to call
929 path = self.split_path
931 # default action to index if the path has no information in it
932 if not path or path[0] in ('', 'index'):
933 action = 'index'
934 else:
935 action = path[0]
937 # Everthing ignores path[1:]
938 # - The file download link generator actually relies on this - it
939 # appends the name of the file to the URL so the download file name
940 # is correct, but doesn't actually use it.
942 # everyone is allowed to try to log in
943 if action == 'login_action':
944 # try to login
945 if not self.login_action():
946 return
947 # figure the resulting page
948 action = self.form['__destination_url'].value
949 if not action:
950 action = 'index'
951 self.do_action(action)
952 return
954 # allow anonymous people to register
955 if action == 'newuser_action':
956 # if we don't have a login and anonymous people aren't allowed to
957 # register, then spit up the login form
958 if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
959 if action == 'login':
960 self.login() # go to the index after login
961 else:
962 self.login(action=action)
963 return
964 # try to add the user
965 if not self.newuser_action():
966 return
967 # figure the resulting page
968 action = self.form['__destination_url'].value
969 if not action:
970 action = 'index'
972 # no login or registration, make sure totally anonymous access is OK
973 elif self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
974 if action == 'login':
975 self.login() # go to the index after login
976 else:
977 self.login(action=action)
978 return
980 # just a regular action
981 self.do_action(action)
983 # commit all changes to the database
984 self.db.commit()
986 def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
987 nre=re.compile(r'new(\w+)')):
988 '''Figure the user's action and do it.
989 '''
990 # here be the "normal" functionality
991 if action == 'index':
992 self.index()
993 return
994 if action == 'list_classes':
995 self.classes()
996 return
997 if action == 'login':
998 self.login()
999 return
1000 if action == 'logout':
1001 self.logout()
1002 return
1003 m = dre.match(action)
1004 if m:
1005 self.classname = m.group(1)
1006 self.nodeid = m.group(2)
1007 try:
1008 cl = self.db.classes[self.classname]
1009 except KeyError:
1010 raise NotFound
1011 try:
1012 cl.get(self.nodeid, 'id')
1013 except IndexError:
1014 raise NotFound
1015 try:
1016 func = getattr(self, 'show%s'%self.classname)
1017 except AttributeError:
1018 raise NotFound
1019 func()
1020 return
1021 m = nre.match(action)
1022 if m:
1023 self.classname = m.group(1)
1024 try:
1025 func = getattr(self, 'new%s'%self.classname)
1026 except AttributeError:
1027 raise NotFound
1028 func()
1029 return
1030 self.classname = action
1031 try:
1032 self.db.getclass(self.classname)
1033 except KeyError:
1034 raise NotFound
1035 self.list()
1038 class ExtendedClient(Client):
1039 '''Includes pages and page heading information that relate to the
1040 extended schema.
1041 '''
1042 showsupport = Client.shownode
1043 showtimelog = Client.shownode
1044 newsupport = Client.newnode
1045 newtimelog = Client.newnode
1047 default_index_sort = ['-activity']
1048 default_index_group = ['priority']
1049 default_index_filter = ['status']
1050 default_index_columns = ['activity','status','title','assignedto']
1051 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
1053 def pagehead(self, title, message=None):
1054 url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
1055 machine = self.env['SERVER_NAME']
1056 port = self.env['SERVER_PORT']
1057 if port != '80': machine = machine + ':' + port
1058 base = urlparse.urlunparse(('http', machine, url, None, None, None))
1059 if message is not None:
1060 message = _('<div class="system-msg">%(message)s</div>')%locals()
1061 else:
1062 message = ''
1063 style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
1064 user_name = self.user or ''
1065 if self.user == 'admin':
1066 admin_links = _(' | <a href="list_classes">Class List</a>' \
1067 ' | <a href="user">User List</a>' \
1068 ' | <a href="newuser">Add User</a>')
1069 else:
1070 admin_links = ''
1071 if self.user not in (None, 'anonymous'):
1072 userid = self.db.user.lookup(self.user)
1073 user_info = _('''
1074 <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> |
1075 <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> |
1076 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
1077 ''')%locals()
1078 else:
1079 user_info = _('<a href="login">Login</a>')
1080 if self.user is not None:
1081 add_links = _('''
1082 | Add
1083 <a href="newissue">Issue</a>,
1084 <a href="newsupport">Support</a>,
1085 ''')
1086 else:
1087 add_links = ''
1088 self.write(_('''<html><head>
1089 <title>%(title)s</title>
1090 <style type="text/css">%(style)s</style>
1091 </head>
1092 <body bgcolor=#ffffff>
1093 %(message)s
1094 <table width=100%% border=0 cellspacing=0 cellpadding=2>
1095 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
1096 <td align=right valign=bottom>%(user_name)s</td></tr>
1097 <tr class="location-bar">
1098 <td align=left>All
1099 <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>,
1100 <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>
1101 | Unassigned
1102 <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>,
1103 <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>
1104 %(add_links)s
1105 %(admin_links)s</td>
1106 <td align=right>%(user_info)s</td>
1107 </table>
1108 ''')%locals())
1110 def parsePropsFromForm(db, cl, form, nodeid=0):
1111 '''Pull properties for the given class out of the form.
1112 '''
1113 props = {}
1114 changed = {}
1115 keys = form.keys()
1116 num_re = re.compile('^\d+$')
1117 for key in keys:
1118 if not cl.properties.has_key(key):
1119 continue
1120 proptype = cl.properties[key]
1121 if isinstance(proptype, hyperdb.String):
1122 value = form[key].value.strip()
1123 elif isinstance(proptype, hyperdb.Password):
1124 value = password.Password(form[key].value.strip())
1125 elif isinstance(proptype, hyperdb.Date):
1126 value = date.Date(form[key].value.strip())
1127 elif isinstance(proptype, hyperdb.Interval):
1128 value = date.Interval(form[key].value.strip())
1129 elif isinstance(proptype, hyperdb.Link):
1130 value = form[key].value.strip()
1131 # see if it's the "no selection" choice
1132 if value == '-1':
1133 # don't set this property
1134 continue
1135 else:
1136 # handle key values
1137 link = cl.properties[key].classname
1138 if not num_re.match(value):
1139 try:
1140 value = db.classes[link].lookup(value)
1141 except KeyError:
1142 raise ValueError, _('property "%(propname)s": '
1143 '%(value)s not a %(classname)s')%{'propname':key,
1144 'value': value, 'classname': link}
1145 elif isinstance(proptype, hyperdb.Multilink):
1146 value = form[key]
1147 if type(value) != type([]):
1148 value = [i.strip() for i in value.value.split(',')]
1149 else:
1150 value = [i.value.strip() for i in value]
1151 link = cl.properties[key].classname
1152 l = []
1153 for entry in map(str, value):
1154 if entry == '': continue
1155 if not num_re.match(entry):
1156 try:
1157 entry = db.classes[link].lookup(entry)
1158 except KeyError:
1159 raise ValueError, _('property "%(propname)s": '
1160 '"%(value)s" not an entry of %(classname)s')%{
1161 'propname':key, 'value': entry, 'classname': link}
1162 l.append(entry)
1163 l.sort()
1164 value = l
1165 props[key] = value
1167 # get the old value
1168 if nodeid:
1169 try:
1170 existing = cl.get(nodeid, key)
1171 except KeyError:
1172 # this might be a new property for which there is no existing
1173 # value
1174 if not cl.properties.has_key(key): raise
1176 # if changed, set it
1177 if nodeid and value != existing:
1178 changed[key] = value
1179 props[key] = value
1180 return props, changed
1182 #
1183 # $Log: not supported by cvs2svn $
1184 # Revision 1.89 2002/01/07 20:24:45 richard
1185 # *mutter* stupid cutnpaste
1186 #
1187 # Revision 1.88 2002/01/02 02:31:38 richard
1188 # Sorry for the huge checkin message - I was only intending to implement #496356
1189 # but I found a number of places where things had been broken by transactions:
1190 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1191 # for _all_ roundup-generated smtp messages to be sent to.
1192 # . the transaction cache had broken the roundupdb.Class set() reactors
1193 # . newly-created author users in the mailgw weren't being committed to the db
1194 #
1195 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1196 # on when I found that stuff :):
1197 # . #496356 ] Use threading in messages
1198 # . detectors were being registered multiple times
1199 # . added tests for mailgw
1200 # . much better attaching of erroneous messages in the mail gateway
1201 #
1202 # Revision 1.87 2001/12/23 23:18:49 richard
1203 # We already had an admin-specific section of the web heading, no need to add
1204 # another one :)
1205 #
1206 # Revision 1.86 2001/12/20 15:43:01 rochecompaan
1207 # Features added:
1208 # . Multilink properties are now displayed as comma separated values in
1209 # a textbox
1210 # . The add user link is now only visible to the admin user
1211 # . Modified the mail gateway to reject submissions from unknown
1212 # addresses if ANONYMOUS_ACCESS is denied
1213 #
1214 # Revision 1.85 2001/12/20 06:13:24 rochecompaan
1215 # Bugs fixed:
1216 # . Exception handling in hyperdb for strings-that-look-like numbers got
1217 # lost somewhere
1218 # . Internet Explorer submits full path for filename - we now strip away
1219 # the path
1220 # Features added:
1221 # . Link and multilink properties are now displayed sorted in the cgi
1222 # interface
1223 #
1224 # Revision 1.84 2001/12/18 15:30:30 rochecompaan
1225 # Fixed bugs:
1226 # . Fixed file creation and retrieval in same transaction in anydbm
1227 # backend
1228 # . Cgi interface now renders new issue after issue creation
1229 # . Could not set issue status to resolved through cgi interface
1230 # . Mail gateway was changing status back to 'chatting' if status was
1231 # omitted as an argument
1232 #
1233 # Revision 1.83 2001/12/15 23:51:01 richard
1234 # Tested the changes and fixed a few problems:
1235 # . files are now attached to the issue as well as the message
1236 # . newuser is a real method now since we don't want to do the message/file
1237 # stuff for it
1238 # . added some documentation
1239 # The really big changes in the diff are a result of me moving some code
1240 # around to keep like methods together a bit better.
1241 #
1242 # Revision 1.82 2001/12/15 19:24:39 rochecompaan
1243 # . Modified cgi interface to change properties only once all changes are
1244 # collected, files created and messages generated.
1245 # . Moved generation of change note to nosyreactors.
1246 # . We now check for changes to "assignedto" to ensure it's added to the
1247 # nosy list.
1248 #
1249 # Revision 1.81 2001/12/12 23:55:00 richard
1250 # Fixed some problems with user editing
1251 #
1252 # Revision 1.80 2001/12/12 23:27:14 richard
1253 # Added a Zope frontend for roundup.
1254 #
1255 # Revision 1.79 2001/12/10 22:20:01 richard
1256 # Enabled transaction support in the bsddb backend. It uses the anydbm code
1257 # where possible, only replacing methods where the db is opened (it uses the
1258 # btree opener specifically.)
1259 # Also cleaned up some change note generation.
1260 # Made the backends package work with pydoc too.
1261 #
1262 # Revision 1.78 2001/12/07 05:59:27 rochecompaan
1263 # Fixed small bug that prevented adding issues through the web.
1264 #
1265 # Revision 1.77 2001/12/06 22:48:29 richard
1266 # files multilink was being nuked in post_edit_node
1267 #
1268 # Revision 1.76 2001/12/05 14:26:44 rochecompaan
1269 # Removed generation of change note from "sendmessage" in roundupdb.py.
1270 # The change note is now generated when the message is created.
1271 #
1272 # Revision 1.75 2001/12/04 01:25:08 richard
1273 # Added some rollbacks where we were catching exceptions that would otherwise
1274 # have stopped committing.
1275 #
1276 # Revision 1.74 2001/12/02 05:06:16 richard
1277 # . We now use weakrefs in the Classes to keep the database reference, so
1278 # the close() method on the database is no longer needed.
1279 # I bumped the minimum python requirement up to 2.1 accordingly.
1280 # . #487480 ] roundup-server
1281 # . #487476 ] INSTALL.txt
1282 #
1283 # I also cleaned up the change message / post-edit stuff in the cgi client.
1284 # There's now a clearly marked "TODO: append the change note" where I believe
1285 # the change note should be added there. The "changes" list will obviously
1286 # have to be modified to be a dict of the changes, or somesuch.
1287 #
1288 # More testing needed.
1289 #
1290 # Revision 1.73 2001/12/01 07:17:50 richard
1291 # . We now have basic transaction support! Information is only written to
1292 # the database when the commit() method is called. Only the anydbm
1293 # backend is modified in this way - neither of the bsddb backends have been.
1294 # The mail, admin and cgi interfaces all use commit (except the admin tool
1295 # doesn't have a commit command, so interactive users can't commit...)
1296 # . Fixed login/registration forwarding the user to the right page (or not,
1297 # on a failure)
1298 #
1299 # Revision 1.72 2001/11/30 20:47:58 rochecompaan
1300 # Links in page header are now consistent with default sort order.
1301 #
1302 # Fixed bugs:
1303 # - When login failed the list of issues were still rendered.
1304 # - User was redirected to index page and not to his destination url
1305 # if his first login attempt failed.
1306 #
1307 # Revision 1.71 2001/11/30 20:28:10 rochecompaan
1308 # Property changes are now completely traceable, whether changes are
1309 # made through the web or by email
1310 #
1311 # Revision 1.70 2001/11/30 00:06:29 richard
1312 # Converted roundup/cgi_client.py to use _()
1313 # Added the status file, I18N_PROGRESS.txt
1314 #
1315 # Revision 1.69 2001/11/29 23:19:51 richard
1316 # Removed the "This issue has been edited through the web" when a valid
1317 # change note is supplied.
1318 #
1319 # Revision 1.68 2001/11/29 04:57:23 richard
1320 # a little comment
1321 #
1322 # Revision 1.67 2001/11/28 21:55:35 richard
1323 # . login_action and newuser_action return values were being ignored
1324 # . Woohoo! Found that bloody re-login bug that was killing the mail
1325 # gateway.
1326 # (also a minor cleanup in hyperdb)
1327 #
1328 # Revision 1.66 2001/11/27 03:00:50 richard
1329 # couple of bugfixes from latest patch integration
1330 #
1331 # Revision 1.65 2001/11/26 23:00:53 richard
1332 # This config stuff is getting to be a real mess...
1333 #
1334 # Revision 1.64 2001/11/26 22:56:35 richard
1335 # typo
1336 #
1337 # Revision 1.63 2001/11/26 22:55:56 richard
1338 # Feature:
1339 # . Added INSTANCE_NAME to configuration - used in web and email to identify
1340 # the instance.
1341 # . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1342 # signature info in e-mails.
1343 # . Some more flexibility in the mail gateway and more error handling.
1344 # . Login now takes you to the page you back to the were denied access to.
1345 #
1346 # Fixed:
1347 # . Lots of bugs, thanks Roché and others on the devel mailing list!
1348 #
1349 # Revision 1.62 2001/11/24 00:45:42 jhermann
1350 # typeof() instead of type(): avoid clash with database field(?) "type"
1351 #
1352 # Fixes this traceback:
1353 #
1354 # Traceback (most recent call last):
1355 # File "roundup\cgi_client.py", line 535, in newnode
1356 # self._post_editnode(nid)
1357 # File "roundup\cgi_client.py", line 415, in _post_editnode
1358 # if type(value) != type([]): value = [value]
1359 # UnboundLocalError: local variable 'type' referenced before assignment
1360 #
1361 # Revision 1.61 2001/11/22 15:46:42 jhermann
1362 # Added module docstrings to all modules.
1363 #
1364 # Revision 1.60 2001/11/21 22:57:28 jhermann
1365 # Added dummy hooks for I18N and some preliminary (test) markup of
1366 # translatable messages
1367 #
1368 # Revision 1.59 2001/11/21 03:21:13 richard
1369 # oops
1370 #
1371 # Revision 1.58 2001/11/21 03:11:28 richard
1372 # Better handling of new properties.
1373 #
1374 # Revision 1.57 2001/11/15 10:24:27 richard
1375 # handle the case where there is no file attached
1376 #
1377 # Revision 1.56 2001/11/14 21:35:21 richard
1378 # . users may attach files to issues (and support in ext) through the web now
1379 #
1380 # Revision 1.55 2001/11/07 02:34:06 jhermann
1381 # Handling of damaged login cookies
1382 #
1383 # Revision 1.54 2001/11/07 01:16:12 richard
1384 # Remove the '=' padding from cookie value so quoting isn't an issue.
1385 #
1386 # Revision 1.53 2001/11/06 23:22:05 jhermann
1387 # More IE fixes: it does not like quotes around cookie values; in the
1388 # hope this does not break anything for other browser; if it does, we
1389 # need to check HTTP_USER_AGENT
1390 #
1391 # Revision 1.52 2001/11/06 23:11:22 jhermann
1392 # Fixed debug output in page footer; added expiry date to the login cookie
1393 # (expires 1 year in the future) to prevent probs with certain versions
1394 # of IE
1395 #
1396 # Revision 1.51 2001/11/06 22:00:34 jhermann
1397 # Get debug level from ROUNDUP_DEBUG env var
1398 #
1399 # Revision 1.50 2001/11/05 23:45:40 richard
1400 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1401 # Also made it present nicer error messages (not tracebacks).
1402 #
1403 # Revision 1.49 2001/11/04 03:07:12 richard
1404 # Fixed various cookie-related bugs:
1405 # . bug #477685 ] base64.decodestring breaks
1406 # . bug #477837 ] lynx does not like the cookie
1407 # . bug #477892 ] Password edit doesn't fix login cookie
1408 # Also closed a security hole - a logged-in user could edit another user's
1409 # details.
1410 #
1411 # Revision 1.48 2001/11/03 01:30:18 richard
1412 # Oops. uses pagefoot now.
1413 #
1414 # Revision 1.47 2001/11/03 01:29:28 richard
1415 # Login page didn't have all close tags.
1416 #
1417 # Revision 1.46 2001/11/03 01:26:55 richard
1418 # possibly fix truncated base64'ed user:pass
1419 #
1420 # Revision 1.45 2001/11/01 22:04:37 richard
1421 # Started work on supporting a pop3-fetching server
1422 # Fixed bugs:
1423 # . bug #477104 ] HTML tag error in roundup-server
1424 # . bug #477107 ] HTTP header problem
1425 #
1426 # Revision 1.44 2001/10/28 23:03:08 richard
1427 # Added more useful header to the classic schema.
1428 #
1429 # Revision 1.43 2001/10/24 00:01:42 richard
1430 # More fixes to lockout logic.
1431 #
1432 # Revision 1.42 2001/10/23 23:56:03 richard
1433 # HTML typo
1434 #
1435 # Revision 1.41 2001/10/23 23:52:35 richard
1436 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1437 #
1438 # Revision 1.40 2001/10/23 23:06:39 richard
1439 # Some cleanup.
1440 #
1441 # Revision 1.39 2001/10/23 01:00:18 richard
1442 # Re-enabled login and registration access after lopping them off via
1443 # disabling access for anonymous users.
1444 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1445 # a couple of bugs while I was there. Probably introduced a couple, but
1446 # things seem to work OK at the moment.
1447 #
1448 # Revision 1.38 2001/10/22 03:25:01 richard
1449 # Added configuration for:
1450 # . anonymous user access and registration (deny/allow)
1451 # . filter "widget" location on index page (top, bottom, both)
1452 # Updated some documentation.
1453 #
1454 # Revision 1.37 2001/10/21 07:26:35 richard
1455 # feature #473127: Filenames. I modified the file.index and htmltemplate
1456 # source so that the filename is used in the link and the creation
1457 # information is displayed.
1458 #
1459 # Revision 1.36 2001/10/21 04:44:50 richard
1460 # bug #473124: UI inconsistency with Link fields.
1461 # This also prompted me to fix a fairly long-standing usability issue -
1462 # that of being able to turn off certain filters.
1463 #
1464 # Revision 1.35 2001/10/21 00:17:54 richard
1465 # CGI interface view customisation section may now be hidden (patch from
1466 # Roch'e Compaan.)
1467 #
1468 # Revision 1.34 2001/10/20 11:58:48 richard
1469 # Catch errors in login - no username or password supplied.
1470 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1471 #
1472 # Revision 1.33 2001/10/17 00:18:41 richard
1473 # Manually constructing cookie headers now.
1474 #
1475 # Revision 1.32 2001/10/16 03:36:21 richard
1476 # CGI interface wasn't handling checkboxes at all.
1477 #
1478 # Revision 1.31 2001/10/14 10:55:00 richard
1479 # Handle empty strings in HTML template Link function
1480 #
1481 # Revision 1.30 2001/10/09 07:38:58 richard
1482 # Pushed the base code for the extended schema CGI interface back into the
1483 # code cgi_client module so that future updates will be less painful.
1484 # Also removed a debugging print statement from cgi_client.
1485 #
1486 # Revision 1.29 2001/10/09 07:25:59 richard
1487 # Added the Password property type. See "pydoc roundup.password" for
1488 # implementation details. Have updated some of the documentation too.
1489 #
1490 # Revision 1.28 2001/10/08 00:34:31 richard
1491 # Change message was stuffing up for multilinks with no key property.
1492 #
1493 # Revision 1.27 2001/10/05 02:23:24 richard
1494 # . roundup-admin create now prompts for property info if none is supplied
1495 # on the command-line.
1496 # . hyperdb Class getprops() method may now return only the mutable
1497 # properties.
1498 # . Login now uses cookies, which makes it a whole lot more flexible. We can
1499 # now support anonymous user access (read-only, unless there's an
1500 # "anonymous" user, in which case write access is permitted). Login
1501 # handling has been moved into cgi_client.Client.main()
1502 # . The "extended" schema is now the default in roundup init.
1503 # . The schemas have had their page headings modified to cope with the new
1504 # login handling. Existing installations should copy the interfaces.py
1505 # file from the roundup lib directory to their instance home.
1506 # . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1507 # Ping - has been removed.
1508 # . Fixed a whole bunch of places in the CGI interface where we should have
1509 # been returning Not Found instead of throwing an exception.
1510 # . Fixed a deviation from the spec: trying to modify the 'id' property of
1511 # an item now throws an exception.
1512 #
1513 # Revision 1.26 2001/09/12 08:31:42 richard
1514 # handle cases where mime type is not guessable
1515 #
1516 # Revision 1.25 2001/08/29 05:30:49 richard
1517 # change messages weren't being saved when there was no-one on the nosy list.
1518 #
1519 # Revision 1.24 2001/08/29 04:49:39 richard
1520 # didn't clean up fully after debugging :(
1521 #
1522 # Revision 1.23 2001/08/29 04:47:18 richard
1523 # Fixed CGI client change messages so they actually include the properties
1524 # changed (again).
1525 #
1526 # Revision 1.22 2001/08/17 00:08:10 richard
1527 # reverted back to sending messages always regardless of who is doing the web
1528 # edit. change notes weren't being saved. bleah. hackish.
1529 #
1530 # Revision 1.21 2001/08/15 23:43:18 richard
1531 # Fixed some isFooTypes that I missed.
1532 # Refactored some code in the CGI code.
1533 #
1534 # Revision 1.20 2001/08/12 06:32:36 richard
1535 # using isinstance(blah, Foo) now instead of isFooType
1536 #
1537 # Revision 1.19 2001/08/07 00:24:42 richard
1538 # stupid typo
1539 #
1540 # Revision 1.18 2001/08/07 00:15:51 richard
1541 # Added the copyright/license notice to (nearly) all files at request of
1542 # Bizar Software.
1543 #
1544 # Revision 1.17 2001/08/02 06:38:17 richard
1545 # Roundupdb now appends "mailing list" information to its messages which
1546 # include the e-mail address and web interface address. Templates may
1547 # override this in their db classes to include specific information (support
1548 # instructions, etc).
1549 #
1550 # Revision 1.16 2001/08/02 05:55:25 richard
1551 # Web edit messages aren't sent to the person who did the edit any more. No
1552 # message is generated if they are the only person on the nosy list.
1553 #
1554 # Revision 1.15 2001/08/02 00:34:10 richard
1555 # bleah syntax error
1556 #
1557 # Revision 1.14 2001/08/02 00:26:16 richard
1558 # Changed the order of the information in the message generated by web edits.
1559 #
1560 # Revision 1.13 2001/07/30 08:12:17 richard
1561 # Added time logging and file uploading to the templates.
1562 #
1563 # Revision 1.12 2001/07/30 06:26:31 richard
1564 # Added some documentation on how the newblah works.
1565 #
1566 # Revision 1.11 2001/07/30 06:17:45 richard
1567 # Features:
1568 # . Added ability for cgi newblah forms to indicate that the new node
1569 # should be linked somewhere.
1570 # Fixed:
1571 # . Fixed the agument handling for the roundup-admin find command.
1572 # . Fixed handling of summary when no note supplied for newblah. Again.
1573 # . Fixed detection of no form in htmltemplate Field display.
1574 #
1575 # Revision 1.10 2001/07/30 02:37:34 richard
1576 # Temporary measure until we have decent schema migration...
1577 #
1578 # Revision 1.9 2001/07/30 01:25:07 richard
1579 # Default implementation is now "classic" rather than "extended" as one would
1580 # expect.
1581 #
1582 # Revision 1.8 2001/07/29 08:27:40 richard
1583 # Fixed handling of passed-in values in form elements (ie. during a
1584 # drill-down)
1585 #
1586 # Revision 1.7 2001/07/29 07:01:39 richard
1587 # Added vim command to all source so that we don't get no steenkin' tabs :)
1588 #
1589 # Revision 1.6 2001/07/29 04:04:00 richard
1590 # Moved some code around allowing for subclassing to change behaviour.
1591 #
1592 # Revision 1.5 2001/07/28 08:16:52 richard
1593 # New issue form handles lack of note better now.
1594 #
1595 # Revision 1.4 2001/07/28 00:34:34 richard
1596 # Fixed some non-string node ids.
1597 #
1598 # Revision 1.3 2001/07/23 03:56:30 richard
1599 # oops, missed a config removal
1600 #
1601 # Revision 1.2 2001/07/22 12:09:32 richard
1602 # Final commit of Grande Splite
1603 #
1604 # Revision 1.1 2001/07/22 11:58:35 richard
1605 # More Grande Splite
1606 #
1607 #
1608 # vim: set filetype=python ts=4 sw=4 et si