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