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