Code

e8e6c0ba3f230ce3b32a09764acec9cb9011683e
[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.29 2001-10-09 07:25:59 richard Exp $
20 import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes
21 import base64, Cookie, time
23 import roundupdb, htmltemplate, date, hyperdb, password
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, key))
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         pw = self.db.user.get(uid, 'password')
507         print password, pw, `pw`
508         if password != self.db.user.get(uid, 'password'):
509             self.make_user_anonymous()
510             return self.login(message='Incorrect password')
512         # construct the cookie
513         uid = self.db.user.lookup(self.user)
514         user = base64.encodestring('%s:%s'%(self.user, password))[:-1]
515         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
516             ''))
517         cookie = Cookie.SmartCookie()
518         cookie['roundup_user'] = user
519         cookie['roundup_user']['path'] = path
520         self.header({'Set-Cookie': str(cookie)})
521         return self.index()
523     def make_user_anonymous(self):
524         # make us anonymous if we can
525         try:
526             self.db.user.lookup('anonymous')
527             self.user = 'anonymous'
528         except KeyError:
529             self.user = None
531     def logout(self, message=None):
532         self.make_user_anonymous()
533         # construct the logout cookie
534         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
535             ''))
536         cookie = Cookie.SmartCookie()
537         cookie['roundup_user'] = 'deleted'
538         cookie['roundup_user']['path'] = path
539         cookie['roundup_user']['expires'] = 0
540         cookie['roundup_user']['max-age'] = 0
541         self.header({'Set-Cookie': str(cookie)})
542         return self.index()
544     def newuser_action(self, message=None):
545         ''' create a new user based on the contents of the form and then
546         set the cookie
547         '''
548         # TODO: pre-check the required fields and username key property
549         cl = self.db.classes['user']
550         props, dummy = parsePropsFromForm(cl, self.form)
551         uid = cl.create(**props)
552         self.user = self.db.user.get(uid, 'username')
553         password = self.db.user.get(uid, 'password')
554         # construct the cookie
555         uid = self.db.user.lookup(self.user)
556         user = base64.encodestring('%s:%s'%(self.user, password))[:-1]
557         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
558             ''))
559         cookie = Cookie.SmartCookie()
560         cookie['roundup_user'] = user
561         cookie['roundup_user']['path'] = path
562         self.header({'Set-Cookie': str(cookie)})
563         return self.index()
565     def main(self, dre=re.compile(r'([^\d]+)(\d+)'),
566             nre=re.compile(r'new(\w+)')):
568         # determine the uid to use
569         self.db = self.instance.open('admin')
570         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
571         user = 'anonymous'
572         if (cookie.has_key('roundup_user') and
573                 cookie['roundup_user'].value != 'deleted'):
574             cookie = cookie['roundup_user'].value
575             user, password = base64.decodestring(cookie).split(':')
576             # make sure the user exists
577             try:
578                 uid = self.db.user.lookup(user)
579                 # now validate the password
580                 if password != self.db.user.get(uid, 'password'):
581                     user = 'anonymous'
582             except KeyError:
583                 user = 'anonymous'
585         # make sure the anonymous user is valid if we're using it
586         if user == 'anonymous':
587             self.make_user_anonymous()
588         else:
589             self.user = user
590         self.db.close()
592         # re-open the database for real, using the user
593         self.db = self.instance.open(self.user)
595         # now figure which function to call
596         path = self.split_path
597         if not path or path[0] in ('', 'index'):
598             self.index()
599         elif len(path) == 1:
600             if path[0] == 'list_classes':
601                 self.classes()
602                 return
603             if path[0] == 'login':
604                 self.login()
605                 return
606             if path[0] == 'login_action':
607                 self.login_action()
608                 return
609             if path[0] == 'newuser_action':
610                 self.newuser_action()
611                 return
612             if path[0] == 'logout':
613                 self.logout()
614                 return
615             m = dre.match(path[0])
616             if m:
617                 self.classname = m.group(1)
618                 self.nodeid = m.group(2)
619                 try:
620                     cl = self.db.classes[self.classname]
621                 except KeyError:
622                     raise NotFound
623                 try:
624                     cl.get(self.nodeid, 'id')
625                 except IndexError:
626                     raise NotFound
627                 try:
628                     getattr(self, 'show%s'%self.classname)()
629                 except AttributeError:
630                     raise NotFound
631                 return
632             m = nre.match(path[0])
633             if m:
634                 self.classname = m.group(1)
635                 try:
636                     getattr(self, 'new%s'%self.classname)()
637                 except AttributeError:
638                     raise NotFound
639                 return
640             self.classname = path[0]
641             try:
642                 self.db.getclass(self.classname)
643             except KeyError:
644                 raise NotFound
645             self.list()
646         else:
647             raise 'ValueError', 'Path not understood'
649     def __del__(self):
650         self.db.close()
652 def parsePropsFromForm(cl, form, nodeid=0):
653     '''Pull properties for the given class out of the form.
654     '''
655     props = {}
656     changed = []
657     keys = form.keys()
658     num_re = re.compile('^\d+$')
659     for key in keys:
660         if not cl.properties.has_key(key):
661             continue
662         proptype = cl.properties[key]
663         if isinstance(proptype, hyperdb.String):
664             value = form[key].value.strip()
665         elif isinstance(proptype, hyperdb.Password):
666             value = password.Password(form[key].value.strip())
667         elif isinstance(proptype, hyperdb.Date):
668             value = date.Date(form[key].value.strip())
669         elif isinstance(proptype, hyperdb.Interval):
670             value = date.Interval(form[key].value.strip())
671         elif isinstance(proptype, hyperdb.Link):
672             value = form[key].value.strip()
673             # handle key values
674             link = cl.properties[key].classname
675             if not num_re.match(value):
676                 try:
677                     value = self.db.classes[link].lookup(value)
678                 except:
679                     raise ValueError, 'property "%s": %s not a %s'%(
680                         key, value, link)
681         elif isinstance(proptype, hyperdb.Multilink):
682             value = form[key]
683             if type(value) != type([]):
684                 value = [i.strip() for i in value.value.split(',')]
685             else:
686                 value = [i.value.strip() for i in value]
687             link = cl.properties[key].classname
688             l = []
689             for entry in map(str, value):
690                 if not num_re.match(entry):
691                     try:
692                         entry = self.db.classes[link].lookup(entry)
693                     except:
694                         raise ValueError, \
695                             'property "%s": %s not a %s'%(key,
696                             entry, link)
697                 l.append(entry)
698             l.sort()
699             value = l
700         props[key] = value
701         # if changed, set it
702         if nodeid and value != cl.get(nodeid, key):
703             changed.append(key)
704             props[key] = value
705     return props, changed
708 # $Log: not supported by cvs2svn $
709 # Revision 1.28  2001/10/08 00:34:31  richard
710 # Change message was stuffing up for multilinks with no key property.
712 # Revision 1.27  2001/10/05 02:23:24  richard
713 #  . roundup-admin create now prompts for property info if none is supplied
714 #    on the command-line.
715 #  . hyperdb Class getprops() method may now return only the mutable
716 #    properties.
717 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
718 #    now support anonymous user access (read-only, unless there's an
719 #    "anonymous" user, in which case write access is permitted). Login
720 #    handling has been moved into cgi_client.Client.main()
721 #  . The "extended" schema is now the default in roundup init.
722 #  . The schemas have had their page headings modified to cope with the new
723 #    login handling. Existing installations should copy the interfaces.py
724 #    file from the roundup lib directory to their instance home.
725 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
726 #    Ping - has been removed.
727 #  . Fixed a whole bunch of places in the CGI interface where we should have
728 #    been returning Not Found instead of throwing an exception.
729 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
730 #    an item now throws an exception.
732 # Revision 1.26  2001/09/12 08:31:42  richard
733 # handle cases where mime type is not guessable
735 # Revision 1.25  2001/08/29 05:30:49  richard
736 # change messages weren't being saved when there was no-one on the nosy list.
738 # Revision 1.24  2001/08/29 04:49:39  richard
739 # didn't clean up fully after debugging :(
741 # Revision 1.23  2001/08/29 04:47:18  richard
742 # Fixed CGI client change messages so they actually include the properties
743 # changed (again).
745 # Revision 1.22  2001/08/17 00:08:10  richard
746 # reverted back to sending messages always regardless of who is doing the web
747 # edit. change notes weren't being saved. bleah. hackish.
749 # Revision 1.21  2001/08/15 23:43:18  richard
750 # Fixed some isFooTypes that I missed.
751 # Refactored some code in the CGI code.
753 # Revision 1.20  2001/08/12 06:32:36  richard
754 # using isinstance(blah, Foo) now instead of isFooType
756 # Revision 1.19  2001/08/07 00:24:42  richard
757 # stupid typo
759 # Revision 1.18  2001/08/07 00:15:51  richard
760 # Added the copyright/license notice to (nearly) all files at request of
761 # Bizar Software.
763 # Revision 1.17  2001/08/02 06:38:17  richard
764 # Roundupdb now appends "mailing list" information to its messages which
765 # include the e-mail address and web interface address. Templates may
766 # override this in their db classes to include specific information (support
767 # instructions, etc).
769 # Revision 1.16  2001/08/02 05:55:25  richard
770 # Web edit messages aren't sent to the person who did the edit any more. No
771 # message is generated if they are the only person on the nosy list.
773 # Revision 1.15  2001/08/02 00:34:10  richard
774 # bleah syntax error
776 # Revision 1.14  2001/08/02 00:26:16  richard
777 # Changed the order of the information in the message generated by web edits.
779 # Revision 1.13  2001/07/30 08:12:17  richard
780 # Added time logging and file uploading to the templates.
782 # Revision 1.12  2001/07/30 06:26:31  richard
783 # Added some documentation on how the newblah works.
785 # Revision 1.11  2001/07/30 06:17:45  richard
786 # Features:
787 #  . Added ability for cgi newblah forms to indicate that the new node
788 #    should be linked somewhere.
789 # Fixed:
790 #  . Fixed the agument handling for the roundup-admin find command.
791 #  . Fixed handling of summary when no note supplied for newblah. Again.
792 #  . Fixed detection of no form in htmltemplate Field display.
794 # Revision 1.10  2001/07/30 02:37:34  richard
795 # Temporary measure until we have decent schema migration...
797 # Revision 1.9  2001/07/30 01:25:07  richard
798 # Default implementation is now "classic" rather than "extended" as one would
799 # expect.
801 # Revision 1.8  2001/07/29 08:27:40  richard
802 # Fixed handling of passed-in values in form elements (ie. during a
803 # drill-down)
805 # Revision 1.7  2001/07/29 07:01:39  richard
806 # Added vim command to all source so that we don't get no steenkin' tabs :)
808 # Revision 1.6  2001/07/29 04:04:00  richard
809 # Moved some code around allowing for subclassing to change behaviour.
811 # Revision 1.5  2001/07/28 08:16:52  richard
812 # New issue form handles lack of note better now.
814 # Revision 1.4  2001/07/28 00:34:34  richard
815 # Fixed some non-string node ids.
817 # Revision 1.3  2001/07/23 03:56:30  richard
818 # oops, missed a config removal
820 # Revision 1.2  2001/07/22 12:09:32  richard
821 # Final commit of Grande Splite
823 # Revision 1.1  2001/07/22 11:58:35  richard
824 # More Grande Splite
827 # vim: set filetype=python ts=4 sw=4 et si