Code

forward-porting of fixed edit action / parsePropsFromForm to handle index-page edits...
[roundup.git] / roundup / cgi / templating.py
index 1c461bf631ac81e906c0ffeca6e8b4b31deb4cdf..dc1530951eb343f35c932b4a84b14cfb5a9682c0 100644 (file)
@@ -1,3 +1,7 @@
+"""Implements the API used in the HTML templating for the web interface.
+"""
+__docformat__ = 'restructuredtext'
+
 from __future__ import nested_scopes
 
 import sys, cgi, urllib, os, re, os.path, time, errno, mimetypes
@@ -27,6 +31,14 @@ from roundup.cgi import ZTUtils
 class NoTemplate(Exception):
     pass
 
+class Unauthorised(Exception):
+    def __init__(self, action, klass):
+        self.action = action
+        self.klass = klass
+    def __str__(self):
+        return 'You are not allowed to %s items of class %s'%(self.action,
+            self.klass)
+
 def find_template(dir, name, extension):
     ''' Find a template in the nominated dir
     '''
@@ -135,35 +147,37 @@ class Templates:
             raise KeyError, message
 
 class RoundupPageTemplate(PageTemplate.PageTemplate):
-    ''' A Roundup-specific PageTemplate.
-
-        Interrogate the client to set up the various template variables to
-        be available:
-
-        *context*
-         this is one of three things:
-         1. None - we're viewing a "home" page
-         2. The current class of item being displayed. This is an HTMLClass
-            instance.
-         3. The current item from the database, if we're viewing a specific
-            item, as an HTMLItem instance.
-        *request*
-          Includes information about the current request, including:
-           - the url
-           - the current index information (``filterspec``, ``filter`` args,
-             ``properties``, etc) parsed out of the form. 
-           - methods for easy filterspec link generation
-           - *user*, the current user node as an HTMLItem instance
-           - *form*, the current CGI form information as a FieldStorage
-        *config*
-          The current tracker config.
-        *db*
-          The current database, used to access arbitrary database items.
-        *utils*
-          This is a special class that has its base in the TemplatingUtils
-          class in this file. If the tracker interfaces module defines a
-          TemplatingUtils class then it is mixed in, overriding the methods
-          in the base class.
+    '''A Roundup-specific PageTemplate.
+
+    Interrogate the client to set up the various template variables to
+    be available:
+
+    *context*
+     this is one of three things:
+
+     1. None - we're viewing a "home" page
+     2. The current class of item being displayed. This is an HTMLClass
+        instance.
+     3. The current item from the database, if we're viewing a specific
+        item, as an HTMLItem instance.
+    *request*
+      Includes information about the current request, including:
+
+       - the url
+       - the current index information (``filterspec``, ``filter`` args,
+         ``properties``, etc) parsed out of the form. 
+       - methods for easy filterspec link generation
+       - *user*, the current user node as an HTMLItem instance
+       - *form*, the current CGI form information as a FieldStorage
+    *config*
+      The current tracker config.
+    *db*
+      The current database, used to access arbitrary database items.
+    *utils*
+      This is a special class that has its base in the TemplatingUtils
+      class in this file. If the tracker interfaces module defines a
+      TemplatingUtils class then it is mixed in, overriding the methods
+      in the base class.
     '''
     def getContext(self, client, classname, request):
         # construct the TemplatingUtils class
@@ -218,6 +232,9 @@ class RoundupPageTemplate(PageTemplate.PageTemplate):
             getEngine().getContext(c), output, tal=1, strictinsert=0)()
         return output.getvalue()
 
+    def __repr__(self):
+        return '<Roundup PageTemplate %r>'%self.id
+
 class HTMLDatabase:
     ''' Return HTMLClasses for valid class fetches
     '''
@@ -271,17 +288,52 @@ class HTMLPermissions:
         '''
         return self._db.security.hasPermission('Edit', self._client.userid,
             self._classname)
