[EXTENSION] simpleWorkflow

Yes, I’ve also been thinking about how validators and/or scenario could be used to manage constraints…

For me, validators and status constraints are 2 different things and one should take care not to move validation process, from the model to the workflow. For your example, i agree that it would make sense to postpone constraint evaluation at the time the model is validated, but this may not be appropriate in other case. Imagine that a given state is only reachable if user have sufficient permissions. It is clear that the list of reachable statuses should not contain statuses that can’t be reached because of insufficient user permission, and so constraint evaluation would have to occur before the list is built.

But tell me, from a conceptual point of view, shouldn’t the ‘tag not empty’ rule be actually a part of the model validation ? I mean I know that’s the example I used to illustrate constraints in the documentation, but maybe that"s not the best idea I’ve had, because by doing so, I’m moving a small part of validation, from the model into the workflow … which I think is not good.

Guess you are right. It was just your example which was not really the best to choose ;)

If there should be a validation for the tags to be filled you just have to add a rule for it.




public function tags_check($attribute,$params)

    {

        if($this->status=='swPost/ready'||$this->status=='swPost/published')

        {

           if(!$this->tags)

              $this->addError('tags','The Tags must filled for the selected status');

        }

    }

and to the rules   

  array('tags', 'tags_check'),



This would be then checked if the post will be set to ready or published. So if your constraint is only used for access handling you are right.

Nevertheless a validation from and to specific states would be also nice.

I will play around with the extension and reply here if i find some enhancement possibilities.

ok then … and regarding validation based on status change that will need some more thinking (maybe scenario could be the way).

I’ll be grateful for any enhancements, ideas, or anything that could contribute to make this extension better… and I’ll update the documentation as soon as possible (that is, when I’m back from holidays ;) )

thanks for your help

8)

[size="2"]simpleWorkflow RC2 is available[/size]

The main feature is workflow driven validation that allows to validate a model on a workflow transition. You can now implement validation rules like "I want this attribute to be required when the model instance goes from statusA to statusB", or even something more general like "I want this attribute to be validated when the model enters in statusC"… Ok, have a look at the documentation for more details and examples.

I hope it will be useful to others, and like always : any feedback is welcome ;)

Direct Download : simpleWorkflow_RC2.zip

Ressources :

  • Extension Page
  • Demo & website
  • API Documentation

ChangeLog :

  • fix : replace ‘split’ with ‘explode’ (got 2 doodle)
  • enh : SWActiveRecordBehavior->swValidate now returns boolean
  • enh : Workflow Driven Model Validation. It is now possible to define validators which are only executed upon specific transitions (this is done by defining specific scenario names).

Really cool extension! :D

I am a happy consumer. ;)

I’m hoping someone can help me here because my brain is about to explode!

I am using the simpleWorkflow validator in my model. It’s a great extension, thank you so much Raoul.

I have a scenario with contributors, reviewers, a master editor a publisher etc.

In order to provide unique workflows for each role I set up my swSource file with a switch/case construct - example.


