backport fixes done accidentaly in default stable
authorSylvain Thénault <sylvain.thenault@logilab.fr>
Tue, 16 Feb 2010 11:31:12 +0100
branchstable
changeset 4598 437867dde236
parent 4586 440e340c61fe (current diff)
parent 4597 e872097f2287 (diff)
child 4599 dafa39be525d
backport fixes done accidentaly in default
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/crypto.py	Tue Feb 16 11:31:12 2010 +0100
@@ -0,0 +1,35 @@
+"""Simple cryptographic routines, based on python-crypto.
+
+:organization: Logilab
+:copyright: 2009-2010 LOGILAB S.A. (Paris, FRANCE), license is LGPL v2.
+:contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
+:license: GNU Lesser General Public License, v2.1 - http://www.gnu.org/licenses
+"""
+__docformat__ = "restructuredtext en"
+
+from pickle import dumps, loads
+from base64 import b64encode, b64decode
+
+from Crypto.Cipher import Blowfish
+
+
+_CYPHERERS = {}
+def _cypherer(seed):
+    try:
+        return _CYPHERERS[seed]
+    except KeyError:
+        _CYPHERERS[seed] = Blowfish.new(seed, Blowfish.MODE_ECB)
+        return _CYPHERERS[seed]
+
+
+def encrypt(data, seed):
+    string = dumps(data)
+    string = string + '*' * (8 - len(string) % 8)
+    string = b64encode(_cypherer(seed).encrypt(string))
+    return unicode(string)
+
+
+def decrypt(string, seed):
+    # pickle ignores trailing characters so we do not need to strip them off
+    string = _cypherer(seed).decrypt(b64decode(string))
+    return loads(string)
--- a/dbapi.py	Mon Feb 15 18:36:34 2010 +0100
+++ b/dbapi.py	Tue Feb 16 11:31:12 2010 +0100
@@ -263,6 +263,8 @@
 
     def get_session_data(self, key, default=None, pop=False):
         """return value associated to `key` in session data"""
+        if self.cnx is None:
+            return None # before the connection has been established
         return self.cnx.get_session_data(key, default, pop)
 
     def set_session_data(self, key, value):
--- a/debian/control	Mon Feb 15 18:36:34 2010 +0100
+++ b/debian/control	Tue Feb 16 11:31:12 2010 +0100
@@ -63,7 +63,7 @@
 Architecture: all
 XB-Python-Version: ${python:Versions}
 Depends: ${python:Depends}, cubicweb-common (= ${source:Version}), python-simplejson (>= 1.3), python-elementtree
-Recommends: python-docutils, python-vobject, fckeditor, python-fyzz, python-pysixt, fop
+Recommends: python-docutils, python-vobject, fckeditor, python-fyzz, python-pysixt, fop, python-imaging
 Description: web interface library for the CubicWeb framework
  CubicWeb is a semantic web application framework.
  .
@@ -78,7 +78,7 @@
 Architecture: all
 XB-Python-Version: ${python:Versions}
 Depends: ${python:Depends}, graphviz, gettext, python-logilab-mtconverter (>= 0.6.0), python-logilab-common (>= 0.47.0), python-yams (>= 0.27.0), python-rql (>= 0.24.0), python-lxml
-Recommends: python-simpletal (>= 4.0)
+Recommends: python-simpletal (>= 4.0), python-crypto
 Conflicts: cubicweb-core
 Replaces: cubicweb-core
 Description: common library for the CubicWeb framework
--- a/hooks/syncschema.py	Mon Feb 15 18:36:34 2010 +0100
+++ b/hooks/syncschema.py	Tue Feb 16 11:31:12 2010 +0100
@@ -161,11 +161,16 @@
     def commit_event(self):
         rebuildinfered = self.session.data.get('rebuild-infered', True)
         repo = self.session.repo
-        repo.set_schema(repo.schema, rebuildinfered=rebuildinfered)
-        # CWUser class might have changed, update current session users
-        cwuser_cls = self.session.vreg['etypes'].etype_class('CWUser')
-        for session in repo._sessions.values():
-            session.user.__class__ = cwuser_cls
+        # commit event should not raise error, while set_schema has chances to
+        # do so because it triggers full vreg reloading
+        try:
+            repo.set_schema(repo.schema, rebuildinfered=rebuildinfered)
+            # CWUser class might have changed, update current session users
+            cwuser_cls = self.session.vreg['etypes'].etype_class('CWUser')
+            for session in repo._sessions.values():
+                session.user.__class__ = cwuser_cls
+        except:
+            self.critical('error while setting schmea', exc_info=True)
 
     def rollback_event(self):
         self.precommit_event()
