2fef807d88188dd5d9ad8283518ee0504de7afc9
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.96 2002-01-10 05:26:10 richard Exp $
20 __doc__ = """
21 WWW request handler (also used in the stand-alone server).
22 """
24 import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes
25 import binascii, Cookie, time, random
27 import roundupdb, htmltemplate, date, hyperdb, password
28 from roundup.i18n import _
30 class Unauthorised(ValueError):
31 pass
33 class NotFound(ValueError):
34 pass
36 class Client:
37 '''
38 A note about login
39 ------------------
41 If the user has no login cookie, then they are anonymous. There
42 are two levels of anonymous use. If there is no 'anonymous' user, there
43 is no login at all and the database is opened in read-only mode. If the
44 'anonymous' user exists, the user is logged in using that user (though
45 there is no cookie). This allows them to modify the database, and all
46 modifications are attributed to the 'anonymous' user.
49 Customisation
50 -------------
51 FILTER_POSITION - one of 'top', 'bottom', 'top and bottom'
52 ANONYMOUS_ACCESS - one of 'deny', 'allow'
53 ANONYMOUS_REGISTER - one of 'deny', 'allow'
55 from the roundup class:
56 INSTANCE_NAME - defaults to 'Roundup issue tracker'
58 '''
59 FILTER_POSITION = 'bottom' # one of 'top', 'bottom', 'top and bottom'
60 ANONYMOUS_ACCESS = 'deny' # one of 'deny', 'allow'
61 ANONYMOUS_REGISTER = 'deny' # one of 'deny', 'allow'
63 def __init__(self, instance, request, env, form=None):
64 self.instance = instance
65 self.request = request
66 self.env = env
67 self.path = env['PATH_INFO']
68 self.split_path = self.path.split('/')
70 if form is None:
71 self.form = cgi.FieldStorage(environ=env)
72 else:
73 self.form = form
74 self.headers_done = 0
75 try:
76 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
77 except ValueError:
78 # someone gave us a non-int debug level, turn it off
79 self.debug = 0
81 def getuid(self):
82 return self.db.user.lookup(self.user)
84 def header(self, headers={'Content-Type':'text/html'}):
85 '''Put up the appropriate header.
86 '''
87 if not headers.has_key('Content-Type'):
88 headers['Content-Type'] = 'text/html'
89 self.request.send_response(200)
90 for entry in headers.items():
91 self.request.send_header(*entry)
92 self.request.end_headers()
93 self.headers_done = 1
94 if self.debug:
95 self.headers_sent = headers
97 def pagehead(self, title, message=None):
98 url = self.env['SCRIPT_NAME'] + '/'
99 machine = self.env['SERVER_NAME']
100 port = self.env['SERVER_PORT']
101 if port != '80': machine = machine + ':' + port
102 base = urlparse.urlunparse(('http', machine, url, None, None, None))
103 if message is not None:
104 message = _('<div class="system-msg">%(message)s</div>')%locals()
105 else:
106 message = ''
107 style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
108 user_name = self.user or ''
109 if self.user == 'admin':
110 admin_links = _(' | <a href="list_classes">Class List</a>' \
111 ' | <a href="user">User List</a>' \
112 ' | <a href="newuser">Add User</a>')
113 else:
114 admin_links = ''
115 if self.user not in (None, 'anonymous'):
116 userid = self.db.user.lookup(self.user)
117 user_info = _('''
118 <a href="issue?assignedto=%(userid)s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=-activity&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">My Issues</a> |
119 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
120 ''')%locals()
121 else:
122 user_info = _('<a href="login">Login</a>')
123 if self.user is not None:
124 add_links = _('''
125 | Add
126 <a href="newissue">Issue</a>
127 ''')
128 else:
129 add_links = ''
130 self.write(_('''<html><head>
131 <title>%(title)s</title>
132 <style type="text/css">%(style)s</style>
133 </head>
134 <body bgcolor=#ffffff>
135 %(message)s
136 <table width=100%% border=0 cellspacing=0 cellpadding=2>
137 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
138 <td align=right valign=bottom>%(user_name)s</td></tr>
139 <tr class="location-bar">
140 <td align=left>All
141 <a href="issue?status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=-activity&:filter=status&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>
142 | Unassigned
143 <a href="issue?assignedto=-1&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=-activity&:filter=status,assignedto&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>
144 %(add_links)s
145 %(admin_links)s</td>
146 <td align=right>%(user_info)s</td>
147 </table>
148 ''')%locals())
150 def pagefoot(self):
151 if self.debug:
152 self.write(_('<hr><small><dl><dt><b>Path</b></dt>'))
153 self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
154 keys = self.form.keys()
155 keys.sort()
156 if keys:
157 self.write(_('<dt><b>Form entries</b></dt>'))
158 for k in self.form.keys():
159 v = self.form.getvalue(k, "<empty>")
160 if type(v) is type([]):
161 # Multiple username fields specified
162 v = "|".join(v)
163 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
164 keys = self.headers_sent.keys()
165 keys.sort()
166 self.write(_('<dt><b>Sent these HTTP headers</b></dt>'))
167 for k in keys:
168 v = self.headers_sent[k]
169 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
170 keys = self.env.keys()
171 keys.sort()
172 self.write(_('<dt><b>CGI environment</b></dt>'))
173 for k in keys:
174 v = self.env[k]
175 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
176 self.write('</dl></small>')
177 self.write('</body></html>')
179 def write(self, content):
180 if not self.headers_done:
181 self.header()
182 self.request.wfile.write(content)
184 def index_arg(self, arg):
185 ''' handle the args to index - they might be a list from the form
186 (ie. submitted from a form) or they might be a command-separated
187 single string (ie. manually constructed GET args)
188 '''
189 if self.form.has_key(arg):
190 arg = self.form[arg]
191 if type(arg) == type([]):
192 return [arg.value for arg in arg]
193 return arg.value.split(',')
194 return []
196 def index_filterspec(self, filter):
197 ''' pull the index filter spec from the form
199 Links and multilinks want to be lists - the rest are straight
200 strings.
201 '''
202 props = self.db.classes[self.classname].getprops()
203 # all the form args not starting with ':' are filters
204 filterspec = {}
205 for key in self.form.keys():
206 if key[0] == ':': continue
207 if not props.has_key(key): continue
208 if key not in filter: continue
209 prop = props[key]
210 value = self.form[key]
211 if (isinstance(prop, hyperdb.Link) or
212 isinstance(prop, hyperdb.Multilink)):
213 if type(value) == type([]):
214 value = [arg.value for arg in value]
215 else:
216 value = value.value.split(',')
217 l = filterspec.get(key, [])
218 l = l + value
219 filterspec[key] = l
220 else:
221 filterspec[key] = value.value
222 return filterspec
224 def customization_widget(self):
225 ''' The customization widget is visible by default. The widget
226 visibility is remembered by show_customization. Visibility
227 is not toggled if the action value is "Redisplay"
228 '''
229 if not self.form.has_key('show_customization'):
230 visible = 1
231 else:
232 visible = int(self.form['show_customization'].value)
233 if self.form.has_key('action'):
234 if self.form['action'].value != 'Redisplay':
235 visible = self.form['action'].value == '+'
237 return visible
239 default_index_sort = ['-activity']
240 default_index_group = ['priority']
241 default_index_filter = ['status']
242 default_index_columns = ['id','activity','title','status','assignedto']
243 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
244 def index(self):
245 ''' put up an index
246 '''
247 self.classname = 'issue'
248 # see if the web has supplied us with any customisation info
249 defaults = 1
250 for key in ':sort', ':group', ':filter', ':columns':
251 if self.form.has_key(key):
252 defaults = 0
253 break
254 if defaults:
255 # no info supplied - use the defaults
256 sort = self.default_index_sort
257 group = self.default_index_group
258 filter = self.default_index_filter
259 columns = self.default_index_columns
260 filterspec = self.default_index_filterspec
261 else:
262 sort = self.index_arg(':sort')
263 group = self.index_arg(':group')
264 filter = self.index_arg(':filter')
265 columns = self.index_arg(':columns')
266 filterspec = self.index_filterspec(filter)
267 return self.list(columns=columns, filter=filter, group=group,
268 sort=sort, filterspec=filterspec)
270 # XXX deviates from spec - loses the '+' (that's a reserved character
271 # in URLS
272 def list(self, sort=None, group=None, filter=None, columns=None,
273 filterspec=None, show_customization=None):
274 ''' call the template index with the args
276 :sort - sort by prop name, optionally preceeded with '-'
277 to give descending or nothing for ascending sorting.
278 :group - group by prop name, optionally preceeded with '-' or
279 to sort in descending or nothing for ascending order.
280 :filter - selects which props should be displayed in the filter
281 section. Default is all.
282 :columns - selects the columns that should be displayed.
283 Default is all.
285 '''
286 cn = self.classname
287 cl = self.db.classes[cn]
288 self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
289 'classname': cn, 'instancename': self.INSTANCE_NAME})
290 if sort is None: sort = self.index_arg(':sort')
291 if group is None: group = self.index_arg(':group')
292 if filter is None: filter = self.index_arg(':filter')
293 if columns is None: columns = self.index_arg(':columns')
294 if filterspec is None: filterspec = self.index_filterspec(filter)
295 if show_customization is None:
296 show_customization = self.customization_widget()
298 index = htmltemplate.IndexTemplate(self, self.TEMPLATES, cn)
299 index.render(filterspec, filter, columns, sort, group,
300 show_customization=show_customization)
301 self.pagefoot()
303 def shownode(self, message=None):
304 ''' display an item
305 '''
306 cn = self.classname
307 cl = self.db.classes[cn]
309 # possibly perform an edit
310 keys = self.form.keys()
311 num_re = re.compile('^\d+$')
312 # don't try to set properties if the user has just logged in
313 if keys and not self.form.has_key('__login_name'):
314 try:
315 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
316 # make changes to the node
317 self._changenode(props)
318 # handle linked nodes
319 self._post_editnode(self.nodeid)
320 # and some nice feedback for the user
321 if props:
322 message = _('%(changes)s edited ok')%{'changes':
323 ', '.join(props.keys())}
324 elif self.form.has_key('__note') and self.form['__note'].value:
325 message = _('note added')
326 elif self.form.has_key('__file'):
327 message = _('file added')
328 else:
329 message = _('nothing changed')
330 except:
331 self.db.rollback()
332 s = StringIO.StringIO()
333 traceback.print_exc(None, s)
334 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
336 # now the display
337 id = self.nodeid
338 if cl.getkey():
339 id = cl.get(id, cl.getkey())
340 self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
342 nodeid = self.nodeid
344 # use the template to display the item
345 item = htmltemplate.ItemTemplate(self, self.TEMPLATES, self.classname)
346 item.render(nodeid)
348 self.pagefoot()
349 showissue = shownode
350 showmsg = shownode
352 def _add_assignedto_to_nosy(self, props):
353 ''' add the assignedto value from the props to the nosy list
354 '''
355 if not props.has_key('assignedto'):
356 return
357 assignedto_id = props['assignedto']
358 if not props.has_key('nosy'):
359 props['nosy'] = [assignedto_id]
360 elif assignedto_id not in props['nosy']:
361 props['nosy'].append(assignedto_id)
363 def _changenode(self, props):
364 ''' change the node based on the contents of the form
365 '''
366 cl = self.db.classes[self.classname]
367 # set status to chatting if 'unread' or 'resolved'
368 try:
369 # determine the id of 'unread','resolved' and 'chatting'
370 unread_id = self.db.status.lookup('unread')
371 resolved_id = self.db.status.lookup('resolved')
372 chatting_id = self.db.status.lookup('chatting')
373 current_status = cl.get(self.nodeid, 'status')
374 if props.has_key('status'):
375 new_status = props['status']
376 else:
377 # apparently there's a chance that some browsers don't
378 # send status...
379 new_status = current_status
380 except KeyError:
381 pass
382 else:
383 if new_status == unread_id or (new_status == resolved_id
384 and current_status == resolved_id):
385 props['status'] = chatting_id
387 self._add_assignedto_to_nosy(props)
389 # create the message
390 message, files = self._handle_message()
391 if message:
392 props['messages'] = cl.get(self.nodeid, 'messages') + [message]
393 if files:
394 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 = 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 = 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 = parsePropsFromForm(self.db, user, self.form,
678 self.nodeid)
679 set_cookie = 0
680 if props.has_key('password'):
681 password = self.form['password'].value.strip()
682 if not password:
683 # no password was supplied - don't change it
684 del props['password']
685 elif self.nodeid == self.getuid():
686 # this is the logged-in user's password
687 set_cookie = password
688 user.set(self.nodeid, **props)
689 # and some feedback for the user
690 message = _('%(changes)s edited ok')%{'changes':
691 ', '.join(props.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 = 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()
890 def main(self):
891 '''Wrap the database accesses so we can close the database cleanly
892 '''
893 # determine the uid to use
894 self.db = self.instance.open('admin')
895 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
896 user = 'anonymous'
897 if (cookie.has_key('roundup_user') and
898 cookie['roundup_user'].value != 'deleted'):
899 cookie = cookie['roundup_user'].value
900 if len(cookie)%4:
901 cookie = cookie + '='*(4-len(cookie)%4)
902 try:
903 user, password = binascii.a2b_base64(cookie).split(':')
904 except (TypeError, binascii.Error, binascii.Incomplete):
905 # damaged cookie!
906 user, password = 'anonymous', ''
908 # make sure the user exists
909 try:
910 uid = self.db.user.lookup(user)
911 # now validate the password
912 if password != self.db.user.get(uid, 'password'):
913 user = 'anonymous'
914 except KeyError:
915 user = 'anonymous'
917 # make sure the anonymous user is valid if we're using it
918 if user == 'anonymous':
919 self.make_user_anonymous()
920 else:
921 self.user = user
923 # re-open the database for real, using the user
924 self.db = self.instance.open(self.user)
926 # now figure which function to call
927 path = self.split_path
929 # default action to index if the path has no information in it
930 if not path or path[0] in ('', 'index'):
931 action = 'index'
932 else:
933 action = path[0]
935 # Everthing ignores path[1:]
936 # - The file download link generator actually relies on this - it
937 # appends the name of the file to the URL so the download file name
938 # is correct, but doesn't actually use it.
940 # everyone is allowed to try to log in
941 if action == 'login_action':
942 # try to login
943 if not self.login_action():
944 return
945 # figure the resulting page
946 action = self.form['__destination_url'].value
947 if not action:
948 action = 'index'
949 self.do_action(action)
950 return
952 # allow anonymous people to register
953 if action == 'newuser_action':
954 # if we don't have a login and anonymous people aren't allowed to
955 # register, then spit up the login form
956 if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
957 if action == 'login':
958 self.login() # go to the index after login
959 else:
960 self.login(action=action)
961 return
962 # try to add the user
963 if not self.newuser_action():
964 return
965 # figure the resulting page
966 action = self.form['__destination_url'].value
967 if not action:
968 action = 'index'
970 # no login or registration, make sure totally anonymous access is OK
971 elif self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
972 if action == 'login':
973 self.login() # go to the index after login
974 else:
975 self.login(action=action)
976 return
978 # just a regular action
979 self.do_action(action)
981 # commit all changes to the database
982 self.db.commit()
984 def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
985 nre=re.compile(r'new(\w+)')):
986 '''Figure the user's action and do it.
987 '''
988 # here be the "normal" functionality
989 if action == 'index':
990 self.index()
991 return
992 if action == 'list_classes':
993 self.classes()
994 return
995 if action == 'login':
996 self.login()
997 return
998 if action == 'logout':
999 self.logout()
1000 return
1001 m = dre.match(action)
1002 if m:
1003 self.classname = m.group(1)
1004 self.nodeid = m.group(2)
1005 try:
1006 cl = self.db.classes[self.classname]
1007 except KeyError:
1008 raise NotFound
1009 try:
1010 cl.get(self.nodeid, 'id')
1011 except IndexError:
1012 raise NotFound
1013 try:
1014 func = getattr(self, 'show%s'%self.classname)
1015 except AttributeError:
1016 raise NotFound
1017 func()
1018 return
1019 m = nre.match(action)
1020 if m:
1021 self.classname = m.group(1)
1022 try:
1023 func = getattr(self, 'new%s'%self.classname)
1024 except AttributeError:
1025 raise NotFound
1026 func()
1027 return
1028 self.classname = action
1029 try:
1030 self.db.getclass(self.classname)
1031 except KeyError:
1032 raise NotFound
1033 self.list()
1036 class ExtendedClient(Client):
1037 '''Includes pages and page heading information that relate to the
1038 extended schema.
1039 '''
1040 showsupport = Client.shownode
1041 showtimelog = Client.shownode
1042 newsupport = Client.newnode
1043 newtimelog = Client.newnode
1045 default_index_sort = ['-activity']
1046 default_index_group = ['priority']
1047 default_index_filter = ['status']
1048 default_index_columns = ['activity','status','title','assignedto']
1049 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
1051 def pagehead(self, title, message=None):
1052 url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
1053 machine = self.env['SERVER_NAME']
1054 port = self.env['SERVER_PORT']
1055 if port != '80': machine = machine + ':' + port
1056 base = urlparse.urlunparse(('http', machine, url, None, None, None))
1057 if message is not None:
1058 message = _('<div class="system-msg">%(message)s</div>')%locals()
1059 else:
1060 message = ''
1061 style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
1062 user_name = self.user or ''
1063 if self.user == 'admin':
1064 admin_links = _(' | <a href="list_classes">Class List</a>' \
1065 ' | <a href="user">User List</a>' \
1066 ' | <a href="newuser">Add User</a>')
1067 else:
1068 admin_links = ''
1069 if self.user not in (None, 'anonymous'):
1070 userid = self.db.user.lookup(self.user)
1071 user_info = _('''
1072 <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> |
1073 <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> |
1074 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
1075 ''')%locals()
1076 else:
1077 user_info = _('<a href="login">Login</a>')
1078 if self.user is not None:
1079 add_links = _('''
1080 | Add
1081 <a href="newissue">Issue</a>,
1082 <a href="newsupport">Support</a>,
1083 ''')
1084 else:
1085 add_links = ''
1086 self.write(_('''<html><head>
1087 <title>%(title)s</title>
1088 <style type="text/css">%(style)s</style>
1089 </head>
1090 <body bgcolor=#ffffff>
1091 %(message)s
1092 <table width=100%% border=0 cellspacing=0 cellpadding=2>
1093 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
1094 <td align=right valign=bottom>%(user_name)s</td></tr>
1095 <tr class="location-bar">
1096 <td align=left>All
1097 <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>,
1098 <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>
1099 | Unassigned
1100 <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>,
1101 <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>
1102 %(add_links)s
1103 %(admin_links)s</td>
1104 <td align=right>%(user_info)s</td>
1105 </table>
1106 ''')%locals())
1108 def parsePropsFromForm(db, cl, form, nodeid=0):
1109 '''Pull properties for the given class out of the form.
1110 '''
1111 props = {}
1112 keys = form.keys()
1113 num_re = re.compile('^\d+$')
1114 for key in keys:
1115 if not cl.properties.has_key(key):
1116 continue
1117 proptype = cl.properties[key]
1118 if isinstance(proptype, hyperdb.String):
1119 value = form[key].value.strip()
1120 elif isinstance(proptype, hyperdb.Password):
1121 value = password.Password(form[key].value.strip())
1122 elif isinstance(proptype, hyperdb.Date):
1123 value = date.Date(form[key].value.strip())
1124 elif isinstance(proptype, hyperdb.Interval):
1125 value = date.Interval(form[key].value.strip())
1126 elif isinstance(proptype, hyperdb.Link):
1127 value = form[key].value.strip()
1128 # see if it's the "no selection" choice
1129 if value == '-1':
1130 # don't set this property
1131 continue
1132 else:
1133 # handle key values
1134 link = cl.properties[key].classname
1135 if not num_re.match(value):
1136 try:
1137 value = db.classes[link].lookup(value)
1138 except KeyError:
1139 raise ValueError, _('property "%(propname)s": '
1140 '%(value)s not a %(classname)s')%{'propname':key,
1141 'value': value, 'classname': link}
1142 elif isinstance(proptype, hyperdb.Multilink):
1143 value = form[key]
1144 if type(value) != type([]):
1145 value = [i.strip() for i in value.value.split(',')]
1146 else:
1147 value = [i.value.strip() for i in value]
1148 link = cl.properties[key].classname
1149 l = []
1150 for entry in map(str, value):
1151 if entry == '': continue
1152 if not num_re.match(entry):
1153 try:
1154 entry = db.classes[link].lookup(entry)
1155 except KeyError:
1156 raise ValueError, _('property "%(propname)s": '
1157 '"%(value)s" not an entry of %(classname)s')%{
1158 'propname':key, 'value': entry, 'classname': link}
1159 l.append(entry)
1160 l.sort()
1161 value = l
1163 # get the old value
1164 if nodeid:
1165 try:
1166 existing = cl.get(nodeid, key)
1167 except KeyError:
1168 # this might be a new property for which there is no existing
1169 # value
1170 if not cl.properties.has_key(key): raise
1172 # if changed, set it
1173 if value != existing:
1174 props[key] = value
1175 else:
1176 props[key] = value
1177 return props
1179 #
1180 # $Log: not supported by cvs2svn $
1181 # Revision 1.95 2002/01/10 03:39:45 richard
1182 # . fixed some problems with web editing and change detection
1183 #
1184 # Revision 1.94 2002/01/09 13:54:21 grubert
1185 # _add_assignedto_to_nosy did set nosy to assignedto only, no adding.
1186 #
1187 # Revision 1.93 2002/01/08 11:57:12 richard
1188 # crying out for real configuration handling... :(
1189 #
1190 # Revision 1.92 2002/01/08 04:12:05 richard
1191 # Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
1192 #
1193 # Revision 1.91 2002/01/08 04:03:47 richard
1194 # I mucked the intent of the code up.
1195 #
1196 # Revision 1.90 2002/01/08 03:56:55 richard
1197 # Oops, missed this before the beta:
1198 # . #495392 ] empty nosy -patch
1199 #
1200 # Revision 1.89 2002/01/07 20:24:45 richard
1201 # *mutter* stupid cutnpaste
1202 #
1203 # Revision 1.88 2002/01/02 02:31:38 richard
1204 # Sorry for the huge checkin message - I was only intending to implement #496356
1205 # but I found a number of places where things had been broken by transactions:
1206 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1207 # for _all_ roundup-generated smtp messages to be sent to.
1208 # . the transaction cache had broken the roundupdb.Class set() reactors
1209 # . newly-created author users in the mailgw weren't being committed to the db
1210 #
1211 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1212 # on when I found that stuff :):
1213 # . #496356 ] Use threading in messages
1214 # . detectors were being registered multiple times
1215 # . added tests for mailgw
1216 # . much better attaching of erroneous messages in the mail gateway
1217 #
1218 # Revision 1.87 2001/12/23 23:18:49 richard
1219 # We already had an admin-specific section of the web heading, no need to add
1220 # another one :)
1221 #
1222 # Revision 1.86 2001/12/20 15:43:01 rochecompaan
1223 # Features added:
1224 # . Multilink properties are now displayed as comma separated values in
1225 # a textbox
1226 # . The add user link is now only visible to the admin user
1227 # . Modified the mail gateway to reject submissions from unknown
1228 # addresses if ANONYMOUS_ACCESS is denied
1229 #
1230 # Revision 1.85 2001/12/20 06:13:24 rochecompaan
1231 # Bugs fixed:
1232 # . Exception handling in hyperdb for strings-that-look-like numbers got
1233 # lost somewhere
1234 # . Internet Explorer submits full path for filename - we now strip away
1235 # the path
1236 # Features added:
1237 # . Link and multilink properties are now displayed sorted in the cgi
1238 # interface
1239 #
1240 # Revision 1.84 2001/12/18 15:30:30 rochecompaan
1241 # Fixed bugs:
1242 # . Fixed file creation and retrieval in same transaction in anydbm
1243 # backend
1244 # . Cgi interface now renders new issue after issue creation
1245 # . Could not set issue status to resolved through cgi interface
1246 # . Mail gateway was changing status back to 'chatting' if status was
1247 # omitted as an argument
1248 #
1249 # Revision 1.83 2001/12/15 23:51:01 richard
1250 # Tested the changes and fixed a few problems:
1251 # . files are now attached to the issue as well as the message
1252 # . newuser is a real method now since we don't want to do the message/file
1253 # stuff for it
1254 # . added some documentation
1255 # The really big changes in the diff are a result of me moving some code
1256 # around to keep like methods together a bit better.
1257 #
1258 # Revision 1.82 2001/12/15 19:24:39 rochecompaan
1259 # . Modified cgi interface to change properties only once all changes are
1260 # collected, files created and messages generated.
1261 # . Moved generation of change note to nosyreactors.
1262 # . We now check for changes to "assignedto" to ensure it's added to the
1263 # nosy list.
1264 #
1265 # Revision 1.81 2001/12/12 23:55:00 richard
1266 # Fixed some problems with user editing
1267 #
1268 # Revision 1.80 2001/12/12 23:27:14 richard
1269 # Added a Zope frontend for roundup.
1270 #
1271 # Revision 1.79 2001/12/10 22:20:01 richard
1272 # Enabled transaction support in the bsddb backend. It uses the anydbm code
1273 # where possible, only replacing methods where the db is opened (it uses the
1274 # btree opener specifically.)
1275 # Also cleaned up some change note generation.
1276 # Made the backends package work with pydoc too.
1277 #
1278 # Revision 1.78 2001/12/07 05:59:27 rochecompaan
1279 # Fixed small bug that prevented adding issues through the web.
1280 #
1281 # Revision 1.77 2001/12/06 22:48:29 richard
1282 # files multilink was being nuked in post_edit_node
1283 #
1284 # Revision 1.76 2001/12/05 14:26:44 rochecompaan
1285 # Removed generation of change note from "sendmessage" in roundupdb.py.
1286 # The change note is now generated when the message is created.
1287 #
1288 # Revision 1.75 2001/12/04 01:25:08 richard
1289 # Added some rollbacks where we were catching exceptions that would otherwise
1290 # have stopped committing.
1291 #
1292 # Revision 1.74 2001/12/02 05:06:16 richard
1293 # . We now use weakrefs in the Classes to keep the database reference, so
1294 # the close() method on the database is no longer needed.
1295 # I bumped the minimum python requirement up to 2.1 accordingly.
1296 # . #487480 ] roundup-server
1297 # . #487476 ] INSTALL.txt
1298 #
1299 # I also cleaned up the change message / post-edit stuff in the cgi client.
1300 # There's now a clearly marked "TODO: append the change note" where I believe
1301 # the change note should be added there. The "changes" list will obviously
1302 # have to be modified to be a dict of the changes, or somesuch.
1303 #
1304 # More testing needed.
1305 #
1306 # Revision 1.73 2001/12/01 07:17:50 richard
1307 # . We now have basic transaction support! Information is only written to
1308 # the database when the commit() method is called. Only the anydbm
1309 # backend is modified in this way - neither of the bsddb backends have been.
1310 # The mail, admin and cgi interfaces all use commit (except the admin tool
1311 # doesn't have a commit command, so interactive users can't commit...)
1312 # . Fixed login/registration forwarding the user to the right page (or not,
1313 # on a failure)
1314 #
1315 # Revision 1.72 2001/11/30 20:47:58 rochecompaan
1316 # Links in page header are now consistent with default sort order.
1317 #
1318 # Fixed bugs:
1319 # - When login failed the list of issues were still rendered.
1320 # - User was redirected to index page and not to his destination url
1321 # if his first login attempt failed.
1322 #
1323 # Revision 1.71 2001/11/30 20:28:10 rochecompaan
1324 # Property changes are now completely traceable, whether changes are
1325 # made through the web or by email
1326 #
1327 # Revision 1.70 2001/11/30 00:06:29 richard
1328 # Converted roundup/cgi_client.py to use _()
1329 # Added the status file, I18N_PROGRESS.txt
1330 #
1331 # Revision 1.69 2001/11/29 23:19:51 richard
1332 # Removed the "This issue has been edited through the web" when a valid
1333 # change note is supplied.
1334 #
1335 # Revision 1.68 2001/11/29 04:57:23 richard
1336 # a little comment
1337 #
1338 # Revision 1.67 2001/11/28 21:55:35 richard
1339 # . login_action and newuser_action return values were being ignored
1340 # . Woohoo! Found that bloody re-login bug that was killing the mail
1341 # gateway.
1342 # (also a minor cleanup in hyperdb)
1343 #
1344 # Revision 1.66 2001/11/27 03:00:50 richard
1345 # couple of bugfixes from latest patch integration
1346 #
1347 # Revision 1.65 2001/11/26 23:00:53 richard
1348 # This config stuff is getting to be a real mess...
1349 #
1350 # Revision 1.64 2001/11/26 22:56:35 richard
1351 # typo
1352 #
1353 # Revision 1.63 2001/11/26 22:55:56 richard
1354 # Feature:
1355 # . Added INSTANCE_NAME to configuration - used in web and email to identify
1356 # the instance.
1357 # . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1358 # signature info in e-mails.
1359 # . Some more flexibility in the mail gateway and more error handling.
1360 # . Login now takes you to the page you back to the were denied access to.
1361 #
1362 # Fixed:
1363 # . Lots of bugs, thanks Roché and others on the devel mailing list!
1364 #
1365 # Revision 1.62 2001/11/24 00:45:42 jhermann
1366 # typeof() instead of type(): avoid clash with database field(?) "type"
1367 #
1368 # Fixes this traceback:
1369 #
1370 # Traceback (most recent call last):
1371 # File "roundup\cgi_client.py", line 535, in newnode
1372 # self._post_editnode(nid)
1373 # File "roundup\cgi_client.py", line 415, in _post_editnode
1374 # if type(value) != type([]): value = [value]
1375 # UnboundLocalError: local variable 'type' referenced before assignment
1376 #
1377 # Revision 1.61 2001/11/22 15:46:42 jhermann
1378 # Added module docstrings to all modules.
1379 #
1380 # Revision 1.60 2001/11/21 22:57:28 jhermann
1381 # Added dummy hooks for I18N and some preliminary (test) markup of
1382 # translatable messages
1383 #
1384 # Revision 1.59 2001/11/21 03:21:13 richard
1385 # oops
1386 #
1387 # Revision 1.58 2001/11/21 03:11:28 richard
1388 # Better handling of new properties.
1389 #
1390 # Revision 1.57 2001/11/15 10:24:27 richard
1391 # handle the case where there is no file attached
1392 #
1393 # Revision 1.56 2001/11/14 21:35:21 richard
1394 # . users may attach files to issues (and support in ext) through the web now
1395 #
1396 # Revision 1.55 2001/11/07 02:34:06 jhermann
1397 # Handling of damaged login cookies
1398 #
1399 # Revision 1.54 2001/11/07 01:16:12 richard
1400 # Remove the '=' padding from cookie value so quoting isn't an issue.
1401 #
1402 # Revision 1.53 2001/11/06 23:22:05 jhermann
1403 # More IE fixes: it does not like quotes around cookie values; in the
1404 # hope this does not break anything for other browser; if it does, we
1405 # need to check HTTP_USER_AGENT
1406 #
1407 # Revision 1.52 2001/11/06 23:11:22 jhermann
1408 # Fixed debug output in page footer; added expiry date to the login cookie
1409 # (expires 1 year in the future) to prevent probs with certain versions
1410 # of IE
1411 #
1412 # Revision 1.51 2001/11/06 22:00:34 jhermann
1413 # Get debug level from ROUNDUP_DEBUG env var
1414 #
1415 # Revision 1.50 2001/11/05 23:45:40 richard
1416 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1417 # Also made it present nicer error messages (not tracebacks).
1418 #
1419 # Revision 1.49 2001/11/04 03:07:12 richard
1420 # Fixed various cookie-related bugs:
1421 # . bug #477685 ] base64.decodestring breaks
1422 # . bug #477837 ] lynx does not like the cookie
1423 # . bug #477892 ] Password edit doesn't fix login cookie
1424 # Also closed a security hole - a logged-in user could edit another user's
1425 # details.
1426 #
1427 # Revision 1.48 2001/11/03 01:30:18 richard
1428 # Oops. uses pagefoot now.
1429 #
1430 # Revision 1.47 2001/11/03 01:29:28 richard
1431 # Login page didn't have all close tags.
1432 #
1433 # Revision 1.46 2001/11/03 01:26:55 richard
1434 # possibly fix truncated base64'ed user:pass
1435 #
1436 # Revision 1.45 2001/11/01 22:04:37 richard
1437 # Started work on supporting a pop3-fetching server
1438 # Fixed bugs:
1439 # . bug #477104 ] HTML tag error in roundup-server
1440 # . bug #477107 ] HTTP header problem
1441 #
1442 # Revision 1.44 2001/10/28 23:03:08 richard
1443 # Added more useful header to the classic schema.
1444 #
1445 # Revision 1.43 2001/10/24 00:01:42 richard
1446 # More fixes to lockout logic.
1447 #
1448 # Revision 1.42 2001/10/23 23:56:03 richard
1449 # HTML typo
1450 #
1451 # Revision 1.41 2001/10/23 23:52:35 richard
1452 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1453 #
1454 # Revision 1.40 2001/10/23 23:06:39 richard
1455 # Some cleanup.
1456 #
1457 # Revision 1.39 2001/10/23 01:00:18 richard
1458 # Re-enabled login and registration access after lopping them off via
1459 # disabling access for anonymous users.
1460 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1461 # a couple of bugs while I was there. Probably introduced a couple, but
1462 # things seem to work OK at the moment.
1463 #
1464 # Revision 1.38 2001/10/22 03:25:01 richard
1465 # Added configuration for:
1466 # . anonymous user access and registration (deny/allow)
1467 # . filter "widget" location on index page (top, bottom, both)
1468 # Updated some documentation.
1469 #
1470 # Revision 1.37 2001/10/21 07:26:35 richard
1471 # feature #473127: Filenames. I modified the file.index and htmltemplate
1472 # source so that the filename is used in the link and the creation
1473 # information is displayed.
1474 #
1475 # Revision 1.36 2001/10/21 04:44:50 richard
1476 # bug #473124: UI inconsistency with Link fields.
1477 # This also prompted me to fix a fairly long-standing usability issue -
1478 # that of being able to turn off certain filters.
1479 #
1480 # Revision 1.35 2001/10/21 00:17:54 richard
1481 # CGI interface view customisation section may now be hidden (patch from
1482 # Roch'e Compaan.)
1483 #
1484 # Revision 1.34 2001/10/20 11:58:48 richard
1485 # Catch errors in login - no username or password supplied.
1486 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1487 #
1488 # Revision 1.33 2001/10/17 00:18:41 richard
1489 # Manually constructing cookie headers now.
1490 #
1491 # Revision 1.32 2001/10/16 03:36:21 richard
1492 # CGI interface wasn't handling checkboxes at all.
1493 #
1494 # Revision 1.31 2001/10/14 10:55:00 richard
1495 # Handle empty strings in HTML template Link function
1496 #
1497 # Revision 1.30 2001/10/09 07:38:58 richard
1498 # Pushed the base code for the extended schema CGI interface back into the
1499 # code cgi_client module so that future updates will be less painful.
1500 # Also removed a debugging print statement from cgi_client.
1501 #
1502 # Revision 1.29 2001/10/09 07:25:59 richard
1503 # Added the Password property type. See "pydoc roundup.password" for
1504 # implementation details. Have updated some of the documentation too.
1505 #
1506 # Revision 1.28 2001/10/08 00:34:31 richard
1507 # Change message was stuffing up for multilinks with no key property.
1508 #
1509 # Revision 1.27 2001/10/05 02:23:24 richard
1510 # . roundup-admin create now prompts for property info if none is supplied
1511 # on the command-line.
1512 # . hyperdb Class getprops() method may now return only the mutable
1513 # properties.
1514 # . Login now uses cookies, which makes it a whole lot more flexible. We can
1515 # now support anonymous user access (read-only, unless there's an
1516 # "anonymous" user, in which case write access is permitted). Login
1517 # handling has been moved into cgi_client.Client.main()
1518 # . The "extended" schema is now the default in roundup init.
1519 # . The schemas have had their page headings modified to cope with the new
1520 # login handling. Existing installations should copy the interfaces.py
1521 # file from the roundup lib directory to their instance home.
1522 # . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1523 # Ping - has been removed.
1524 # . Fixed a whole bunch of places in the CGI interface where we should have
1525 # been returning Not Found instead of throwing an exception.
1526 # . Fixed a deviation from the spec: trying to modify the 'id' property of
1527 # an item now throws an exception.
1528 #
1529 # Revision 1.26 2001/09/12 08:31:42 richard
1530 # handle cases where mime type is not guessable
1531 #
1532 # Revision 1.25 2001/08/29 05:30:49 richard
1533 # change messages weren't being saved when there was no-one on the nosy list.
1534 #
1535 # Revision 1.24 2001/08/29 04:49:39 richard
1536 # didn't clean up fully after debugging :(
1537 #
1538 # Revision 1.23 2001/08/29 04:47:18 richard
1539 # Fixed CGI client change messages so they actually include the properties
1540 # changed (again).
1541 #
1542 # Revision 1.22 2001/08/17 00:08:10 richard
1543 # reverted back to sending messages always regardless of who is doing the web
1544 # edit. change notes weren't being saved. bleah. hackish.
1545 #
1546 # Revision 1.21 2001/08/15 23:43:18 richard
1547 # Fixed some isFooTypes that I missed.
1548 # Refactored some code in the CGI code.
1549 #
1550 # Revision 1.20 2001/08/12 06:32:36 richard
1551 # using isinstance(blah, Foo) now instead of isFooType
1552 #
1553 # Revision 1.19 2001/08/07 00:24:42 richard
1554 # stupid typo
1555 #
1556 # Revision 1.18 2001/08/07 00:15:51 richard
1557 # Added the copyright/license notice to (nearly) all files at request of
1558 # Bizar Software.
1559 #
1560 # Revision 1.17 2001/08/02 06:38:17 richard
1561 # Roundupdb now appends "mailing list" information to its messages which
1562 # include the e-mail address and web interface address. Templates may
1563 # override this in their db classes to include specific information (support
1564 # instructions, etc).
1565 #
1566 # Revision 1.16 2001/08/02 05:55:25 richard
1567 # Web edit messages aren't sent to the person who did the edit any more. No
1568 # message is generated if they are the only person on the nosy list.
1569 #
1570 # Revision 1.15 2001/08/02 00:34:10 richard
1571 # bleah syntax error
1572 #
1573 # Revision 1.14 2001/08/02 00:26:16 richard
1574 # Changed the order of the information in the message generated by web edits.
1575 #
1576 # Revision 1.13 2001/07/30 08:12:17 richard
1577 # Added time logging and file uploading to the templates.
1578 #
1579 # Revision 1.12 2001/07/30 06:26:31 richard
1580 # Added some documentation on how the newblah works.
1581 #
1582 # Revision 1.11 2001/07/30 06:17:45 richard
1583 # Features:
1584 # . Added ability for cgi newblah forms to indicate that the new node
1585 # should be linked somewhere.
1586 # Fixed:
1587 # . Fixed the agument handling for the roundup-admin find command.
1588 # . Fixed handling of summary when no note supplied for newblah. Again.
1589 # . Fixed detection of no form in htmltemplate Field display.
1590 #
1591 # Revision 1.10 2001/07/30 02:37:34 richard
1592 # Temporary measure until we have decent schema migration...
1593 #
1594 # Revision 1.9 2001/07/30 01:25:07 richard
1595 # Default implementation is now "classic" rather than "extended" as one would
1596 # expect.
1597 #
1598 # Revision 1.8 2001/07/29 08:27:40 richard
1599 # Fixed handling of passed-in values in form elements (ie. during a
1600 # drill-down)
1601 #
1602 # Revision 1.7 2001/07/29 07:01:39 richard
1603 # Added vim command to all source so that we don't get no steenkin' tabs :)
1604 #
1605 # Revision 1.6 2001/07/29 04:04:00 richard
1606 # Moved some code around allowing for subclassing to change behaviour.
1607 #
1608 # Revision 1.5 2001/07/28 08:16:52 richard
1609 # New issue form handles lack of note better now.
1610 #
1611 # Revision 1.4 2001/07/28 00:34:34 richard
1612 # Fixed some non-string node ids.
1613 #
1614 # Revision 1.3 2001/07/23 03:56:30 richard
1615 # oops, missed a config removal
1616 #
1617 # Revision 1.2 2001/07/22 12:09:32 richard
1618 # Final commit of Grande Splite
1619 #
1620 # Revision 1.1 2001/07/22 11:58:35 richard
1621 # More Grande Splite
1622 #
1623 #
1624 # vim: set filetype=python ts=4 sw=4 et si