+
     def is_view_ok(self):
         ''' Is the user allowed to View the current class?
         '''
         return self._db.security.hasPermission('View', self._client.userid,
             self._classname)
+
     def is_only_view_ok(self):
         ''' Is the user only allowed to View (ie. not Edit) the current class?
         '''
         return self.is_view_ok() and not self.is_edit_ok()
 
-class HTMLClass(HTMLPermissions):
+    def view_check(self):
+        ''' Raise the Unauthorised exception if the user's not permitted to
+            view this class.
+        '''
+        if not self.is_view_ok():
+            raise Unauthorised("view", self._classname)
+
+    def edit_check(self):
+        ''' Raise the Unauthorised exception if the user's not permitted to
+            edit this class.
+        '''
+        if not self.is_edit_ok():
+            raise Unauthorised("edit", self._classname)
+
+def input_html4(**attrs):
+    """Generate an 'input' (html4) element with given attributes"""
+    return '<input %s>'%' '.join(['%s="%s"'%item for item in attrs.items()])
+
+def input_xhtml(**attrs):
+    """Generate an 'input' (xhtml) element with given attributes"""
+    return '<input %s/>'%' '.join(['%s="%s"'%item for item in attrs.items()])
+
+class HTMLInputMixin:
+    ''' requires a _client property '''
+    def __init__(self):
+        html_version = 'html4'
+        if hasattr(self._client.instance.config, 'HTML_VERSION'):
+            html_version = self._client.instance.config.HTML_VERSION
+        if html_version == 'xhtml':
+            self.input = input_xhtml
+        else:
+            self.input = input_html4
+
+class HTMLClass(HTMLInputMixin, HTMLPermissions):
     ''' Accesses through a class (either through *class* or *db.<classname>*)
     '''
     def __init__(self, client, classname, anonymous=0):
@@ -295,6 +347,8 @@ class HTMLClass(HTMLPermissions):
         self._klass = self._db.getclass(self.classname)
         self._props = self._klass.getprops()
 
+        HTMLInputMixin.__init__(self)
+
     def __repr__(self):
         return '<HTMLClass(0x%x) %s>'%(id(self), self.classname)
 
@@ -353,7 +407,7 @@ class HTMLClass(HTMLPermissions):
         ''' Get an item of this class by its item id.
         '''
         # make sure we're looking at an itemid
-        if not num_re.match(itemid):
+        if not isinstance(itemid, type(1)) and not num_re.match(itemid):
             itemid = self._klass.lookup(itemid)
 
         if self.classname == 'user':
@@ -380,7 +434,7 @@ class HTMLClass(HTMLPermissions):
             l.sort(lambda a,b:cmp(a._name, b._name))
         return l
 
-    def list(self):
+    def list(self, sort_on=None):
         ''' List all items in this class.
         '''
         if self.classname == 'user':
@@ -390,7 +444,7 @@ class HTMLClass(HTMLPermissions):
 
         # get the list and sort it nicely
         l = self._klass.list()
-        sortfunc = make_sort_function(self._db, self.classname)
+        sortfunc = make_sort_function(self._db, self.classname, sort_on)
         l.sort(sortfunc)
 
         l = [klass(self._client, self.classname, x) for x in l]
@@ -426,19 +480,17 @@ class HTMLClass(HTMLPermissions):
         idlessprops.sort()
         return ['id'] + idlessprops
 
-    def filter(self, request=None):
+    def filter(self, request=None, filterspec={}, sort=(None,None),
+            group=(None,None)):
         ''' Return a list of items from this class, filtered and sorted
             by the current requested filterspec/filter/sort/group args
+
+            "request" takes precedence over the other three arguments.
         '''
-        # XXX allow direct specification of the filterspec etc.
         if request is not None:
             filterspec = request.filterspec
             sort = request.sort
             group = request.group
-        else:
-            filterspec = {}
-            sort = (None,None)
-            group = (None,None)
         if self.classname == 'user':
             klass = HTMLUser
         else:
