backport stable branch
authorSylvain Thénault <sylvain.thenault@logilab.fr>
Wed, 30 Sep 2009 18:57:42 +0200
changeset 3536 f6c9a5df80fb
parent 3524 a3431f4e2f40 (current diff)
parent 3535 106b95ec1144 (diff)
child 3589 a5432f99f2d9
backport stable branch
entities/test/unittest_wfobjs.py
entities/wfobjs.py
hooks/notification.py
hooks/syncschema.py
hooks/test/unittest_hooks.py
hooks/workflow.py
schemas/workflow.py
sobjects/notification.py
web/formfields.py
web/test/unittest_formfields.py
web/views/editforms.py
--- a/entities/test/unittest_wfobjs.py	Tue Sep 29 15:58:44 2009 +0200
+++ b/entities/test/unittest_wfobjs.py	Wed Sep 30 18:57:42 2009 +0200
@@ -339,6 +339,46 @@
                            ('asleep', 'activated', None, 'workflow changed to "default user workflow"'),])
 
 
+class AutoTransitionTC(CubicWebTC):
+
+    def setup_database(self):
+        self.wf = add_wf(self, 'CWUser')
+        asleep = self.wf.add_state('asleep', initial=True)
+        dead = self.wf.add_state('dead')
+        self.wf.add_transition('rest', asleep, asleep)
+        self.wf.add_transition('sick', asleep, dead, type=u'auto',
+                               conditions=({'expr': u'U surname "toto"',
+                                            'mainvars': u'U'},))
+
+    def test_auto_transition_fired(self):
+        user = self.create_user('member')
+        self.execute('SET X custom_workflow WF WHERE X eid %(x)s, WF eid %(wf)s',
+                     {'wf': self.wf.eid, 'x': user.eid})
+        self.commit()
+        user.clear_all_caches()
+        self.assertEquals(user.state, 'asleep')
+        self.assertEquals([t.name for t in user.possible_transitions()],
+                          ['rest'])
+        user.fire_transition('rest')
+        self.commit()
+        user.clear_all_caches()
+        self.assertEquals(user.state, 'asleep')
+        self.assertEquals([t.name for t in user.possible_transitions()],
+                          ['rest'])
+        self.assertEquals(parse_hist(user.workflow_history),
+                          [('asleep', 'asleep', 'rest', None)])
+        self.request().user.set_attributes(surname=u'toto') # fulfill condition
+        self.commit()
+        user.fire_transition('rest')
+        self.commit()
+        user.clear_all_caches()
+        self.assertEquals(user.state, 'dead')
+        self.assertEquals(parse_hist(user.workflow_history),
+                          [('asleep', 'asleep', 'rest', None),
+                           ('asleep', 'asleep', 'rest', None),
+                           ('asleep', 'dead', 'sick', None),])
+
+
 class WorkflowHooksTC(CubicWebTC):
 
     def setUp(self):
--- a/entities/wfobjs.py	Tue Sep 29 15:58:44 2009 +0200
+++ b/entities/wfobjs.py	Wed Sep 30 18:57:42 2009 +0200
@@ -223,11 +223,15 @@
             conditions = (conditions,)
         for expr in conditions:
             if isinstance(expr, str):
-                expr = unicode(expr)
+                kwargs = {'expr': unicode(expr)}
+            elif isinstance(expr, dict):
+                kwargs = expr
+            kwargs['x'] = self.eid
+            kwargs.setdefault('mainvars', u'X')
             self._cw.execute('INSERT RQLExpression X: X exprtype "ERQLExpression", '
                              'X expression %(expr)s, T condition X '
                              'WHERE T eid %(x)s',
-                             {'x': self.eid, 'expr': expr}, 'x')
+                             'T condition X WHERE T eid %(x)s', kwargs, 'x')
         # XXX clear caches?
 
 
@@ -415,16 +419,24 @@
             self.warning("can't find any workflow for %s", self.__regid__)
         return None
 
-    def possible_transitions(self):
+    def possible_transitions(self, type='normal'):
         """generates transition that MAY be fired for the given entity,
         expected to be in this state
         """
         if self.current_state is None or self.current_workflow is None:
             return