--- a/schemas/base.py	Mon Feb 15 18:36:34 2010 +0100
+++ b/schemas/base.py	Tue Feb 16 11:31:12 2010 +0100
@@ -170,19 +170,11 @@
     """link a permission to the entity. This permission should be used in the
     security definition of the entity's type to be useful.
     """
-    __permissions__ = {
-        'read':   ('managers', 'users', 'guests'),
-        'add':    ('managers',),
-        'delete': ('managers',),
-        }
+    __permissions__ = META_RTYPE_PERMS
 
 class require_group(RelationType):
     """used to grant a permission to a group"""
-    __permissions__ = {
-        'read':   ('managers', 'users', 'guests'),
-        'add':    ('managers',),
-        'delete': ('managers',),
-        }
+    __permissions__ = META_RTYPE_PERMS
 
 
 class ExternalUri(EntityType):
--- a/schemas/bootstrap.py	Mon Feb 15 18:36:34 2010 +0100
+++ b/schemas/bootstrap.py	Tue Feb 16 11:31:12 2010 +0100
@@ -8,8 +8,8 @@
 __docformat__ = "restructuredtext en"
 _ = unicode
 
-from yams.buildobjs import (EntityType, RelationType, SubjectRelation,
-                            RichString, String, Boolean, Int)
+from yams.buildobjs import (EntityType, RelationType, RelationDefinition,
+                            SubjectRelation, RichString, String, Boolean, Int)
 from cubicweb.schema import RQLConstraint
 from cubicweb.schemas import META_ETYPE_PERMS, META_RTYPE_PERMS
 
@@ -195,43 +195,72 @@
     __permissions__ = META_RTYPE_PERMS
     inlined = True
 
-class read_permission(RelationType):
-    """grant permission to read entity or relation through a group or rql
-    expression
-    """
+
+class read_permission_cwgroup(RelationDefinition):
+    """groups allowed to read entities/relations of this type"""
+    __permissions__ = META_RTYPE_PERMS
+    name = 'read_permission'
+    subject = ('CWEType', 'CWAttribute', 'CWRelation')
+    object = 'CWGroup'
+    cardinality = '**'
+
+class add_permission_cwgroup(RelationDefinition):
+    """groups allowed to add entities/relations of this type"""
+    __permissions__ = META_RTYPE_PERMS
+    name = 'add_permission'
+    subject = ('CWEType', 'CWRelation')
+    object = 'CWGroup'
+    cardinality = '**'
+
+class delete_permission_cwgroup(RelationDefinition):
+    """groups allowed to delete entities/relations of this type"""
     __permissions__ = META_RTYPE_PERMS
+    name = 'delete_permission'
+    subject = ('CWEType', 'CWRelation')
+    object = 'CWGroup'
+    cardinality = '**'
+
+class update_permission_cwgroup(RelationDefinition):
+    """groups allowed to update entities/relations of this type"""
+    __permissions__ = META_RTYPE_PERMS
+    name = 'update_permission'
+    subject = ('CWEType', 'CWAttribute')
+    object = 'CWGroup'
+    cardinality = '**'
+
+class read_permission_rqlexpr(RelationDefinition):
+    """rql expression allowing to read entities/relations of this type"""
+    __permissions__ = META_RTYPE_PERMS
+    name = 'read_permission'
     subject = ('CWEType', 'CWAttribute', 'CWRelation')
-    object = ('CWGroup', 'RQLExpression')
+    object = 'RQLExpression'
     cardinality = '*?'
     composite = 'subject'
 
-class add_permission(RelationType):
-    """grant permission to add entity or relation through a group or rql
-    expression
-    """
+class add_permission_rqlexpr(RelationDefinition):
+    """rql expression allowing to add entities/relations of this type"""
     __permissions__ = META_RTYPE_PERMS
+    name = 'add_permission'
     subject = ('CWEType', 'CWRelation')
