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