$level = MyUtils::userLevel();

	switch($level) 

	{

	case User::CONTRIBUTOR_USER:

            

		$statusArray = array(

		'initial' => 'draft',

		'node' => array(

			array('id'=>'draft',	 'transition'=>array(

                            'submitted'=>'MyUtils::contributorMail($this->id,0)'

                            )

                            ),// CONTRIBUTOR

			array('id'=>'revise', 'transition'=>array(

                            'submitted'=>'MyUtils::contributorMail($this->id,1)',

                            'withdrawn'=>'MyUtils::contributorMail($this->id,2)'

                            )

                            ),// CONTRIBUTOR

			array('id'=>'withdrawn'),// CONTRIBUTOR

            array('id'=>'submitted'), // editor or contibuter

			array('id'=>'rejected'), // CONTRIBUTOR

			array('id'=>'accepted'), // publisher

                        array('id'=>'on-hold'), // publisher

			array('id'=>'scheduled')	// end of the line for all users									

		)

	);

	break;

	case User::REVIEWER_USER:

		$statusArray = array(

		'initial' => 'draft',

		'node' => array(

			array('id'=>'draft'), // CONTRIBUTOR

			array('id'=>'revise'),// CONTRIBUTOR

			array('id'=>'withdrawn'), // CONTRIBUTOR

			array('id'=>'submitted',

                            'transition'=>array(

                            'accepted'=>'MyUtils::reviewerMail($this->id,1)',

                            'revise'=>'MyUtils::reviewerMail($this->id,0)',

                            )), // editor or contibuter

			array('id'=>'rejected'), // CONTRIBUTOR

			array('id'=>'accepted'), // publisher

                        array('id'=>'on-hold'), // publisher

			array('id'=>'scheduled') // end of the line for all users

		)

	);

	break;

So each user role has options based on their role, I am not sure if this is best practice but at least this part is working.

I am also able to send an email using a method in a component called ‘MyUtils’, I simply pass the document ID and the mode or function.

All is working well, except it seems sometimes a reviewer will be a contributor and sometimes a master editor will be a contributor.

Contributor should be the only one who can pass his own ‘draft’ document to ‘submitted’.

Within this workflow source document I do not seem to be able to establish what the ID of the document is. I can do a down and dirty $_GET[‘id’] because on an update I have this is the URL but then I have to check if $_GET[‘id’] is set and it’s just not working. I also tried setting an constraint but that didn’t seem to work.

I don’t quite understand how the swSource document works but it seems to be like a configuration file that is loaded when required so I don’t think that the document (record) id is available at this time.

Next I am going to try a constraint again. Using something like.


array('id'=>'draft',

'constraint'=>'MyUtils::isOwner($this->id)',	 

'transition'=>array(

                   'submitted'=>'MyUtils::contributorMail($this->id,0)'

                            )

                            ),




Any help or advice would be much appreciated.

I am using the original version of this extension, should I upgrade?

doodle ???

OK this is typical after posting a cry for help I solve my own problem. Plus I went back to the documentation ::)

So now my master editor who normally doesn’t review documents can’t do very much except if they are defined as the reviewer for this document (different process)


    case User::MASTEREDITOR_USER:

 // $owner = EventAbstract::isOwner();

      

		$statusArray = array(

		'initial' => 'draft',

		'node' => array(

			array('id'=>'draft','transition'=>'submitted,withdrawn'), // CONTRIBUTOR

			array('id'=>'revise','constraint'=>'MyUtils::abstractReviewer($this->id)',  'transition'=>'submitted,withdrawn'),// CONTRIBUTOR

			array('id'=>'withdrawn', 'constraint'=>'MyUtils::abstractOwner($this->id)', 'transition'=>'submitted'), // CONTRIBUTOR

			array('id'=>'submitted','constraint'=>'MyUtils::abstractOwner($this->id)',  'transition'=>'accepted,revise,rejected'), // editor or contibuter

			array('id'=>'rejected','constraint'=>'MyUtils::abstractReviewer($this->id)' ), // CONTRIBUTOR

			array('id'=>'accepted','constraint'=>'MyUtils::abstractReviewer($this->id)' ), // publisher

                        array('id'=>'on-hold'), // publisher

			array('id'=>'scheduled')

		)

                    );


	break;



If the master editor is the reviewer they can send back for revision, accept or reject the document.

If the master editor is the owner of the document they can submit or withdraw the document.

Since checks will be in place elsewhere to to ensure that the master editor is never their own reviewer ;) this should be no problem.

FWIW these methods are in my MyUtils component.


        public function abstractOwner($abstractID)

	{

                    $doc = EventAbstract::model()->findByPk($abstractID);

             if($doc){

                    $userID = Yii::app()->user->id;

              return ($doc->user_id == $userID ? true : false);

             }

	}

        public function abstractNotOwner($abstractID)

	{

                    $doc = EventAbstract::model()->findByPk($abstractID);

             if($doc){

                    $userID = Yii::app()->user->id;

              return ($doc->user_id == $userID ? false : true);

             }

	}


        public function abstractReviewer($abstractID)

	{

                    $doc = EventAbstract::model()->findByPk($abstractID);

             if($doc){

                    $userID = Yii::app()->user->id;

              return ($doc->editor_id == $userID ? true : false);

             }

	}