-    object = ('CWGroup', 'RQLExpression')
+    object = 'RQLExpression'
     cardinality = '*?'
     composite = 'subject'
 
-class delete_permission(RelationType):
-    """grant permission to delete entity or relation through a group or rql
-    expression
-    """
+class delete_permission_rqlexpr(RelationDefinition):
+    """rql expression allowing to delete entities/relations of this type"""
     __permissions__ = META_RTYPE_PERMS
+    name = 'delete_permission'
     subject = ('CWEType', 'CWRelation')
-    object = ('CWGroup', 'RQLExpression')
+    object = 'RQLExpression'
     cardinality = '*?'
     composite = 'subject'
 
-class update_permission(RelationType):
-    """grant permission to update entity or attribute through a group or rql
-    expression
-    """
+class update_permission_rqlexpr(RelationDefinition):
+    """rql expression allowing to update entities/relations of this type"""
     __permissions__ = META_RTYPE_PERMS
+    name = 'update_permission'
     subject = ('CWEType', 'CWAttribute')
-    object = ('CWGroup', 'RQLExpression')
+    object = 'RQLExpression'
     cardinality = '*?'
     composite = 'subject'
 
--- a/schemas/workflow.py	Mon Feb 15 18:36:34 2010 +0100
+++ b/schemas/workflow.py	Tue Feb 16 11:31:12 2010 +0100
@@ -172,6 +172,7 @@
     }
     inlined = True
 
+
 class workflow_of(RelationType):
     """link a workflow to one or more entity type"""
     __permissions__ = META_RTYPE_PERMS
@@ -186,20 +187,15 @@
     __permissions__ = META_RTYPE_PERMS
     inlined = True
 
-class subworkflow(RelationType):
-    """link a transition to one or more workflow"""
+class destination_state(RelationType):
+    """destination state of a transition"""
     __permissions__ = META_RTYPE_PERMS
     inlined = True
 
-class exit_point(RelationType):
-    """link a transition to one or more workflow"""
+class allowed_transition(RelationType):
+    """allowed transitions from this state"""
     __permissions__ = META_RTYPE_PERMS
 
