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