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.136 2002-07-10 06:51:08 richard Exp $
20 __doc__ = """
21 WWW request handler (also used in the stand-alone server).
22 """
24 import os, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
25 import binascii, Cookie, time, random
27 import roundupdb, htmltemplate, date, hyperdb, password
28 from roundup.i18n import _
30 class Unauthorised(ValueError):
31 pass
33 class NotFound(ValueError):
34 pass
36 class Client:
37 '''
38 A note about login
39 ------------------
41 If the user has no login cookie, then they are anonymous. There
42 are two levels of anonymous use. If there is no 'anonymous' user, there
43 is no login at all and the database is opened in read-only mode. If the
44 'anonymous' user exists, the user is logged in using that user (though
45 there is no cookie). This allows them to modify the database, and all
46 modifications are attributed to the 'anonymous' user.
47 '''
49 def __init__(self, instance, request, env, form=None):
50 hyperdb.traceMark()
51 self.instance = instance
52 self.request = request
53 self.env = env
54 self.path = env['PATH_INFO']
55 self.split_path = self.path.split('/')
56 self.instance_path_name = env['INSTANCE_NAME']
57 url = self.env['SCRIPT_NAME'] + '/'
58 machine = self.env['SERVER_NAME']
59 port = self.env['SERVER_PORT']
60 if port != '80': machine = machine + ':' + port
61 self.base = urlparse.urlunparse(('http', env['HTTP_HOST'], url,
62 None, None, None))
64 if form is None:
65 self.form = cgi.FieldStorage(environ=env)
66 else:
67 self.form = form
68 self.headers_done = 0
69 try:
70 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
71 except ValueError:
72 # someone gave us a non-int debug level, turn it off
73 self.debug = 0
75 def getuid(self):
76 try:
77 return self.db.user.lookup(self.user)
78 except KeyError:
79 if self.user is None:
80 # user is not logged in and username 'anonymous' doesn't
81 # exist in the database
82 err = _('anonymous users have read-only access only')
83 else:
84 err = _("sanity check: unknown user name `%s'")%self.user
85 raise Unauthorised, errmsg
87 def header(self, headers=None):
88 '''Put up the appropriate header.
89 '''
90 if headers is None:
91 headers = {'Content-Type':'text/html'}
92 if not headers.has_key('Content-Type'):
93 headers['Content-Type'] = 'text/html'
94 self.request.send_response(200)
95 for entry in headers.items():
96 self.request.send_header(*entry)
97 self.request.end_headers()
98 self.headers_done = 1
99 if self.debug:
100 self.headers_sent = headers
102 global_javascript = '''
103 <script language="javascript">
104 submitted = false;
105 function submit_once() {
106 if (submitted) {
107 alert("Your request is being processed.\\nPlease be patient.");
108 return 0;
109 }
110 submitted = true;
111 return 1;
112 }
114 function help_window(helpurl, width, height) {
115 HelpWin = window.open('%(base)s%(instance_path_name)s/' + helpurl, 'RoundupHelpWindow', 'scrollbars=yes,resizable=yes,toolbar=no,height='+height+',width='+width);
116 }
118 </script>
119 '''
120 def make_index_link(self, name):
121 '''Turn a configuration entry into a hyperlink...
122 '''
123 # get the link label and spec
124 spec = getattr(self.instance, name+'_INDEX')
126 d = {}
127 d[':sort'] = ','.join(map(urllib.quote, spec['SORT']))
128 d[':group'] = ','.join(map(urllib.quote, spec['GROUP']))
129 d[':filter'] = ','.join(map(urllib.quote, spec['FILTER']))
130 d[':columns'] = ','.join(map(urllib.quote, spec['COLUMNS']))
131 d[':pagesize'] = spec.get('PAGESIZE','50')
133 # snarf the filterspec
134 filterspec = spec['FILTERSPEC'].copy()
136 # now format the filterspec
137 for k, l in filterspec.items():
138 # fix up the CURRENT USER if needed (handle None too since that's
139 # the old flag value)
140 if l in (None, 'CURRENT USER'):
141 if not self.user:
142 continue
143 l = [self.db.user.lookup(self.user)]
145 # add
146 d[urllib.quote(k)] = ','.join(map(urllib.quote, l))
148 # finally, format the URL
149 return '<a href="%s?%s">%s</a>'%(spec['CLASS'],
150 '&'.join([k+'='+v for k,v in d.items()]), spec['LABEL'])
153 def pagehead(self, title, message=None):
154 '''Display the page heading, with information about the tracker and
155 links to more information
156 '''
158 # include any important message
159 if message is not None:
160 message = _('<div class="system-msg">%(message)s</div>')%locals()
161 else:
162 message = ''
164 # style sheet (CSS)
165 style = open(os.path.join(self.instance.TEMPLATES, 'style.css')).read()
167 # figure who the user is
168 user_name = self.user or ''
169 if user_name not in ('', 'anonymous'):
170 userid = self.db.user.lookup(self.user)
171 else:
172 userid = None
174 # figure all the header links
175 if hasattr(self.instance, 'HEADER_INDEX_LINKS'):
176 links = []
177 for name in self.instance.HEADER_INDEX_LINKS:
178 spec = getattr(self.instance, name + '_INDEX')
179 # skip if we need to fill in the logged-in user id there's
180 # no user logged in
181 if (spec['FILTERSPEC'].has_key('assignedto') and
182 spec['FILTERSPEC']['assignedto'] in ('CURRENT USER',
183 None) and userid is None):
184 continue
185 links.append(self.make_index_link(name))
186 else:
187 # no config spec - hard-code
188 links = [
189 _('All <a href="issue?status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=-activity&:filter=status&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>'),
190 _('Unassigned <a href="issue?assignedto=-1&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=-activity&:filter=status,assignedto&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>')
191 ]
193 # if they're logged in, include links to their information, and the
194 # ability to add an issue
195 if user_name not in ('', 'anonymous'):
196 user_info = _('''
197 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
198 ''')%locals()
200 # figure the "add class" links
201 if hasattr(self.instance, 'HEADER_ADD_LINKS'):
202 classes = self.instance.HEADER_ADD_LINKS
203 else:
204 classes = ['issue']
205 l = []
206 for class_name in classes:
207 cap_class = class_name.capitalize()
208 links.append(_('Add <a href="new%(class_name)s">'
209 '%(cap_class)s</a>')%locals())
211 # if there's no config header link spec, force a user link here
212 if not hasattr(self.instance, 'HEADER_INDEX_LINKS'):
213 links.append(_('<a href="issue?assignedto=%(userid)s&status=-1,unread,chatting,open,pending&:filter=status,resolution,assignedto&:sort=-activity&:columns=id,activity,status,resolution,title,creator&:group=type&show_customization=1">My Issues</a>')%locals())
214 else:
215 user_info = _('<a href="login">Login</a>')
216 add_links = ''
218 # if the user is admin, include admin links
219 admin_links = ''
220 if user_name == 'admin':
221 links.append(_('<a href="list_classes">Class List</a>'))
222 links.append(_('<a href="user">User List</a>'))
223 links.append(_('<a href="newuser">Add User</a>'))
225 # add the search links
226 if hasattr(self.instance, 'HEADER_SEARCH_LINKS'):
227 classes = self.instance.HEADER_SEARCH_LINKS
228 else:
229 classes = ['issue']
230 l = []
231 for class_name in classes:
232 cap_class = class_name.capitalize()
233 links.append(_('Search <a href="search%(class_name)s">'
234 '%(cap_class)s</a>')%locals())
236 # now we have all the links, join 'em
237 links = '\n | '.join(links)
239 # include the javascript bit
240 global_javascript = self.global_javascript%self.__dict__
242 # finally, format the header
243 self.write(_('''<html><head>
244 <title>%(title)s</title>
245 <style type="text/css">%(style)s</style>
246 </head>
247 %(global_javascript)s
248 <body bgcolor=#ffffff>
249 %(message)s
250 <table width=100%% border=0 cellspacing=0 cellpadding=2>
251 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
252 <td align=right valign=bottom>%(user_name)s</td></tr>
253 <tr class="location-bar">
254 <td align=left>%(links)s</td>
255 <td align=right>%(user_info)s</td>
256 </table><br>
257 ''')%locals())
259 def pagefoot(self):
260 if self.debug:
261 self.write(_('<hr><small><dl><dt><b>Path</b></dt>'))
262 self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
263 keys = self.form.keys()
264 keys.sort()
265 if keys:
266 self.write(_('<dt><b>Form entries</b></dt>'))
267 for k in self.form.keys():
268 v = self.form.getvalue(k, "<empty>")
269 if type(v) is type([]):
270 # Multiple username fields specified
271 v = "|".join(v)
272 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
273 keys = self.headers_sent.keys()
274 keys.sort()
275 self.write(_('<dt><b>Sent these HTTP headers</b></dt>'))
276 for k in keys:
277 v = self.headers_sent[k]
278 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
279 keys = self.env.keys()
280 keys.sort()
281 self.write(_('<dt><b>CGI environment</b></dt>'))
282 for k in keys:
283 v = self.env[k]
284 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
285 self.write('</dl></small>')
286 self.write('</body></html>')
288 def write(self, content):
289 if not self.headers_done:
290 self.header()
291 self.request.wfile.write(content)
293 def index_arg(self, arg):
294 ''' handle the args to index - they might be a list from the form
295 (ie. submitted from a form) or they might be a command-separated
296 single string (ie. manually constructed GET args)
297 '''
298 if self.form.has_key(arg):
299 arg = self.form[arg]
300 if type(arg) == type([]):
301 return [arg.value for arg in arg]
302 return arg.value.split(',')
303 return []
305 def index_sort(self):
306 # first try query string
307 x = self.index_arg(':sort')
308 if x:
309 return x
310 # nope - get the specs out of the form
311 specs = []
312 for colnm in self.db.getclass(self.classname).getprops().keys():
313 desc = ''
314 try:
315 spec = self.form[':%s_ss' % colnm]
316 except KeyError:
317 continue
318 spec = spec.value
319 if spec:
320 if spec[-1] == '-':
321 desc='-'
322 spec = spec[0]
323 specs.append((int(spec), colnm, desc))
324 specs.sort()
325 x = []
326 for _, colnm, desc in specs:
327 x.append('%s%s' % (desc, colnm))
328 return x
330 def index_filterspec(self, filter):
331 ''' pull the index filter spec from the form
333 Links and multilinks want to be lists - the rest are straight
334 strings.
335 '''
336 filterspec = {}
337 props = self.db.classes[self.classname].getprops()
338 for colnm in filter:
339 widget = ':%s_fs' % colnm
340 try:
341 val = self.form[widget]
342 except KeyError:
343 try:
344 val = self.form[colnm]
345 except KeyError:
346 # they checked the filter box but didn't enter a value
347 continue
348 propdescr = props.get(colnm, None)
349 if propdescr is None:
350 print "huh? %r is in filter & form, but not in Class!" % colnm
351 raise "butthead programmer"
352 if (isinstance(propdescr, hyperdb.Link) or
353 isinstance(propdescr, hyperdb.Multilink)):
354 if type(val) == type([]):
355 val = [arg.value for arg in val]
356 else:
357 val = val.value.split(',')
358 l = filterspec.get(colnm, [])
359 l = l + val
360 filterspec[colnm] = l
361 else:
362 filterspec[colnm] = val.value
364 return filterspec
366 def customization_widget(self):
367 ''' The customization widget is visible by default. The widget
368 visibility is remembered by show_customization. Visibility
369 is not toggled if the action value is "Redisplay"
370 '''
371 if not self.form.has_key('show_customization'):
372 visible = 1
373 else:
374 visible = int(self.form['show_customization'].value)
375 if self.form.has_key('action'):
376 if self.form['action'].value != 'Redisplay':
377 visible = self.form['action'].value == '+'
379 return visible
381 # TODO: make this go away some day...
382 default_index_sort = ['-activity']
383 default_index_group = ['priority']
384 default_index_filter = ['status']
385 default_index_columns = ['id','activity','title','status','assignedto']
386 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
387 default_pagesize = '50'
389 def _get_customisation_info(self):
390 # see if the web has supplied us with any customisation info
391 defaults = 1
392 for key in ':sort', ':group', ':filter', ':columns', ':pagesize':
393 if self.form.has_key(key):
394 defaults = 0
395 break
396 if defaults:
397 # try the instance config first
398 if hasattr(self.instance, 'DEFAULT_INDEX'):
399 d = self.instance.DEFAULT_INDEX
400 self.classname = d['CLASS']
401 sort = d['SORT']
402 group = d['GROUP']
403 filter = d['FILTER']
404 columns = d['COLUMNS']
405 filterspec = d['FILTERSPEC']
406 pagesize = d.get('PAGESIZE', '50')
408 else:
409 # nope - fall back on the old way of doing it
410 self.classname = 'issue'
411 sort = self.default_index_sort
412 group = self.default_index_group
413 filter = self.default_index_filter
414 columns = self.default_index_columns
415 filterspec = self.default_index_filterspec
416 pagesize = self.default_pagesize
417 else:
418 # make list() extract the info from the CGI environ
419 self.classname = 'issue'
420 sort = group = filter = columns = filterspec = pagesize = None
421 return columns, filter, group, sort, filterspec, pagesize
423 def index(self):
424 ''' put up an index - no class specified
425 '''
426 columns, filter, group, sort, filterspec, pagesize = \
427 self._get_customisation_info()
428 return self.list(columns=columns, filter=filter, group=group,
429 sort=sort, filterspec=filterspec, pagesize=pagesize)
431 def searchnode(self):
432 columns, filter, group, sort, filterspec, pagesize = \
433 self._get_customisation_info()
434 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 # TODO: pre-check the required fields and username key property
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
1146 self.user = cl.get(uid, 'username')
1147 # re-open the database for real, using the user
1148 self.opendb(self.user)
1149 password = cl.get(uid, 'password')
1150 self.set_cookie(self.user, self.form['password'].value)
1151 return 1
1153 def set_cookie(self, user, password):
1154 # TODO generate a much, much stronger session key ;)
1155 session = binascii.b2a_base64(repr(time.time())).strip()
1157 # clean up the base64
1158 if session[-1] == '=':
1159 if session[-2] == '=':
1160 session = session[:-2]
1161 else:
1162 session = session[:-1]
1164 print 'session set to', `session`
1166 # insert the session in the sessiondb
1167 self.db.getclass('__sessions').create(sessid=session, user=user,
1168 last_use=date.Date())
1170 # and commit immediately
1171 self.db.commit()
1173 # expire us in a long, long time
1174 # TODO: hrm, how long should this be, and how many sessions can one
1175 # user have?
1176 expire = Cookie._getdate(86400*365)
1178 # generate the cookie path - make sure it has a trailing '/'
1179 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
1180 ''))
1181 self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;'%(
1182 session, expire, path)})
1184 def make_user_anonymous(self):
1185 # make us anonymous if we can
1186 try:
1187 self.db.user.lookup('anonymous')
1188 self.user = 'anonymous'
1189 except KeyError:
1190 self.user = None
1192 def logout(self, message=None):
1193 self.make_user_anonymous()
1194 # construct the logout cookie
1195 now = Cookie._getdate()
1196 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
1197 ''))
1198 self.header({'Set-Cookie':
1199 'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
1200 path)})
1201 self.login()
1203 def opendb(self, user):
1204 ''' Open the database - but include the definition of the sessions db.
1205 '''
1206 # open the db
1207 self.db = self.instance.open(user)
1209 # make sure we have the session Class
1210 try:
1211 sessions = self.db.getclass('__sessions')
1212 except:
1213 # add the sessions Class
1214 sessions = hyperdb.Class(self.db, '__sessions',
1215 sessid=hyperdb.String(), user=hyperdb.String(),
1216 last_use=hyperdb.Date())
1217 sessions.setkey('sessid')
1219 def main(self):
1220 '''Wrap the database accesses so we can close the database cleanly
1221 '''
1222 # determine the uid to use
1223 self.opendb('admin')
1225 # make sure we have the session Class
1226 sessions = self.db.getclass('__sessions')
1228 # age sessions, remove when they haven't been used for a week
1229 # TODO: this doesn't need to be done every access
1230 week = date.Interval('7d')
1231 now = date.Date()
1232 for sessid in sessions.list():
1233 interval = now - sessions.get(sessid, 'last_use')
1234 if interval > week:
1235 sessions.destroy(sessid)
1237 # look up the user session cookie
1238 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
1239 user = 'anonymous'
1240 if (cookie.has_key('roundup_user') and
1241 cookie['roundup_user'].value != 'deleted'):
1242 print cookie
1244 # get the session key from the cookie
1245 session = cookie['roundup_user'].value
1246 print 'session is', `session`
1248 # get the user from the session
1249 try:
1250 sessid = sessions.lookup(session)
1251 except KeyError:
1252 user = 'anonymous'
1253 else:
1254 sessions.set(sessid, last_use=date.Date())
1255 self.db.commit()
1256 user = sessions.get(sessid, 'user')
1258 # make sure the anonymous user is valid if we're using it
1259 if user == 'anonymous':
1260 self.make_user_anonymous()
1261 else:
1262 self.user = user
1264 # now figure which function to call
1265 path = self.split_path
1267 # default action to index if the path has no information in it
1268 if not path or path[0] in ('', 'index'):
1269 action = 'index'
1270 else:
1271 action = path[0]
1273 # Everthing ignores path[1:]
1274 # - The file download link generator actually relies on this - it
1275 # appends the name of the file to the URL so the download file name
1276 # is correct, but doesn't actually use it.
1278 # everyone is allowed to try to log in
1279 if action == 'login_action':
1280 # try to login
1281 if not self.login_action():
1282 return
1283 # figure the resulting page
1284 action = self.form['__destination_url'].value
1285 if not action:
1286 action = 'index'
1287 self.do_action(action)
1288 return
1290 # allow anonymous people to register
1291 if action == 'newuser_action':
1292 # if we don't have a login and anonymous people aren't allowed to
1293 # register, then spit up the login form
1294 if self.instance.ANONYMOUS_REGISTER == 'deny' and self.user is None:
1295 if action == 'login':
1296 self.login() # go to the index after login
1297 else:
1298 self.login(action=action)
1299 return
1300 # try to add the user
1301 if not self.newuser_action():
1302 return
1303 # figure the resulting page
1304 action = self.form['__destination_url'].value
1305 if not action:
1306 action = 'index'
1308 # no login or registration, make sure totally anonymous access is OK
1309 elif self.instance.ANONYMOUS_ACCESS == 'deny' and self.user is None:
1310 if action == 'login':
1311 self.login() # go to the index after login
1312 else:
1313 self.login(action=action)
1314 return
1316 # re-open the database for real, using the user
1317 self.opendb(self.user)
1319 # just a regular action
1320 self.do_action(action)
1322 # commit all changes to the database
1323 self.db.commit()
1325 def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
1326 nre=re.compile(r'new(\w+)'), sre=re.compile(r'search(\w+)')):
1327 '''Figure the user's action and do it.
1328 '''
1329 # here be the "normal" functionality
1330 if action == 'index':
1331 self.index()
1332 return
1333 if action == 'list_classes':
1334 self.classes()
1335 return
1336 if action == 'classhelp':
1337 self.classhelp()
1338 return
1339 if action == 'login':
1340 self.login()
1341 return
1342 if action == 'logout':
1343 self.logout()
1344 return
1346 # see if we're to display an existing node
1347 m = dre.match(action)
1348 if m:
1349 self.classname = m.group(1)
1350 self.nodeid = m.group(2)
1351 try:
1352 cl = self.db.classes[self.classname]
1353 except KeyError:
1354 raise NotFound, self.classname
1355 try:
1356 cl.get(self.nodeid, 'id')
1357 except IndexError:
1358 raise NotFound, self.nodeid
1359 try:
1360 func = getattr(self, 'show%s'%self.classname)
1361 except AttributeError:
1362 raise NotFound, 'show%s'%self.classname
1363 func()
1364 return
1366 # see if we're to put up the new node page
1367 m = nre.match(action)
1368 if m:
1369 self.classname = m.group(1)
1370 try:
1371 func = getattr(self, 'new%s'%self.classname)
1372 except AttributeError:
1373 raise NotFound, 'new%s'%self.classname
1374 func()
1375 return
1377 # see if we're to put up the new node page
1378 m = sre.match(action)
1379 if m:
1380 self.classname = m.group(1)
1381 try:
1382 func = getattr(self, 'search%s'%self.classname)
1383 except AttributeError:
1384 raise NotFound
1385 func()
1386 return
1388 # otherwise, display the named class
1389 self.classname = action
1390 try:
1391 self.db.getclass(self.classname)
1392 except KeyError:
1393 raise NotFound, self.classname
1394 self.list()
1397 class ExtendedClient(Client):
1398 '''Includes pages and page heading information that relate to the
1399 extended schema.
1400 '''
1401 showsupport = Client.shownode
1402 showtimelog = Client.shownode
1403 newsupport = Client.newnode
1404 newtimelog = Client.newnode
1405 searchsupport = Client.searchnode
1407 default_index_sort = ['-activity']
1408 default_index_group = ['priority']
1409 default_index_filter = ['status']
1410 default_index_columns = ['activity','status','title','assignedto']
1411 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
1412 default_pagesize = '50'
1414 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1415 '''Pull properties for the given class out of the form.
1416 '''
1417 props = {}
1418 keys = form.keys()
1419 for key in keys:
1420 if not cl.properties.has_key(key):
1421 continue
1422 proptype = cl.properties[key]
1423 if isinstance(proptype, hyperdb.String):
1424 value = form[key].value.strip()
1425 elif isinstance(proptype, hyperdb.Password):
1426 value = password.Password(form[key].value.strip())
1427 elif isinstance(proptype, hyperdb.Date):
1428 value = form[key].value.strip()
1429 if value:
1430 value = date.Date(form[key].value.strip())
1431 else:
1432 value = None
1433 elif isinstance(proptype, hyperdb.Interval):
1434 value = form[key].value.strip()
1435 if value:
1436 value = date.Interval(form[key].value.strip())
1437 else:
1438 value = None
1439 elif isinstance(proptype, hyperdb.Link):
1440 value = form[key].value.strip()
1441 # see if it's the "no selection" choice
1442 if value == '-1':
1443 # don't set this property
1444 continue
1445 else:
1446 # handle key values
1447 link = cl.properties[key].classname
1448 if not num_re.match(value):
1449 try:
1450 value = db.classes[link].lookup(value)
1451 except KeyError:
1452 raise ValueError, _('property "%(propname)s": '
1453 '%(value)s not a %(classname)s')%{'propname':key,
1454 'value': value, 'classname': link}
1455 elif isinstance(proptype, hyperdb.Multilink):
1456 value = form[key]
1457 if hasattr(value, 'value'):
1458 # Quite likely to be a FormItem instance
1459 value = value.value
1460 if not isinstance(value, type([])):
1461 value = [i.strip() for i in value.value.split(',')]
1462 else:
1463 value = [i.strip() for i in value]
1464 link = cl.properties[key].classname
1465 l = []
1466 for entry in map(str, value):
1467 if entry == '': continue
1468 if not num_re.match(entry):
1469 try:
1470 entry = db.classes[link].lookup(entry)
1471 except KeyError:
1472 raise ValueError, _('property "%(propname)s": '
1473 '"%(value)s" not an entry of %(classname)s')%{
1474 'propname':key, 'value': entry, 'classname': link}
1475 l.append(entry)
1476 l.sort()
1477 value = l
1479 # get the old value
1480 if nodeid:
1481 try:
1482 existing = cl.get(nodeid, key)
1483 except KeyError:
1484 # this might be a new property for which there is no existing
1485 # value
1486 if not cl.properties.has_key(key): raise
1488 # if changed, set it
1489 if value != existing:
1490 props[key] = value
1491 else:
1492 props[key] = value
1493 return props
1495 #
1496 # $Log: not supported by cvs2svn $
1497 # Revision 1.135 2002/07/10 00:22:34 richard
1498 # . switched to using a session-based web login
1499 #
1500 # Revision 1.134 2002/07/09 04:19:09 richard
1501 # Added reindex command to roundup-admin.
1502 # Fixed reindex on first access.
1503 # Also fixed reindexing of entries that change.
1504 #
1505 # Revision 1.133 2002/07/08 15:32:05 gmcm
1506 # Pagination of index pages.
1507 # New search form.
1508 #
1509 # Revision 1.132 2002/07/08 07:26:14 richard
1510 # ehem
1511 #
1512 # Revision 1.131 2002/07/08 06:53:57 richard
1513 # Not sure why the cgi_client had an indexer argument.
1514 #
1515 # Revision 1.130 2002/06/27 12:01:53 gmcm
1516 # If the form has a :multilink, put a back href in the pageheader (back to the linked-to node).
1517 # Some minor optimizations (only compile regexes once).
1518 #
1519 # Revision 1.129 2002/06/20 23:52:11 richard
1520 # Better handling of unauth attempt to edit stuff
1521 #
1522 # Revision 1.128 2002/06/12 21:28:25 gmcm
1523 # Allow form to set user-properties on a Fileclass.
1524 # Don't assume that a Fileclass is named "files".
1525 #
1526 # Revision 1.127 2002/06/11 06:38:24 richard
1527 # . #565996 ] The "Attach a File to this Issue" fails
1528 #
1529 # Revision 1.126 2002/05/29 01:16:17 richard
1530 # Sorry about this huge checkin! It's fixing a lot of related stuff in one go
1531 # though.
1532 #
1533 # . #541941 ] changing multilink properties by mail
1534 # . #526730 ] search for messages capability
1535 # . #505180 ] split MailGW.handle_Message
1536 # - also changed cgi client since it was duplicating the functionality
1537 # . build htmlbase if tests are run using CVS checkout (removed note from
1538 # installation.txt)
1539 # . don't create an empty message on email issue creation if the email is empty
1540 #
1541 # Revision 1.125 2002/05/25 07:16:24 rochecompaan
1542 # Merged search_indexing-branch with HEAD
1543 #
1544 # Revision 1.124 2002/05/24 02:09:24 richard
1545 # Nothing like a live demo to show up the bugs ;)
1546 #
1547 # Revision 1.123 2002/05/22 05:04:13 richard
1548 # Oops
1549 #
1550 # Revision 1.122 2002/05/22 04:12:05 richard
1551 # . applied patch #558876 ] cgi client customization
1552 # ... with significant additions and modifications ;)
1553 # - extended handling of ML assignedto to all places it's handled
1554 # - added more NotFound info
1555 #
1556 # Revision 1.121 2002/05/21 06:08:10 richard
1557 # Handle migration
1558 #
1559 # Revision 1.120 2002/05/21 06:05:53 richard
1560 # . #551483 ] assignedto in Client.make_index_link
1561 #
1562 # Revision 1.119 2002/05/15 06:21:21 richard
1563 # . node caching now works, and gives a small boost in performance
1564 #
1565 # As a part of this, I cleaned up the DEBUG output and implemented TRACE
1566 # output (HYPERDBTRACE='file to trace to') with checkpoints at the start of
1567 # CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff
1568 # (using if __debug__ which is compiled out with -O)
1569 #
1570 # Revision 1.118 2002/05/12 23:46:33 richard
1571 # ehem, part 2
1572 #
1573 # Revision 1.117 2002/05/12 23:42:29 richard
1574 # ehem
1575 #
1576 # Revision 1.116 2002/05/02 08:07:49 richard
1577 # Added the ADD_AUTHOR_TO_NOSY handling to the CGI interface.
1578 #
1579 # Revision 1.115 2002/04/02 01:56:10 richard
1580 # . stop sending blank (whitespace-only) notes
1581 #
1582 # Revision 1.114.2.4 2002/05/02 11:49:18 rochecompaan
1583 # Allow customization of the search filters that should be displayed
1584 # on the search page.
1585 #
1586 # Revision 1.114.2.3 2002/04/20 13:23:31 rochecompaan
1587 # We now have a separate search page for nodes. Search links for
1588 # different classes can be customized in instance_config similar to
1589 # index links.
1590 #
1591 # Revision 1.114.2.2 2002/04/19 19:54:42 rochecompaan
1592 # cgi_client.py
1593 # removed search link for the time being
1594 # moved rendering of matches to htmltemplate
1595 # hyperdb.py
1596 # filtering of nodes on full text search incorporated in filter method
1597 # roundupdb.py
1598 # added paramater to call of filter method
1599 # roundup_indexer.py
1600 # added search method to RoundupIndexer class
1601 #
1602 # Revision 1.114.2.1 2002/04/03 11:55:57 rochecompaan
1603 # . Added feature #526730 - search for messages capability
1604 #
1605 # Revision 1.114 2002/03/17 23:06:05 richard
1606 # oops
1607 #
1608 # Revision 1.113 2002/03/14 23:59:24 richard
1609 # . #517734 ] web header customisation is obscure
1610 #
1611 # Revision 1.112 2002/03/12 22:52:26 richard
1612 # more pychecker warnings removed
1613 #
1614 # Revision 1.111 2002/02/25 04:32:21 richard
1615 # ahem
1616 #
1617 # Revision 1.110 2002/02/21 07:19:08 richard
1618 # ... and label, width and height control for extra flavour!
1619 #
1620 # Revision 1.109 2002/02/21 07:08:19 richard
1621 # oops
1622 #
1623 # Revision 1.108 2002/02/21 07:02:54 richard
1624 # The correct var is "HTTP_HOST"
1625 #
1626 # Revision 1.107 2002/02/21 06:57:38 richard
1627 # . Added popup help for classes using the classhelp html template function.
1628 # - add <display call="classhelp('priority', 'id,name,description')">
1629 # to an item page, and it generates a link to a popup window which displays
1630 # the id, name and description for the priority class. The description
1631 # field won't exist in most installations, but it will be added to the
1632 # default templates.
1633 #
1634 # Revision 1.106 2002/02/21 06:23:00 richard
1635 # *** empty log message ***
1636 #
1637 # Revision 1.105 2002/02/20 05:52:10 richard
1638 # better error handling
1639 #
1640 # Revision 1.104 2002/02/20 05:45:17 richard
1641 # Use the csv module for generating the form entry so it's correct.
1642 # [also noted the sf.net feature request id in the change log]
1643 #
1644 # Revision 1.103 2002/02/20 05:05:28 richard
1645 # . Added simple editing for classes that don't define a templated interface.
1646 # - access using the admin "class list" interface
1647 # - limited to admin-only
1648 # - requires the csv module from object-craft (url given if it's missing)
1649 #
1650 # Revision 1.102 2002/02/15 07:08:44 richard
1651 # . Alternate email addresses are now available for users. See the MIGRATION
1652 # file for info on how to activate the feature.
1653 #
1654 # Revision 1.101 2002/02/14 23:39:18 richard
1655 # . All forms now have "double-submit" protection when Javascript is enabled
1656 # on the client-side.
1657 #
1658 # Revision 1.100 2002/01/16 07:02:57 richard
1659 # . lots of date/interval related changes:
1660 # - more relaxed date format for input
1661 #
1662 # Revision 1.99 2002/01/16 03:02:42 richard
1663 # #503793 ] changing assignedto resets nosy list
1664 #
1665 # Revision 1.98 2002/01/14 02:20:14 richard
1666 # . changed all config accesses so they access either the instance or the
1667 # config attriubute on the db. This means that all config is obtained from
1668 # instance_config instead of the mish-mash of classes. This will make
1669 # switching to a ConfigParser setup easier too, I hope.
1670 #
1671 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1672 # 0.5.0 switch, I hope!)
1673 #
1674 # Revision 1.97 2002/01/11 23:22:29 richard
1675 # . #502437 ] rogue reactor and unittest
1676 # in short, the nosy reactor was modifying the nosy list. That code had
1677 # been there for a long time, and I suspsect it was there because we
1678 # weren't generating the nosy list correctly in other places of the code.
1679 # We're now doing that, so the nosy-modifying code can go away from the
1680 # nosy reactor.
1681 #
1682 # Revision 1.96 2002/01/10 05:26:10 richard
1683 # missed a parsePropsFromForm in last update
1684 #
1685 # Revision 1.95 2002/01/10 03:39:45 richard
1686 # . fixed some problems with web editing and change detection
1687 #
1688 # Revision 1.94 2002/01/09 13:54:21 grubert
1689 # _add_assignedto_to_nosy did set nosy to assignedto only, no adding.
1690 #
1691 # Revision 1.93 2002/01/08 11:57:12 richard
1692 # crying out for real configuration handling... :(
1693 #
1694 # Revision 1.92 2002/01/08 04:12:05 richard
1695 # Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
1696 #
1697 # Revision 1.91 2002/01/08 04:03:47 richard
1698 # I mucked the intent of the code up.
1699 #
1700 # Revision 1.90 2002/01/08 03:56:55 richard
1701 # Oops, missed this before the beta:
1702 # . #495392 ] empty nosy -patch
1703 #
1704 # Revision 1.89 2002/01/07 20:24:45 richard
1705 # *mutter* stupid cutnpaste
1706 #
1707 # Revision 1.88 2002/01/02 02:31:38 richard
1708 # Sorry for the huge checkin message - I was only intending to implement #496356
1709 # but I found a number of places where things had been broken by transactions:
1710 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1711 # for _all_ roundup-generated smtp messages to be sent to.
1712 # . the transaction cache had broken the roundupdb.Class set() reactors
1713 # . newly-created author users in the mailgw weren't being committed to the db
1714 #
1715 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1716 # on when I found that stuff :):
1717 # . #496356 ] Use threading in messages
1718 # . detectors were being registered multiple times
1719 # . added tests for mailgw
1720 # . much better attaching of erroneous messages in the mail gateway
1721 #
1722 # Revision 1.87 2001/12/23 23:18:49 richard
1723 # We already had an admin-specific section of the web heading, no need to add
1724 # another one :)
1725 #
1726 # Revision 1.86 2001/12/20 15:43:01 rochecompaan
1727 # Features added:
1728 # . Multilink properties are now displayed as comma separated values in
1729 # a textbox
1730 # . The add user link is now only visible to the admin user
1731 # . Modified the mail gateway to reject submissions from unknown
1732 # addresses if ANONYMOUS_ACCESS is denied
1733 #
1734 # Revision 1.85 2001/12/20 06:13:24 rochecompaan
1735 # Bugs fixed:
1736 # . Exception handling in hyperdb for strings-that-look-like numbers got
1737 # lost somewhere
1738 # . Internet Explorer submits full path for filename - we now strip away
1739 # the path
1740 # Features added:
1741 # . Link and multilink properties are now displayed sorted in the cgi
1742 # interface
1743 #
1744 # Revision 1.84 2001/12/18 15:30:30 rochecompaan
1745 # Fixed bugs:
1746 # . Fixed file creation and retrieval in same transaction in anydbm
1747 # backend
1748 # . Cgi interface now renders new issue after issue creation
1749 # . Could not set issue status to resolved through cgi interface
1750 # . Mail gateway was changing status back to 'chatting' if status was
1751 # omitted as an argument
1752 #
1753 # Revision 1.83 2001/12/15 23:51:01 richard
1754 # Tested the changes and fixed a few problems:
1755 # . files are now attached to the issue as well as the message
1756 # . newuser is a real method now since we don't want to do the message/file
1757 # stuff for it
1758 # . added some documentation
1759 # The really big changes in the diff are a result of me moving some code
1760 # around to keep like methods together a bit better.
1761 #
1762 # Revision 1.82 2001/12/15 19:24:39 rochecompaan
1763 # . Modified cgi interface to change properties only once all changes are
1764 # collected, files created and messages generated.
1765 # . Moved generation of change note to nosyreactors.
1766 # . We now check for changes to "assignedto" to ensure it's added to the
1767 # nosy list.
1768 #
1769 # Revision 1.81 2001/12/12 23:55:00 richard
1770 # Fixed some problems with user editing
1771 #
1772 # Revision 1.80 2001/12/12 23:27:14 richard
1773 # Added a Zope frontend for roundup.
1774 #
1775 # Revision 1.79 2001/12/10 22:20:01 richard
1776 # Enabled transaction support in the bsddb backend. It uses the anydbm code
1777 # where possible, only replacing methods where the db is opened (it uses the
1778 # btree opener specifically.)
1779 # Also cleaned up some change note generation.
1780 # Made the backends package work with pydoc too.
1781 #
1782 # Revision 1.78 2001/12/07 05:59:27 rochecompaan
1783 # Fixed small bug that prevented adding issues through the web.
1784 #
1785 # Revision 1.77 2001/12/06 22:48:29 richard
1786 # files multilink was being nuked in post_edit_node
1787 #
1788 # Revision 1.76 2001/12/05 14:26:44 rochecompaan
1789 # Removed generation of change note from "sendmessage" in roundupdb.py.
1790 # The change note is now generated when the message is created.
1791 #
1792 # Revision 1.75 2001/12/04 01:25:08 richard
1793 # Added some rollbacks where we were catching exceptions that would otherwise
1794 # have stopped committing.
1795 #
1796 # Revision 1.74 2001/12/02 05:06:16 richard
1797 # . We now use weakrefs in the Classes to keep the database reference, so
1798 # the close() method on the database is no longer needed.
1799 # I bumped the minimum python requirement up to 2.1 accordingly.
1800 # . #487480 ] roundup-server
1801 # . #487476 ] INSTALL.txt
1802 #
1803 # I also cleaned up the change message / post-edit stuff in the cgi client.
1804 # There's now a clearly marked "TODO: append the change note" where I believe
1805 # the change note should be added there. The "changes" list will obviously
1806 # have to be modified to be a dict of the changes, or somesuch.
1807 #
1808 # More testing needed.
1809 #
1810 # Revision 1.73 2001/12/01 07:17:50 richard
1811 # . We now have basic transaction support! Information is only written to
1812 # the database when the commit() method is called. Only the anydbm
1813 # backend is modified in this way - neither of the bsddb backends have been.
1814 # The mail, admin and cgi interfaces all use commit (except the admin tool
1815 # doesn't have a commit command, so interactive users can't commit...)
1816 # . Fixed login/registration forwarding the user to the right page (or not,
1817 # on a failure)
1818 #
1819 # Revision 1.72 2001/11/30 20:47:58 rochecompaan
1820 # Links in page header are now consistent with default sort order.
1821 #
1822 # Fixed bugs:
1823 # - When login failed the list of issues were still rendered.
1824 # - User was redirected to index page and not to his destination url
1825 # if his first login attempt failed.
1826 #
1827 # Revision 1.71 2001/11/30 20:28:10 rochecompaan
1828 # Property changes are now completely traceable, whether changes are
1829 # made through the web or by email
1830 #
1831 # Revision 1.70 2001/11/30 00:06:29 richard
1832 # Converted roundup/cgi_client.py to use _()
1833 # Added the status file, I18N_PROGRESS.txt
1834 #
1835 # Revision 1.69 2001/11/29 23:19:51 richard
1836 # Removed the "This issue has been edited through the web" when a valid
1837 # change note is supplied.
1838 #
1839 # Revision 1.68 2001/11/29 04:57:23 richard
1840 # a little comment
1841 #
1842 # Revision 1.67 2001/11/28 21:55:35 richard
1843 # . login_action and newuser_action return values were being ignored
1844 # . Woohoo! Found that bloody re-login bug that was killing the mail
1845 # gateway.
1846 # (also a minor cleanup in hyperdb)
1847 #
1848 # Revision 1.66 2001/11/27 03:00:50 richard
1849 # couple of bugfixes from latest patch integration
1850 #
1851 # Revision 1.65 2001/11/26 23:00:53 richard
1852 # This config stuff is getting to be a real mess...
1853 #
1854 # Revision 1.64 2001/11/26 22:56:35 richard
1855 # typo
1856 #
1857 # Revision 1.63 2001/11/26 22:55:56 richard
1858 # Feature:
1859 # . Added INSTANCE_NAME to configuration - used in web and email to identify
1860 # the instance.
1861 # . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1862 # signature info in e-mails.
1863 # . Some more flexibility in the mail gateway and more error handling.
1864 # . Login now takes you to the page you back to the were denied access to.
1865 #
1866 # Fixed:
1867 # . Lots of bugs, thanks Roché and others on the devel mailing list!
1868 #
1869 # Revision 1.62 2001/11/24 00:45:42 jhermann
1870 # typeof() instead of type(): avoid clash with database field(?) "type"
1871 #
1872 # Fixes this traceback:
1873 #
1874 # Traceback (most recent call last):
1875 # File "roundup\cgi_client.py", line 535, in newnode
1876 # self._post_editnode(nid)
1877 # File "roundup\cgi_client.py", line 415, in _post_editnode
1878 # if type(value) != type([]): value = [value]
1879 # UnboundLocalError: local variable 'type' referenced before assignment
1880 #
1881 # Revision 1.61 2001/11/22 15:46:42 jhermann
1882 # Added module docstrings to all modules.
1883 #
1884 # Revision 1.60 2001/11/21 22:57:28 jhermann
1885 # Added dummy hooks for I18N and some preliminary (test) markup of
1886 # translatable messages
1887 #
1888 # Revision 1.59 2001/11/21 03:21:13 richard
1889 # oops
1890 #
1891 # Revision 1.58 2001/11/21 03:11:28 richard
1892 # Better handling of new properties.
1893 #
1894 # Revision 1.57 2001/11/15 10:24:27 richard
1895 # handle the case where there is no file attached
1896 #
1897 # Revision 1.56 2001/11/14 21:35:21 richard
1898 # . users may attach files to issues (and support in ext) through the web now
1899 #
1900 # Revision 1.55 2001/11/07 02:34:06 jhermann
1901 # Handling of damaged login cookies
1902 #
1903 # Revision 1.54 2001/11/07 01:16:12 richard
1904 # Remove the '=' padding from cookie value so quoting isn't an issue.
1905 #
1906 # Revision 1.53 2001/11/06 23:22:05 jhermann
1907 # More IE fixes: it does not like quotes around cookie values; in the
1908 # hope this does not break anything for other browser; if it does, we
1909 # need to check HTTP_USER_AGENT
1910 #
1911 # Revision 1.52 2001/11/06 23:11:22 jhermann
1912 # Fixed debug output in page footer; added expiry date to the login cookie
1913 # (expires 1 year in the future) to prevent probs with certain versions
1914 # of IE
1915 #
1916 # Revision 1.51 2001/11/06 22:00:34 jhermann
1917 # Get debug level from ROUNDUP_DEBUG env var
1918 #
1919 # Revision 1.50 2001/11/05 23:45:40 richard
1920 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1921 # Also made it present nicer error messages (not tracebacks).
1922 #
1923 # Revision 1.49 2001/11/04 03:07:12 richard
1924 # Fixed various cookie-related bugs:
1925 # . bug #477685 ] base64.decodestring breaks
1926 # . bug #477837 ] lynx does not like the cookie
1927 # . bug #477892 ] Password edit doesn't fix login cookie
1928 # Also closed a security hole - a logged-in user could edit another user's
1929 # details.
1930 #
1931 # Revision 1.48 2001/11/03 01:30:18 richard
1932 # Oops. uses pagefoot now.
1933 #
1934 # Revision 1.47 2001/11/03 01:29:28 richard
1935 # Login page didn't have all close tags.
1936 #
1937 # Revision 1.46 2001/11/03 01:26:55 richard
1938 # possibly fix truncated base64'ed user:pass
1939 #
1940 # Revision 1.45 2001/11/01 22:04:37 richard
1941 # Started work on supporting a pop3-fetching server
1942 # Fixed bugs:
1943 # . bug #477104 ] HTML tag error in roundup-server
1944 # . bug #477107 ] HTTP header problem
1945 #
1946 # Revision 1.44 2001/10/28 23:03:08 richard
1947 # Added more useful header to the classic schema.
1948 #
1949 # Revision 1.43 2001/10/24 00:01:42 richard
1950 # More fixes to lockout logic.
1951 #
1952 # Revision 1.42 2001/10/23 23:56:03 richard
1953 # HTML typo
1954 #
1955 # Revision 1.41 2001/10/23 23:52:35 richard
1956 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1957 #
1958 # Revision 1.40 2001/10/23 23:06:39 richard
1959 # Some cleanup.
1960 #
1961 # Revision 1.39 2001/10/23 01:00:18 richard
1962 # Re-enabled login and registration access after lopping them off via
1963 # disabling access for anonymous users.
1964 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1965 # a couple of bugs while I was there. Probably introduced a couple, but
1966 # things seem to work OK at the moment.
1967 #
1968 # Revision 1.38 2001/10/22 03:25:01 richard
1969 # Added configuration for:
1970 # . anonymous user access and registration (deny/allow)
1971 # . filter "widget" location on index page (top, bottom, both)
1972 # Updated some documentation.
1973 #
1974 # Revision 1.37 2001/10/21 07:26:35 richard
1975 # feature #473127: Filenames. I modified the file.index and htmltemplate
1976 # source so that the filename is used in the link and the creation
1977 # information is displayed.
1978 #
1979 # Revision 1.36 2001/10/21 04:44:50 richard
1980 # bug #473124: UI inconsistency with Link fields.
1981 # This also prompted me to fix a fairly long-standing usability issue -
1982 # that of being able to turn off certain filters.
1983 #
1984 # Revision 1.35 2001/10/21 00:17:54 richard
1985 # CGI interface view customisation section may now be hidden (patch from
1986 # Roch'e Compaan.)
1987 #
1988 # Revision 1.34 2001/10/20 11:58:48 richard
1989 # Catch errors in login - no username or password supplied.
1990 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1991 #
1992 # Revision 1.33 2001/10/17 00:18:41 richard
1993 # Manually constructing cookie headers now.
1994 #
1995 # Revision 1.32 2001/10/16 03:36:21 richard
1996 # CGI interface wasn't handling checkboxes at all.
1997 #
1998 # Revision 1.31 2001/10/14 10:55:00 richard
1999 # Handle empty strings in HTML template Link function
2000 #
2001 # Revision 1.30 2001/10/09 07:38:58 richard
2002 # Pushed the base code for the extended schema CGI interface back into the
2003 # code cgi_client module so that future updates will be less painful.
2004 # Also removed a debugging print statement from cgi_client.
2005 #
2006 # Revision 1.29 2001/10/09 07:25:59 richard
2007 # Added the Password property type. See "pydoc roundup.password" for
2008 # implementation details. Have updated some of the documentation too.
2009 #
2010 # Revision 1.28 2001/10/08 00:34:31 richard
2011 # Change message was stuffing up for multilinks with no key property.
2012 #
2013 # Revision 1.27 2001/10/05 02:23:24 richard
2014 # . roundup-admin create now prompts for property info if none is supplied
2015 # on the command-line.
2016 # . hyperdb Class getprops() method may now return only the mutable
2017 # properties.
2018 # . Login now uses cookies, which makes it a whole lot more flexible. We can
2019 # now support anonymous user access (read-only, unless there's an
2020 # "anonymous" user, in which case write access is permitted). Login
2021 # handling has been moved into cgi_client.Client.main()
2022 # . The "extended" schema is now the default in roundup init.
2023 # . The schemas have had their page headings modified to cope with the new
2024 # login handling. Existing installations should copy the interfaces.py
2025 # file from the roundup lib directory to their instance home.
2026 # . Incorrectly had a Bizar Software copyright on the cgitb.py module from
2027 # Ping - has been removed.
2028 # . Fixed a whole bunch of places in the CGI interface where we should have
2029 # been returning Not Found instead of throwing an exception.
2030 # . Fixed a deviation from the spec: trying to modify the 'id' property of
2031 # an item now throws an exception.
2032 #
2033 # Revision 1.26 2001/09/12 08:31:42 richard
2034 # handle cases where mime type is not guessable
2035 #
2036 # Revision 1.25 2001/08/29 05:30:49 richard
2037 # change messages weren't being saved when there was no-one on the nosy list.
2038 #
2039 # Revision 1.24 2001/08/29 04:49:39 richard
2040 # didn't clean up fully after debugging :(
2041 #
2042 # Revision 1.23 2001/08/29 04:47:18 richard
2043 # Fixed CGI client change messages so they actually include the properties
2044 # changed (again).
2045 #
2046 # Revision 1.22 2001/08/17 00:08:10 richard
2047 # reverted back to sending messages always regardless of who is doing the web
2048 # edit. change notes weren't being saved. bleah. hackish.
2049 #
2050 # Revision 1.21 2001/08/15 23:43:18 richard
2051 # Fixed some isFooTypes that I missed.
2052 # Refactored some code in the CGI code.
2053 #
2054 # Revision 1.20 2001/08/12 06:32:36 richard
2055 # using isinstance(blah, Foo) now instead of isFooType
2056 #
2057 # Revision 1.19 2001/08/07 00:24:42 richard
2058 # stupid typo
2059 #
2060 # Revision 1.18 2001/08/07 00:15:51 richard
2061 # Added the copyright/license notice to (nearly) all files at request of
2062 # Bizar Software.
2063 #
2064 # Revision 1.17 2001/08/02 06:38:17 richard
2065 # Roundupdb now appends "mailing list" information to its messages which
2066 # include the e-mail address and web interface address. Templates may
2067 # override this in their db classes to include specific information (support
2068 # instructions, etc).
2069 #
2070 # Revision 1.16 2001/08/02 05:55:25 richard
2071 # Web edit messages aren't sent to the person who did the edit any more. No
2072 # message is generated if they are the only person on the nosy list.
2073 #
2074 # Revision 1.15 2001/08/02 00:34:10 richard
2075 # bleah syntax error
2076 #
2077 # Revision 1.14 2001/08/02 00:26:16 richard
2078 # Changed the order of the information in the message generated by web edits.
2079 #
2080 # Revision 1.13 2001/07/30 08:12:17 richard
2081 # Added time logging and file uploading to the templates.
2082 #
2083 # Revision 1.12 2001/07/30 06:26:31 richard
2084 # Added some documentation on how the newblah works.
2085 #
2086 # Revision 1.11 2001/07/30 06:17:45 richard
2087 # Features:
2088 # . Added ability for cgi newblah forms to indicate that the new node
2089 # should be linked somewhere.
2090 # Fixed:
2091 # . Fixed the agument handling for the roundup-admin find command.
2092 # . Fixed handling of summary when no note supplied for newblah. Again.
2093 # . Fixed detection of no form in htmltemplate Field display.
2094 #
2095 # Revision 1.10 2001/07/30 02:37:34 richard
2096 # Temporary measure until we have decent schema migration...
2097 #
2098 # Revision 1.9 2001/07/30 01:25:07 richard
2099 # Default implementation is now "classic" rather than "extended" as one would
2100 # expect.
2101 #
2102 # Revision 1.8 2001/07/29 08:27:40 richard
2103 # Fixed handling of passed-in values in form elements (ie. during a
2104 # drill-down)
2105 #
2106 # Revision 1.7 2001/07/29 07:01:39 richard
2107 # Added vim command to all source so that we don't get no steenkin' tabs :)
2108 #
2109 # Revision 1.6 2001/07/29 04:04:00 richard
2110 # Moved some code around allowing for subclassing to change behaviour.
2111 #
2112 # Revision 1.5 2001/07/28 08:16:52 richard
2113 # New issue form handles lack of note better now.
2114 #
2115 # Revision 1.4 2001/07/28 00:34:34 richard
2116 # Fixed some non-string node ids.
2117 #
2118 # Revision 1.3 2001/07/23 03:56:30 richard
2119 # oops, missed a config removal
2120 #
2121 # Revision 1.2 2001/07/22 12:09:32 richard
2122 # Final commit of Grande Splite
2123 #
2124 # Revision 1.1 2001/07/22 11:58:35 richard
2125 # More Grande Splite
2126 #
2127 #
2128 # vim: set filetype=python ts=4 sw=4 et si