Hopefully all is good, still open to suggestions or comments.

doodle

Hi doodle,

glad to hear you solved your problem … and I think you did the right thing by using constraints In fact I was quite surprise when I saw this switch that returns different workflow depending on the user role. It seems to me that from a theoretical point of view, this breaks workflow uniqueness (one id, one workflow) and could lead to very complex issues.

In your case, all workflow have the exact same nodes right ? … so you need more than one workflow because depending on user role, transitions are not the same, correct ? Typically this could be solved by applying constraints to workflow nodes (I mean, that what constraints are designed for)… and that’s exactly what you did in your last example.

However, please note that I am talking again from a theoretical point of view…

Now regarding how the swSource document works, you are right : it loads a workflow from a PHP file, when it is needed. This can occur :

  • when a Model is created (new MyModel()) - this happens only if autoInsert is set to true, which means that a model is automatically inserted into a workflow
  • when a model is read from DB - the afterFind event it handled by the simpleWorkflow behavior

…and of course when you call directly swWorkflowSource component :


Yii::app()->sw->loadWorkflow('myWorkflow');

At last, you don’t have to upgrade unless you want to use the Workflow Driven Model validation feature which is the main change.

I hope that my extension will bring you more help than issues for your application (and that your brain will not explode ;) )

@raoul at first after reading your comments I thought that perhaps I had over complicated things using the switch statement and I thought a bit about whether I could or should lose the switch statement.

It seems for me that it adds considerable flexibility. For my particular project I have a few requirements that I think I can only address this way.

[list=1]

[*]The users are not experienced so I want the super-user to be able to ‘fix’ things or do things that are essentially ‘illogical’

[*]different people are emailed depending on where in the process the status changes

[/list]

I have a down and dirty page up on my website to explain this project to you. You can access it here

Also a comment about the documentation. Possibly because I am a left to right reader I found the documentation confusing regarding the introduction of the constraints.

Example: one of my workflow arrays, it works fine.


$statusArray = array(

		'initial' => 'draft',

		'node' => array(

			array('id'=>'draft','transition'=>'submitted,withdrawn'), // CONTRIBUTOR

			array('id'=>'revise','constraint'=>'MyUtils::abstractReviewer($this->id)',  'transition'=>'submitted,withdrawn'),// CONTRIBUTOR

			array('id'=>'withdrawn', 'constraint'=>'MyUtils::abstractOwner($this->id)', 'transition'=>'submitted'), // CONTRIBUTOR

			array('id'=>'submitted','constraint'=>'MyUtils::abstractOwner($this->id)',  'transition'=>'accepted,revise,rejected'), // editor or contibuter

			array('id'=>'rejected','constraint'=>'MyUtils::abstractReviewer($this->id)' ), // CONTRIBUTOR

			array('id'=>'accepted','constraint'=>'MyUtils::abstractReviewer($this->id)' ), // publisher

                        array('id'=>'on-hold'), // publisher

			array('id'=>'scheduled')

		)

                    );

The same array in a slightly more understandable format (not necessarily more readable)


$statusArray = array(

		'initial' => 'draft',

		'node' => array(

			array('id'=>'draft','transition'=>'submitted,withdrawn'), // CONTRIBUTOR

			array('constraint'=>'MyUtils::abstractReviewer($this->id)', 'id'=>'revise',  'transition'=>'submitted,withdrawn'),// CONTRIBUTOR

			array('constraint'=>'MyUtils::abstractOwner($this->id)','id'=>'withdrawn',  'transition'=>'submitted'), // CONTRIBUTOR

			array('constraint'=>'MyUtils::abstractOwner($this->id)','id'=>'submitted',  'transition'=>'accepted,revise,rejected'), // editor or contibuter

			array('constraint'=>'MyUtils::abstractReviewer($this->id)','id'=>'rejected', ), // CONTRIBUTOR

			array('constraint'=>'MyUtils::abstractReviewer($this->id)','id'=>'accepted' ), // publisher

                        array('id'=>'on-hold'), // publisher

			array('id'=>'scheduled')

		)

                    );

