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