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