@@ -478,10 +530,14 @@ class HTMLClass(HTMLPermissions):
     def submit(self, label="Submit New Entry"):
         ''' Generate a submit button (and action hidden element)
         '''
-        return '  <input type="hidden" name="@action" value="new">\n'\
-        '  <input type="submit" name="submit" value="%s">'%label
+        self.view_check()
+        if self.is_edit_ok():
+            return self.input(type="hidden",name="@action",value="new") + \
+                   '\n' + self.input(type="submit",name="submit",value=label)
+        return ''
 
     def history(self):
+        self.view_check()
         return 'New node - no history'
 
     def renderWith(self, name, **kwargs):
@@ -496,9 +552,13 @@ class HTMLClass(HTMLPermissions):
         pt = Templates(self._db.config.TEMPLATES).get(self.classname, name)
 
         # use our fabricated request
-        return pt.render(self._client, self.classname, req)
+        args = {
+            'ok_message': self._client.ok_message,
+            'error_message': self._client.error_message
+        }
+        return pt.render(self._client, self.classname, req, **args)
 
-class HTMLItem(HTMLPermissions):
+class HTMLItem(HTMLInputMixin, HTMLPermissions):
     ''' Accesses through an *item*
     '''
     def __init__(self, client, classname, nodeid, anonymous=0):
@@ -512,6 +572,8 @@ class HTMLItem(HTMLPermissions):
         # do we prefix the form items with the item's identification?
         self._anonymous = anonymous
 
+        HTMLInputMixin.__init__(self)
+
     def __repr__(self):
         return '<HTMLItem(0x%x) %s %s>'%(id(self), self._classname,
             self._nodeid)
@@ -556,8 +618,8 @@ class HTMLItem(HTMLPermissions):
     def submit(self, label="Submit Changes"):
         ''' Generate a submit button (and action hidden element)
         '''
-        return '  <input type="hidden" name="@action" value="edit">\n'\
-        '  <input type="submit" name="submit" value="%s">'%label
+        return self.input(type="hidden",name="@action",value="edit") + '\n' + \
+               self.input(type="submit",name="submit",value=label)
 
     def journal(self, direction='descending'):
         ''' Return a list of HTMLJournalEntry instances.
@@ -566,6 +628,8 @@ class HTMLItem(HTMLPermissions):
         return []
 
     def history(self, direction='descending', dre=re.compile('\d+')):
+        self.view_check()
+
         l = ['<table class="history">'
              '<tr><th colspan="4" class="header">',
              _('History'),
@@ -816,11 +880,11 @@ class HTMLUser(HTMLItem):
         ''' Is the user allowed to View the current class?
             Also check whether this is the current user's info.
         '''
-        return self._db.security.hasPermission('Edit', self._client.userid,
+        return self._db.security.hasPermission('View', self._client.userid,
             self._classname) or (self._nodeid == self._client.userid and
             self._db.user.get(self._client.userid, 'username') != 'anonymous')
 
-class HTMLProperty:
+class HTMLProperty(HTMLInputMixin, HTMLPermissions):
     ''' String, Number, Date, Interval HTMLProperty
 
         Has useful attributes:
@@ -844,6 +908,9 @@ class HTMLProperty:
             self._formname = '%s%s@%s'%(classname, nodeid, name)
         else:
             self._formname = name
+
+        HTMLInputMixin.__init__(self)
+
     def __repr__(self):
         return '<HTMLProperty(0x%x) %s %r %r>'%(id(self), self._formname,
             self._prop, self._value)
@@ -854,6 +921,26 @@ class HTMLProperty:
             return cmp(self._value, other._value)
         return cmp(self._value, other)
 