So now all logic flows left to right

In order to make it to an ID you must pass THROUGH the constraint after reaching the ID the possible transitions are the next things to the right.

It helped me visualize what was happening.

Now the only issue I am facing is that notification is sent out before the save, this means that when a reviewer makes a note I don’t know how to access the most recent changes.

FWIW here is a chunk of the method that sends the mail


public function autoMail($subject,$docID,$userID=null,$fromID=null )

	{

               $doc = EventAbstract::model()->findByPk($docID);


		if(!isset(Yii::app()->params->sendAdminMail)) // don't even try to mail if this setting is null

		return;

                /*

                 * If there is no FROM id assume that it is from the admin

                 * if it is a number it is a user id otherwise it must be an email address

                 */

                if(!$fromID){

		$from=Yii::app()->params->masterEditorEmail;

                } else {

                  $from = (is_numeric($fromID) ? User::model()->findByPk($fromID)->email : $userID);

                }

                /*

                 * If there is no TO id assume that it is to the admin

                 * if it is a number it is a user id otherwise it is an email address

                 */

                if($userID){

		$to = (is_numeric($userID) ? User::model()->findByPk($userID)->email : $userID);

                } else {

                $to=Yii::app()->params->masterEditorEmail;

                }

                $headers = "MIME-Version: 1.0\r\nFrom: $from\r\nReply-To: $to\r\nContent-Type: text/html; charset=utf-8";


                $body = '<em><strong>'.$subject.'</strong></em>';.....

As you can see it pulls the data from the existing record based on an ID.

Here is an example of an action that is triggered by a status change.


case User::CONTRIBUTOR_USER:

           	$statusArray = array(

		'initial' => 'draft',

		'node' => array(

			array('id'=>'draft',	 'transition'=>array(

                            'submitted'=>'MyUtils::contributorMail($this->id,0)'

                            )

                            ),.....

@raoul I think I could learn a lot by examining your extension, thanks for sharing.

doodle

Hi doodle,

like I say, if you think that this switch statement is appropriate to your project (regarding flexibility) that’s all right, you’re with no doubt better judge than me who don’t know issues you have faced (and by the way, the web page is perfect to understant, and a nice graphical representation of a workflow is better than long writings).

Regarding the order in which you choose to define a given node, I had not thought that begining with the constraint could help understand better the purpose of constraints. I’ll keep that in mind for the next doc update.

At last, for your last point : receiving save notification before the record is actually saved. Again, even if I made an effort to provide a documentation, I realize that it is missing some important things. Among them is the possibility to initialize the sW Behavior so transition tasks are executed after the record is saved. Now I don’t know if it fits your needs, but here is how to use it :




public function behaviors()

{

	return array(

		'swBehavior' => array(

			'class'   		=> 'application.extensions.simpleWorkflow.SWActiveRecordBehavior',

            'transitionBeforeSave' => false

		),			

	);

}

As you can see, the option name is transitionBeforeSave, which is by default set to TRUE. You can find more info in the SimpleWorkflow Class Reference. Here is what it says :

I hope this will help you solve your problem.

@raoul - YES! transitionBeforeSave this works!

Now everything is working exactly as I had envisioned.

Great work Raoul!

:D

doodle

glad to hear that !

Thank you for excellent extension! It works great in php version 5.2.0, but in 5.1.6 I get PHP error "Object of class SWNode to string conversion". Is it related to fact that before PHP 5.2.0 the __toString method is only called when it is directly combined with echo() or print() ?

Don’t use php 5.1 if you have a choice. That one is buggy. (In my experience).

Hi Kire,

I’ve never tested the extension on PHP 5.1.6, so I can’t guarantee you it works ok … and it seems that it doesn’t.

The SWNode class has a [i]toString/i method that I’ve implemented to provide a string representation of the node, in the form : workflowId/nodeId… maybe that is what cause the problem… so ‘normally’ there should not be any automatic SWNode -> String conversion (but maybe I missed one).

Can you please provide me the complete stack trace so I check the source code that throws the exception ?

Thank you for your answer! Complete Stack Trace in case of blog-demo under PHP 5.1.6 is as follows:

#0 /var/www/html/yii/framework/YiiBase.php(551): strtr()

#1 /var/www/html/yii/extensions/components/simpleWorkflow/SWActiveRecordBehavior.php(757): t()

#2 /var/www/html/yii/extensions/components/simpleWorkflow/SWActiveRecordBehavior.php(765): SWActiveRecordBehavior->_logEventFire()

#3 /var/www/html/yii/extensions/components/simpleWorkflow/SWActiveRecordBehavior.php(788): SWActiveRecordBehavior->_raiseEvent()

#4 /var/www/html/yii/extensions/components/simpleWorkflow/SWActiveRecordBehavior.php(315): SWActiveRecordBehavior->onEnterWorkflow()

#5 /var/www/html/yii/extensions/components/simpleWorkflow/SWActiveRecordBehavior.php(232): SWActiveRecordBehavior->swInsertToWorkflow()

#6 /var/www/html/yii/extensions/components/simpleWorkflow/SWActiveRecordBehavior.php(208): SWActiveRecordBehavior->initialize()

#7 /var/www/html/yii/framework/base/CComponent.php(333): SWActiveRecordBehavior->attach()

#8 /var/www/html/yii/framework/base/CComponent.php(300): Post->attachBehavior()

#9 /var/www/html/yii/framework/db/ar/CActiveRecord.php(354): Post->attachBehaviors()

#10 /var/www/html/labs/blog-demo/protected/models/Post.php(28): model()

#11 /var/www/html/labs/blog-demo/protected/controllers/PostController.php(180): model()

#12 /var/www/html/labs/blog-demo/protected/controllers/PostController.php(82): PostController->loadModel()

#13 /var/www/html/yii/framework/web/actions/CInlineAction.php(57): PostController->actionUpdate()

#14 /var/www/html/yii/framework/web/CController.php(300): CInlineAction->run()

#15 /var/www/html/yii/framework/web/filters/CFilterChain.php(133): PostController->runAction()

#16 /var/www/html/yii/framework/web/filters/CFilter.php(41): CFilterChain->run()

#17 /var/www/html/yii/framework/web/CController.php(1084): CAccessControlFilter->filter()

#18 /var/www/html/yii/framework/web/filters/CInlineFilter.php(59): PostController->filterAccessControl()

#19 /var/www/html/yii/framework/web/filters/CFilterChain.php(130): CInlineFilter->filter()

#20 /var/www/html/yii/framework/web/CController.php(283): CFilterChain->run()

#21 /var/www/html/yii/framework/web/CController.php(257): PostController->runActionWithFilters()

#22 /var/www/html/yii/framework/web/CWebApplication.php(324): PostController->run()

#23 /var/www/html/yii/framework/web/CWebApplication.php(121): CWebApplication->runController()

#24 /var/www/html/yii/framework/base/CApplication.php(135): CWebApplication->processRequest()

#25 /var/www/html/labs/blog-demo/index.php(18): CWebApplication->run()

ok, I found the problem : it seems that I forgot one SWNode -> toString conversion automatically handled by PHP…

I will modify this in a next release, but in the meantime, you can fix it right now so you can continue using the extension

file : [color="#1C2837"][size="2"]SWActiveRecordBehavior.php[/size][/color]

[color="#1C2837"][size="2"]line : 757[/size][/color]

[color="#1C2837"][size="2"]




private function _logEventFire($ev,$source,$dest){

Yii::log(Yii::t('simpleWorkflow','event fired : \'{event}\' status [{source}] -> [{destination}]',

 	array(

          '{event}'		=> $ev,

          '{source}'		=> (is_null($source)?'null':$source->toString()),	// added '->toString()'

          '{destination}'	=> $dest->toString(),                    // added '->toString()'

   	)),

   	CLogger::LEVEL_INFO,

   	self::SW_LOG_CATEGORY

   );		

}	

[/size][/color]

[color="#1C2837"][size="2"]This method is only used to log all event fire so as you see, it is not important and should not block anything.[/size][/color]

[color="#1C2837"][size=“2”]If you find some other errors like this one, please do not hesitate to tell me so I can correct them for the next release. Again, I’m sorry for the inconvenience, but the simpleWorkflow extension has not been tested under PHP 5.1.[/size][/color]

[color="#1C2837"][size="2"][/size][/color]

Hi! There seems to be another similar PHP 5.1.6 specific error:

Description

Object of class SWNode could not be converted to int

Source File

/var/www/html/yii/extensions/components/simpleWorkflow/SWPhpWorkflowSource.php(193)

00181: return $result;

00182: }

00183: /**

00184: *

00185: */

00186: public function isNextNode($sourceNode,$targetNode,$workflowId=null){

00187:

00188: $startNode=$this->createSWNode($sourceNode,$workflowId);

00189: $nextNode=$this->createSWNode($targetNode,($workflowId!=null?$workflowId:$startNode->getWorkflowId()));

00190:

00191: $nxt=$this->getNextNodes($startNode);

00192: if( $nxt != null){

00193: return in_array($nextNode->toString(),$nxt);

00194: }else {

00195: return false;

00196: }

00197: }

00198: /**

00199: *

00200: */

00201: public function getInitialNode($workflowId){

00202: $this->_load($workflowId,false);

00203: return $this->_workflow[$workflowId][‘swInitialNode’];

00204: }

00205: /**

Stack Trace

#0 /var/www/html/yii/extensions/components/simpleWorkflow/SWPhpWorkflowSource.php(193): in_array()

#1 /var/www/html/yii/extensions/components/simpleWorkflow/SWActiveRecordBehavior.php(389): SWPhpWorkflowSource->isNextNode()

#2 /var/www/html/yii/extensions/components/simpleWorkflow/SWActiveRecordBehavior.php(339): SWActiveRecordBehavior->swIsNextStatus()

#3 unknown(0): SWActiveRecordBehavior->swGetNextStatus()

#4 /var/www/html/yii/framework/base/CComponent.php(261): call_user_func_array()

#5 /var/www/html/yii/framework/db/ar/CActiveRecord.php(195): Post->__call()

#6 unknown(0): Post->__call()

#7 /var/www/html/yii/extensions/components/simpleWorkflow/SWHelper.php(26): Post->swGetNextStatus()

#8 /var/www/html/labs/blog-demo/protected/views/post/_form.php(38): nextStatuslistData()

Hi kire,

sorry for the inconvenience. Unfortunately I’ll be away from keyboard during sometime and I’m not sure I’ll be able to fix that in a short delay.

In the mean time, you can try to replace following lines in SWNode.php :


public function toString(){return $this->__toString();}

…with…


public function toString(){return $this->getWorkflowId().'/'.$this->getId();}

I don’t garantee it works as I can’t test it now … but give it a try.

bye

ps: so you’re still using PHP 5.1.6 ?

Hi!

Thank you for the extension, it looks very promising. I am having a bit of a problem using it though.

In my program unlinke the blog example, i do not wish to select the next state from a dropdownlist, i would like to have a set of buttons at each view, corresponding to the specific state, wich push the object to the chosen state.

To make it clear: in the building state, i see a page with some data, and a button saying ‘built’. If i press this button, the state changes to ‘built’ state.

After reading through the behaviour file, i thought of using the ‘swNextStatus’ function. So pressing a button jumps to an action, where $model->swNextStatus(‘built’); $model->save(); is called. The function runs without errors, the transition is possible, return TRUE, but the new state is not stored in the database.

Is my solution wrong? Where is the problem?