Code

. roundup-admin create now prompts for property info if none is supplied
[roundup.git] / roundup / cgi_client.py
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.27 2001-10-05 02:23:24 richard Exp $
20 import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes
21 import base64, Cookie, time
23 import roundupdb, htmltemplate, date, hyperdb
25 class Unauthorised(ValueError):
26     pass
28 class NotFound(ValueError):
29     pass
31 class Client:
32     '''
34     A note about login
35     ------------------
37     If the user has no login cookie, then they are anonymous. There
38     are two levels of anonymous use. If there is no 'anonymous' user, there
39     is no login at all and the database is opened in read-only mode. If the
40     'anonymous' user exists, the user is logged in using that user (though
41     there is no cookie). This allows them to modify the database, and all
42     modifications are attributed to the 'anonymous' user.
43     '''
45     def __init__(self, instance, out, env):
46         self.instance = instance
47         self.out = out
48         self.env = env
49         self.path = env['PATH_INFO']
50         self.split_path = self.path.split('/')
52         self.headers_done = 0
53         self.form = cgi.FieldStorage(environ=env)
54         self.headers_done = 0
55         self.debug = 0
57     def getuid(self):
58         return self.db.user.lookup(self.user)
60     def header(self, headers={'Content-Type':'text/html'}):
61         if not headers.has_key('Content-Type'):
62             headers['Content-Type'] = 'text/html'
63         for entry in headers.items():
64             self.out.write('%s: %s\n'%entry)
65         self.out.write('\n')
66         self.headers_done = 1
68     def pagehead(self, title, message=None):
69         url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
70         machine = self.env['SERVER_NAME']
71         port = self.env['SERVER_PORT']
72         if port != '80': machine = machine + ':' + port
73         base = urlparse.urlunparse(('http', machine, url, None, None, None))
74         if message is not None:
75             message = '<div class="system-msg">%s</div>'%message
76         else:
77             message = ''
78         style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
79         if self.user is not None:
80             userid = self.db.user.lookup(self.user)
81             user_info = '(login: <a href="user%s">%s</a>)'%(userid, self.user)
82         else:
83             user_info = ''
84         self.write('''<html><head>
85 <title>%s</title>
86 <style type="text/css">%s</style>
87 </head>
88 <body bgcolor=#ffffff>
89 %s
90 <table width=100%% border=0 cellspacing=0 cellpadding=2>
91 <tr class="location-bar"><td><big><strong>%s</strong></big> %s</td></tr>
92 </table>
93 '''%(title, style, message, title, user_info))
95     def pagefoot(self):
96         if self.debug:
97             self.write('<hr><small><dl>')
98             self.write('<dt><b>Path</b></dt>')
99             self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
100             keys = self.form.keys()
101             keys.sort()
102             if keys:
103                 self.write('<dt><b>Form entries</b></dt>')
104                 for k in self.form.keys():
105                     v = str(self.form[k].value)
106                     self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
107             keys = self.env.keys()
108             keys.sort()
109             self.write('<dt><b>CGI environment</b></dt>')
110             for k in keys:
111                 v = self.env[k]
112                 self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
113             self.write('</dl></small>')
114         self.write('</body></html>')
116     def write(self, content):
117         if not self.headers_done:
118             self.header()
119         self.out.write(content)
121     def index_arg(self, arg):
122         ''' handle the args to index - they might be a list from the form
123             (ie. submitted from a form) or they might be a command-separated
124             single string (ie. manually constructed GET args)
125         '''
126         if self.form.has_key(arg):
127             arg =  self.form[arg]
128             if type(arg) == type([]):
129                 return [arg.value for arg in arg]
130             return arg.value.split(',')
131         return []
133     def index_filterspec(self):
134         ''' pull the index filter spec from the form
136         Links and multilinks want to be lists - the rest are straight
137         strings.
138         '''
139         props = self.db.classes[self.classname].getprops()
140         # all the form args not starting with ':' are filters
141         filterspec = {}
142         for key in self.form.keys():
143             if key[0] == ':': continue
144             if not props.has_key(key): continue
145             prop = props[key]
146             value = self.form[key]
147             if (isinstance(prop, hyperdb.Link) or
148                     isinstance(prop, hyperdb.Multilink)):
149                 if type(value) == type([]):
150                     value = [arg.value for arg in value]
151                 else:
152                     value = value.value.split(',')
153                 l = filterspec.get(key, [])
154                 l = l + value
155                 filterspec[key] = l
156             else:
157                 filterspec[key] = value.value
158         return filterspec
160     default_index_sort = ['-activity']
161     default_index_group = ['priority']
162     default_index_filter = []
163     default_index_columns = ['id','activity','title','status','assignedto']
164     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
165     def index(self):
166         ''' put up an index
167         '''
168         self.classname = 'issue'
169         if self.form.has_key(':sort'): sort = self.index_arg(':sort')
170         else: sort = self.default_index_sort
171         if self.form.has_key(':group'): group = self.index_arg(':group')
172         else: group = self.default_index_group
173         if self.form.has_key(':filter'): filter = self.index_arg(':filter')
174         else: filter = self.default_index_filter
175         if self.form.has_key(':columns'): columns = self.index_arg(':columns')
176         else: columns = self.default_index_columns
177         filterspec = self.index_filterspec()
178         if not filterspec:
179             filterspec = self.default_index_filterspec
180         return self.list(columns=columns, filter=filter, group=group,
181             sort=sort, filterspec=filterspec)
183     # XXX deviates from spec - loses the '+' (that's a reserved character
184     # in URLS
185     def list(self, sort=None, group=None, filter=None, columns=None,
186             filterspec=None):
187         ''' call the template index with the args
189             :sort    - sort by prop name, optionally preceeded with '-'
190                      to give descending or nothing for ascending sorting.
191             :group   - group by prop name, optionally preceeded with '-' or
192                      to sort in descending or nothing for ascending order.
193             :filter  - selects which props should be displayed in the filter
194                      section. Default is all.
195             :columns - selects the columns that should be displayed.
196                      Default is all.
198         '''
199         cn = self.classname
200         self.pagehead('Index of %s'%cn)
201         if sort is None: sort = self.index_arg(':sort')
202         if group is None: group = self.index_arg(':group')
203         if filter is None: filter = self.index_arg(':filter')
204         if columns is None: columns = self.index_arg(':columns')
205         if filterspec is None: filterspec = self.index_filterspec()
207         htmltemplate.index(self, self.TEMPLATES, self.db, cn, filterspec,
208             filter, columns, sort, group)
209         self.pagefoot()
211     def shownode(self, message=None):
212         ''' display an item
213         '''
214         cn = self.classname
215         cl = self.db.classes[cn]
217         # possibly perform an edit
218         keys = self.form.keys()
219         num_re = re.compile('^\d+$')
220         if keys:
221             try:
222                 props, changed = parsePropsFromForm(cl, self.form, self.nodeid)
223                 cl.set(self.nodeid, **props)
224                 self._post_editnode(self.nodeid, changed)
225                 # and some nice feedback for the user
226                 message = '%s edited ok'%', '.join(changed)
227             except:
228                 s = StringIO.StringIO()
229                 traceback.print_exc(None, s)
230                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
232         # now the display
233         id = self.nodeid
234         if cl.getkey():
235             id = cl.get(id, cl.getkey())
236         self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
238         nodeid = self.nodeid
240         # use the template to display the item
241         htmltemplate.item(self, self.TEMPLATES, self.db, self.classname, nodeid)
242         self.pagefoot()
243     showissue = shownode
244     showmsg = shownode
246     def showuser(self, message=None):
247         ''' display an item
248         '''
249         if self.user in ('admin', self.db.user.get(self.nodeid, 'username')):
250             self.shownode(message)
251         else:
252             raise Unauthorised
254     def showfile(self):
255         ''' display a file
256         '''
257         nodeid = self.nodeid
258         cl = self.db.file
259         type = cl.get(nodeid, 'type')
260         if type == 'message/rfc822':
261             type = 'text/plain'
262         self.header(headers={'Content-Type': type})
263         self.write(cl.get(nodeid, 'content'))
265     def _createnode(self):
266         ''' create a node based on the contents of the form
267         '''
268         cl = self.db.classes[self.classname]
269         props, dummy = parsePropsFromForm(cl, self.form)
270         return cl.create(**props)
272     def _post_editnode(self, nid, changes=None):
273         ''' do the linking and message sending part of the node creation
274         '''
275         cn = self.classname
276         cl = self.db.classes[cn]
277         # link if necessary
278         keys = self.form.keys()
279         for key in keys:
280             if key == ':multilink':
281                 value = self.form[key].value
282                 if type(value) != type([]): value = [value]
283                 for value in value:
284                     designator, property = value.split(':')
285                     link, nodeid = roundupdb.splitDesignator(designator)
286                     link = self.db.classes[link]
287                     value = link.get(nodeid, property)
288                     value.append(nid)
289                     link.set(nodeid, **{property: value})
290             elif key == ':link':
291                 value = self.form[key].value
292                 if type(value) != type([]): value = [value]
293                 for value in value:
294                     designator, property = value.split(':')
295                     link, nodeid = roundupdb.splitDesignator(designator)
296                     link = self.db.classes[link]
297                     link.set(nodeid, **{property: nid})
299         # generate an edit message
300         # don't bother if there's no messages or nosy list 
301         props = cl.getprops()
302         note = None
303         if self.form.has_key('__note'):
304             note = self.form['__note']
305             note = note.value
306         send = len(cl.get(nid, 'nosy', [])) or note
307         if (send and props.has_key('messages') and
308                 isinstance(props['messages'], hyperdb.Multilink) and
309                 props['messages'].classname == 'msg'):
311             # handle the note
312             if note:
313                 if '\n' in note:
314                     summary = re.split(r'\n\r?', note)[0]
315                 else:
316                     summary = note
317                 m = ['%s\n'%note]
318             else:
319                 summary = 'This %s has been edited through the web.\n'%cn
320                 m = [summary]
322             first = 1
323             for name, prop in props.items():
324                 if changes is not None and name not in changes: continue
325                 if first:
326                     m.append('\n-------')
327                     first = 0
328                 value = cl.get(nid, name, None)
329                 if isinstance(prop, hyperdb.Link):
330                     link = self.db.classes[prop.classname]
331                     key = link.labelprop(default_to_id=1)
332                     if value is not None and key:
333                         value = link.get(value, key)
334                     else:
335                         value = '-'
336                 elif isinstance(prop, hyperdb.Multilink):
337                     if value is None: value = []
338                     l = []
339                     link = self.db.classes[prop.classname]
340                     key = link.labelprop(default_to_id=1)
341                     for entry in value:
342                         if key:
343                             l.append(link.get(entry, link.getkey()))
344                         else:
345                             l.append(entry)
346                     value = ', '.join(l)
347                 m.append('%s: %s'%(name, value))
349             # now create the message
350             content = '\n'.join(m)
351             message_id = self.db.msg.create(author=self.getuid(),
352                 recipients=[], date=date.Date('.'), summary=summary,
353                 content=content)
354             messages = cl.get(nid, 'messages')
355             messages.append(message_id)
356             props = {'messages': messages}
357             cl.set(nid, **props)
359     def newnode(self, message=None):
360         ''' Add a new node to the database.
361         
362         The form works in two modes: blank form and submission (that is,
363         the submission goes to the same URL). **Eventually this means that
364         the form will have previously entered information in it if
365         submission fails.
367         The new node will be created with the properties specified in the
368         form submission. For multilinks, multiple form entries are handled,
369         as are prop=value,value,value. You can't mix them though.
371         If the new node is to be referenced from somewhere else immediately
372         (ie. the new node is a file that is to be attached to a support
373         issue) then supply one of these arguments in addition to the usual
374         form entries:
375             :link=designator:property
376             :multilink=designator:property
377         ... which means that once the new node is created, the "property"
378         on the node given by "designator" should now reference the new
379         node's id. The node id will be appended to the multilink.
380         '''
381         cn = self.classname
382         cl = self.db.classes[cn]
384         # possibly perform a create
385         keys = self.form.keys()
386         if [i for i in keys if i[0] != ':']:
387             props = {}
388             try:
389                 nid = self._createnode()
390                 self._post_editnode(nid)
391                 # and some nice feedback for the user
392                 message = '%s created ok'%cn
393             except:
394                 s = StringIO.StringIO()
395                 traceback.print_exc(None, s)
396                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
397         self.pagehead('New %s'%self.classname.capitalize(), message)
398         htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
399             self.form)
400         self.pagefoot()
401     newissue = newnode
402     newuser = newnode
404     def newfile(self, message=None):
405         ''' Add a new file to the database.
406         
407         This form works very much the same way as newnode - it just has a
408         file upload.
409         '''
410         cn = self.classname
411         cl = self.db.classes[cn]
413         # possibly perform a create
414         keys = self.form.keys()
415         if [i for i in keys if i[0] != ':']:
416             try:
417                 file = self.form['content']
418                 type = mimetypes.guess_type(file.filename)[0]
419                 if not type:
420                     type = "application/octet-stream"
421                 self._post_editnode(cl.create(content=file.file.read(),
422                     type=type, name=file.filename))
423                 # and some nice feedback for the user
424                 message = '%s created ok'%cn
425             except:
426                 s = StringIO.StringIO()
427                 traceback.print_exc(None, s)
428                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
430         self.pagehead('New %s'%self.classname.capitalize(), message)
431         htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
432             self.form)
433         self.pagefoot()
435     def classes(self, message=None):
436         ''' display a list of all the classes in the database
437         '''
438         if self.user == 'admin':
439             self.pagehead('Table of classes', message)
440             classnames = self.db.classes.keys()
441             classnames.sort()
442             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
443             for cn in classnames:
444                 cl = self.db.getclass(cn)
445                 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
446                 for key, value in cl.properties.items():
447                     if value is None: value = ''
448                     else: value = str(value)
449                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
450                         key, cgi.escape(value)))
451             self.write('</table>')
452             self.pagefoot()
453         else:
454             raise Unauthorised
456     def login(self, message=None):
457         self.pagehead('Login to roundup', message)
458         self.write('''
459 <table>
460 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
461 <form action="login_action" method=POST>
462 <tr><td align=right>Login name: </td>
463     <td><input name="__login_name"></td></tr>
464 <tr><td align=right>Password: </td>
465     <td><input type="password" name="__login_password"></td></tr>
466 <tr><td></td>
467     <td><input type="submit" value="Log In"></td></tr>
468 </form>
470 <p>
471 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
472 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
473 <form action="newuser_action" method=POST>
474 <tr><td align=right><em>Name: </em></td>
475     <td><input name="__newuser_realname"></td></tr>
476 <tr><td align=right><em>Organisation: </em></td>
477     <td><input name="__newuser_organisation"></td></tr>
478 <tr><td align=right>E-Mail Address: </td>
479     <td><input name="__newuser_address"></td></tr>
480 <tr><td align=right><em>Phone: </em></td>
481     <td><input name="__newuser_phone"></td></tr>
482 <tr><td align=right>Preferred Login name: </td>
483     <td><input name="__newuser_username"></td></tr>
484 <tr><td align=right>Password: </td>
485     <td><input type="password" name="__newuser_password"></td></tr>
486 <tr><td align=right>Password Again: </td>
487     <td><input type="password" name="__newuser_confirm"></td></tr>
488 <tr><td></td>
489     <td><input type="submit" value="Register"></td></tr>
490 </form>
491 </table>
492 ''')
494     def login_action(self, message=None):
495         self.user = self.form['__login_name'].value
496         password = self.form['__login_password'].value
497         # make sure the user exists
498         try:
499             uid = self.db.user.lookup(self.user)
500         except KeyError:
501             name = self.user
502             self.make_user_anonymous()
503             return self.login(message='No such user "%s"'%name)
505         # and that the password is correct
506         if password != self.db.user.get(uid, 'password'):
507             return self.login(message='Incorrect password')
509         # construct the cookie
510         uid = self.db.user.lookup(self.user)
511         user = base64.encodestring('%s:%s'%(self.user, password))[:-1]
512         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
513             ''))
514         cookie = Cookie.SmartCookie()
515         cookie['roundup_user'] = user
516         cookie['roundup_user']['path'] = path
517         self.header({'Set-Cookie': str(cookie)})
518         return self.index()
520     def make_user_anonymous(self):
521         # make us anonymous if we can
522         try:
523             self.db.user.lookup('anonymous')
524             self.user = 'anonymous'
525         except KeyError:
526             self.user = None
528     def logout(self, message=None):
529         self.make_user_anonymous()
530         # construct the logout cookie
531         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
532             ''))
533         cookie = Cookie.SmartCookie()
534         cookie['roundup_user'] = 'deleted'
535         cookie['roundup_user']['path'] = path
536         cookie['roundup_user']['expires'] = 0
537         cookie['roundup_user']['max-age'] = 0
538         self.header({'Set-Cookie': str(cookie)})
539         return self.index()
541     def newuser_action(self, message=None):
542         ''' create a new user based on the contents of the form and then
543         set the cookie
544         '''
545         # TODO: pre-check the required fields and username key property
546         cl = self.db.classes['user']
547         props, dummy = parsePropsFromForm(cl, self.form)
548         uid = cl.create(**props)
549         self.user = self.db.user.get(uid, 'username')
550         password = self.db.user.get(uid, 'password')
551         # construct the cookie
552         uid = self.db.user.lookup(self.user)
553         user = base64.encodestring('%s:%s'%(self.user, password))[:-1]
554         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
555             ''))
556         cookie = Cookie.SmartCookie()
557         cookie['roundup_user'] = user
558         cookie['roundup_user']['path'] = path
559         self.header({'Set-Cookie': str(cookie)})
560         return self.index()
562     def main(self, dre=re.compile(r'([^\d]+)(\d+)'),
563             nre=re.compile(r'new(\w+)')):
565         # determine the uid to use
566         self.db = self.instance.open('admin')
567         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
568         user = 'anonymous'
569         if (cookie.has_key('roundup_user') and
570                 cookie['roundup_user'].value != 'deleted'):
571             cookie = cookie['roundup_user'].value
572             user, password = base64.decodestring(cookie).split(':')
573             # make sure the user exists
574             try:
575                 uid = self.db.user.lookup(user)
576                 # now validate the password
577                 if password != self.db.user.get(uid, 'password'):
578                     user = 'anonymous'
579             except KeyError:
580                 user = 'anonymous'
582         # make sure the anonymous user is valid if we're using it
583         if user == 'anonymous':
584             self.make_user_anonymous()
585         else:
586             self.user = user
587         self.db.close()
589         # re-open the database for real, using the user
590         self.db = self.instance.open(self.user)
592         # now figure which function to call
593         path = self.split_path
594         if not path or path[0] in ('', 'index'):
595             self.index()
596         elif len(path) == 1:
597             if path[0] == 'list_classes':
598                 self.classes()
599                 return
600             if path[0] == 'login':
601                 self.login()
602                 return
603             if path[0] == 'login_action':
604                 self.login_action()
605                 return
606             if path[0] == 'newuser_action':
607                 self.newuser_action()
608                 return
609             if path[0] == 'logout':
610                 self.logout()
611                 return
612             m = dre.match(path[0])
613             if m:
614                 self.classname = m.group(1)
615                 self.nodeid = m.group(2)
616                 try:
617                     cl = self.db.classes[self.classname]
618                 except KeyError:
619                     raise NotFound
620                 try:
621                     cl.get(self.nodeid, 'id')
622                 except IndexError:
623                     raise NotFound
624                 try:
625                     getattr(self, 'show%s'%self.classname)()
626                 except AttributeError:
627                     raise NotFound
628                 return
629             m = nre.match(path[0])
630             if m:
631                 self.classname = m.group(1)
632                 try:
633                     getattr(self, 'new%s'%self.classname)()
634                 except AttributeError:
635                     raise NotFound
636                 return
637             self.classname = path[0]
638             try:
639                 self.db.getclass(self.classname)
640             except KeyError:
641                 raise NotFound
642             self.list()
643         else:
644             raise 'ValueError', 'Path not understood'
646     def __del__(self):
647         self.db.close()
649 def parsePropsFromForm(cl, form, nodeid=0):
650     '''Pull properties for the given class out of the form.
651     '''
652     props = {}
653     changed = []
654     keys = form.keys()
655     num_re = re.compile('^\d+$')
656     for key in keys:
657         if not cl.properties.has_key(key):
658             continue
659         proptype = cl.properties[key]
660         if isinstance(proptype, hyperdb.String):
661             value = form[key].value.strip()
662         elif isinstance(proptype, hyperdb.Date):
663             value = date.Date(form[key].value.strip())
664         elif isinstance(proptype, hyperdb.Interval):
665             value = date.Interval(form[key].value.strip())
666         elif isinstance(proptype, hyperdb.Link):
667             value = form[key].value.strip()
668             # handle key values
669             link = cl.properties[key].classname
670             if not num_re.match(value):
671                 try:
672                     value = self.db.classes[link].lookup(value)
673                 except:
674                     raise ValueError, 'property "%s": %s not a %s'%(
675                         key, value, link)
676         elif isinstance(proptype, hyperdb.Multilink):
677             value = form[key]
678             if type(value) != type([]):
679                 value = [i.strip() for i in value.value.split(',')]
680             else:
681                 value = [i.value.strip() for i in value]
682             link = cl.properties[key].classname
683             l = []
684             for entry in map(str, value):
685                 if not num_re.match(entry):
686                     try:
687                         entry = self.db.classes[link].lookup(entry)
688                     except:
689                         raise ValueError, \
690                             'property "%s": %s not a %s'%(key,
691                             entry, link)
692                 l.append(entry)
693             l.sort()
694             value = l
695         props[key] = value
696         # if changed, set it
697         if nodeid and value != cl.get(nodeid, key):
698             changed.append(key)
699             props[key] = value
700     return props, changed
703 # $Log: not supported by cvs2svn $
704 # Revision 1.26  2001/09/12 08:31:42  richard
705 # handle cases where mime type is not guessable
707 # Revision 1.25  2001/08/29 05:30:49  richard
708 # change messages weren't being saved when there was no-one on the nosy list.
710 # Revision 1.24  2001/08/29 04:49:39  richard
711 # didn't clean up fully after debugging :(
713 # Revision 1.23  2001/08/29 04:47:18  richard
714 # Fixed CGI client change messages so they actually include the properties
715 # changed (again).
717 # Revision 1.22  2001/08/17 00:08:10  richard
718 # reverted back to sending messages always regardless of who is doing the web
719 # edit. change notes weren't being saved. bleah. hackish.
721 # Revision 1.21  2001/08/15 23:43:18  richard
722 # Fixed some isFooTypes that I missed.
723 # Refactored some code in the CGI code.
725 # Revision 1.20  2001/08/12 06:32:36  richard
726 # using isinstance(blah, Foo) now instead of isFooType
728 # Revision 1.19  2001/08/07 00:24:42  richard
729 # stupid typo
731 # Revision 1.18  2001/08/07 00:15:51  richard
732 # Added the copyright/license notice to (nearly) all files at request of
733 # Bizar Software.
735 # Revision 1.17  2001/08/02 06:38:17  richard
736 # Roundupdb now appends "mailing list" information to its messages which
737 # include the e-mail address and web interface address. Templates may
738 # override this in their db classes to include specific information (support
739 # instructions, etc).
741 # Revision 1.16  2001/08/02 05:55:25  richard
742 # Web edit messages aren't sent to the person who did the edit any more. No
743 # message is generated if they are the only person on the nosy list.
745 # Revision 1.15  2001/08/02 00:34:10  richard
746 # bleah syntax error
748 # Revision 1.14  2001/08/02 00:26:16  richard
749 # Changed the order of the information in the message generated by web edits.
751 # Revision 1.13  2001/07/30 08:12:17  richard
752 # Added time logging and file uploading to the templates.
754 # Revision 1.12  2001/07/30 06:26:31  richard
755 # Added some documentation on how the newblah works.
757 # Revision 1.11  2001/07/30 06:17:45  richard
758 # Features:
759 #  . Added ability for cgi newblah forms to indicate that the new node
760 #    should be linked somewhere.
761 # Fixed:
762 #  . Fixed the agument handling for the roundup-admin find command.
763 #  . Fixed handling of summary when no note supplied for newblah. Again.
764 #  . Fixed detection of no form in htmltemplate Field display.
766 # Revision 1.10  2001/07/30 02:37:34  richard
767 # Temporary measure until we have decent schema migration...
769 # Revision 1.9  2001/07/30 01:25:07  richard
770 # Default implementation is now "classic" rather than "extended" as one would
771 # expect.
773 # Revision 1.8  2001/07/29 08:27:40  richard
774 # Fixed handling of passed-in values in form elements (ie. during a
775 # drill-down)
777 # Revision 1.7  2001/07/29 07:01:39  richard
778 # Added vim command to all source so that we don't get no steenkin' tabs :)
780 # Revision 1.6  2001/07/29 04:04:00  richard
781 # Moved some code around allowing for subclassing to change behaviour.
783 # Revision 1.5  2001/07/28 08:16:52  richard
784 # New issue form handles lack of note better now.
786 # Revision 1.4  2001/07/28 00:34:34  richard
787 # Fixed some non-string node ids.
789 # Revision 1.3  2001/07/23 03:56:30  richard
790 # oops, missed a config removal
792 # Revision 1.2  2001/07/22 12:09:32  richard
793 # Final commit of Grande Splite
795 # Revision 1.1  2001/07/22 11:58:35  richard
796 # More Grande Splite
799 # vim: set filetype=python ts=4 sw=4 et si