-class subworkflow_state(RelationType):
-    """link a transition to one or more workflow"""
-    __permissions__ = META_RTYPE_PERMS
-    inlined = True
-
 class initial_state(RelationType):
     """indicate which state should be used by default when an entity using
     states is created
@@ -207,14 +203,25 @@
     __permissions__ = META_RTYPE_PERMS
     inlined = True
 
-class destination_state(RelationType):
-    """destination state of a transition"""
+
+class subworkflow(RelationType):
     __permissions__ = META_RTYPE_PERMS
     inlined = True
 
-class allowed_transition(RelationType):
-    """allowed transitions from this state"""
+class exit_point(RelationType):
+    __permissions__ = META_RTYPE_PERMS
+
+class subworkflow_state(RelationType):
     __permissions__ = META_RTYPE_PERMS
+    inlined = True
+
+
+class condition(RelationType):
+    __permissions__ = META_RTYPE_PERMS
+
+# already defined in base.py
+# class require_group(RelationType):
+#     __permissions__ = META_RTYPE_PERMS
 
 
 # "abstract" relations, set by WorkflowableEntityType ##########################
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/captcha.py	Tue Feb 16 11:31:12 2010 +0100
@@ -0,0 +1,85 @@
+"""Simple captcha library, based on PIL. Monkey patch functions in this module
+if you want something better...
+
+:organization: Logilab
+:copyright: 2009-2010 LOGILAB S.A. (Paris, FRANCE), license is LGPL v2.
+:contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
+:license: GNU Lesser General Public License, v2.1 - http://www.gnu.org/licenses
+"""
+__docformat__ = "restructuredtext en"
+
+from random import randint, choice
+from cStringIO import StringIO
+
+import Image, ImageFont, ImageDraw, ImageFilter
+
+
+from time import time
+
+from cubicweb import tags
+from cubicweb.view import StartupView
+from cubicweb.web import httpcache, formwidgets as fw, formfields as ff
+
+
+def pil_captcha(text, fontfile, fontsize):
+    """Generate a captcha image. Return a PIL image object.
+
+    adapted from http://code.activestate.com/recipes/440588/
+    """
+    # randomly select the foreground color
+    fgcolor = randint(0, 0xffff00)
+    # make the background color the opposite of fgcolor
+    bgcolor = fgcolor ^ 0xffffff
+    # create a font object
+    font = ImageFont.truetype(fontfile, fontsize)
+    # determine dimensions of the text
+    dim = font.getsize(text)
+    # create a new image slightly larger that the text
+    img = Image.new('RGB', (dim[0]+5, dim[1]+5), bgcolor)
+    draw = ImageDraw.Draw(img)
+    # draw 100 random colored boxes on the background
+    x, y = img.size
+    for num in range(100):
+        draw.rectangle((randint(0, x), randint(0, y),
+                        randint(0, x), randint(0, y)),
+                       fill=randint(0, 0xffffff))
+    # add the text to the image
+    draw.text((3, 3), text, font=font, fill=fgcolor)
+    img = img.filter(ImageFilter.EDGE_ENHANCE_MORE)
+    return img
+
+
+def captcha(fontfile, fontsize, size=5, format='JPEG'):
+    """Generate an arbitrary text, return it together with a buffer containing
+    the captcha image for the text
+    """
+    text = u''.join(choice('QWERTYUOPASDFGHJKLZXCVBNM') for i in range(size))
+    img = pil_captcha(text, fontfile, fontsize)
+    out = StringIO()
+    img.save(out, format)
+    out.seek(0)
+    return text, out
+
+
+class CaptchaView(StartupView):
+    __regid__ = 'captcha'
+
+    http_cache_manager = httpcache.NoHTTPCacheManager
+    binary = True
+    templatable = False
+    content_type = 'image/jpg'
+
+    def call(self):
+        text, data = captcha.captcha(self._cw.vreg.config['captcha-font-file'],
+                                     self._cw.vreg.config['captcha-font-size'])
+        self._cw.set_session_data('captcha', text)
+        self.w(data.read())
+
+
+class CaptchaWidget(fw.TextInput):
+    def render(self, form, field, renderer=None):
+        # t=int(time()*100) to make sure img is not cached
+        src = form._cw.build_url('view', vid='captcha', t=int(time()*100))
+        img = tags.img(src=src, alt=u'captcha')
+        img = u'<div class="captcha">%s</div>' % img
+        return img + super(CaptchaWidget, self).render(form, field, renderer)
--- a/web/data/cubicweb.login.css	Mon Feb 15 18:36:34 2010 +0100
+++ b/web/data/cubicweb.login.css	Tue Feb 16 11:31:12 2010 +0100
@@ -75,7 +75,7 @@
   width:12em;
 }
 
-input.loginButton {
+.loginButton {
   border: 1px solid #edecd2;
   border-color:#edecd2 #cfceb7 #cfceb7  #edecd2;
   margin: 2px 0px 0px;
Binary file web/data/porkys.ttf has changed
--- a/web/formwidgets.py	Mon Feb 15 18:36:34 2010 +0100
+++ b/web/formwidgets.py	Tue Feb 16 11:31:12 2010 +0100
@@ -318,7 +318,7 @@
                 iattrs['checked'] = u'checked'
             tag = tags.input(name=field.input_name(form, self.suffix),
                              type=self.type, value=value, **iattrs)
-            options.append(tag + label)
+            options.append(u'%s&#160;%s' % (tag, label))
         return sep.join(options)
 
 
@@ -521,9 +521,9 @@
 
 def init_ajax_attributes(attrs, wdgtype, loadtype=u'auto'):
     try:
-        attrs['klass'] += u' widget'
+        attrs['class'] += u' widget'
     except KeyError:
-        attrs['klass'] = u'widget'
+        attrs['class'] = u'widget'
     attrs.setdefault('cubicweb:wdgtype', wdgtype)
     attrs.setdefault('cubicweb:loadtype', loadtype)
 
@@ -639,7 +639,7 @@
         self.value = ''
         self.onclick = onclick
         self.cwaction = cwaction
-        self.attrs.setdefault('klass', 'validateButton')
+        self.attrs.setdefault('class', 'validateButton')
 
     def render(self, form, field=None, renderer=None):
         label = form._cw._(self.label)
--- a/web/views/autoform.py	Mon Feb 15 18:36:34 2010 +0100
+++ b/web/views/autoform.py	Tue Feb 16 11:31:12 2010 +0100
@@ -427,9 +427,6 @@
                      % (pendingid, entity.eid)
             rset = form._cw.eid_rset(reid)
             eview = form._cw.view('text', rset, row=0)
-            # XXX find a clean way to handle baskets
-            if rset.description[0][0] == 'Basket':
-                eview = '%s (%s)' % (eview, display_name(form._cw, 'Basket'))
             yield rtype, pendingid, jscall, label, reid, eview
 
 
@@ -461,8 +458,6 @@
         options.append('<option>%s %s</option>' % (self._cw._('select a'), etypes))
         options += self._get_select_options(entity, rschema, role)
         options += self._get_search_options(entity, rschema, role, targettypes)
-        if 'Basket' in self._cw.vreg.schema: # XXX
-            options += self._get_basket_options(entity, rschema, role, targettypes)
         relname, role = self._cw.form.get('relation').rsplit('_', 1)
         return u"""\
 <div class="%s" id="%s">
