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