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