+    def is_edit_ok(self):
+        ''' Is the user allowed to Edit the current class?
+        '''
+        thing = HTMLDatabase(self._client)[self._classname]
+        if self._nodeid:
+            # this is a special-case for the User class where permission's
+            # on a per-item basis :(
+            thing = thing.getItem(self._nodeid)
+        return thing.is_edit_ok()
+
+    def is_view_ok(self):
+        ''' Is the user allowed to View the current class?
+        '''
+        thing = HTMLDatabase(self._client)[self._classname]
+        if self._nodeid:
+            # this is a special-case for the User class where permission's
+            # on a per-item basis :(
+            thing = thing.getItem(self._nodeid)
+        return thing.is_view_ok()
+
 class StringHTMLProperty(HTMLProperty):
     hyper_re = re.compile(r'((?P<url>\w{3,6}://\S+)|'
                           r'(?P<email>[-+=%/\w\.]+@[\w\.\-]+)|'
@@ -881,12 +968,14 @@ class StringHTMLProperty(HTMLProperty):
         return self.plain(hyperlink=1)
 
     def plain(self, escape=0, hyperlink=0):
-        ''' Render a "plain" representation of the property
+        '''Render a "plain" representation of the property
             
-            "escape" turns on/off HTML quoting
-            "hyperlink" turns on/off in-text hyperlinking of URLs, email
-                addresses and designators
+        - "escape" turns on/off HTML quoting
+        - "hyperlink" turns on/off in-text hyperlinking of URLs, email
+          addresses and designators
         '''
+        self.view_check()
+
         if self._value is None:
             return ''
         if escape:
@@ -905,37 +994,59 @@ class StringHTMLProperty(HTMLProperty):
 
             This requires the StructureText module to be installed separately.
         '''
+        self.view_check()
+
         s = self.plain(escape=escape)
         if not StructuredText:
             return s
         return StructuredText(s,level=1,header=0)
 
     def field(self, size = 30):
-        ''' Render a form edit field for the property
+        ''' Render the property as a field in HTML.
+
+            If not editable, just display the value via plain().
         '''
+        self.view_check()
+
         if self._value is None:
             value = ''
         else:
             value = cgi.escape(str(self._value))
+
+        if self.is_edit_ok():
             value = '&quot;'.join(value.split('"'))
-        return '<input name="%s" value="%s" size="%s">'%(self._formname, value, size)
+            return self.input(name=self._formname,value=value,size=size)
+
+        return self.plain()
 
     def multiline(self, escape=0, rows=5, cols=40):
-        ''' Render a multiline form edit field for the property
+        ''' Render a multiline form edit field for the property.
+
+            If not editable, just display the plain() value in a <pre> tag.
         '''
+        self.view_check()
+
         if self._value is None:
             value = ''
         else:
             value = cgi.escape(str(self._value))
+
+        if self.is_edit_ok():
             value = '&quot;'.join(value.split('"'))
-        return '<textarea name="%s" rows="%s" cols="%s">%s</textarea>'%(
-            self._formname, rows, cols, value)
+            return '<textarea name="%s" rows="%s" cols="%s">%s</textarea>'%(
+                self._formname, rows, cols, value)
+
+        return '<pre>%s</pre>'%self.plain()
 
     def email(self, escape=1):
         ''' Render the value of the property as an obscured email address
         '''
-        if self._value is None: value = ''
-        else: value = str(self._value)
+        self.view_check()
+
+        if self._value is None:
+            value = ''
+        else:
+            value = str(self._value)
         if value.find('@') != -1:
             name, domain = value.split('@')
             domain = ' '.join(domain.split('.')[:-1])
@@ -951,38 +1062,64 @@ class PasswordHTMLProperty(HTMLProperty):
     def plain(self):
         ''' Render a "plain" representation of the property
         '''
+        self.view_check()
+
         if self._value is None:
             return ''
         return _('*encrypted*')
 
     def field(self, size = 30):
         ''' Render a form edit field for the property.
+
+            If not editable, just display the value via plain().
         '''
-        return '<input type="password" name="%s" size="%s">'%(self._formname, size)
+        self.view_check()
+
+        if self.is_edit_ok():
+            return self.input(type="password", name=self._formname, size=size)
+
+        return self.plain()
 
     def confirm(self, size = 30):
         ''' Render a second form edit field for the property, used for 
             confirmation that the user typed the password correctly. Generates
             a field with name "@confirm@name".
+
+            If not editable, display nothing.
         '''
-        return '<input type="password" name="@confirm@%s" size="%s">'%(
-            self._formname, size)
+        self.view_check()
+
+        if self.is_edit_ok():
+            return self.input(type="password",
+                name="@confirm@%s"%self._formname, size=size)
+
+        return ''
 
 class NumberHTMLProperty(HTMLProperty):
     def plain(self):
         ''' Render a "plain" representation of the property
         '''
+        self.view_check()
+
         return str(self._value)
 
     def field(self, size = 30):
-        ''' Render a form edit field for the property
+        ''' Render a form edit field for the property.
+
+            If not editable, just display the value via plain().
         '''
+        self.view_check()
+
         if self._value is None:
             value = ''
         else:
             value = cgi.escape(str(self._value))
+
+        if self.is_edit_ok():
             value = '&quot;'.join(value.split('"'))
-        return '<input name="%s" value="%s" size="%s">'%(self._formname, value, size)
+            return self.input(name=self._formname,value=value,size=size)
+
+        return self.plain()
 
     def __int__(self):
         ''' Return an int of me
@@ -999,28 +1136,43 @@ class BooleanHTMLProperty(HTMLProperty):
     def plain(self):
         ''' Render a "plain" representation of the property
         '''
+        self.view_check()
+
         if self._value is None:
             return ''
         return self._value and "Yes" or "No"
 
     def field(self):
         ''' Render a form edit field for the property
+
+            If not editable, just display the value via plain().
         '''
+        self.view_check()
+
+        if not is_edit_ok():
+            return self.plain()
+
         checked = self._value and "checked" or ""
-        s = '<input type="radio" name="%s" value="yes" %s>Yes'%(self._formname,
-            checked)
-        if checked:
-            checked = ""
+        if self._value:
+            s = self.input(type="radio", name=self._formname, value="yes",
+                checked="checked")
+            s += 'Yes'
+            s +=self.input(type="radio", name=self._formname, value="no")
+            s += 'No'
         else:
-            checked = "checked"
-        s += '<input type="radio" name="%s" value="no" %s>No'%(self._formname,
-            checked)
+            s = self.input(type="radio", name=self._formname, value="yes")
+            s += 'Yes'
+            s +=self.input(type="radio", name=self._formname, value="no",
+                checked="checked")
+            s += 'No'
         return s
 
 class DateHTMLProperty(HTMLProperty):
     def plain(self):
         ''' Render a "plain" representation of the property
         '''
+        self.view_check()
+
         if self._value is None:
             return ''
         return str(self._value.local(self._db.getUserTimezone()))
@@ -1031,29 +1183,42 @@ class DateHTMLProperty(HTMLProperty):
             This is useful for defaulting a new value. Returns a
             DateHTMLProperty.
         '''
+        self.view_check()
+
         return DateHTMLProperty(self._client, self._nodeid, self._prop,
             self._formname, date.Date('.'))
 
     def field(self, size = 30):
         ''' Render a form edit field for the property
+
+            If not editable, just display the value via plain().
         '''
+        self.view_check()
+
         if self._value is None:
             value = ''
         else:
-            value = cgi.escape(str(self._value.local(self._db.getUserTimezone())))
+            tz = self._db.getUserTimezone()
+            value = cgi.escape(str(self._value.local(tz)))
+
+        if is_edit_ok():
             value = '&quot;'.join(value.split('"'))
-        return '<input name="%s" value="%s" size="%s">'%(self._formname, value, size)
+            return self.input(name=self._formname,value=value,size=size)
+        
+        return self.plain()
 
     def reldate(self, pretty=1):
         ''' Render the interval between the date and now.
 
             If the "pretty" flag is true, then make the display pretty.
         '''
+        self.view_check()
+
         if not self._value:
             return ''
 
         # figure the interval
-        interval = date.Date('.') - self._value
+        interval = self._value - date.Date('.')
         if pretty:
             return interval.pretty()
         return str(interval)
@@ -1067,6 +1232,8 @@ class DateHTMLProperty(HTMLProperty):
             string, then it'll be stripped from the output. This is handy
             for the situatin when a date only specifies a month and a year.
         '''
+        self.view_check()
+
         if format is not self._marker:
             return self._value.pretty(format)
         else:
@@ -1075,6 +1242,8 @@ class DateHTMLProperty(HTMLProperty):
     def local(self, offset):
         ''' Return the date/time as a local (timezone offset) date/time.
         '''
+        self.view_check()
+
         return DateHTMLProperty(self._client, self._nodeid, self._prop,
             self._formname, self._value.local(offset))
 
@@ -1082,6 +1251,8 @@ class IntervalHTMLProperty(HTMLProperty):
     def plain(self):
         ''' Render a "plain" representation of the property
         '''
+        self.view_check()
+
         if self._value is None:
             return ''
         return str(self._value)
@@ -1089,17 +1260,27 @@ class IntervalHTMLProperty(HTMLProperty):
     def pretty(self):
         ''' Render the interval in a pretty format (eg. "yesterday")
         '''
+        self.view_check()
+
         return self._value.pretty()
 
     def field(self, size = 30):
         ''' Render a form edit field for the property
+
+            If not editable, just display the value via plain().
         '''
+        self.view_check()
+
         if self._value is None:
             value = ''
         else:
             value = cgi.escape(str(self._value))
+
+        if is_edit_ok():
             value = '&quot;'.join(value.split('"'))
-        return '<input name="%s" value="%s" size="%s">'%(self._formname, value, size)
+            return self.input(name=self._formname,value=value,size=size)
+
+        return self.plain()
 
 class LinkHTMLProperty(HTMLProperty):
     ''' Link HTMLProperty
@@ -1133,6 +1314,8 @@ class LinkHTMLProperty(HTMLProperty):
     def plain(self, escape=0):
         ''' Render a "plain" representation of the property
         '''
+        self.view_check()
+
         if self._value is None:
             return ''
         linkcl = self._db.classes[self._prop.classname]
@@ -1144,55 +1327,40 @@ class LinkHTMLProperty(HTMLProperty):
 
     def field(self, showid=0, size=None):
         ''' Render a form edit field for the property
+
+            If not editable, just display the value via plain().
         '''
+        self.view_check()
+
+        if not self.is_edit_ok():
+            return self.plain()
+
+        # edit field
         linkcl = self._db.getclass(self._prop.classname)
-        if linkcl.getprops().has_key('order'):  
-            sort_on = 'order'  
-        else:  
-            sort_on = linkcl.labelprop()  
-        options = linkcl.filter(None, {}, ('+', sort_on), (None, None))
-        # TODO: make this a field display, not a menu one!
-        l = ['<select name="%s">'%self._formname]
-        k = linkcl.labelprop(1)
         if self._value is None:
-            s = 'selected '
+            value = ''
         else:
-            s = ''
-        l.append(_('<option %svalue="-1">- no selection -</option>')%s)
-
-        # make sure we list the current value if it's retired
-        if self._value and self._value not in options:
-            options.insert(0, self._value)
-
-        for optionid in options:
-            # get the option value, and if it's None use an empty string
-            option = linkcl.get(optionid, k) or ''
-
-            # figure if this option is selected
-            s = ''
-            if optionid == self._value:
-                s = 'selected '
-
-            # figure the label
-            if showid:
-                lab = '%s%s: %s'%(self._prop.classname, optionid, option)
+            k = linkcl.getkey()
+            if k:
+                label = linkcl.get(self._value, k)
             else:
-                lab = option
-
-            # truncate if it's too long
-            if size is not None and len(lab) > size:
-                lab = lab[:size-3] + '...'
-
-            # and generate
-            lab = cgi.escape(lab)
-            l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
-        l.append('</select>')
-        return '\n'.join(l)
+                label = self._value
+            value = cgi.escape(str(self._value))
+            value = '&quot;'.join(value.split('"'))
+        return '<input name="%s" value="%s" size="%s">'%(self._formname,
+            label, size)
 
     def menu(self, size=None, height=None, showid=0, additional=[],
-            **conditions):
+            sort_on=None, **conditions):
         ''' Render a form select list for this property
+
+            If not editable, just display the value via plain().
         '''
+        self.view_check()
+
+        if not self.is_edit_ok():
+            return self.plain()
+
         value = self._value
 
         linkcl = self._db.getclass(self._prop.classname)
@@ -1200,12 +1368,15 @@ class LinkHTMLProperty(HTMLProperty):
         k = linkcl.labelprop(1)
         s = ''
         if value is None:
-            s = 'selected '
+            s = 'selected="selected" '
         l.append(_('<option %svalue="-1">- no selection -</option>')%s)
         if linkcl.getprops().has_key('order'):  
             sort_on = ('+', 'order')
         else:  
-            sort_on = ('+', linkcl.labelprop())
+            if sort_on is None:
+                sort_on = ('+', linkcl.labelprop())
+            else:
+                sort_on = ('+', sort_on)
         options = linkcl.filter(None, conditions, sort_on, (None, None))
 
         # make sure we list the current value if it's retired
@@ -1219,7 +1390,7 @@ class LinkHTMLProperty(HTMLProperty):
             # figure if this option is selected
             s = ''
             if value in [optionid, option]:
-                s = 'selected '
+                s = 'selected="selected" '
 
             # figure the label
             if showid:
@@ -1252,7 +1423,8 @@ class MultilinkHTMLProperty(HTMLProperty):
     def __init__(self, *args, **kwargs):
         HTMLProperty.__init__(self, *args, **kwargs)
         if self._value:
-            self._value.sort(make_sort_function(self._db, self._prop.classname))
+            sortfun = make_sort_function(self._db, self._prop.classname)
+            self._value.sort(sortfun)
     
     def __len__(self):
         ''' length of the multilink '''
@@ -1275,7 +1447,7 @@ class MultilinkHTMLProperty(HTMLProperty):
 
     def __contains__(self, value):
         ''' Support the "in" operator. We have to make sure the passed-in
-            value is a string first, not a *HTMLProperty.
+            value is a string first, not a HTMLProperty.
         '''
         return str(value) in self._value
 
@@ -1293,6 +1465,8 @@ class MultilinkHTMLProperty(HTMLProperty):
     def plain(self, escape=0):
         ''' Render a "plain" representation of the property
         '''
+        self.view_check()
+
         linkcl = self._db.classes[self._prop.classname]
         k = linkcl.labelprop(1)
         labels = []
@@ -1305,7 +1479,14 @@ class MultilinkHTMLProperty(HTMLProperty):
 
     def field(self, size=30, showid=0):
         ''' Render a form edit field for the property
+
+            If not editable, just display the value via plain().
         '''
+        self.view_check()
+
+        if not self.is_edit_ok():
+            return self.plain()
+
         linkcl = self._db.getclass(self._prop.classname)
         value = self._value[:]
         # map the id to the label property
@@ -1315,16 +1496,26 @@ class MultilinkHTMLProperty(HTMLProperty):
             k = linkcl.labelprop(1)
             value = [linkcl.get(v, k) for v in value]
         value = cgi.escape(','.join(value))
-        return '<input name="%s" size="%s" value="%s">'%(self._formname, size, value)
+        return self.input(name=self._formname,size=size,value=value)
 
     def menu(self, size=None, height=None, showid=0, additional=[],
-            **conditions):
+            sort_on=None, **conditions):
         ''' Render a form select list for this property
+
+            If not editable, just display the value via plain().
         '''
+        self.view_check()
+
+        if not self.is_edit_ok():
+            return self.plain()
+
         value = self._value
 
         linkcl = self._db.getclass(self._prop.classname)
-        sort_on = ('+', find_sort_key(linkcl))
+        if sort_on is None:
+            sort_on = ('+', find_sort_key(linkcl))
+        else:
+            sort_on = ('+', sort_on)
         options = linkcl.filter(None, conditions, sort_on)
         height = height or min(len(options), 7)
         l = ['<select multiple name="%s" size="%s">'%(self._formname, height)]
@@ -1342,7 +1533,7 @@ class MultilinkHTMLProperty(HTMLProperty):
             # figure if this option is selected
             s = ''
             if optionid in value or option in value:
-                s = 'selected '
+                s = 'selected="selected" '
 
             # figure the label
             if showid:
@@ -1377,11 +1568,12 @@ propclasses = (
     (hyperdb.Multilink, MultilinkHTMLProperty),
 )
 
-def make_sort_function(db, classname):
+def make_sort_function(db, classname, sort_on=None):
     '''Make a sort function for a given class
     '''
     linkcl = db.getclass(classname)
-    sort_on = find_sort_key(linkcl)
+    if sort_on is None:
+        sort_on = find_sort_key(linkcl)
     def sortfunc(a, b):
         return cmp(linkcl.get(a, sort_on), linkcl.get(b, sort_on))
     return sortfunc
@@ -1414,29 +1606,30 @@ class ShowDict:
     def __getitem__(self, name):
         return self.columns.has_key(name)
 
-class HTMLRequest:
-    ''' The *request*, holding the CGI form and environment.
-
-        "form" the CGI form as a cgi.FieldStorage
-        "env" the CGI environment variables
-        "base" the base URL for this instance
-        "user" a HTMLUser instance for this user
-        "classname" the current classname (possibly None)
-        "template" the current template (suffix, also possibly None)
-
-        Index args:
-        "columns" dictionary of the columns to display in an index page
-        "show" a convenience access to columns - request/show/colname will
-               be true if the columns should be displayed, false otherwise
-        "sort" index sort column (direction, column name)
-        "group" index grouping property (direction, column name)
-        "filter" properties to filter the index on
-        "filterspec" values to filter the index on
-        "search_text" text to perform a full-text search on for an index
-
+class HTMLRequest(HTMLInputMixin):
+    '''The *request*, holding the CGI form and environment.
+
+    - "form" the CGI form as a cgi.FieldStorage
+    - "env" the CGI environment variables
+    - "base" the base URL for this instance
+    - "user" a HTMLUser instance for this user
+    - "classname" the current classname (possibly None)
+    - "template" the current template (suffix, also possibly None)
+
+    Index args:
+
+    - "columns" dictionary of the columns to display in an index page
+    - "show" a convenience access to columns - request/show/colname will
+      be true if the columns should be displayed, false otherwise
+    - "sort" index sort column (direction, column name)
+    - "group" index grouping property (direction, column name)
+    - "filter" properties to filter the index on
+    - "filterspec" values to filter the index on
+    - "search_text" text to perform a full-text search on for an index
     '''
     def __init__(self, client):
-        self.client = client
+        # _client is needed by HTMLInputMixin
+        self._client = self.client = client
 
         # easier access vars
         self.form = client.form
@@ -1451,6 +1644,8 @@ class HTMLRequest:
         # the special char to use for special vars
         self.special_char = '@'
 
+        HTMLInputMixin.__init__(self)
+
         self._post_init()
 
     def _post_init(self):
@@ -1603,7 +1798,7 @@ env: %(env)s
         ''' return the current index args as form elements '''
         l = []
         sc = self.special_char
-        s = '<input type="hidden" name="%s" value="%s">'
+        s = self.input(type="hidden",name="%s",value="%s")
         if columns and self.columns:
             l.append(s%(sc+'columns', ','.join(self.columns)))
         if sort and self.sort[1] is not None:
@@ -1685,6 +1880,7 @@ submitted = false;
 function submit_once() {
     if (submitted) {
         alert("Your request is being processed.\\nPlease be patient.");
+        event.returnValue = 0;    // work-around for IE
         return 0;
     }
     submitted = true;