+<<<<<<< /home/syt/src/fcubicweb/cubicweb/entities/wfobjs.py
         rset = self._cw.execute(
             'Any T,N WHERE S allowed_transition T, S eid %(x)s, '
             'T name N, T transition_of WF, WF eid %(wfeid)s',
             {'x': self.current_state.eid,
+=======
+        rset = self.req.execute(
+            'Any T,TT, TN WHERE S allowed_transition T, S eid %(x)s, '
+            'T type TT, T type %(type)s, '
+            'T name TN, T transition_of WF, WF eid %(wfeid)s',
+            {'x': self.current_state.eid, 'type': type,
+>>>>>>> /tmp/wfobjs.py~other.TyHPqT
              'wfeid': self.current_workflow.eid}, 'x')
         for tr in rset.entities():
             if tr.may_be_fired(self.eid):
@@ -446,15 +458,21 @@
             kwargs['S'] = tseid
         return self._cw.create_entity('TrInfo', *args, **kwargs)
 
-    def fire_transition(self, trname, comment=None, commentformat=None):
+    def fire_transition(self, tr, comment=None, commentformat=None):
         """change the entity's state by firing transition of the given name in
         entity's workflow
         """
         assert self.current_workflow
+<<<<<<< /home/syt/src/fcubicweb/cubicweb/entities/wfobjs.py
         tr = self.current_workflow.transition_by_name(trname)
         if tr is None:
             raise WorkflowException('not a %s transition: %s' % (self.__regid__,
                                                                  trname))
+=======
+        if isinstance(tr, basestring):
+            tr = self.current_workflow.transition_by_name(tr)
+        assert tr is not None, 'not a %s transition: %s' % (self.id, tr)
+>>>>>>> /tmp/wfobjs.py~other.TyHPqT
         return self._add_trinfo(comment, commentformat, tr.eid)
 
     def change_state(self, statename, comment=None, commentformat=None, tr=None):
--- a/hooks/notification.py	Tue Sep 29 15:58:44 2009 +0200
+++ b/hooks/notification.py	Wed Sep 30 18:57:42 2009 +0200
@@ -90,6 +90,52 @@
         RenderAndSendNotificationView(self._cw, view=view)
 
 
+class EntityUpdatedNotificationOp(hook.SingleLastOperation):
+
+    def precommit_event(self):
+        session = self.session
+        for eid in session.transaction_data['changes']:
+            view = session.vreg['views'].select('notif_entity_updated', session,
+                                                rset=session.eid_rset(eid),
+                                                row=0)
+            RenderAndSendNotificationView(session, view=view)
+
+
+class EntityUpdateHook(NotificationHook):
+    __regid__ = 'notifentityupdated'
+    __abstract__ = True # do not register by default
+
+    events = ('before_update_entity',)
+    skip_attrs = set()
+
+    def __call__(self):
+        session = self._cw
+        if self.entity.eid in session.transaction_data.get('neweids', ()):
+            return # entity is being created
+        if session.is_super_session:
+            return # ignore changes triggered by hooks
+        # then compute changes
+        changes = session.transaction_data.setdefault('changes', {})
+        thisentitychanges = changes.setdefault(self.entity.eid, set())
+        attrs = [k for k in self.entity.edited_attributes if not k in self.skip_attrs]
+        if not attrs:
+            return
+        rqlsel, rqlrestr = [], ['X eid %(x)s']
+        for i, attr in enumerate(attrs):
+            var = chr(65+i)
+            rqlsel.append(var)
+            rqlrestr.append('X %s %s' % (attr, var))
+        rql = 'Any %s WHERE %s' % (','.join(rqlsel), ','.join(rqlrestr))
+        rset = session.execute(rql, {'x': self.entity.eid}, 'x')
+        for i, attr in enumerate(attrs):
+            oldvalue = rset[0][i]
+            newvalue = self.entity[attr]
+            if oldvalue != newvalue:
+                thisentitychanges.add((attr, oldvalue, newvalue))
+        if thisentitychanges:
+            EntityUpdatedNotificationOp(session)
+
+
 # supervising ##################################################################
 
 class SomethingChangedHook(NotificationHook):
--- a/hooks/syncschema.py	Tue Sep 29 15:58:44 2009 +0200
+++ b/hooks/syncschema.py	Wed Sep 30 18:57:42 2009 +0200
@@ -317,10 +317,11 @@
         default = entity.defaultval
         if default is not None:
             default = TYPE_CONVERTER[entity.otype.name](default)
-        rdef = self.init_rdef(default=default,
-                              indexed=entity.indexed,
-                              fulltextindexed=entity.fulltextindexed,
-                              internationalizable=entity.internationalizable)
+        props = {'default': default,
+                 'indexed': entity.indexed,
+                 'fulltextindexed': entity.fulltextindexed,
+                 'internationalizable': entity.internationalizable}
+        rdef = self.init_rdef(**props)
         sysource = session.pool.source('system')
         attrtype = type_from_constraints(sysource.dbhelper, rdef.object,
                                          rdef.constraints)
@@ -353,6 +354,23 @@
             except Exception, ex:
                 self.error('error while creating index for %s.%s: %s',
                            table, column, ex)
+        # final relations are not infered, propagate
+        try:
+            eschema = self.schema.eschema(rdef.subject)
+        except KeyError:
+            return # entity type currently being added
+        rschema = self.schema.rschema(rdef.name)
+        props.update({'constraints': rdef.constraints,
+                      'description': rdef.description,
+                      'cardinality': rdef.cardinality,
+                      'constraints': rdef.constraints,
+                      'order': rdef.order})
+        for specialization in eschema.specialized_by(False):
+            if rschema.has_rdef(specialization, rdef.object):
+                continue
+            for rql, args in ss.frdef2rql(rschema, str(specialization),
+                                          rdef.object, props):
+                session.execute(rql, args)
 
 
 class SourceDbCWRelationAdd(SourceDbCWAttributeAdd):
--- a/hooks/test/unittest_hooks.py	Tue Sep 29 15:58:44 2009 +0200
+++ b/hooks/test/unittest_hooks.py	Wed Sep 30 18:57:42 2009 +0200
@@ -470,5 +470,15 @@
                      'RT name "surname", E name "CWUser"')
         self.commit()
 
+
+    def test_add_attribute_to_base_class(self):
+        self.execute('INSERT CWAttribute X: X cardinality "11", X defaultval "noname", X indexed TRUE, X relation_type RT, X from_entity E, X to_entity F '
+                     'WHERE RT name "nom", E name "BaseTransition", F name "String"')
+        self.commit()
+        self.schema.rebuild_infered_relations()
+        self.failUnless('Transition' in self.schema['nom'].subjects())
+        self.failUnless('WorkflowTransition' in self.schema['nom'].subjects())
+        self.execute('Any X WHERE X is_instance_of BaseTransition, X nom "hop"')
+
 if __name__ == '__main__':
     unittest_main()
--- a/hooks/workflow.py	Tue Sep 29 15:58:44 2009 +0200
+++ b/hooks/workflow.py	Wed Sep 30 18:57:42 2009 +0200
@@ -47,6 +47,19 @@
                 session.super_session.add_relation(entity.eid, 'in_state',
                                                    state.eid)
 
+
+class _FireAutotransitionOp(PreCommitOperation):
+    """try to fire auto transition after state changes"""
+
+    def precommit_event(self):
+        session = self.session
+        entity = self.entity
+        autotrs = list(entity.possible_transitions('auto'))
+        if autotrs:
+            assert len(autotrs) == 1
+            entity.fire_transition(autotrs[0])
+
+
 class _WorkflowChangedOp(hook.Operation):
     """fix entity current state when changing its workflow"""
 
@@ -206,6 +219,7 @@
         nocheck = session.transaction_data.setdefault('skip-security', set())
         nocheck.add((entity.eid, 'from_state', fromstate.eid))
         nocheck.add((entity.eid, 'to_state', deststateeid))
+        FireAutotransitionOp(session, entity=forentity)
 
 
 class FiredTransitionHook(WorkflowHook):
--- a/misc/migration/3.5.3_Any.py	Tue Sep 29 15:58:44 2009 +0200
+++ b/misc/migration/3.5.3_Any.py	Wed Sep 30 18:57:42 2009 +0200
@@ -1,2 +1,4 @@
 sync_schema_props_perms('state_of')
 sync_schema_props_perms('transition_of')
+
+add_attribute('BaseTransition', 'type')
--- a/schemas/workflow.py	Tue Sep 29 15:58:44 2009 +0200
+++ b/schemas/workflow.py	Wed Sep 30 18:57:42 2009 +0200
@@ -68,6 +68,7 @@
 
     name = String(required=True, indexed=True, internationalizable=True,
                   maxsize=256)
+    type = String(vocabulary=(_('normal'), _('auto')), default='normal')
     description = RichString(fulltextindexed=True,
                          description=_('semantic description of this transition'))
     condition = SubjectRelation('RQLExpression', cardinality='*?', composite='subject',
@@ -116,15 +117,13 @@
                                         description=_('destination state'))
 
 
-# XXX should we allow managers to delete TrInfo?
-
 class TrInfo(EntityType):
     """workflow history item"""
     # 'add' security actually done by hooks
     permissions = {
         'read':   ('managers', 'users', 'guests',), # XXX U has_read_permission O ?
         'add':    ('managers', 'users', 'guests',),
-        'delete': (),
+        'delete': (), # XXX should we allow managers to delete TrInfo?
         'update': ('managers', 'owners',),
     }
 
--- a/server/__init__.py	Tue Sep 29 15:58:44 2009 +0200
+++ b/server/__init__.py	Wed Sep 30 18:57:42 2009 +0200
@@ -147,7 +147,7 @@
     for fpath in glob(join(CW_SOFTWARE_ROOT, 'schemas', '*.sql.%s' % driver)):
         print '-> installing', fpath
         sqlexec(open(fpath).read(), execute, False, delimiter=';;')
-    for directory in config.cubes_path():
+    for directory in reversed(config.cubes_path()):
         for fpath in glob(join(directory, 'schema', '*.sql.%s' % driver)):
             print '-> installing', fpath
             sqlexec(open(fpath).read(), execute, False, delimiter=';;')
--- a/server/sources/pyrorql.py	Tue Sep 29 15:58:44 2009 +0200
+++ b/server/sources/pyrorql.py	Wed Sep 30 18:57:42 2009 +0200
@@ -218,6 +218,8 @@
         """open and return a connection to the source"""
         nshost = self.config.get('pyro-ns-host') or self.repo.config['pyro-ns-host']
         nsgroup = self.config.get('pyro-ns-group') or self.repo.config['pyro-ns-group']
+        self.info('connecting to instance :%s.%s for user %s',
+                  nsgroup, nshost, self.config['cubicweb-user'])
         #cnxprops = ConnectionProperties(cnxtype=self.config['cnx-type'])
         return dbapi.connect(database=self.config['pyro-ns-id'],
                              login=self.config['cubicweb-user'],
--- a/sobjects/notification.py	Tue Sep 29 15:58:44 2009 +0200
+++ b/sobjects/notification.py	Wed Sep 30 18:57:42 2009 +0200
@@ -19,6 +19,8 @@
 from cubicweb.common.mail import NotificationView
 from cubicweb.server.hook import SendMailOp
 
+parse_message_id = deprecated('parse_message_id is now defined in cubicweb.common.mail')(parse_message_id)
+
 
 class RecipientsFinder(Component):
     """this component is responsible to find recipients of a notification
@@ -118,6 +120,70 @@
                                   entity.eid, self.user_data['login'])
 
 
+def format_value(value):
+    if isinstance(value, unicode):
+        return u'"%s"' % value
+    return value
+
+
+class EntityUpdatedNotificationView(NotificationView):
+    """abstract class for notification on entity/relation
+
+    all you have to do by default is :
+    * set id and __select__ attributes to match desired events and entity types
+    * set a content attribute to define the content of the email (unless you
+      override call)
+    """
+    __abstract__ = True
+    id = 'notif_entity_updated'
+    msgid_timestamp = False
+    message = _('updated')
+    no_detailed_change_attrs = ()
+    content = """
+Properties have been updated by %(user)s:
+
+%(changes)s
+
+url: %(url)s
+"""
+
+    def context(self, **kwargs):
+        context = super(EntityUpdatedNotificationView, self).context(**kwargs)
+        changes = self.req.transaction_data['changes'][self.rset[0][0]]
+        _ = self.req._
+        formatted_changes = []
+        for attr, oldvalue, newvalue in sorted(changes):
+            # check current user has permission to see the attribute
+            rschema = self.vreg.schema[attr]
+            if rschema.is_final():
+                if not rschema.has_perm(self.req, 'read', eid=self.rset[0][0]):
+                    continue
+            # XXX suppose it's a subject relation...
+            elif not rschema.has_perm(self.req, 'read', fromeid=self.rset[0][0]):
+                continue
+            if attr in self.no_detailed_change_attrs:
+                msg = _('%s updated') % _(attr)
+            elif oldvalue not in (None, ''):
+                msg = _('%(attr)s updated from %(oldvalue)s to %(newvalue)s') % {
+                    'attr': _(attr),
+                    'oldvalue': format_value(oldvalue),
+                    'newvalue': format_value(newvalue)}
+            else:
+                msg = _('%(attr)s set to %(newvalue)s') % {
+                    'attr': _(attr), 'newvalue': format_value(newvalue)}
+            formatted_changes.append('* ' + msg)
+        if not formatted_changes:
+            # current user isn't allowed to see changes, skip this notification
+            raise SkipEmail()
+        context['changes'] = '\n'.join(formatted_changes)
+        return context
+
+    def subject(self):
+        entity = self.entity(self.row or 0, self.col or 0)
+        return  u'%s #%s (%s)' % (self.req.__('Updated %s' % entity.e_schema),
+                                  entity.eid, self.user_data['login'])
+
+
 from logilab.common.deprecation import class_renamed, class_moved, deprecated
 from cubicweb.hooks.notification import RenderAndSendNotificationView
 from cubicweb.common.mail import parse_message_id
--- a/web/formfields.py	Tue Sep 29 15:58:44 2009 +0200
+++ b/web/formfields.py	Wed Sep 30 18:57:42 2009 +0200
@@ -181,8 +181,8 @@
         try:
             return widget.render(form, self, renderer)
         except TypeError:
-            warn('widget.render now take the renderer as third argument, please update %s implementation'
-                 % widget.__class__.__name__, DeprecationWarning)
+            warn('[3.3] %s: widget.render now take the renderer as third argument, '
+                 'please update implementation' % widget, DeprecationWarning)
             return widget.render(form, self)
 
     def vocabulary(self, form):
@@ -193,7 +193,7 @@
                 try:
                     vocab = self.choices(form=form)
                 except TypeError:
-                    warn('vocabulary method (eg field.choices) should now take '
+                    warn('[3.3] vocabulary method (eg field.choices) should now take '
                          'the form instance as argument', DeprecationWarning)
                     vocab = self.choices(req=form._cw)
             else:
--- a/web/test/unittest_formfields.py	Tue Sep 29 15:58:44 2009 +0200
+++ b/web/test/unittest_formfields.py	Wed Sep 30 18:57:42 2009 +0200
@@ -80,12 +80,15 @@
         self.assertEquals(data_format_field, None)
         data_encoding_field = guess_field(schema['File'], schema['data_encoding'])
         self.assertEquals(data_encoding_field, None)
+        data_name_field = guess_field(schema['File'], schema['data_name'])
+        self.assertEquals(data_name_field, None)
 
         data_field = guess_field(schema['File'], schema['data'])
         self.assertIsInstance(data_field, FileField)
         self.assertEquals(data_field.required, True)
         self.assertIsInstance(data_field.format_field, StringField)
         self.assertIsInstance(data_field.encoding_field, StringField)
+        self.assertIsInstance(data_field.name_field, StringField)
 
     def test_constraints_priority(self):
         salesterm_field = guess_field(schema['Salesterm'], schema['reason'])
--- a/web/views/editforms.py	Tue Sep 29 15:58:44 2009 +0200
+++ b/web/views/editforms.py	Wed Sep 30 18:57:42 2009 +0200
@@ -16,6 +16,7 @@
 from logilab.mtconverter import xml_escape
 from logilab.common.decorators import cached
 
+from cubicweb import neg_role
 from cubicweb.selectors import (match_kwargs, one_line_rset, non_final_entity,
                                 specified_etype_implements, yes)
 from cubicweb.utils import make_uid
@@ -498,15 +499,13 @@
             counter=self.req.data[countkey], **kwargs))
 
     def add_hiddens(self, form, entity):
-        # to ease overriding (see cubes.vcsfile.views.forms for instance)
-        if self.keep_entity(form, entity):
-            if entity.has_eid():
-                rval = entity.eid
-            else:
-                rval = INTERNAL_FIELD_VALUE
-            form.form_add_hidden('edit%s-%s:%s' % (self.role[0], self.rtype, self.peid), rval)
-        form.form_add_hidden(name='%s:%s' % (self.rtype, self.peid), value=entity.eid,
-                             id='rel-%s-%s-%s'  % (self.peid, self.rtype, entity.eid))
+        """to ease overriding (see cubes.vcsfile.views.forms for instance)"""
+        iid = 'rel-%s-%s-%s' % (self.peid, self.rtype, entity.eid)
+        #  * str(self.rtype) in case it's a schema object 
+        #  * neged_role() since role is the for parent entity, we want the role
+        #    of the inlined entity
+        form.form_add_hidden(name=str(self.rtype), value=self.peid,
+                             role=neg_role(self.role), eidparam=True, id=iid)
 
     def keep_entity(self, form, entity):
         if not entity.has_eid():