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.116 2002-05-02 08:07:49 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_author_to_nosy(self, props):
561 ''' add the author value from the props to the nosy list
562 '''
563 if not props.has_key('author'):
564 return
565 author_id = props['author']
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 author_id in l:
572 return
573 props['nosy'] = l
574 else:
575 props['nosy'] = []
576 if author_id not in props['nosy']:
577 props['nosy'].append(author_id)
579 def _add_assignedto_to_nosy(self, props):
580 ''' add the assignedto value from the props to the nosy list
581 '''
582 if not props.has_key('assignedto'):
583 return
584 assignedto_id = props['assignedto']
585 if not props.has_key('nosy'):
586 # load current nosy
587 if self.nodeid:
588 cl = self.db.classes[self.classname]
589 l = cl.get(self.nodeid, 'nosy')
590 if assignedto_id in l:
591 return
592 props['nosy'] = l
593 else:
594 props['nosy'] = []
595 if assignedto_id not in props['nosy']:
596 props['nosy'].append(assignedto_id)
598 def _changenode(self, props):
599 ''' change the node based on the contents of the form
600 '''
601 cl = self.db.classes[self.classname]
602 # set status to chatting if 'unread' or 'resolved'
603 try:
604 # determine the id of 'unread','resolved' and 'chatting'
605 unread_id = self.db.status.lookup('unread')
606 resolved_id = self.db.status.lookup('resolved')
607 chatting_id = self.db.status.lookup('chatting')
608 current_status = cl.get(self.nodeid, 'status')
609 if props.has_key('status'):
610 new_status = props['status']
611 else:
612 # apparently there's a chance that some browsers don't
613 # send status...
614 new_status = current_status
615 except KeyError:
616 pass
617 else:
618 if new_status == unread_id or (new_status == resolved_id
619 and current_status == resolved_id):
620 props['status'] = chatting_id
622 self._add_assignedto_to_nosy(props)
624 # possibly add the author of the change to the nosy list
625 if self.db.config.ADD_AUTHOR_TO_NOSY == 'yes':
626 self._add_author_to_nosy(props)
628 # create the message
629 message, files = self._handle_message()
630 if message:
631 props['messages'] = cl.get(self.nodeid, 'messages') + [message]
632 if files:
633 props['files'] = cl.get(self.nodeid, 'files') + files
635 # make the changes
636 cl.set(self.nodeid, **props)
638 def _createnode(self):
639 ''' create a node based on the contents of the form
640 '''
641 cl = self.db.classes[self.classname]
642 props = parsePropsFromForm(self.db, cl, self.form)
644 # set status to 'unread' if not specified - a status of '- no
645 # selection -' doesn't make sense
646 if not props.has_key('status') and cl.getprops().has_key('status'):
647 try:
648 unread_id = self.db.status.lookup('unread')
649 except KeyError:
650 pass
651 else:
652 props['status'] = unread_id
654 self._add_assignedto_to_nosy(props)
656 # possibly add the author of the new node to the nosy list
657 if self.db.config.ADD_AUTHOR_TO_NOSY in ('new', 'yes'):
658 self._add_author_to_nosy(props)
660 # check for messages and files
661 message, files = self._handle_message()
662 if message:
663 props['messages'] = [message]
664 if files:
665 props['files'] = files
666 # create the node and return it's id
667 return cl.create(**props)
669 def _handle_message(self):
670 ''' generate an edit message
671 '''
672 # handle file attachments
673 files = []
674 if self.form.has_key('__file'):
675 file = self.form['__file']
676 if file.filename:
677 filename = file.filename.split('\\')[-1]
678 mime_type = mimetypes.guess_type(filename)[0]
679 if not mime_type:
680 mime_type = "application/octet-stream"
681 # create the new file entry
682 files.append(self.db.file.create(type=mime_type,
683 name=filename, content=file.file.read()))
685 # we don't want to do a message if none of the following is true...
686 cn = self.classname
687 cl = self.db.classes[self.classname]
688 props = cl.getprops()
689 note = None
690 # in a nutshell, don't do anything if there's no note or there's no
691 # NOSY
692 if self.form.has_key('__note'):
693 note = self.form['__note'].value.strip()
694 if not props.has_key('messages'):
695 return None, files
696 if not isinstance(props['messages'], hyperdb.Multilink):
697 return None, files
698 if not props['messages'].classname == 'msg':
699 return None, files
700 if not (self.form.has_key('nosy') or note):
701 return None, files
703 # handle the note
704 if note:
705 if '\n' in note:
706 summary = re.split(r'\n\r?', note)[0]
707 else:
708 summary = note
709 m = ['%s\n'%note]
710 elif not files:
711 # don't generate a useless message
712 return None, files
714 # handle the messageid
715 # TODO: handle inreplyto
716 messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
717 self.classname, self.instance.MAIL_DOMAIN)
719 # now create the message, attaching the files
720 content = '\n'.join(m)
721 message_id = self.db.msg.create(author=self.getuid(),
722 recipients=[], date=date.Date('.'), summary=summary,
723 content=content, files=files, messageid=messageid)
725 # update the messages property
726 return message_id, files
728 def _post_editnode(self, nid):
729 '''Do the linking part of the node creation.
731 If a form element has :link or :multilink appended to it, its
732 value specifies a node designator and the property on that node
733 to add _this_ node to as a link or multilink.
735 This is typically used on, eg. the file upload page to indicated
736 which issue to link the file to.
738 TODO: I suspect that this and newfile will go away now that
739 there's the ability to upload a file using the issue __file form
740 element!
741 '''
742 cn = self.classname
743 cl = self.db.classes[cn]
744 # link if necessary
745 keys = self.form.keys()
746 for key in keys:
747 if key == ':multilink':
748 value = self.form[key].value
749 if type(value) != type([]): value = [value]
750 for value in value:
751 designator, property = value.split(':')
752 link, nodeid = roundupdb.splitDesignator(designator)
753 link = self.db.classes[link]
754 value = link.get(nodeid, property)
755 value.append(nid)
756 link.set(nodeid, **{property: value})
757 elif key == ':link':
758 value = self.form[key].value
759 if type(value) != type([]): value = [value]
760 for value in value:
761 designator, property = value.split(':')
762 link, nodeid = roundupdb.splitDesignator(designator)
763 link = self.db.classes[link]
764 link.set(nodeid, **{property: nid})
766 def newnode(self, message=None):
767 ''' Add a new node to the database.
769 The form works in two modes: blank form and submission (that is,
770 the submission goes to the same URL). **Eventually this means that
771 the form will have previously entered information in it if
772 submission fails.
774 The new node will be created with the properties specified in the
775 form submission. For multilinks, multiple form entries are handled,
776 as are prop=value,value,value. You can't mix them though.
778 If the new node is to be referenced from somewhere else immediately
779 (ie. the new node is a file that is to be attached to a support
780 issue) then supply one of these arguments in addition to the usual
781 form entries:
782 :link=designator:property
783 :multilink=designator:property
784 ... which means that once the new node is created, the "property"
785 on the node given by "designator" should now reference the new
786 node's id. The node id will be appended to the multilink.
787 '''
788 cn = self.classname
789 cl = self.db.classes[cn]
791 # possibly perform a create
792 keys = self.form.keys()
793 if [i for i in keys if i[0] != ':']:
794 props = {}
795 try:
796 nid = self._createnode()
797 # handle linked nodes
798 self._post_editnode(nid)
799 # and some nice feedback for the user
800 message = _('%(classname)s created ok')%{'classname': cn}
802 # render the newly created issue
803 self.db.commit()
804 self.nodeid = nid
805 self.pagehead('%s: %s'%(self.classname.capitalize(), nid),
806 message)
807 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES,
808 self.classname)
809 item.render(nid)
810 self.pagefoot()
811 return
812 except:
813 self.db.rollback()
814 s = StringIO.StringIO()
815 traceback.print_exc(None, s)
816 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
817 self.pagehead(_('New %(classname)s')%{'classname':
818 self.classname.capitalize()}, message)
820 # call the template
821 newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
822 self.classname)
823 newitem.render(self.form)
825 self.pagefoot()
826 newissue = newnode
828 def newuser(self, message=None):
829 ''' Add a new user to the database.
831 Don't do any of the message or file handling, just create the node.
832 '''
833 cn = self.classname
834 cl = self.db.classes[cn]
836 # possibly perform a create
837 keys = self.form.keys()
838 if [i for i in keys if i[0] != ':']:
839 try:
840 props = parsePropsFromForm(self.db, cl, self.form)
841 nid = cl.create(**props)
842 # handle linked nodes
843 self._post_editnode(nid)
844 # and some nice feedback for the user
845 message = _('%(classname)s created ok')%{'classname': cn}
846 except:
847 self.db.rollback()
848 s = StringIO.StringIO()
849 traceback.print_exc(None, s)
850 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
851 self.pagehead(_('New %(classname)s')%{'classname':
852 self.classname.capitalize()}, message)
854 # call the template
855 newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
856 self.classname)
857 newitem.render(self.form)
859 self.pagefoot()
861 def newfile(self, message=None):
862 ''' Add a new file to the database.
864 This form works very much the same way as newnode - it just has a
865 file upload.
866 '''
867 cn = self.classname
868 cl = self.db.classes[cn]
870 # possibly perform a create
871 keys = self.form.keys()
872 if [i for i in keys if i[0] != ':']:
873 try:
874 file = self.form['content']
875 mime_type = mimetypes.guess_type(file.filename)[0]
876 if not mime_type:
877 mime_type = "application/octet-stream"
878 # save the file
879 nid = cl.create(content=file.file.read(), type=mime_type,
880 name=file.filename)
881 # handle linked nodes
882 self._post_editnode(nid)
883 # and some nice feedback for the user
884 message = _('%(classname)s created ok')%{'classname': cn}
885 except:
886 self.db.rollback()
887 s = StringIO.StringIO()
888 traceback.print_exc(None, s)
889 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
891 self.pagehead(_('New %(classname)s')%{'classname':
892 self.classname.capitalize()}, message)
893 newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
894 self.classname)
895 newitem.render(self.form)
896 self.pagefoot()
898 def showuser(self, message=None):
899 '''Display a user page for editing. Make sure the user is allowed
900 to edit this node, and also check for password changes.
901 '''
902 if self.user == 'anonymous':
903 raise Unauthorised
905 user = self.db.user
907 # get the username of the node being edited
908 node_user = user.get(self.nodeid, 'username')
910 if self.user not in ('admin', node_user):
911 raise Unauthorised
913 #
914 # perform any editing
915 #
916 keys = self.form.keys()
917 num_re = re.compile('^\d+$')
918 if keys:
919 try:
920 props = parsePropsFromForm(self.db, user, self.form,
921 self.nodeid)
922 set_cookie = 0
923 if props.has_key('password'):
924 password = self.form['password'].value.strip()
925 if not password:
926 # no password was supplied - don't change it
927 del props['password']
928 elif self.nodeid == self.getuid():
929 # this is the logged-in user's password
930 set_cookie = password
931 user.set(self.nodeid, **props)
932 # and some feedback for the user
933 message = _('%(changes)s edited ok')%{'changes':
934 ', '.join(props.keys())}
935 except:
936 self.db.rollback()
937 s = StringIO.StringIO()
938 traceback.print_exc(None, s)
939 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
940 else:
941 set_cookie = 0
943 # fix the cookie if the password has changed
944 if set_cookie:
945 self.set_cookie(self.user, set_cookie)
947 #
948 # now the display
949 #
950 self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
952 # use the template to display the item
953 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 'user')
954 item.render(self.nodeid)
955 self.pagefoot()
957 def showfile(self):
958 ''' display a file
959 '''
960 nodeid = self.nodeid
961 cl = self.db.file
962 mime_type = cl.get(nodeid, 'type')
963 if mime_type == 'message/rfc822':
964 mime_type = 'text/plain'
965 self.header(headers={'Content-Type': mime_type})
966 self.write(cl.get(nodeid, 'content'))
968 def classes(self, message=None):
969 ''' display a list of all the classes in the database
970 '''
971 if self.user == 'admin':
972 self.pagehead(_('Table of classes'), message)
973 classnames = self.db.classes.keys()
974 classnames.sort()
975 self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
976 for cn in classnames:
977 cl = self.db.getclass(cn)
978 self.write('<tr class="list-header"><th colspan=2 align=left>'
979 '<a href="%s">%s</a></th></tr>'%(cn, cn.capitalize()))
980 for key, value in cl.properties.items():
981 if value is None: value = ''
982 else: value = str(value)
983 self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
984 key, cgi.escape(value)))
985 self.write('</table>')
986 self.pagefoot()
987 else:
988 raise Unauthorised
990 def login(self, message=None, newuser_form=None, action='index'):
991 '''Display a login page.
992 '''
993 self.pagehead(_('Login to roundup'), message)
994 self.write(_('''
995 <table>
996 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
997 <form onSubmit="return submit_once()" action="login_action" method=POST>
998 <input type="hidden" name="__destination_url" value="%(action)s">
999 <tr><td align=right>Login name: </td>
1000 <td><input name="__login_name"></td></tr>
1001 <tr><td align=right>Password: </td>
1002 <td><input type="password" name="__login_password"></td></tr>
1003 <tr><td></td>
1004 <td><input type="submit" value="Log In"></td></tr>
1005 </form>
1006 ''')%locals())
1007 if self.user is None and self.instance.ANONYMOUS_REGISTER == 'deny':
1008 self.write('</table>')
1009 self.pagefoot()
1010 return
1011 values = {'realname': '', 'organisation': '', 'address': '',
1012 'phone': '', 'username': '', 'password': '', 'confirm': '',
1013 'action': action, 'alternate_addresses': ''}
1014 if newuser_form is not None:
1015 for key in newuser_form.keys():
1016 values[key] = newuser_form[key].value
1017 self.write(_('''
1018 <p>
1019 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
1020 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
1021 <form onSubmit="return submit_once()" action="newuser_action" method=POST>
1022 <input type="hidden" name="__destination_url" value="%(action)s">
1023 <tr><td align=right><em>Name: </em></td>
1024 <td><input name="realname" value="%(realname)s" size=40></td></tr>
1025 <tr><td align=right><em>Organisation: </em></td>
1026 <td><input name="organisation" value="%(organisation)s" size=40></td></tr>
1027 <tr><td align=right>E-Mail Address: </td>
1028 <td><input name="address" value="%(address)s" size=40></td></tr>
1029 <tr><td align=right><em>Alternate E-mail Addresses: </em></td>
1030 <td><textarea name="alternate_addresses" rows=5 cols=40>%(alternate_addresses)s</textarea></td></tr>
1031 <tr><td align=right><em>Phone: </em></td>
1032 <td><input name="phone" value="%(phone)s"></td></tr>
1033 <tr><td align=right>Preferred Login name: </td>
1034 <td><input name="username" value="%(username)s"></td></tr>
1035 <tr><td align=right>Password: </td>
1036 <td><input type="password" name="password" value="%(password)s"></td></tr>
1037 <tr><td align=right>Password Again: </td>
1038 <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
1039 <tr><td></td>
1040 <td><input type="submit" value="Register"></td></tr>
1041 </form>
1042 </table>
1043 ''')%values)
1044 self.pagefoot()
1046 def login_action(self, message=None):
1047 '''Attempt to log a user in and set the cookie
1049 returns 0 if a page is generated as a result of this call, and
1050 1 if not (ie. the login is successful
1051 '''
1052 if not self.form.has_key('__login_name'):
1053 self.login(message=_('Username required'))
1054 return 0
1055 self.user = self.form['__login_name'].value
1056 if self.form.has_key('__login_password'):
1057 password = self.form['__login_password'].value
1058 else:
1059 password = ''
1060 # make sure the user exists
1061 try:
1062 uid = self.db.user.lookup(self.user)
1063 except KeyError:
1064 name = self.user
1065 self.make_user_anonymous()
1066 action = self.form['__destination_url'].value
1067 self.login(message=_('No such user "%(name)s"')%locals(),
1068 action=action)
1069 return 0
1071 # and that the password is correct
1072 pw = self.db.user.get(uid, 'password')
1073 if password != pw:
1074 self.make_user_anonymous()
1075 action = self.form['__destination_url'].value
1076 self.login(message=_('Incorrect password'), action=action)
1077 return 0
1079 self.set_cookie(self.user, password)
1080 return 1
1082 def newuser_action(self, message=None):
1083 '''Attempt to create a new user based on the contents of the form
1084 and then set the cookie.
1086 return 1 on successful login
1087 '''
1088 # re-open the database as "admin"
1089 self.db = self.instance.open('admin')
1091 # TODO: pre-check the required fields and username key property
1092 cl = self.db.user
1093 try:
1094 props = parsePropsFromForm(self.db, cl, self.form)
1095 uid = cl.create(**props)
1096 except ValueError, message:
1097 action = self.form['__destination_url'].value
1098 self.login(message, action=action)
1099 return 0
1100 self.user = cl.get(uid, 'username')
1101 password = cl.get(uid, 'password')
1102 self.set_cookie(self.user, self.form['password'].value)
1103 return 1
1105 def set_cookie(self, user, password):
1106 # construct the cookie
1107 user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
1108 if user[-1] == '=':
1109 if user[-2] == '=':
1110 user = user[:-2]
1111 else:
1112 user = user[:-1]
1113 expire = Cookie._getdate(86400*365)
1114 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
1115 self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
1116 user, expire, path)})
1118 def make_user_anonymous(self):
1119 # make us anonymous if we can
1120 try:
1121 self.db.user.lookup('anonymous')
1122 self.user = 'anonymous'
1123 except KeyError:
1124 self.user = None
1126 def logout(self, message=None):
1127 self.make_user_anonymous()
1128 # construct the logout cookie
1129 now = Cookie._getdate()
1130 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
1131 self.header({'Set-Cookie':
1132 'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
1133 path)})
1134 self.login()
1136 def main(self):
1137 '''Wrap the database accesses so we can close the database cleanly
1138 '''
1139 # determine the uid to use
1140 self.db = self.instance.open('admin')
1141 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
1142 user = 'anonymous'
1143 if (cookie.has_key('roundup_user') and
1144 cookie['roundup_user'].value != 'deleted'):
1145 cookie = cookie['roundup_user'].value
1146 if len(cookie)%4:
1147 cookie = cookie + '='*(4-len(cookie)%4)
1148 try:
1149 user, password = binascii.a2b_base64(cookie).split(':')
1150 except (TypeError, binascii.Error, binascii.Incomplete):
1151 # damaged cookie!
1152 user, password = 'anonymous', ''
1154 # make sure the user exists
1155 try:
1156 uid = self.db.user.lookup(user)
1157 # now validate the password
1158 if password != self.db.user.get(uid, 'password'):
1159 user = 'anonymous'
1160 except KeyError:
1161 user = 'anonymous'
1163 # make sure the anonymous user is valid if we're using it
1164 if user == 'anonymous':
1165 self.make_user_anonymous()
1166 else:
1167 self.user = user
1169 # re-open the database for real, using the user
1170 self.db = self.instance.open(self.user)
1172 # now figure which function to call
1173 path = self.split_path
1175 # default action to index if the path has no information in it
1176 if not path or path[0] in ('', 'index'):
1177 action = 'index'
1178 else:
1179 action = path[0]
1181 # Everthing ignores path[1:]
1182 # - The file download link generator actually relies on this - it
1183 # appends the name of the file to the URL so the download file name
1184 # is correct, but doesn't actually use it.
1186 # everyone is allowed to try to log in
1187 if action == 'login_action':
1188 # try to login
1189 if not self.login_action():
1190 return
1191 # figure the resulting page
1192 action = self.form['__destination_url'].value
1193 if not action:
1194 action = 'index'
1195 self.do_action(action)
1196 return
1198 # allow anonymous people to register
1199 if action == 'newuser_action':
1200 # if we don't have a login and anonymous people aren't allowed to
1201 # register, then spit up the login form
1202 if self.instance.ANONYMOUS_REGISTER == 'deny' and self.user is None:
1203 if action == 'login':
1204 self.login() # go to the index after login
1205 else:
1206 self.login(action=action)
1207 return
1208 # try to add the user
1209 if not self.newuser_action():
1210 return
1211 # figure the resulting page
1212 action = self.form['__destination_url'].value
1213 if not action:
1214 action = 'index'
1216 # no login or registration, make sure totally anonymous access is OK
1217 elif self.instance.ANONYMOUS_ACCESS == 'deny' and self.user is None:
1218 if action == 'login':
1219 self.login() # go to the index after login
1220 else:
1221 self.login(action=action)
1222 return
1224 # just a regular action
1225 self.do_action(action)
1227 # commit all changes to the database
1228 self.db.commit()
1230 def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
1231 nre=re.compile(r'new(\w+)')):
1232 '''Figure the user's action and do it.
1233 '''
1234 # here be the "normal" functionality
1235 if action == 'index':
1236 self.index()
1237 return
1238 if action == 'list_classes':
1239 self.classes()
1240 return
1241 if action == 'classhelp':
1242 self.classhelp()
1243 return
1244 if action == 'login':
1245 self.login()
1246 return
1247 if action == 'logout':
1248 self.logout()
1249 return
1251 # see if we're to display an existing node
1252 m = dre.match(action)
1253 if m:
1254 self.classname = m.group(1)
1255 self.nodeid = m.group(2)
1256 try:
1257 cl = self.db.classes[self.classname]
1258 except KeyError:
1259 raise NotFound
1260 try:
1261 cl.get(self.nodeid, 'id')
1262 except IndexError:
1263 raise NotFound
1264 try:
1265 func = getattr(self, 'show%s'%self.classname)
1266 except AttributeError:
1267 raise NotFound
1268 func()
1269 return
1271 # see if we're to put up the new node page
1272 m = nre.match(action)
1273 if m:
1274 self.classname = m.group(1)
1275 try:
1276 func = getattr(self, 'new%s'%self.classname)
1277 except AttributeError:
1278 raise NotFound
1279 func()
1280 return
1282 # otherwise, display the named class
1283 self.classname = action
1284 try:
1285 self.db.getclass(self.classname)
1286 except KeyError:
1287 raise NotFound
1288 self.list()
1291 class ExtendedClient(Client):
1292 '''Includes pages and page heading information that relate to the
1293 extended schema.
1294 '''
1295 showsupport = Client.shownode
1296 showtimelog = Client.shownode
1297 newsupport = Client.newnode
1298 newtimelog = Client.newnode
1300 default_index_sort = ['-activity']
1301 default_index_group = ['priority']
1302 default_index_filter = ['status']
1303 default_index_columns = ['activity','status','title','assignedto']
1304 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
1306 def parsePropsFromForm(db, cl, form, nodeid=0):
1307 '''Pull properties for the given class out of the form.
1308 '''
1309 props = {}
1310 keys = form.keys()
1311 num_re = re.compile('^\d+$')
1312 for key in keys:
1313 if not cl.properties.has_key(key):
1314 continue
1315 proptype = cl.properties[key]
1316 if isinstance(proptype, hyperdb.String):
1317 value = form[key].value.strip()
1318 elif isinstance(proptype, hyperdb.Password):
1319 value = password.Password(form[key].value.strip())
1320 elif isinstance(proptype, hyperdb.Date):
1321 value = form[key].value.strip()
1322 if value:
1323 value = date.Date(form[key].value.strip())
1324 else:
1325 value = None
1326 elif isinstance(proptype, hyperdb.Interval):
1327 value = form[key].value.strip()
1328 if value:
1329 value = date.Interval(form[key].value.strip())
1330 else:
1331 value = None
1332 elif isinstance(proptype, hyperdb.Link):
1333 value = form[key].value.strip()
1334 # see if it's the "no selection" choice
1335 if value == '-1':
1336 # don't set this property
1337 continue
1338 else:
1339 # handle key values
1340 link = cl.properties[key].classname
1341 if not num_re.match(value):
1342 try:
1343 value = db.classes[link].lookup(value)
1344 except KeyError:
1345 raise ValueError, _('property "%(propname)s": '
1346 '%(value)s not a %(classname)s')%{'propname':key,
1347 'value': value, 'classname': link}
1348 elif isinstance(proptype, hyperdb.Multilink):
1349 value = form[key]
1350 if type(value) != type([]):
1351 value = [i.strip() for i in value.value.split(',')]
1352 else:
1353 value = [i.value.strip() for i in value]
1354 link = cl.properties[key].classname
1355 l = []
1356 for entry in map(str, value):
1357 if entry == '': continue
1358 if not num_re.match(entry):
1359 try:
1360 entry = db.classes[link].lookup(entry)
1361 except KeyError:
1362 raise ValueError, _('property "%(propname)s": '
1363 '"%(value)s" not an entry of %(classname)s')%{
1364 'propname':key, 'value': entry, 'classname': link}
1365 l.append(entry)
1366 l.sort()
1367 value = l
1369 # get the old value
1370 if nodeid:
1371 try:
1372 existing = cl.get(nodeid, key)
1373 except KeyError:
1374 # this might be a new property for which there is no existing
1375 # value
1376 if not cl.properties.has_key(key): raise
1378 # if changed, set it
1379 if value != existing:
1380 props[key] = value
1381 else:
1382 props[key] = value
1383 return props
1385 #
1386 # $Log: not supported by cvs2svn $
1387 # Revision 1.115 2002/04/02 01:56:10 richard
1388 # . stop sending blank (whitespace-only) notes
1389 #
1390 # Revision 1.114 2002/03/17 23:06:05 richard
1391 # oops
1392 #
1393 # Revision 1.113 2002/03/14 23:59:24 richard
1394 # . #517734 ] web header customisation is obscure
1395 #
1396 # Revision 1.112 2002/03/12 22:52:26 richard
1397 # more pychecker warnings removed
1398 #
1399 # Revision 1.111 2002/02/25 04:32:21 richard
1400 # ahem
1401 #
1402 # Revision 1.110 2002/02/21 07:19:08 richard
1403 # ... and label, width and height control for extra flavour!
1404 #
1405 # Revision 1.109 2002/02/21 07:08:19 richard
1406 # oops
1407 #
1408 # Revision 1.108 2002/02/21 07:02:54 richard
1409 # The correct var is "HTTP_HOST"
1410 #
1411 # Revision 1.107 2002/02/21 06:57:38 richard
1412 # . Added popup help for classes using the classhelp html template function.
1413 # - add <display call="classhelp('priority', 'id,name,description')">
1414 # to an item page, and it generates a link to a popup window which displays
1415 # the id, name and description for the priority class. The description
1416 # field won't exist in most installations, but it will be added to the
1417 # default templates.
1418 #
1419 # Revision 1.106 2002/02/21 06:23:00 richard
1420 # *** empty log message ***
1421 #
1422 # Revision 1.105 2002/02/20 05:52:10 richard
1423 # better error handling
1424 #
1425 # Revision 1.104 2002/02/20 05:45:17 richard
1426 # Use the csv module for generating the form entry so it's correct.
1427 # [also noted the sf.net feature request id in the change log]
1428 #
1429 # Revision 1.103 2002/02/20 05:05:28 richard
1430 # . Added simple editing for classes that don't define a templated interface.
1431 # - access using the admin "class list" interface
1432 # - limited to admin-only
1433 # - requires the csv module from object-craft (url given if it's missing)
1434 #
1435 # Revision 1.102 2002/02/15 07:08:44 richard
1436 # . Alternate email addresses are now available for users. See the MIGRATION
1437 # file for info on how to activate the feature.
1438 #
1439 # Revision 1.101 2002/02/14 23:39:18 richard
1440 # . All forms now have "double-submit" protection when Javascript is enabled
1441 # on the client-side.
1442 #
1443 # Revision 1.100 2002/01/16 07:02:57 richard
1444 # . lots of date/interval related changes:
1445 # - more relaxed date format for input
1446 #
1447 # Revision 1.99 2002/01/16 03:02:42 richard
1448 # #503793 ] changing assignedto resets nosy list
1449 #
1450 # Revision 1.98 2002/01/14 02:20:14 richard
1451 # . changed all config accesses so they access either the instance or the
1452 # config attriubute on the db. This means that all config is obtained from
1453 # instance_config instead of the mish-mash of classes. This will make
1454 # switching to a ConfigParser setup easier too, I hope.
1455 #
1456 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1457 # 0.5.0 switch, I hope!)
1458 #
1459 # Revision 1.97 2002/01/11 23:22:29 richard
1460 # . #502437 ] rogue reactor and unittest
1461 # in short, the nosy reactor was modifying the nosy list. That code had
1462 # been there for a long time, and I suspsect it was there because we
1463 # weren't generating the nosy list correctly in other places of the code.
1464 # We're now doing that, so the nosy-modifying code can go away from the
1465 # nosy reactor.
1466 #
1467 # Revision 1.96 2002/01/10 05:26:10 richard
1468 # missed a parsePropsFromForm in last update
1469 #
1470 # Revision 1.95 2002/01/10 03:39:45 richard
1471 # . fixed some problems with web editing and change detection
1472 #
1473 # Revision 1.94 2002/01/09 13:54:21 grubert
1474 # _add_assignedto_to_nosy did set nosy to assignedto only, no adding.
1475 #
1476 # Revision 1.93 2002/01/08 11:57:12 richard
1477 # crying out for real configuration handling... :(
1478 #
1479 # Revision 1.92 2002/01/08 04:12:05 richard
1480 # Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
1481 #
1482 # Revision 1.91 2002/01/08 04:03:47 richard
1483 # I mucked the intent of the code up.
1484 #
1485 # Revision 1.90 2002/01/08 03:56:55 richard
1486 # Oops, missed this before the beta:
1487 # . #495392 ] empty nosy -patch
1488 #
1489 # Revision 1.89 2002/01/07 20:24:45 richard
1490 # *mutter* stupid cutnpaste
1491 #
1492 # Revision 1.88 2002/01/02 02:31:38 richard
1493 # Sorry for the huge checkin message - I was only intending to implement #496356
1494 # but I found a number of places where things had been broken by transactions:
1495 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1496 # for _all_ roundup-generated smtp messages to be sent to.
1497 # . the transaction cache had broken the roundupdb.Class set() reactors
1498 # . newly-created author users in the mailgw weren't being committed to the db
1499 #
1500 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1501 # on when I found that stuff :):
1502 # . #496356 ] Use threading in messages
1503 # . detectors were being registered multiple times
1504 # . added tests for mailgw
1505 # . much better attaching of erroneous messages in the mail gateway
1506 #
1507 # Revision 1.87 2001/12/23 23:18:49 richard
1508 # We already had an admin-specific section of the web heading, no need to add
1509 # another one :)
1510 #
1511 # Revision 1.86 2001/12/20 15:43:01 rochecompaan
1512 # Features added:
1513 # . Multilink properties are now displayed as comma separated values in
1514 # a textbox
1515 # . The add user link is now only visible to the admin user
1516 # . Modified the mail gateway to reject submissions from unknown
1517 # addresses if ANONYMOUS_ACCESS is denied
1518 #
1519 # Revision 1.85 2001/12/20 06:13:24 rochecompaan
1520 # Bugs fixed:
1521 # . Exception handling in hyperdb for strings-that-look-like numbers got
1522 # lost somewhere
1523 # . Internet Explorer submits full path for filename - we now strip away
1524 # the path
1525 # Features added:
1526 # . Link and multilink properties are now displayed sorted in the cgi
1527 # interface
1528 #
1529 # Revision 1.84 2001/12/18 15:30:30 rochecompaan
1530 # Fixed bugs:
1531 # . Fixed file creation and retrieval in same transaction in anydbm
1532 # backend
1533 # . Cgi interface now renders new issue after issue creation
1534 # . Could not set issue status to resolved through cgi interface
1535 # . Mail gateway was changing status back to 'chatting' if status was
1536 # omitted as an argument
1537 #
1538 # Revision 1.83 2001/12/15 23:51:01 richard
1539 # Tested the changes and fixed a few problems:
1540 # . files are now attached to the issue as well as the message
1541 # . newuser is a real method now since we don't want to do the message/file
1542 # stuff for it
1543 # . added some documentation
1544 # The really big changes in the diff are a result of me moving some code
1545 # around to keep like methods together a bit better.
1546 #
1547 # Revision 1.82 2001/12/15 19:24:39 rochecompaan
1548 # . Modified cgi interface to change properties only once all changes are
1549 # collected, files created and messages generated.
1550 # . Moved generation of change note to nosyreactors.
1551 # . We now check for changes to "assignedto" to ensure it's added to the
1552 # nosy list.
1553 #
1554 # Revision 1.81 2001/12/12 23:55:00 richard
1555 # Fixed some problems with user editing
1556 #
1557 # Revision 1.80 2001/12/12 23:27:14 richard
1558 # Added a Zope frontend for roundup.
1559 #
1560 # Revision 1.79 2001/12/10 22:20:01 richard
1561 # Enabled transaction support in the bsddb backend. It uses the anydbm code
1562 # where possible, only replacing methods where the db is opened (it uses the
1563 # btree opener specifically.)
1564 # Also cleaned up some change note generation.
1565 # Made the backends package work with pydoc too.
1566 #
1567 # Revision 1.78 2001/12/07 05:59:27 rochecompaan
1568 # Fixed small bug that prevented adding issues through the web.
1569 #
1570 # Revision 1.77 2001/12/06 22:48:29 richard
1571 # files multilink was being nuked in post_edit_node
1572 #
1573 # Revision 1.76 2001/12/05 14:26:44 rochecompaan
1574 # Removed generation of change note from "sendmessage" in roundupdb.py.
1575 # The change note is now generated when the message is created.
1576 #
1577 # Revision 1.75 2001/12/04 01:25:08 richard
1578 # Added some rollbacks where we were catching exceptions that would otherwise
1579 # have stopped committing.
1580 #
1581 # Revision 1.74 2001/12/02 05:06:16 richard
1582 # . We now use weakrefs in the Classes to keep the database reference, so
1583 # the close() method on the database is no longer needed.
1584 # I bumped the minimum python requirement up to 2.1 accordingly.
1585 # . #487480 ] roundup-server
1586 # . #487476 ] INSTALL.txt
1587 #
1588 # I also cleaned up the change message / post-edit stuff in the cgi client.
1589 # There's now a clearly marked "TODO: append the change note" where I believe
1590 # the change note should be added there. The "changes" list will obviously
1591 # have to be modified to be a dict of the changes, or somesuch.
1592 #
1593 # More testing needed.
1594 #
1595 # Revision 1.73 2001/12/01 07:17:50 richard
1596 # . We now have basic transaction support! Information is only written to
1597 # the database when the commit() method is called. Only the anydbm
1598 # backend is modified in this way - neither of the bsddb backends have been.
1599 # The mail, admin and cgi interfaces all use commit (except the admin tool
1600 # doesn't have a commit command, so interactive users can't commit...)
1601 # . Fixed login/registration forwarding the user to the right page (or not,
1602 # on a failure)
1603 #
1604 # Revision 1.72 2001/11/30 20:47:58 rochecompaan
1605 # Links in page header are now consistent with default sort order.
1606 #
1607 # Fixed bugs:
1608 # - When login failed the list of issues were still rendered.
1609 # - User was redirected to index page and not to his destination url
1610 # if his first login attempt failed.
1611 #
1612 # Revision 1.71 2001/11/30 20:28:10 rochecompaan
1613 # Property changes are now completely traceable, whether changes are
1614 # made through the web or by email
1615 #
1616 # Revision 1.70 2001/11/30 00:06:29 richard
1617 # Converted roundup/cgi_client.py to use _()
1618 # Added the status file, I18N_PROGRESS.txt
1619 #
1620 # Revision 1.69 2001/11/29 23:19:51 richard
1621 # Removed the "This issue has been edited through the web" when a valid
1622 # change note is supplied.
1623 #
1624 # Revision 1.68 2001/11/29 04:57:23 richard
1625 # a little comment
1626 #
1627 # Revision 1.67 2001/11/28 21:55:35 richard
1628 # . login_action and newuser_action return values were being ignored
1629 # . Woohoo! Found that bloody re-login bug that was killing the mail
1630 # gateway.
1631 # (also a minor cleanup in hyperdb)
1632 #
1633 # Revision 1.66 2001/11/27 03:00:50 richard
1634 # couple of bugfixes from latest patch integration
1635 #
1636 # Revision 1.65 2001/11/26 23:00:53 richard
1637 # This config stuff is getting to be a real mess...
1638 #
1639 # Revision 1.64 2001/11/26 22:56:35 richard
1640 # typo
1641 #
1642 # Revision 1.63 2001/11/26 22:55:56 richard
1643 # Feature:
1644 # . Added INSTANCE_NAME to configuration - used in web and email to identify
1645 # the instance.
1646 # . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1647 # signature info in e-mails.
1648 # . Some more flexibility in the mail gateway and more error handling.
1649 # . Login now takes you to the page you back to the were denied access to.
1650 #
1651 # Fixed:
1652 # . Lots of bugs, thanks Roché and others on the devel mailing list!
1653 #
1654 # Revision 1.62 2001/11/24 00:45:42 jhermann
1655 # typeof() instead of type(): avoid clash with database field(?) "type"
1656 #
1657 # Fixes this traceback:
1658 #
1659 # Traceback (most recent call last):
1660 # File "roundup\cgi_client.py", line 535, in newnode
1661 # self._post_editnode(nid)
1662 # File "roundup\cgi_client.py", line 415, in _post_editnode
1663 # if type(value) != type([]): value = [value]
1664 # UnboundLocalError: local variable 'type' referenced before assignment
1665 #
1666 # Revision 1.61 2001/11/22 15:46:42 jhermann
1667 # Added module docstrings to all modules.
1668 #
1669 # Revision 1.60 2001/11/21 22:57:28 jhermann
1670 # Added dummy hooks for I18N and some preliminary (test) markup of
1671 # translatable messages
1672 #
1673 # Revision 1.59 2001/11/21 03:21:13 richard
1674 # oops
1675 #
1676 # Revision 1.58 2001/11/21 03:11:28 richard
1677 # Better handling of new properties.
1678 #
1679 # Revision 1.57 2001/11/15 10:24:27 richard
1680 # handle the case where there is no file attached
1681 #
1682 # Revision 1.56 2001/11/14 21:35:21 richard
1683 # . users may attach files to issues (and support in ext) through the web now
1684 #
1685 # Revision 1.55 2001/11/07 02:34:06 jhermann
1686 # Handling of damaged login cookies
1687 #
1688 # Revision 1.54 2001/11/07 01:16:12 richard
1689 # Remove the '=' padding from cookie value so quoting isn't an issue.
1690 #
1691 # Revision 1.53 2001/11/06 23:22:05 jhermann
1692 # More IE fixes: it does not like quotes around cookie values; in the
1693 # hope this does not break anything for other browser; if it does, we
1694 # need to check HTTP_USER_AGENT
1695 #
1696 # Revision 1.52 2001/11/06 23:11:22 jhermann
1697 # Fixed debug output in page footer; added expiry date to the login cookie
1698 # (expires 1 year in the future) to prevent probs with certain versions
1699 # of IE
1700 #
1701 # Revision 1.51 2001/11/06 22:00:34 jhermann
1702 # Get debug level from ROUNDUP_DEBUG env var
1703 #
1704 # Revision 1.50 2001/11/05 23:45:40 richard
1705 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1706 # Also made it present nicer error messages (not tracebacks).
1707 #
1708 # Revision 1.49 2001/11/04 03:07:12 richard
1709 # Fixed various cookie-related bugs:
1710 # . bug #477685 ] base64.decodestring breaks
1711 # . bug #477837 ] lynx does not like the cookie
1712 # . bug #477892 ] Password edit doesn't fix login cookie
1713 # Also closed a security hole - a logged-in user could edit another user's
1714 # details.
1715 #
1716 # Revision 1.48 2001/11/03 01:30:18 richard
1717 # Oops. uses pagefoot now.
1718 #
1719 # Revision 1.47 2001/11/03 01:29:28 richard
1720 # Login page didn't have all close tags.
1721 #
1722 # Revision 1.46 2001/11/03 01:26:55 richard
1723 # possibly fix truncated base64'ed user:pass
1724 #
1725 # Revision 1.45 2001/11/01 22:04:37 richard
1726 # Started work on supporting a pop3-fetching server
1727 # Fixed bugs:
1728 # . bug #477104 ] HTML tag error in roundup-server
1729 # . bug #477107 ] HTTP header problem
1730 #
1731 # Revision 1.44 2001/10/28 23:03:08 richard
1732 # Added more useful header to the classic schema.
1733 #
1734 # Revision 1.43 2001/10/24 00:01:42 richard
1735 # More fixes to lockout logic.
1736 #
1737 # Revision 1.42 2001/10/23 23:56:03 richard
1738 # HTML typo
1739 #
1740 # Revision 1.41 2001/10/23 23:52:35 richard
1741 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1742 #
1743 # Revision 1.40 2001/10/23 23:06:39 richard
1744 # Some cleanup.
1745 #
1746 # Revision 1.39 2001/10/23 01:00:18 richard
1747 # Re-enabled login and registration access after lopping them off via
1748 # disabling access for anonymous users.
1749 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1750 # a couple of bugs while I was there. Probably introduced a couple, but
1751 # things seem to work OK at the moment.
1752 #
1753 # Revision 1.38 2001/10/22 03:25:01 richard
1754 # Added configuration for:
1755 # . anonymous user access and registration (deny/allow)
1756 # . filter "widget" location on index page (top, bottom, both)
1757 # Updated some documentation.
1758 #
1759 # Revision 1.37 2001/10/21 07:26:35 richard
1760 # feature #473127: Filenames. I modified the file.index and htmltemplate
1761 # source so that the filename is used in the link and the creation
1762 # information is displayed.
1763 #
1764 # Revision 1.36 2001/10/21 04:44:50 richard
1765 # bug #473124: UI inconsistency with Link fields.
1766 # This also prompted me to fix a fairly long-standing usability issue -
1767 # that of being able to turn off certain filters.
1768 #
1769 # Revision 1.35 2001/10/21 00:17:54 richard
1770 # CGI interface view customisation section may now be hidden (patch from
1771 # Roch'e Compaan.)
1772 #
1773 # Revision 1.34 2001/10/20 11:58:48 richard
1774 # Catch errors in login - no username or password supplied.
1775 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1776 #
1777 # Revision 1.33 2001/10/17 00:18:41 richard
1778 # Manually constructing cookie headers now.
1779 #
1780 # Revision 1.32 2001/10/16 03:36:21 richard
1781 # CGI interface wasn't handling checkboxes at all.
1782 #
1783 # Revision 1.31 2001/10/14 10:55:00 richard
1784 # Handle empty strings in HTML template Link function
1785 #
1786 # Revision 1.30 2001/10/09 07:38:58 richard
1787 # Pushed the base code for the extended schema CGI interface back into the
1788 # code cgi_client module so that future updates will be less painful.
1789 # Also removed a debugging print statement from cgi_client.
1790 #
1791 # Revision 1.29 2001/10/09 07:25:59 richard
1792 # Added the Password property type. See "pydoc roundup.password" for
1793 # implementation details. Have updated some of the documentation too.
1794 #
1795 # Revision 1.28 2001/10/08 00:34:31 richard
1796 # Change message was stuffing up for multilinks with no key property.
1797 #
1798 # Revision 1.27 2001/10/05 02:23:24 richard
1799 # . roundup-admin create now prompts for property info if none is supplied
1800 # on the command-line.
1801 # . hyperdb Class getprops() method may now return only the mutable
1802 # properties.
1803 # . Login now uses cookies, which makes it a whole lot more flexible. We can
1804 # now support anonymous user access (read-only, unless there's an
1805 # "anonymous" user, in which case write access is permitted). Login
1806 # handling has been moved into cgi_client.Client.main()
1807 # . The "extended" schema is now the default in roundup init.
1808 # . The schemas have had their page headings modified to cope with the new
1809 # login handling. Existing installations should copy the interfaces.py
1810 # file from the roundup lib directory to their instance home.
1811 # . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1812 # Ping - has been removed.
1813 # . Fixed a whole bunch of places in the CGI interface where we should have
1814 # been returning Not Found instead of throwing an exception.
1815 # . Fixed a deviation from the spec: trying to modify the 'id' property of
1816 # an item now throws an exception.
1817 #
1818 # Revision 1.26 2001/09/12 08:31:42 richard
1819 # handle cases where mime type is not guessable
1820 #
1821 # Revision 1.25 2001/08/29 05:30:49 richard
1822 # change messages weren't being saved when there was no-one on the nosy list.
1823 #
1824 # Revision 1.24 2001/08/29 04:49:39 richard
1825 # didn't clean up fully after debugging :(
1826 #
1827 # Revision 1.23 2001/08/29 04:47:18 richard
1828 # Fixed CGI client change messages so they actually include the properties
1829 # changed (again).
1830 #
1831 # Revision 1.22 2001/08/17 00:08:10 richard
1832 # reverted back to sending messages always regardless of who is doing the web
1833 # edit. change notes weren't being saved. bleah. hackish.
1834 #
1835 # Revision 1.21 2001/08/15 23:43:18 richard
1836 # Fixed some isFooTypes that I missed.
1837 # Refactored some code in the CGI code.
1838 #
1839 # Revision 1.20 2001/08/12 06:32:36 richard
1840 # using isinstance(blah, Foo) now instead of isFooType
1841 #
1842 # Revision 1.19 2001/08/07 00:24:42 richard
1843 # stupid typo
1844 #
1845 # Revision 1.18 2001/08/07 00:15:51 richard
1846 # Added the copyright/license notice to (nearly) all files at request of
1847 # Bizar Software.
1848 #
1849 # Revision 1.17 2001/08/02 06:38:17 richard
1850 # Roundupdb now appends "mailing list" information to its messages which
1851 # include the e-mail address and web interface address. Templates may
1852 # override this in their db classes to include specific information (support
1853 # instructions, etc).
1854 #
1855 # Revision 1.16 2001/08/02 05:55:25 richard
1856 # Web edit messages aren't sent to the person who did the edit any more. No
1857 # message is generated if they are the only person on the nosy list.
1858 #
1859 # Revision 1.15 2001/08/02 00:34:10 richard
1860 # bleah syntax error
1861 #
1862 # Revision 1.14 2001/08/02 00:26:16 richard
1863 # Changed the order of the information in the message generated by web edits.
1864 #
1865 # Revision 1.13 2001/07/30 08:12:17 richard
1866 # Added time logging and file uploading to the templates.
1867 #
1868 # Revision 1.12 2001/07/30 06:26:31 richard
1869 # Added some documentation on how the newblah works.
1870 #
1871 # Revision 1.11 2001/07/30 06:17:45 richard
1872 # Features:
1873 # . Added ability for cgi newblah forms to indicate that the new node
1874 # should be linked somewhere.
1875 # Fixed:
1876 # . Fixed the agument handling for the roundup-admin find command.
1877 # . Fixed handling of summary when no note supplied for newblah. Again.
1878 # . Fixed detection of no form in htmltemplate Field display.
1879 #
1880 # Revision 1.10 2001/07/30 02:37:34 richard
1881 # Temporary measure until we have decent schema migration...
1882 #
1883 # Revision 1.9 2001/07/30 01:25:07 richard
1884 # Default implementation is now "classic" rather than "extended" as one would
1885 # expect.
1886 #
1887 # Revision 1.8 2001/07/29 08:27:40 richard
1888 # Fixed handling of passed-in values in form elements (ie. during a
1889 # drill-down)
1890 #
1891 # Revision 1.7 2001/07/29 07:01:39 richard
1892 # Added vim command to all source so that we don't get no steenkin' tabs :)
1893 #
1894 # Revision 1.6 2001/07/29 04:04:00 richard
1895 # Moved some code around allowing for subclassing to change behaviour.
1896 #
1897 # Revision 1.5 2001/07/28 08:16:52 richard
1898 # New issue form handles lack of note better now.
1899 #
1900 # Revision 1.4 2001/07/28 00:34:34 richard
1901 # Fixed some non-string node ids.
1902 #
1903 # Revision 1.3 2001/07/23 03:56:30 richard
1904 # oops, missed a config removal
1905 #
1906 # Revision 1.2 2001/07/22 12:09:32 richard
1907 # Final commit of Grande Splite
1908 #
1909 # Revision 1.1 2001/07/22 11:58:35 richard
1910 # More Grande Splite
1911 #
1912 #
1913 # vim: set filetype=python ts=4 sw=4 et si