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