# Plugin for TWiki Collaboration Platform, http://TWiki.org/ # # Copyright (C) 2005 Thomas Hartkens # Copyright (C) 2005 Thomas Weigert # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details, published at # http://www.gnu.org/copyleft/gpl.html # ========================= package TWiki::Plugins::WorkflowPlugin; #use strict 'vars'; use strict; # ========================= use vars qw( $web $topic $user $installWeb $VERSION $RELEASE $pluginName $inited $debug $prefWorkflow $prefNeedsWorkflow $globWorkflow $globForm $globWebName $globCurrentState $globHistory $globWorkflowMessage $globAllowEdit $CalledByMyself %globPreferences $SHORTDESCRIPTION $globSignoff $globTotalAllowed $globGroup ); # This should always be $Rev: 11491$ so that TWiki can determine the checked-in # status of the plugin. It is used by the build automation tools, so # you should leave it alone. $VERSION = '$Rev: 11491$'; # This is a free-form string you can use to "name" your own plugin version. # It is *not* used by the build automation tools, but is reported as part # of the version number in PLUGINDESCRIPTIONS. $RELEASE = 'Dakar'; $SHORTDESCRIPTION = 'Supports work flows associated with topics'; $pluginName = 'WorkflowPlugin'; # Name of this Plugin # ========================= sub initPlugin { ( $topic, $web, $user, $installWeb ) = @_; # check for Plugins.pm versions if( $TWiki::Plugins::VERSION < 1.021 ) { TWiki::Func::writeWarning( "Version mismatch between $pluginName and Plugins.pm" ); return 0; } # Force re-init on each run $inited = 0; # Get plugin debug flag $debug = TWiki::Func::getPreferencesFlag("WORKFLOWPLUGIN_DEBUG"); return 1; } # Return 1 if we need workflow in this topic, -1 if not sub _init { my ($web, $topic) = @_; return $inited if $inited; $inited = -1; my( $meta, $text ) = TWiki::Func::readTopic( $web, $topic ); my $prefWorkflow = TWiki::Func::getPreferencesValue( "WORKFLOW" ) || 0; return $inited unless ($prefWorkflow); my ($defweb, $deftopic) = TWiki::Func::normalizeWebTopicName($web, $prefWorkflow); return $inited unless (TWiki::Func::topicExists( $defweb, $deftopic)); $user = $TWiki::Plugins::SESSION->{user}; $globCurrentState = getWorkflowState($meta); ($globWorkflow, $globCurrentState, $globWorkflowMessage, $globAllowEdit, $globForm, $globSignoff, $globTotalAllowed) = parseWorkflow($defweb, $deftopic, $user, $globCurrentState); $globHistory = $meta->get( 'WORKFLOWHISTORY' ) || ''; $globHistory = $globHistory->{value} if $globHistory; Debug("initPlugin State in the document: '" . $globCurrentState->{name} . "'"); $inited = 1; return 1; } # ========================= sub commonTagsHandler { ### my ( $text, $topic, $web ) = @_; # do not uncomment, use $_[0], $_[1]... instead my $query = TWiki::Func::getCgiQuery(); return unless ($query); return unless (_init($_[2], $_[1]) > 0); my $action = $query->param( 'WORKFLOWACTION' ); my $state = $query->param( 'WORKFLOWSTATE' ); # find out if the user is allowed to perform the action if ($action && ($state eq $globCurrentState->{name}) && defined($globWorkflow->{$action})) { # store new status as meta data changeWorkflowState($globWorkflow->{$action}, $globForm->{$action}, $globSignoff->{$action}, $globTotalAllowed->{$action}); # we need to parse the workflow again since the state of the document # has changed which will effect the actions the user can do now. my $user = $TWiki::Plugins::SESSION->{user}; $inited = 0; return unless (_init($_[2], $_[1]) > 0); } # replace edit tag if ($globAllowEdit) { $_[0] =~ s!%WORKFLOWEDITTOPIC%!Edit!g; } else { $_[0] =~ s!%WORKFLOWEDITTOPIC%! Edit<\/strike> !g; } # show all tags defined by the preferences foreach my $key (keys %globPreferences) { if ($key =~ /^WORKFLOW/) { $_[0] =~ s/%$key%/$globPreferences{$key}/g; } } # show last version tags foreach my $key (keys %{$globCurrentState}) { if ($key =~ /^LASTVERSION_/) { my $url = TWiki::Func::getScriptUrl( $web,$topic,"view" ); my $foo = "revision ". $globCurrentState->{$key}.""; $_[0] =~ s/%WORKFLOW$key%/$foo/g; } } # show last time tags foreach my $key (keys %{$globCurrentState}) { if ($key =~ /^LASTTIME_/) { $_[0] =~ s/%WORKFLOW$key%/$globCurrentState->{$key}/g; } } # display the message for current status $_[0] =~ s/%WORKFLOWSTATEMESSAGE%/$globWorkflowMessage/g; # show who has reviewed the current state $_[0] =~ s/%REVIEWEDBY%/$globCurrentState->{"REVIEWED"}/g; # display the workflow history $_[0] =~ s/%WORKFLOWHISTORY%/$globHistory/g; # # Build the button to change the current status # my @actions = keys(%{$globWorkflow}); my $NumberOfActions = scalar(@actions); if ($NumberOfActions > 0) { my $button; my $url = TWiki::Func::getScriptUrl( $web,$topic,"view" ); if ($NumberOfActions == 1) { $button = '
'; # $button = ' '. # $actions[0].''; } else { my $select=""; foreach my $key (sort(@actions)) { $select .= ""; } $button = "
". "". " ". "". "
"; } # build the final form # my $form = '
'. # ''. # ''. # "". # '
".$globPreferences{"TEXTBEFORECHANGEBUTTON"}."   '.$button .'
'; $_[0] =~ s/%WORKFLOWTRANSITION%/$button/g; } $_[0] =~ s!%WORKFLOWEDITTOPIC%!Edit!g; # delete all tags which start with the word WORKFLOW $_[0] =~ s/%WORKFLOW([a-zA-Z_]*)%//g; $_[0] =~ s/%REVIEWEDBY%//g; } # ========================= sub beforeEditHandler { ### my ( $text, $topic, $web, $meta ) = @_; # do not uncomment, use $_[0], $_[1]... instead return unless (_init($_[2], $_[1]) > 0); # This handler is called by the edit script just before presenting the edit text # in the edit box. Use it to process the text before editing. if (! $globAllowEdit) { throw TWiki::OopsException( 'accessdenied', def => 'topic_access', web => $_[2], topic => $_[1], params => [ 'Edit topic', 'You are blocked from editing this topic by the %TWIKIWEB%.WorkflowPlugin' ] ); return 0; } } # ========================= sub beforeSaveHandler { ### my ( $text, $topic, $web ) = @_; # do not uncomment, use $_[0], $_[1]... instead # This handler is called by TWiki::Store::saveTopic just before the save action. return unless (_init($_[2], $_[1]) > 0); #Debug("---------- beforeSaveHandler"); if (! $globAllowEdit && ! $CalledByMyself) { throw TWiki::OopsException( 'accessdenied', def => 'topic_access', web => $_[2], topic => $_[1], params => [ 'Save topic', 'You are not permitted to edit this topic' ] ); return 0; } } # ========================= sub beforeAttachmentSaveHandler { return unless (_init($_[2], $_[1]) > 0); #Debug("---------- beforeAttachmentSaveHandler"); if (! $globAllowEdit && ! $CalledByMyself) { throw TWiki::OopsException( 'accessdenied', def => 'topic_access', web => $_[2], topic => $_[1], params => [ 'Attach file', 'You are not permitted to edit this topic' ] ); return 0; } } # ========================= sub changeWorkflowState { my ($state, $form, $signoff, $totalAllowed) = @_; my ($meta, $text) = TWiki::Func::readTopic( $web, $topic ); my ($revdate, $revuser, $version, $revcmt) = $meta->getRevisionInfo(); Debug("changeWorkflowState from $globCurrentState->{name} to $state"); # If a signoff percentage is defined, do not change state and store the reviewer # and total signoffs in meta. Else change state as usual my $minSignoff = $signoff / 100 * $totalAllowed if $signoff; $globCurrentState->{"SIGNOFF_$state"} ++; if($minSignoff && $globCurrentState->{"SIGNOFF_$state"} < $minSignoff) { Debug("Concurrent Review - Minimum required to signoff: $minSignoff | Signoff's so far: ".$globCurrentState->{"SIGNOFF_$state"}); # Store the reviewers wikiname, and also group if reviewing on behalf of group $globCurrentState->{"REVIEWED"} .= ", " if $globCurrentState->{"REVIEWED"}; $globCurrentState->{"REVIEWED"} .= $revuser->webDotWikiName(); $globCurrentState->{"REVIEWED"} .= ", $globGroup" if $globGroup; } else { $globCurrentState->{name}=$state; delete($globCurrentState->{"REVIEWED"}); # delete any SIGNOFF keys foreach my $key (keys %{$globCurrentState}) { if ($key =~ /^SIGNOFF_/) { delete($globCurrentState->{"$key"}); } } } $globCurrentState->{"LASTVERSION_$state"}="$version"; $globCurrentState->{"LASTTIME_$state"} = TWiki::Func::formatTime( time(), undef, 'servertime' ); $meta->remove( "WORKFLOW" ); $meta->put( "WORKFLOW", $globCurrentState); # If reviewing on behalf of group, store the group name aswell my $reviewer = $revuser->webDotWikiName(); $reviewer .= " ($globGroup)" if $globGroup; my $mixedAlpha = $TWiki::regex{mixedAlpha}; my $fmt = TWiki::Func::getPreferencesValue( "WORKFLOWHISTORYFORMAT" ) || '$state -- $date'; $fmt =~ s/\"//go; $fmt =~ s/\$quot/\"/go; $fmt =~ s/\$n/
/go; $fmt =~ s/\$n\(\)/
/go; $fmt =~ s/\$n([^$mixedAlpha]|$)/\n$1/gos; $fmt =~ s/\$state/$state/go; $fmt =~ s/\$wikiusername/$reviewer/geo; $fmt =~ s/\$date/$globCurrentState->{"LASTTIME_$state"}/geo; $globHistory .= "\r\n" if $globHistory; $globHistory .= $fmt; $meta->remove( "WORKFLOWHISTORY" ); $meta->put( "WORKFLOWHISTORY", { value => $globHistory } ); my $oldForm = $meta->get( 'FORM' ); my $unlock=1; my $dontNotify=1; $CalledByMyself=1; my $error = TWiki::Func::saveTopic( $web, $topic, $meta, $text, { minor => $dontNotify } ); if( $error ) { my $url = TWiki::Func::oops( $web, $topic, "saveerr", $error ); TWiki::Func::redirectCgiQuery(undef, $url); return 0; } # If we want to have a form attached initially, we need to have # values in the topic, due to the TWiki form initialization # algorithm, or pass them here via URL parameters (take from # initialization topic) if ( $form && !($oldForm && $oldForm eq $form)) { my $url = TWiki::Func::getScriptUrl( $web, $topic, 'edit' ); $url .= "?formtemplate=$form"; TWiki::Func::redirectCgiQuery(undef, $url); return 0; } } sub getWorkflowState { my $meta = shift; return $meta->get('WORKFLOW'); } sub Debug { my $text = shift; TWiki::Func::writeDebug( "- TWiki::Plugins::${pluginName}: $text" ) if $debug; } # # return a hash table representing the actions alowed by # the current user. The hash-key is the possible action # while the value is the next state. # sub parseWorkflow { my ($WorkflowWeb, $WorkflowTopic, $User, $CurrentState) = @_; my %workflow = (); my %workflowSignoff = (); my %workflowForm = (); my $WorkflowMessage = ""; my $AllowEdit = 0; my %totalAllowed = (); # take care that $CurrentState is a HASH table $CurrentState = {} unless defined($CurrentState); # the default state is the first row in the state table my $defaultState; my $CurrentStateIsValid = 0; # Read topic that defines the statemachine if( TWiki::Func::topicExists( $WorkflowWeb, $WorkflowTopic ) ) { my( $meta, $text ) = TWiki::Func::readTopic( $WorkflowWeb, $WorkflowTopic ); # Allows us to use a %SEARCH% to populate tables with users $text = TWiki::Func::expandCommonVariables( $text, $WorkflowTopic, $WorkflowWeb ); my $inBlock = 0; # | *Current state* | *Action* | *Next state* | *Allowed* | foreach( split( /\n/, $text ) ) { if ( /^\s*\|.*State[^|]*\|.*Action[^|]*\|.*Next State[^|]*\|.*Allowed[^|]*\|/ ) { # from now on, we are in the TRANSITION table $inBlock = 1; } elsif ( /^\s*\|.*State[^|]*\|.*Allow Edit[^|]*\|.*Message[^|]*\|/ ) { # from now on, we are in the STATE table $inBlock = 2; } elsif ( /^(\t+\*\sSet\s)([A-Za-z]+)(\s\=\s*)(.*)$/ ) { # store preferences $globPreferences{$2}=$4; } elsif( ($inBlock == 1) && s/^\s*\|//o ) { # read row in TRANSITION table my( $state, $action, $next, $allowed, $signoff, $form ) = split( /\s*\|\s*/ ); $state = _cleanField($state); $signoff =~ s/%//; #Debug("TRANSITION: '$state', $action, $next, $allowed, $signoff, $form"); if (UserIsAllowed($User, $allowed) && ($state eq $CurrentState->{name})) { $globGroup = UserIsGroup($User, $allowed); Debug($User->webDotWikiName() . " is reviewing on behalf of $globGroup") if $globGroup; # store the transition in user's workflow $workflow{$action} = $next; $workflowForm{$action} = $form; $workflowSignoff{$action} = $signoff; # Counts the amount of state reviewers for use in signoffs my @users = split( /\s*,\s*/, $allowed ); $totalAllowed{$action} = scalar(@users); } } elsif( ($inBlock == 2) && s/^\s*\|//o ) { # read row in STATE table my( $state, $allowedit, $message ) = split( /\s*\|\s*/ ); $state = _cleanField($state); #Debug("STATE: '$state', $allowedit, $message CurrentState: '$CurrentState->{name}'"); # the first state in the table defines the default state if (!defined($defaultState)) { $defaultState=$state; $CurrentState->{name} = $state unless defined($CurrentState->{name}); } if ($state eq $CurrentState->{name}) { $CurrentStateIsValid=1; $WorkflowMessage=$message; if (UserIsAllowed($User, $allowedit)) { $AllowEdit = 1; } } } else { $inBlock = 0; } } # we need to treat the case that the workflow states have changed and that the # status written in the document is not valid anymore. In this case we go back to # the default status! if (!$CurrentStateIsValid && defined($defaultState)) { $CurrentState->{name}=$defaultState; return parseWorkflow($WorkflowWeb, $WorkflowTopic, $User, $CurrentState); } } else { # FIXME - do what if there is an error? } return ( \%workflow, $CurrentState, $WorkflowMessage, $AllowEdit, \%workflowForm, \%workflowSignoff, \%totalAllowed); } # finds out if the user $User is allowed to do something sub UserIsAllowed { my ($User, $allow) = @_; return 0 if $User->isInList( $globCurrentState->{"REVIEWED"} ); # user has already reviewed in this state # return 1 if $User->isAdmin(); # admins are always allowed to edit topic/change state if ( defined( $allow ) && $allow ) { return 0 if $allow =~ /^\s*nobody\s*$/; return $User->isInList( $allow ); } # user IS allowed! return 1; } # finds out if $User is part of a group listed in $allow # if so, assume they are reviewing on behalf of the group sub UserIsGroup { my ($User, $allow) = @_; my @groups = $User->getGroups(); foreach my $groupObject (@groups) { my $group = $groupObject->webDotWikiName(); return $group if $allow =~ /$group/; } return 0; } sub _cleanField { my( $text ) = @_; $text = "" if( ! $text ); $text =~ s/^\s*//go; $text =~ s/\s*$//go; $text =~ s/[^A-Za-z0-9_\.]//go; # Need do for web.topic return $text; } 1;