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