@@ -509,37 +504,6 @@
                 xml_escape(url), _('Search for'), eschema.display_name(self._cw))))
         return [o for l, o in sorted(options)]
 
-    # XXX move this out
-    def _get_basket_options(self, entity, rschema, role, targettypes):
-        options = []
-        rtype = rschema.type
-        _ = self._cw._
-        for basketeid, basketname in self._get_basket_links(self._cw.user.eid,
-                                                            role, targettypes):
-            optionid = relation_id(entity.eid, rtype, role, basketeid)
-            options.append('<option id="%s" value="%s">%s %s</option>' % (
-                optionid, basketeid, _('link to each item in'), xml_escape(basketname)))
-        return options
-
-    def _get_basket_links(self, ueid, role, targettypes):
-        targettypes = set(targettypes)
-        for basketeid, basketname, elements in self._get_basket_info(ueid):
-            baskettypes = elements.column_types(0)
-            # if every elements in the basket can be attached to the
-            # edited entity
-            if baskettypes & targettypes:
-                yield basketeid, basketname
-
-    def _get_basket_info(self, ueid):
-        basketref = []
-        basketrql = 'Any B,N WHERE B is Basket, B owned_by U, U eid %(x)s, B name N'
-        basketresultset = self._cw.execute(basketrql, {'x': ueid}, 'x')
-        for result in basketresultset:
-            basketitemsrql = 'Any X WHERE X in_basket B, B eid %(x)s'
-            rset = self._cw.execute(basketitemsrql, {'x': result[0]}, 'x')
-            basketref.append((result[0], result[1], rset))
-        return basketref
-
 
 # The automatic entity form ####################################################
 
--- a/web/views/basetemplates.py	Mon Feb 15 18:36:34 2010 +0100
+++ b/web/views/basetemplates.py	Tue Feb 16 11:31:12 2010 +0100
@@ -15,6 +15,8 @@
 from cubicweb.view import View, MainTemplate, NOINDEX, NOFOLLOW
 from cubicweb.utils import UStringIO, can_do_pdf_conversion
 from cubicweb.schema import display_name
+from cubicweb.web import formfields as ff, formwidgets as fw
+from cubicweb.web.views import forms
 
 # main templates ##############################################################
 
@@ -389,7 +391,7 @@
         self.w(u'</td>\n')
         self.w(u'</tr></table>\n')
         self.wview('logform', rset=self.cw_rset, id='popupLoginBox', klass='hidden',
-                   title=False, message=False)
+                   title=False, showmessage=False)
 
     def state_header(self):
         state = self._cw.search_state
@@ -460,13 +462,28 @@
             self.w(u'</div>')
 
 
-class LogFormTemplate(View):
+class LogForm(forms.FieldsForm):
+    __regid__ = 'logform'
+    domid = 'loginForm'
+    # XXX have to recall fields name since python is mangling __login/__password
+    __login = ff.StringField('__login', widget=fw.TextInput({'class': 'data'}))
+    __password = ff.StringField('__password', label=_('password'),
+                                widget=fw.PasswordSingleInput({'class': 'data'}))
+    form_buttons = [fw.SubmitButton(label=_('log in'),
+                                    attrs={'class': 'loginButton right'})]
+
+    @property
+    def action(self):
+        return xml_escape(login_form_url(self._cw))
+
+
+class LogFormView(View):
     __regid__ = 'logform'
     __select__ = match_kwargs('id', 'klass')
 
     title = 'log in'
 
