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