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