-    def call(self, id, klass, title=True, message=True):
+    def call(self, id, klass, title=True, showmessage=True):
         self._cw.add_css('cubicweb.login.css')
         self.w(u'<div id="%s" class="%s">' % (id, klass))
         if title:
@@ -477,52 +494,28 @@
                 stitle = u'&#160;'
             self.w(u'<div id="loginTitle">%s</div>' % stitle)
         self.w(u'<div id="loginContent">\n')
-
-        if message:
-            self.display_message()
-        if self._cw.vreg.config['auth-mode'] == 'http':
-            # HTTP authentication
-            pass
-        else:
+        if showmessage and self._cw.message:
+            self.w(u'<div class="simpleMessage">%s</div>\n' % self._cw.message)
+        if self._cw.vreg.config['auth-mode'] != 'http':
             # Cookie authentication
             self.login_form(id)
         self.w(u'</div></div>\n')
 
-    def display_message(self):
-        message = self._cw.message
-        if message:
-            self.w(u'<div class="simpleMessage">%s</div>\n' % message)
+    def login_form(self, id):
+        cw = self._cw
+        form = cw.vreg['forms'].select('logform', cw)
+        if cw.vreg.config['allow-email-login']:
+            label = cw._('login or email')
+        else:
+            label = cw._('login')
+        form.field_by_name('__login').label = label
+        self.w(form.render(table_class='', display_progress_div=False))
+        cw.html_headers.add_onload('jQuery("#__login:visible").focus()')
 
-    def login_form(self, id):
-        _ = self._cw._
-        # XXX turn into a form
-        self.w(u'<form method="post" action="%s" id="login_form">\n'
-               % xml_escape(login_form_url(self._cw.vreg.config, self._cw)))
-        self.w(u'<table>\n')
-        self.add_fields()
-        self.w(u'<tr>\n')
-        self.w(u'<td>&#160;</td><td><input type="submit" class="loginButton right" value="%s" />\n</td>' % _('log in'))
-        self.w(u'</tr>\n')
-        self.w(u'</table>\n')
-        self.w(u'</form>\n')
-        self._cw.html_headers.add_onload('jQuery("#__login:visible").focus()')
-
-    def add_fields(self):
-        msg = (self._cw.vreg.config['allow-email-login'] and _('login or email')) or _('login')
-        self.add_field('__login', msg, 'text')
-        self.add_field('__password', self._cw._('password'), 'password')
-
-    def add_field(self, name, label, inputtype):
-        self.w(u'<tr>\n')
-        self.w(u'<td><label for="%s" >%s</label></td>' % (name, label))
-        self.w(u'<td><input name="%s" id="%s" class="data" type="%s" /></td>\n' %
-               (name, name, inputtype))
-        self.w(u'</tr>\n')
-
-
-def login_form_url(config, req):
+def login_form_url(req):
     if req.https:
         return req.url()
-    if config.get('https-url'):
-        return req.url().replace(req.base_url(), config['https-url'])
+    httpsurl = req.vreg.config.get('https-url')
+    if httpsurl:
+        return req.url().replace(req.base_url(), httpsurl)
     return req.url()
--- a/web/webconfig.py	Mon Feb 15 18:36:34 2010 +0100
+++ b/web/webconfig.py	Tue Feb 16 11:31:12 2010 +0100
@@ -176,6 +176,22 @@
           'help': 'print the traceback on the error page when an error occured',
           'group': 'web', 'inputlevel': 2,
           }),
+
+        ('captcha-font-file',
+         {'type' : 'string',
+          'default': join(CubicWebConfiguration.shared_dir(), 'data', 'porkys.ttf'),
+          'help': 'True type font to use for captcha image generation (you \
+must have the python imaging library installed to use captcha)',
+          'group': 'web', 'inputlevel': 2,
+          }),
+        ('captcha-font-size',
+         {'type' : 'int',
+          'default': 25,
+          'help': 'Font size to use for captcha image generation (you must \
+have the python imaging library installed to use captcha)',
+          'group': 'web', 'inputlevel': 2,
+          }),
+
         ))
 
     def fckeditor_installed(self):