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.135 2002-07-10 00:22:34 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 type(value) != type([]):
1458 value = [i.strip() for i in value.value.split(',')]
1459 else:
1460 value = [i.value.strip() for i in value]
1461 link = cl.properties[key].classname
1462 l = []
1463 for entry in map(str, value):
1464 if entry == '': continue
1465 if not num_re.match(entry):
1466 try:
1467 entry = db.classes[link].lookup(entry)
1468 except KeyError:
1469 raise ValueError, _('property "%(propname)s": '
1470 '"%(value)s" not an entry of %(classname)s')%{
1471 'propname':key, 'value': entry, 'classname': link}
1472 l.append(entry)
1473 l.sort()
1474 value = l
1476 # get the old value
1477 if nodeid:
1478 try:
1479 existing = cl.get(nodeid, key)
1480 except KeyError:
1481 # this might be a new property for which there is no existing
1482 # value
1483 if not cl.properties.has_key(key): raise
1485 # if changed, set it
1486 if value != existing:
1487 props[key] = value
1488 else:
1489 props[key] = value
1490 return props
1492 #
1493 # $Log: not supported by cvs2svn $
1494 # Revision 1.134 2002/07/09 04:19:09 richard
1495 # Added reindex command to roundup-admin.
1496 # Fixed reindex on first access.
1497 # Also fixed reindexing of entries that change.
1498 #
1499 # Revision 1.133 2002/07/08 15:32:05 gmcm
1500 # Pagination of index pages.
1501 # New search form.
1502 #
1503 # Revision 1.132 2002/07/08 07:26:14 richard
1504 # ehem
1505 #
1506 # Revision 1.131 2002/07/08 06:53:57 richard
1507 # Not sure why the cgi_client had an indexer argument.
1508 #
1509 # Revision 1.130 2002/06/27 12:01:53 gmcm
1510 # If the form has a :multilink, put a back href in the pageheader (back to the linked-to node).
1511 # Some minor optimizations (only compile regexes once).
1512 #
1513 # Revision 1.129 2002/06/20 23:52:11 richard
1514 # Better handling of unauth attempt to edit stuff
1515 #
1516 # Revision 1.128 2002/06/12 21:28:25 gmcm
1517 # Allow form to set user-properties on a Fileclass.
1518 # Don't assume that a Fileclass is named "files".
1519 #
1520 # Revision 1.127 2002/06/11 06:38:24 richard
1521 # . #565996 ] The "Attach a File to this Issue" fails
1522 #
1523 # Revision 1.126 2002/05/29 01:16:17 richard
1524 # Sorry about this huge checkin! It's fixing a lot of related stuff in one go
1525 # though.
1526 #
1527 # . #541941 ] changing multilink properties by mail
1528 # . #526730 ] search for messages capability
1529 # . #505180 ] split MailGW.handle_Message
1530 # - also changed cgi client since it was duplicating the functionality
1531 # . build htmlbase if tests are run using CVS checkout (removed note from
1532 # installation.txt)
1533 # . don't create an empty message on email issue creation if the email is empty
1534 #
1535 # Revision 1.125 2002/05/25 07:16:24 rochecompaan
1536 # Merged search_indexing-branch with HEAD
1537 #
1538 # Revision 1.124 2002/05/24 02:09:24 richard
1539 # Nothing like a live demo to show up the bugs ;)
1540 #
1541 # Revision 1.123 2002/05/22 05:04:13 richard
1542 # Oops
1543 #
1544 # Revision 1.122 2002/05/22 04:12:05 richard
1545 # . applied patch #558876 ] cgi client customization
1546 # ... with significant additions and modifications ;)
1547 # - extended handling of ML assignedto to all places it's handled
1548 # - added more NotFound info
1549 #
1550 # Revision 1.121 2002/05/21 06:08:10 richard
1551 # Handle migration
1552 #
1553 # Revision 1.120 2002/05/21 06:05:53 richard
1554 # . #551483 ] assignedto in Client.make_index_link
1555 #
1556 # Revision 1.119 2002/05/15 06:21:21 richard
1557 # . node caching now works, and gives a small boost in performance
1558 #
1559 # As a part of this, I cleaned up the DEBUG output and implemented TRACE
1560 # output (HYPERDBTRACE='file to trace to') with checkpoints at the start of
1561 # CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff
1562 # (using if __debug__ which is compiled out with -O)
1563 #
1564 # Revision 1.118 2002/05/12 23:46:33 richard
1565 # ehem, part 2
1566 #
1567 # Revision 1.117 2002/05/12 23:42:29 richard
1568 # ehem
1569 #
1570 # Revision 1.116 2002/05/02 08:07:49 richard
1571 # Added the ADD_AUTHOR_TO_NOSY handling to the CGI interface.
1572 #
1573 # Revision 1.115 2002/04/02 01:56:10 richard
1574 # . stop sending blank (whitespace-only) notes
1575 #
1576 # Revision 1.114.2.4 2002/05/02 11:49:18 rochecompaan
1577 # Allow customization of the search filters that should be displayed
1578 # on the search page.
1579 #
1580 # Revision 1.114.2.3 2002/04/20 13:23:31 rochecompaan
1581 # We now have a separate search page for nodes. Search links for
1582 # different classes can be customized in instance_config similar to
1583 # index links.
1584 #
1585 # Revision 1.114.2.2 2002/04/19 19:54:42 rochecompaan
1586 # cgi_client.py
1587 # removed search link for the time being
1588 # moved rendering of matches to htmltemplate
1589 # hyperdb.py
1590 # filtering of nodes on full text search incorporated in filter method
1591 # roundupdb.py
1592 # added paramater to call of filter method
1593 # roundup_indexer.py
1594 # added search method to RoundupIndexer class
1595 #
1596 # Revision 1.114.2.1 2002/04/03 11:55:57 rochecompaan
1597 # . Added feature #526730 - search for messages capability
1598 #
1599 # Revision 1.114 2002/03/17 23:06:05 richard
1600 # oops
1601 #
1602 # Revision 1.113 2002/03/14 23:59:24 richard
1603 # . #517734 ] web header customisation is obscure
1604 #
1605 # Revision 1.112 2002/03/12 22:52:26 richard
1606 # more pychecker warnings removed
1607 #
1608 # Revision 1.111 2002/02/25 04:32:21 richard
1609 # ahem
1610 #
1611 # Revision 1.110 2002/02/21 07:19:08 richard
1612 # ... and label, width and height control for extra flavour!
1613 #
1614 # Revision 1.109 2002/02/21 07:08:19 richard
1615 # oops
1616 #
1617 # Revision 1.108 2002/02/21 07:02:54 richard
1618 # The correct var is "HTTP_HOST"
1619 #
1620 # Revision 1.107 2002/02/21 06:57:38 richard
1621 # . Added popup help for classes using the classhelp html template function.
1622 # - add <display call="classhelp('priority', 'id,name,description')">
1623 # to an item page, and it generates a link to a popup window which displays
1624 # the id, name and description for the priority class. The description
1625 # field won't exist in most installations, but it will be added to the
1626 # default templates.
1627 #
1628 # Revision 1.106 2002/02/21 06:23:00 richard
1629 # *** empty log message ***
1630 #
1631 # Revision 1.105 2002/02/20 05:52:10 richard
1632 # better error handling
1633 #
1634 # Revision 1.104 2002/02/20 05:45:17 richard
1635 # Use the csv module for generating the form entry so it's correct.
1636 # [also noted the sf.net feature request id in the change log]
1637 #
1638 # Revision 1.103 2002/02/20 05:05:28 richard
1639 # . Added simple editing for classes that don't define a templated interface.
1640 # - access using the admin "class list" interface
1641 # - limited to admin-only
1642 # - requires the csv module from object-craft (url given if it's missing)
1643 #
1644 # Revision 1.102 2002/02/15 07:08:44 richard
1645 # . Alternate email addresses are now available for users. See the MIGRATION
1646 # file for info on how to activate the feature.
1647 #
1648 # Revision 1.101 2002/02/14 23:39:18 richard
1649 # . All forms now have "double-submit" protection when Javascript is enabled
1650 # on the client-side.
1651 #
1652 # Revision 1.100 2002/01/16 07:02:57 richard
1653 # . lots of date/interval related changes:
1654 # - more relaxed date format for input
1655 #
1656 # Revision 1.99 2002/01/16 03:02:42 richard
1657 # #503793 ] changing assignedto resets nosy list
1658 #
1659 # Revision 1.98 2002/01/14 02:20:14 richard
1660 # . changed all config accesses so they access either the instance or the
1661 # config attriubute on the db. This means that all config is obtained from
1662 # instance_config instead of the mish-mash of classes. This will make
1663 # switching to a ConfigParser setup easier too, I hope.
1664 #
1665 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1666 # 0.5.0 switch, I hope!)
1667 #
1668 # Revision 1.97 2002/01/11 23:22:29 richard
1669 # . #502437 ] rogue reactor and unittest
1670 # in short, the nosy reactor was modifying the nosy list. That code had
1671 # been there for a long time, and I suspsect it was there because we
1672 # weren't generating the nosy list correctly in other places of the code.
1673 # We're now doing that, so the nosy-modifying code can go away from the
1674 # nosy reactor.
1675 #
1676 # Revision 1.96 2002/01/10 05:26:10 richard
1677 # missed a parsePropsFromForm in last update
1678 #
1679 # Revision 1.95 2002/01/10 03:39:45 richard
1680 # . fixed some problems with web editing and change detection
1681 #
1682 # Revision 1.94 2002/01/09 13:54:21 grubert
1683 # _add_assignedto_to_nosy did set nosy to assignedto only, no adding.
1684 #
1685 # Revision 1.93 2002/01/08 11:57:12 richard
1686 # crying out for real configuration handling... :(
1687 #
1688 # Revision 1.92 2002/01/08 04:12:05 richard
1689 # Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
1690 #
1691 # Revision 1.91 2002/01/08 04:03:47 richard
1692 # I mucked the intent of the code up.
1693 #
1694 # Revision 1.90 2002/01/08 03:56:55 richard
1695 # Oops, missed this before the beta:
1696 # . #495392 ] empty nosy -patch
1697 #
1698 # Revision 1.89 2002/01/07 20:24:45 richard
1699 # *mutter* stupid cutnpaste
1700 #
1701 # Revision 1.88 2002/01/02 02:31:38 richard
1702 # Sorry for the huge checkin message - I was only intending to implement #496356
1703 # but I found a number of places where things had been broken by transactions:
1704 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1705 # for _all_ roundup-generated smtp messages to be sent to.
1706 # . the transaction cache had broken the roundupdb.Class set() reactors
1707 # . newly-created author users in the mailgw weren't being committed to the db
1708 #
1709 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1710 # on when I found that stuff :):
1711 # . #496356 ] Use threading in messages
1712 # . detectors were being registered multiple times
1713 # . added tests for mailgw
1714 # . much better attaching of erroneous messages in the mail gateway
1715 #
1716 # Revision 1.87 2001/12/23 23:18:49 richard
1717 # We already had an admin-specific section of the web heading, no need to add
1718 # another one :)
1719 #
1720 # Revision 1.86 2001/12/20 15:43:01 rochecompaan
1721 # Features added:
1722 # . Multilink properties are now displayed as comma separated values in
1723 # a textbox
1724 # . The add user link is now only visible to the admin user
1725 # . Modified the mail gateway to reject submissions from unknown
1726 # addresses if ANONYMOUS_ACCESS is denied
1727 #
1728 # Revision 1.85 2001/12/20 06:13:24 rochecompaan
1729 # Bugs fixed:
1730 # . Exception handling in hyperdb for strings-that-look-like numbers got
1731 # lost somewhere
1732 # . Internet Explorer submits full path for filename - we now strip away
1733 # the path
1734 # Features added:
1735 # . Link and multilink properties are now displayed sorted in the cgi
1736 # interface
1737 #
1738 # Revision 1.84 2001/12/18 15:30:30 rochecompaan
1739 # Fixed bugs:
1740 # . Fixed file creation and retrieval in same transaction in anydbm
1741 # backend
1742 # . Cgi interface now renders new issue after issue creation
1743 # . Could not set issue status to resolved through cgi interface
1744 # . Mail gateway was changing status back to 'chatting' if status was
1745 # omitted as an argument
1746 #
1747 # Revision 1.83 2001/12/15 23:51:01 richard
1748 # Tested the changes and fixed a few problems:
1749 # . files are now attached to the issue as well as the message
1750 # . newuser is a real method now since we don't want to do the message/file
1751 # stuff for it
1752 # . added some documentation
1753 # The really big changes in the diff are a result of me moving some code
1754 # around to keep like methods together a bit better.
1755 #
1756 # Revision 1.82 2001/12/15 19:24:39 rochecompaan
1757 # . Modified cgi interface to change properties only once all changes are
1758 # collected, files created and messages generated.
1759 # . Moved generation of change note to nosyreactors.
1760 # . We now check for changes to "assignedto" to ensure it's added to the
1761 # nosy list.
1762 #
1763 # Revision 1.81 2001/12/12 23:55:00 richard
1764 # Fixed some problems with user editing
1765 #
1766 # Revision 1.80 2001/12/12 23:27:14 richard
1767 # Added a Zope frontend for roundup.
1768 #
1769 # Revision 1.79 2001/12/10 22:20:01 richard
1770 # Enabled transaction support in the bsddb backend. It uses the anydbm code
1771 # where possible, only replacing methods where the db is opened (it uses the
1772 # btree opener specifically.)
1773 # Also cleaned up some change note generation.
1774 # Made the backends package work with pydoc too.
1775 #
1776 # Revision 1.78 2001/12/07 05:59:27 rochecompaan
1777 # Fixed small bug that prevented adding issues through the web.
1778 #
1779 # Revision 1.77 2001/12/06 22:48:29 richard
1780 # files multilink was being nuked in post_edit_node
1781 #
1782 # Revision 1.76 2001/12/05 14:26:44 rochecompaan
1783 # Removed generation of change note from "sendmessage" in roundupdb.py.
1784 # The change note is now generated when the message is created.
1785 #
1786 # Revision 1.75 2001/12/04 01:25:08 richard
1787 # Added some rollbacks where we were catching exceptions that would otherwise
1788 # have stopped committing.
1789 #
1790 # Revision 1.74 2001/12/02 05:06:16 richard
1791 # . We now use weakrefs in the Classes to keep the database reference, so
1792 # the close() method on the database is no longer needed.
1793 # I bumped the minimum python requirement up to 2.1 accordingly.
1794 # . #487480 ] roundup-server
1795 # . #487476 ] INSTALL.txt
1796 #
1797 # I also cleaned up the change message / post-edit stuff in the cgi client.
1798 # There's now a clearly marked "TODO: append the change note" where I believe
1799 # the change note should be added there. The "changes" list will obviously
1800 # have to be modified to be a dict of the changes, or somesuch.
1801 #
1802 # More testing needed.
1803 #
1804 # Revision 1.73 2001/12/01 07:17:50 richard
1805 # . We now have basic transaction support! Information is only written to
1806 # the database when the commit() method is called. Only the anydbm
1807 # backend is modified in this way - neither of the bsddb backends have been.
1808 # The mail, admin and cgi interfaces all use commit (except the admin tool
1809 # doesn't have a commit command, so interactive users can't commit...)
1810 # . Fixed login/registration forwarding the user to the right page (or not,
1811 # on a failure)
1812 #
1813 # Revision 1.72 2001/11/30 20:47:58 rochecompaan
1814 # Links in page header are now consistent with default sort order.
1815 #
1816 # Fixed bugs:
1817 # - When login failed the list of issues were still rendered.
1818 # - User was redirected to index page and not to his destination url
1819 # if his first login attempt failed.
1820 #
1821 # Revision 1.71 2001/11/30 20:28:10 rochecompaan
1822 # Property changes are now completely traceable, whether changes are
1823 # made through the web or by email
1824 #
1825 # Revision 1.70 2001/11/30 00:06:29 richard
1826 # Converted roundup/cgi_client.py to use _()
1827 # Added the status file, I18N_PROGRESS.txt
1828 #
1829 # Revision 1.69 2001/11/29 23:19:51 richard
1830 # Removed the "This issue has been edited through the web" when a valid
1831 # change note is supplied.
1832 #
1833 # Revision 1.68 2001/11/29 04:57:23 richard
1834 # a little comment
1835 #
1836 # Revision 1.67 2001/11/28 21:55:35 richard
1837 # . login_action and newuser_action return values were being ignored
1838 # . Woohoo! Found that bloody re-login bug that was killing the mail
1839 # gateway.
1840 # (also a minor cleanup in hyperdb)
1841 #
1842 # Revision 1.66 2001/11/27 03:00:50 richard
1843 # couple of bugfixes from latest patch integration
1844 #
1845 # Revision 1.65 2001/11/26 23:00:53 richard
1846 # This config stuff is getting to be a real mess...
1847 #
1848 # Revision 1.64 2001/11/26 22:56:35 richard
1849 # typo
1850 #
1851 # Revision 1.63 2001/11/26 22:55:56 richard
1852 # Feature:
1853 # . Added INSTANCE_NAME to configuration - used in web and email to identify
1854 # the instance.
1855 # . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1856 # signature info in e-mails.
1857 # . Some more flexibility in the mail gateway and more error handling.
1858 # . Login now takes you to the page you back to the were denied access to.
1859 #
1860 # Fixed:
1861 # . Lots of bugs, thanks Roché and others on the devel mailing list!
1862 #
1863 # Revision 1.62 2001/11/24 00:45:42 jhermann
1864 # typeof() instead of type(): avoid clash with database field(?) "type"
1865 #
1866 # Fixes this traceback:
1867 #
1868 # Traceback (most recent call last):
1869 # File "roundup\cgi_client.py", line 535, in newnode
1870 # self._post_editnode(nid)
1871 # File "roundup\cgi_client.py", line 415, in _post_editnode
1872 # if type(value) != type([]): value = [value]
1873 # UnboundLocalError: local variable 'type' referenced before assignment
1874 #
1875 # Revision 1.61 2001/11/22 15:46:42 jhermann
1876 # Added module docstrings to all modules.
1877 #
1878 # Revision 1.60 2001/11/21 22:57:28 jhermann
1879 # Added dummy hooks for I18N and some preliminary (test) markup of
1880 # translatable messages
1881 #
1882 # Revision 1.59 2001/11/21 03:21:13 richard
1883 # oops
1884 #
1885 # Revision 1.58 2001/11/21 03:11:28 richard
1886 # Better handling of new properties.
1887 #
1888 # Revision 1.57 2001/11/15 10:24:27 richard
1889 # handle the case where there is no file attached
1890 #
1891 # Revision 1.56 2001/11/14 21:35:21 richard
1892 # . users may attach files to issues (and support in ext) through the web now
1893 #
1894 # Revision 1.55 2001/11/07 02:34:06 jhermann
1895 # Handling of damaged login cookies
1896 #
1897 # Revision 1.54 2001/11/07 01:16:12 richard
1898 # Remove the '=' padding from cookie value so quoting isn't an issue.
1899 #
1900 # Revision 1.53 2001/11/06 23:22:05 jhermann
1901 # More IE fixes: it does not like quotes around cookie values; in the
1902 # hope this does not break anything for other browser; if it does, we
1903 # need to check HTTP_USER_AGENT
1904 #
1905 # Revision 1.52 2001/11/06 23:11:22 jhermann
1906 # Fixed debug output in page footer; added expiry date to the login cookie
1907 # (expires 1 year in the future) to prevent probs with certain versions
1908 # of IE
1909 #
1910 # Revision 1.51 2001/11/06 22:00:34 jhermann
1911 # Get debug level from ROUNDUP_DEBUG env var
1912 #
1913 # Revision 1.50 2001/11/05 23:45:40 richard
1914 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1915 # Also made it present nicer error messages (not tracebacks).
1916 #
1917 # Revision 1.49 2001/11/04 03:07:12 richard
1918 # Fixed various cookie-related bugs:
1919 # . bug #477685 ] base64.decodestring breaks
1920 # . bug #477837 ] lynx does not like the cookie
1921 # . bug #477892 ] Password edit doesn't fix login cookie
1922 # Also closed a security hole - a logged-in user could edit another user's
1923 # details.
1924 #
1925 # Revision 1.48 2001/11/03 01:30:18 richard
1926 # Oops. uses pagefoot now.
1927 #
1928 # Revision 1.47 2001/11/03 01:29:28 richard
1929 # Login page didn't have all close tags.
1930 #
1931 # Revision 1.46 2001/11/03 01:26:55 richard
1932 # possibly fix truncated base64'ed user:pass
1933 #
1934 # Revision 1.45 2001/11/01 22:04:37 richard
1935 # Started work on supporting a pop3-fetching server
1936 # Fixed bugs:
1937 # . bug #477104 ] HTML tag error in roundup-server
1938 # . bug #477107 ] HTTP header problem
1939 #
1940 # Revision 1.44 2001/10/28 23:03:08 richard
1941 # Added more useful header to the classic schema.
1942 #
1943 # Revision 1.43 2001/10/24 00:01:42 richard
1944 # More fixes to lockout logic.
1945 #
1946 # Revision 1.42 2001/10/23 23:56:03 richard
1947 # HTML typo
1948 #
1949 # Revision 1.41 2001/10/23 23:52:35 richard
1950 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1951 #
1952 # Revision 1.40 2001/10/23 23:06:39 richard
1953 # Some cleanup.
1954 #
1955 # Revision 1.39 2001/10/23 01:00:18 richard
1956 # Re-enabled login and registration access after lopping them off via
1957 # disabling access for anonymous users.
1958 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1959 # a couple of bugs while I was there. Probably introduced a couple, but
1960 # things seem to work OK at the moment.
1961 #
1962 # Revision 1.38 2001/10/22 03:25:01 richard
1963 # Added configuration for:
1964 # . anonymous user access and registration (deny/allow)
1965 # . filter "widget" location on index page (top, bottom, both)
1966 # Updated some documentation.
1967 #
1968 # Revision 1.37 2001/10/21 07:26:35 richard
1969 # feature #473127: Filenames. I modified the file.index and htmltemplate
1970 # source so that the filename is used in the link and the creation
1971 # information is displayed.
1972 #
1973 # Revision 1.36 2001/10/21 04:44:50 richard
1974 # bug #473124: UI inconsistency with Link fields.
1975 # This also prompted me to fix a fairly long-standing usability issue -
1976 # that of being able to turn off certain filters.
1977 #
1978 # Revision 1.35 2001/10/21 00:17:54 richard
1979 # CGI interface view customisation section may now be hidden (patch from
1980 # Roch'e Compaan.)
1981 #
1982 # Revision 1.34 2001/10/20 11:58:48 richard
1983 # Catch errors in login - no username or password supplied.
1984 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1985 #
1986 # Revision 1.33 2001/10/17 00:18:41 richard
1987 # Manually constructing cookie headers now.
1988 #
1989 # Revision 1.32 2001/10/16 03:36:21 richard
1990 # CGI interface wasn't handling checkboxes at all.
1991 #
1992 # Revision 1.31 2001/10/14 10:55:00 richard
1993 # Handle empty strings in HTML template Link function
1994 #
1995 # Revision 1.30 2001/10/09 07:38:58 richard
1996 # Pushed the base code for the extended schema CGI interface back into the
1997 # code cgi_client module so that future updates will be less painful.
1998 # Also removed a debugging print statement from cgi_client.
1999 #
2000 # Revision 1.29 2001/10/09 07:25:59 richard
2001 # Added the Password property type. See "pydoc roundup.password" for
2002 # implementation details. Have updated some of the documentation too.
2003 #
2004 # Revision 1.28 2001/10/08 00:34:31 richard
2005 # Change message was stuffing up for multilinks with no key property.
2006 #
2007 # Revision 1.27 2001/10/05 02:23:24 richard
2008 # . roundup-admin create now prompts for property info if none is supplied
2009 # on the command-line.
2010 # . hyperdb Class getprops() method may now return only the mutable
2011 # properties.
2012 # . Login now uses cookies, which makes it a whole lot more flexible. We can
2013 # now support anonymous user access (read-only, unless there's an
2014 # "anonymous" user, in which case write access is permitted). Login
2015 # handling has been moved into cgi_client.Client.main()
2016 # . The "extended" schema is now the default in roundup init.
2017 # . The schemas have had their page headings modified to cope with the new
2018 # login handling. Existing installations should copy the interfaces.py
2019 # file from the roundup lib directory to their instance home.
2020 # . Incorrectly had a Bizar Software copyright on the cgitb.py module from
2021 # Ping - has been removed.
2022 # . Fixed a whole bunch of places in the CGI interface where we should have
2023 # been returning Not Found instead of throwing an exception.
2024 # . Fixed a deviation from the spec: trying to modify the 'id' property of
2025 # an item now throws an exception.
2026 #
2027 # Revision 1.26 2001/09/12 08:31:42 richard
2028 # handle cases where mime type is not guessable
2029 #
2030 # Revision 1.25 2001/08/29 05:30:49 richard
2031 # change messages weren't being saved when there was no-one on the nosy list.
2032 #
2033 # Revision 1.24 2001/08/29 04:49:39 richard
2034 # didn't clean up fully after debugging :(
2035 #
2036 # Revision 1.23 2001/08/29 04:47:18 richard
2037 # Fixed CGI client change messages so they actually include the properties
2038 # changed (again).
2039 #
2040 # Revision 1.22 2001/08/17 00:08:10 richard
2041 # reverted back to sending messages always regardless of who is doing the web
2042 # edit. change notes weren't being saved. bleah. hackish.
2043 #
2044 # Revision 1.21 2001/08/15 23:43:18 richard
2045 # Fixed some isFooTypes that I missed.
2046 # Refactored some code in the CGI code.
2047 #
2048 # Revision 1.20 2001/08/12 06:32:36 richard
2049 # using isinstance(blah, Foo) now instead of isFooType
2050 #
2051 # Revision 1.19 2001/08/07 00:24:42 richard
2052 # stupid typo
2053 #
2054 # Revision 1.18 2001/08/07 00:15:51 richard
2055 # Added the copyright/license notice to (nearly) all files at request of
2056 # Bizar Software.
2057 #
2058 # Revision 1.17 2001/08/02 06:38:17 richard
2059 # Roundupdb now appends "mailing list" information to its messages which
2060 # include the e-mail address and web interface address. Templates may
2061 # override this in their db classes to include specific information (support
2062 # instructions, etc).
2063 #
2064 # Revision 1.16 2001/08/02 05:55:25 richard
2065 # Web edit messages aren't sent to the person who did the edit any more. No
2066 # message is generated if they are the only person on the nosy list.
2067 #
2068 # Revision 1.15 2001/08/02 00:34:10 richard
2069 # bleah syntax error
2070 #
2071 # Revision 1.14 2001/08/02 00:26:16 richard
2072 # Changed the order of the information in the message generated by web edits.
2073 #
2074 # Revision 1.13 2001/07/30 08:12:17 richard
2075 # Added time logging and file uploading to the templates.
2076 #
2077 # Revision 1.12 2001/07/30 06:26:31 richard
2078 # Added some documentation on how the newblah works.
2079 #
2080 # Revision 1.11 2001/07/30 06:17:45 richard
2081 # Features:
2082 # . Added ability for cgi newblah forms to indicate that the new node
2083 # should be linked somewhere.
2084 # Fixed:
2085 # . Fixed the agument handling for the roundup-admin find command.
2086 # . Fixed handling of summary when no note supplied for newblah. Again.
2087 # . Fixed detection of no form in htmltemplate Field display.
2088 #
2089 # Revision 1.10 2001/07/30 02:37:34 richard
2090 # Temporary measure until we have decent schema migration...
2091 #
2092 # Revision 1.9 2001/07/30 01:25:07 richard
2093 # Default implementation is now "classic" rather than "extended" as one would
2094 # expect.
2095 #
2096 # Revision 1.8 2001/07/29 08:27:40 richard
2097 # Fixed handling of passed-in values in form elements (ie. during a
2098 # drill-down)
2099 #
2100 # Revision 1.7 2001/07/29 07:01:39 richard
2101 # Added vim command to all source so that we don't get no steenkin' tabs :)
2102 #
2103 # Revision 1.6 2001/07/29 04:04:00 richard
2104 # Moved some code around allowing for subclassing to change behaviour.
2105 #
2106 # Revision 1.5 2001/07/28 08:16:52 richard
2107 # New issue form handles lack of note better now.
2108 #
2109 # Revision 1.4 2001/07/28 00:34:34 richard
2110 # Fixed some non-string node ids.
2111 #
2112 # Revision 1.3 2001/07/23 03:56:30 richard
2113 # oops, missed a config removal
2114 #
2115 # Revision 1.2 2001/07/22 12:09:32 richard
2116 # Final commit of Grande Splite
2117 #
2118 # Revision 1.1 2001/07/22 11:58:35 richard
2119 # More Grande Splite
2120 #
2121 #
2122 # vim: set filetype=python ts=4 sw=4 et si