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