Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue 11265 create 4 eyes workflow actionlet #13366

Merged
merged 10 commits into from
Jan 29, 2018
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package com.dotmarketing.portlets.workflows.actionlet;

import static com.dotmarketing.portlets.workflows.util.WorkflowActionletUtil.getApproversFromHistory;
import static com.dotmarketing.portlets.workflows.util.WorkflowActionletUtil.getParameterValue;
import static com.dotmarketing.portlets.workflows.util.WorkflowActionletUtil.getUsersFromIds;

import com.dotcms.util.ConversionUtils;
import com.dotmarketing.business.APILocator;
import com.dotmarketing.exception.DotDataException;
import com.dotmarketing.portlets.workflows.model.MultiUserReferenceParameter;
import com.dotmarketing.portlets.workflows.model.WorkflowActionClassParameter;
import com.dotmarketing.portlets.workflows.model.WorkflowActionletParameter;
import com.dotmarketing.portlets.workflows.model.WorkflowHistory;
import com.dotmarketing.portlets.workflows.model.WorkflowProcessor;
import com.dotmarketing.portlets.workflows.util.WorkflowEmailUtil;
import com.dotmarketing.util.Logger;
import com.liferay.portal.model.User;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
* Sometimes, customers would like content to be published if a specific number of people approve
* their content. They are not particular as to which users, they just need a specific number of
* users to approve it before the content goes live. This actionlet enforces what is called the
* '4-Eyes' principle.
* <p>
* This workflow actionlet allows a user to specify the user IDs, email addresses, or role keys
* (i.e., all the users assigned to those roles) which will be in charge of approving the new
* content. If the users that approve the content are greater or equal to the specified number of
* minimum approvers, then the content will move on to the next actionlet in the workflow.
* Otherwise, an email will be sent to all users that haven't approved the content yet.
*
* @author Jose Castro
* @version 4.3.0
* @since Jan 3, 2018
*/
public class FourEyeApproverActionlet extends WorkFlowActionlet {

private static final long serialVersionUID = 1177885642438262884L;

private static final String ACTIONLET_NAME = "'4 Eye' Approval";
private static final String HOW_TO =
"This actionlet implements the '4 Eyes' principle for verifying new content. It takes a comma-separated list of user IDs, email addresses, or role keys "

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR Split this 165 characters long line (which is greater than 150 authorized). rule

+ "(i.e., the users assigned to those roles) which will be in charge of reviewing the content, and a minimum number of approvers. "

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR Split this 151 characters long line (which is greater than 150 authorized). rule

+ "If the number of approvers is greater or equal than the specified value, the next sub-action will be executed.";
private static final String USER_ID_DELIMITER = ",";
private static final String PARAM_CONTENT_APPROVERS = "approvers";
private static final String PARAM_MINIMUM_CONTENT_APPROVERS = "minimumApprovers";
private static final String PARAM_EMAIL_SUBJECT = "emailSubject";
private static final String PARAM_EMAIL_BODY = "emailBody";
private static final String PARAM_IS_HTML = "isHtml";

private static final int DEFAULT_MINIMUM_CONTENT_APPROVERS = 2;

private boolean shouldStop = false;

private static ArrayList<WorkflowActionletParameter> ACTIONLET_PARAMETERS = null;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR Rename this field "ACTIONLET_PARAMETERS" to match the regular expression '^[a-z][a-zA-Z0-9]*$'. rule


@Override
public synchronized List<WorkflowActionletParameter> getParameters() {
if (null == ACTIONLET_PARAMETERS) {
ACTIONLET_PARAMETERS = new ArrayList<>();
ACTIONLET_PARAMETERS
.add(new MultiUserReferenceParameter(PARAM_CONTENT_APPROVERS,
"User IDs, Emails, or Role Keys", null,
true));
ACTIONLET_PARAMETERS
.add(new WorkflowActionletParameter(PARAM_MINIMUM_CONTENT_APPROVERS,
"Number of Approvers",
String.valueOf(DEFAULT_MINIMUM_CONTENT_APPROVERS), true));
ACTIONLET_PARAMETERS
.add(new WorkflowActionletParameter(PARAM_EMAIL_SUBJECT,
"Email Subject",
"'4 Eye' Approval Required", true));
ACTIONLET_PARAMETERS
.add(new WorkflowActionletParameter(PARAM_EMAIL_BODY, "Email Message",
null,
false));
}
return ACTIONLET_PARAMETERS;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR Return a copy of "ACTIONLET_PARAMETERS". rule

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why is this syncr, I would create a static code to init it and return the static value every time

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jdotcms I think the intention was to do lazy init. But what you said makes sense to me. Also the ACTIONLET_PARAMETERS field should final, at least that what the name in uppercase implies (constant)

}

@Override
public String getName() {
return ACTIONLET_NAME;
}

@Override
public String getHowTo() {
return HOW_TO;
}

@Override
public boolean stopProcessing() {
return this.shouldStop;
}

@Override
public boolean equals(Object obj) {
if (null != obj && obj instanceof WorkFlowActionlet) {
return getClass().equals(obj.getClass());
}
return false;
}

@Override
public int hashCode() {
return (shouldStop ? 1 : 0);
}

@Override
public void executeAction(final WorkflowProcessor processor,
final Map<String, WorkflowActionClassParameter> params) {
final String userIds = getParameterValue(params.get(PARAM_CONTENT_APPROVERS));
final int minimumContentApprovers = ConversionUtils
.toInt(getParameterValue(params.get(PARAM_MINIMUM_CONTENT_APPROVERS)),
DEFAULT_MINIMUM_CONTENT_APPROVERS);
final String emailSubject = getParameterValue(params.get(PARAM_EMAIL_SUBJECT));
final String emailBody = getParameterValue(params.get(PARAM_EMAIL_BODY));
final boolean isHtml = getParameterValue(params.get(PARAM_IS_HTML), false);
final Set<User> requiredContentApprovers = getUsersFromIds(userIds, USER_ID_DELIMITER);
// Add this approval to the history
final WorkflowHistory history = new WorkflowHistory();
history.setActionId(processor.getAction().getId());
history.setMadeBy(processor.getUser().getUserId());
List<WorkflowHistory> historyList = processor.getHistory();
if (null == historyList) {
historyList = new ArrayList<>();
}
historyList.add(history);
final Set<User> hasApproved = getApproversFromHistory(historyList, requiredContentApprovers,
processor.getAction().getId(), minimumContentApprovers);
if (hasApproved.size() < minimumContentApprovers) {
this.shouldStop = true;
// Keep the workflow process on the same step
processor.setNextStep(processor.getStep());
// Send email to users who have NOT approved only
final List<String> emails = new ArrayList<>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be extracted to a private method sendMailsToNotApprovingUsers(). it would include this for loop and the sending part below from lines 159-162

for (final User user : requiredContentApprovers) {
if (!hasApproved.contains(user)) {
emails.add(user.getEmailAddress());
}
}
// Assign the workflow step for next assignee
for (final User user : requiredContentApprovers) {
if (!hasApproved.contains(user)) {
try {
processor.setNextAssign(APILocator.getRoleAPI().getUserRole(user));
break;
} catch (DotDataException e) {
Logger.error(this,
"An error occurred when reassigning workflow step to user '" + user
.getUserId() + "': " + e.getMessage(), e);
}
}
}
final String[] emailsToSend = emails.toArray(new String[emails.size()]);
processor.setWorkflowMessage(emailSubject);
WorkflowEmailUtil
.sendWorkflowEmail(processor, emailsToSend, emailSubject, emailBody, isHtml);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,27 @@
import com.dotmarketing.portlets.contentlet.model.Contentlet;
import com.dotmarketing.portlets.fileassets.business.IFileAsset;
import com.dotmarketing.portlets.structure.model.Structure;
import com.dotmarketing.portlets.workflows.actionlet.*;
import com.dotmarketing.portlets.workflows.actionlet.ArchiveContentActionlet;
import com.dotmarketing.portlets.workflows.actionlet.CheckURLAccessibilityActionlet;
import com.dotmarketing.portlets.workflows.actionlet.CheckinContentActionlet;
import com.dotmarketing.portlets.workflows.actionlet.CheckoutContentActionlet;
import com.dotmarketing.portlets.workflows.actionlet.CommentOnWorkflowActionlet;
import com.dotmarketing.portlets.workflows.actionlet.DeleteContentActionlet;
import com.dotmarketing.portlets.workflows.actionlet.EmailActionlet;
import com.dotmarketing.portlets.workflows.actionlet.FourEyeApproverActionlet;
import com.dotmarketing.portlets.workflows.actionlet.MultipleApproverActionlet;
import com.dotmarketing.portlets.workflows.actionlet.NotifyAssigneeActionlet;
import com.dotmarketing.portlets.workflows.actionlet.NotifyUsersActionlet;
import com.dotmarketing.portlets.workflows.actionlet.PublishContentActionlet;
import com.dotmarketing.portlets.workflows.actionlet.PushNowActionlet;
import com.dotmarketing.portlets.workflows.actionlet.PushPublishActionlet;
import com.dotmarketing.portlets.workflows.actionlet.ResetTaskActionlet;
import com.dotmarketing.portlets.workflows.actionlet.SetValueActionlet;
import com.dotmarketing.portlets.workflows.actionlet.TranslationActionlet;
import com.dotmarketing.portlets.workflows.actionlet.TwitterActionlet;
import com.dotmarketing.portlets.workflows.actionlet.UnarchiveContentActionlet;
import com.dotmarketing.portlets.workflows.actionlet.UnpublishContentActionlet;
import com.dotmarketing.portlets.workflows.actionlet.WorkFlowActionlet;
import com.dotmarketing.portlets.workflows.model.*;
import com.dotmarketing.util.Config;
import com.dotmarketing.util.Logger;
Expand Down Expand Up @@ -61,6 +81,7 @@ public WorkflowAPIImpl() {
UnarchiveContentActionlet.class,
ResetTaskActionlet.class,
MultipleApproverActionlet.class,
FourEyeApproverActionlet.class,
TwitterActionlet.class,
PushPublishActionlet.class,
CheckURLAccessibilityActionlet.class,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package com.dotmarketing.portlets.workflows.model;

import com.dotmarketing.business.APILocator;
import com.dotmarketing.business.Role;
import com.dotmarketing.util.Logger;
import com.dotmarketing.util.UtilMethods;
import com.liferay.util.Validator;
import java.util.StringTokenizer;

/**
* This type of workflow actionlet parameter allows content managers to enter a comma-separated list
* of values used to specify different system users. This class provides 3 mechanisms to specify
* users in you actionlet:
* <ol>
* <li>The user ID.</li>
* <li>The user's email address.</li>
* <li>The role key (this includes all the users associated to the specified role).</li>
* </ol>
*
* @author Jose Castro
* @version 4.3.0
* @since Jan 9, 2018
*/
public class MultiUserReferenceParameter extends WorkflowActionletParameter {

/**
* Creates a new instance of this class.
*
* @param key The unique key for this parameter.
* @param displayName The human-readable name of this parameter.
* @param defaultValue The optional default value of this parameter.
* @param isRequired If {@code true}, the value of this parameter is required. Otherwise, set
* to {@code false}.
*/
public MultiUserReferenceParameter(String key, String displayName,
String defaultValue, boolean isRequired) {
super(key, displayName, defaultValue, isRequired);
}

@Override
public String hasError(final String stringToValidate) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL Refactor this method to reduce its Cognitive Complexity from 29 to the 15 allowed. rule

final StringBuffer errorMsg = new StringBuffer();
if (UtilMethods.isSet(stringToValidate)) {
final StringTokenizer tokenizer = new StringTokenizer(stringToValidate, ",");
while (tokenizer.hasMoreTokens()) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR Reduce the total number of break and continue statements in this loop to use at most one. rule

final String token = tokenizer.nextToken().trim();
if (Validator.isEmailAddress(token)) {
try {
APILocator.getUserAPI()
.loadByUserByEmail(token, APILocator.getUserAPI().getSystemUser(),
false);
} catch (Exception e) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR Either log or rethrow this exception. rule
MINOR Catch a list of specific exception subtypes instead. rule

Logger.error(this.getClass(), "Unable to find user with email: " + token);
errorMsg.append("Unable to find user with email: " + token + "</br>");
}
} else {
String error = null;
if (isUserId(token)) {
continue;
} else {
error = "Unable to find user with ID: " + token + "</br>";
if (isRoleKey(token)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL Refactor this code to not nest more than 4 if/for/while/switch/try statements. rule

continue;
} else {
error = (UtilMethods.isSet(error)) ? error
: "Unable to find users assigned to role key: " + token;
}
errorMsg.append(error);
}
}
}
}
return (UtilMethods.isSet(errorMsg.toString())) ? errorMsg.toString() : null;
}

/**
* Checks whether the specified value belongs to an existing user ID or not.
*
* @param userId The potential user ID.
*
* @return Returns {@code true} if the specified value is a valid user ID. Otherwise, returns
* {@code false}.
*/
private boolean isUserId(final String userId) {
try {
APILocator.getUserAPI()
.loadUserById(userId, APILocator.getUserAPI().getSystemUser(), false);
return Boolean.TRUE;
} catch (Exception e) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR Either log or rethrow this exception. rule
MINOR Catch a list of specific exception subtypes instead. rule

return Boolean.FALSE;
}
}

/**
* Checks whether the specified value belongs to an existing role key or not.
*
* @param roleKey The potential role key.
*
* @return Returns {@code true} if the specified value is a valid role key. Otherwise, returns
* {@code false}.
*/
private boolean isRoleKey(final String roleKey) {
try {
final Role role = APILocator.getRoleAPI().loadRoleByKey(roleKey);
APILocator.getRoleAPI()
.findUsersForRole(role);
return Boolean.TRUE;
} catch (Exception e) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR Either log or rethrow this exception. rule
MINOR Catch a list of specific exception subtypes instead. rule

return Boolean.FALSE;
}
}

}
Loading