diff --git a/lib/taskana-core/src/main/java/pro/taskana/task/api/TaskService.java b/lib/taskana-core/src/main/java/pro/taskana/task/api/TaskService.java index bf503ff34..60090b74d 100644 --- a/lib/taskana-core/src/main/java/pro/taskana/task/api/TaskService.java +++ b/lib/taskana-core/src/main/java/pro/taskana/task/api/TaskService.java @@ -325,6 +325,21 @@ public interface TaskService { void forceDeleteTask(String taskId) throws TaskNotFoundException, InvalidStateException, NotAuthorizedException; + /** + * Selects and claims the first task which is returned by the task query. + * + * @param taskQuery the task query. + * @return the task that got selected and claimed + * @throws TaskNotFoundException if the task with taskId was not found + * @throws InvalidStateException if the state of the task with taskId is not READY + * @throws InvalidOwnerException if the task with taskId is claimed by someone else + * @throws NotAuthorizedException if the current user has no read permission for the + * workbasket the task is in + */ + Task selectAndClaim(TaskQuery taskQuery) + throws TaskNotFoundException, NotAuthorizedException, InvalidStateException, + InvalidOwnerException; + /** * Deletes a list of tasks. * diff --git a/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskQueryImpl.java b/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskQueryImpl.java index c7c743920..4381d8cbd 100644 --- a/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskQueryImpl.java +++ b/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskQueryImpl.java @@ -149,6 +149,7 @@ public class TaskQueryImpl implements TaskQuery { private List orderColumns; private WildcardSearchField[] wildcardSearchFieldIn; private String wildcardSearchValueLike; + private boolean selectAndClaim; private boolean useDistinctKeyword = false; private boolean joinWithAttachments = false; @@ -449,6 +450,11 @@ public class TaskQueryImpl implements TaskQuery { return this; } + public TaskQuery selectAndClaimEquals(boolean selectAndClaim) { + this.selectAndClaim = selectAndClaim; + return this; + } + @Override public TaskQuery parentBusinessProcessIdIn(String... parentBusinessProcessIds) { this.parentBusinessProcessIdIn = parentBusinessProcessIds; @@ -1105,7 +1111,11 @@ public class TaskQueryImpl implements TaskQuery { } public String getLinkToMapperScript() { - return DB.DB2.dbProductId.equals(getDatabaseId()) ? LINK_TO_MAPPER_DB2 : LINK_TO_MAPPER; + if (DB.DB2.dbProductId.equals(getDatabaseId()) && !selectAndClaim) { + return LINK_TO_MAPPER_DB2; + } else { + return LINK_TO_MAPPER; + } } public String getLinkToCounterTaskScript() { @@ -1206,6 +1216,10 @@ public class TaskQueryImpl implements TaskQuery { return isTransferred; } + public boolean getIsSelectAndClaim() { + return selectAndClaim; + } + public String[] getPorCompanyIn() { return porCompanyIn; } @@ -1931,6 +1945,8 @@ public class TaskQueryImpl implements TaskQuery { + wildcardSearchFieldIn + ", wildcardSearchValueLike=" + wildcardSearchValueLike + + ", selectAndClaim=" + + selectAndClaim + "]"; } } diff --git a/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskQueryMapper.java b/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskQueryMapper.java index 37931a8ec..9ecb92b72 100644 --- a/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskQueryMapper.java +++ b/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskQueryMapper.java @@ -134,8 +134,11 @@ public interface TaskQueryMapper { + "AND (UPPER(a.REF_VALUE) LIKE #{item}) " + " AND ( ( a.RECEIVED >= #{item.begin} AND a.RECEIVED <=#{item.end} )) " + "AND (t.${item} LIKE #{wildcardSearchValueLike}) " + + " AND t.STATE = 'READY' " + "" + "ORDER BY ${item} " + + " FETCH FIRST ROW ONLY FOR UPDATE " + + "WITH RS USE AND KEEP UPDATE LOCKS " + "") @Results( value = { @@ -334,6 +337,7 @@ public interface TaskQueryMapper { + "" + "" + ", ACNAME " + + "" + ", FLAG ) " + "AS " @@ -376,7 +380,8 @@ public interface TaskQueryMapper { + "${item}" + "" + " " - + "with UR " + + "FETCH FIRST ROW ONLY FOR UPDATE WITH RS USE AND KEEP UPDATE LOCKS" + + " with UR" + "") @Results( value = { diff --git a/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskServiceImpl.java b/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskServiceImpl.java index 88024fa26..985fd2b07 100644 --- a/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskServiceImpl.java +++ b/lib/taskana-core/src/main/java/pro/taskana/task/internal/TaskServiceImpl.java @@ -477,6 +477,37 @@ public class TaskServiceImpl implements TaskService { deleteTask(taskId, true); } + @Override + public Task selectAndClaim(TaskQuery taskQuery) + throws TaskNotFoundException, NotAuthorizedException, InvalidStateException, + InvalidOwnerException { + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("entry to selectAndClaim(taskQuery = {})", taskQuery); + } + + try { + + taskanaEngine.openConnection(); + + ((TaskQueryImpl) taskQuery).selectAndClaimEquals(true); + + TaskSummary taskSummary = taskQuery.single(); + + if (taskSummary == null) { + throw new SystemException( + "No tasks matched the specified filter and sorting options," + + " task query returned nothing!"); + } + + return claim(taskSummary.getId()); + + } finally { + LOGGER.debug("exit from selectAndClaim()"); + taskanaEngine.returnConnection(); + } + } + @Override public BulkOperationResults deleteTasks(List taskIds) throws InvalidArgumentException, NotAuthorizedException { diff --git a/lib/taskana-core/src/test/java/acceptance/AbstractAccTest.java b/lib/taskana-core/src/test/java/acceptance/AbstractAccTest.java index 8af0b7d0d..1d401b003 100644 --- a/lib/taskana-core/src/test/java/acceptance/AbstractAccTest.java +++ b/lib/taskana-core/src/test/java/acceptance/AbstractAccTest.java @@ -44,6 +44,7 @@ public abstract class AbstractAccTest { } public static void resetDb(boolean dropTables) throws SQLException { + DataSource dataSource = TaskanaEngineTestConfiguration.getDataSource(); String schemaName = TaskanaEngineTestConfiguration.getSchemaName(); SampleDataGenerator sampleDataGenerator = new SampleDataGenerator(dataSource, schemaName); diff --git a/lib/taskana-core/src/test/java/acceptance/task/SelectAndClaimTaskAccTest.java b/lib/taskana-core/src/test/java/acceptance/task/SelectAndClaimTaskAccTest.java new file mode 100644 index 000000000..01324d32a --- /dev/null +++ b/lib/taskana-core/src/test/java/acceptance/task/SelectAndClaimTaskAccTest.java @@ -0,0 +1,108 @@ +package acceptance.task; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import acceptance.AbstractAccTest; +import java.security.PrivilegedAction; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.security.auth.Subject; +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import pro.taskana.common.api.BaseQuery.SortDirection; +import pro.taskana.common.api.exceptions.SystemException; +import pro.taskana.common.internal.security.JaasExtension; +import pro.taskana.common.internal.security.UserPrincipal; +import pro.taskana.common.internal.security.WithAccessId; +import pro.taskana.common.internal.util.CheckedConsumer; +import pro.taskana.task.api.TaskQuery; +import pro.taskana.task.api.TaskService; +import pro.taskana.task.api.models.Task; + +@ExtendWith(JaasExtension.class) +class SelectAndClaimTaskAccTest extends AbstractAccTest { + + @Test + void should_claimDifferentTasks_For_ConcurrentSelectAndClaimCalls() throws Exception { + + List selectedAndClaimedTasks = Collections.synchronizedList(new ArrayList<>()); + + List accessIds = + Collections.synchronizedList( + Stream.of("admin", "teamlead-1", "teamlead-2", "taskadmin") + .collect(Collectors.toList())); + + Runnable test = getRunnableTest(selectedAndClaimedTasks, accessIds); + + Thread[] threads = new Thread[4]; + for (int i = 0; i < threads.length; i++) { + threads[i] = new Thread(test); + threads[i].start(); + } + for (int i = 0; i < threads.length; i++) { + threads[i].join(); + } + + assertThat(selectedAndClaimedTasks.stream().map(Task::getId)) + .containsExactlyInAnyOrder( + "TKI:000000000000000000000000000000000003", + "TKI:000000000000000000000000000000000004", + "TKI:000000000000000000000000000000000005", + "TKI:000000000000000000000000000000000006"); + + assertThat(selectedAndClaimedTasks.stream().map(Task::getOwner)) + .containsExactlyInAnyOrder("admin", "taskadmin", "teamlead-1", "teamlead-2"); + } + + @Test + @WithAccessId(user = "admin") + void should_ThrowException_When_TryingToSelectAndClaimNonExistingTask() throws Exception { + + TaskQuery query = taskanaEngine.getTaskService().createTaskQuery(); + query.idIn("notexisting"); + ThrowingCallable call = + () -> { + taskanaEngine.getTaskService().selectAndClaim(query); + }; + assertThatThrownBy(call) + .isInstanceOf(SystemException.class) + .hasMessageContaining( + "No tasks matched the specified filter and sorting options, " + + "task query returned nothing!"); + } + + private Runnable getRunnableTest(List selectedAndClaimedTasks, List accessIds) { + + Runnable test = + () -> { + Subject subject = new Subject(); + subject.getPrincipals().add(new UserPrincipal(accessIds.remove(0))); + + Consumer consumer = + CheckedConsumer.wrap( + taskService -> { + Task task = taskService.selectAndClaim(getTaskQuery()); + selectedAndClaimedTasks.add(task); + }); + PrivilegedAction action = + () -> { + consumer.accept(taskanaEngine.getTaskService()); + return null; + }; + Subject.doAs(subject, action); + }; + + return test; + } + + private TaskQuery getTaskQuery() { + return taskanaEngine.getTaskService().createTaskQuery().orderByTaskId(SortDirection.ASCENDING); + } +} diff --git a/rest/taskana-rest-spring-example-boot/src/main/resources/application-db2.properties b/rest/taskana-rest-spring-example-boot/src/main/resources/application-db2.properties new file mode 100644 index 000000000..78fd35a8d --- /dev/null +++ b/rest/taskana-rest-spring-example-boot/src/main/resources/application-db2.properties @@ -0,0 +1,79 @@ +logging.level.pro.taskana=INFO +logging.level.org.springframework.security=INFO + +server.servlet.context-path=/taskana + +######## Taskana DB ####### +######## h2 configuration ######## +########spring.datasource.url=jdbc:h2:mem:taskana;IGNORECASE=TRUE;LOCK_MODE=0 +########spring.datasource.driverClassName=org.h2.Driver +########spring.datasource.username=sa +########spring.datasource.password=sa +taskana.schemaName=TASKANA + +######## h2 console configuration ######## +########spring.h2.console.enabled=true +########spring.h2.console.path=/h2-console + +######## db2 configuration ######## +spring.datasource.driverClassName=com.ibm.db2.jcc.DB2Driver +spring.datasource.url=jdbc:db2://localhost:50000/tskdb +spring.datasource.username=db2inst1 +spring.datasource.password=db2inst1-pwd + +######## Postgres configuration ######## +########spring.datasource.url=jdbc:postgresql://localhost/taskana +########spring.datasource.driverClassName=org.postgresql.Driver +########spring.datasource.username=postgres +########spring.datasource.password=1234 +########spring.jpa.generate-ddl=true +########spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true +####### property that control rest api security deploy use true for no security. +devMode=false + +####### property that control if the database is cleaned and sample data is generated +generateSampleData=true + +####### JobScheduler cron expression that specifies when the JobSchedler runs +taskana.jobscheduler.async.cron=0 * * * * * +####### cache static resources properties +spring.resources.cache.cachecontrol.cache-private=true +####### for upload of big workbasket- or classification-files +spring.servlet.multipart.max-file-size=10MB +spring.servlet.multipart.max-request-size=10MB + +spring.main.allow-bean-definition-overriding=true + +server.tomcat.max-http-post-size=-1 +server.tomcat.max-save-post-size=-1 +server.tomcat.max-swallow-size=-1 +####### tomcat is not detecting the x-forward headers from bluemix as a trustworthy proxy +server.tomcat.internal-proxies=.* +server.use-forward-headers=true + +####### Properties for AccessIdController to connect to LDAP +taskana.ldap.serverUrl=ldap://localhost:10389 +taskana.ldap.bindDn=uid=admin +taskana.ldap.bindPassword=secret +taskana.ldap.baseDn=ou=Test,O=TASKANA +taskana.ldap.userSearchBase=cn=users +taskana.ldap.userSearchFilterName=objectclass +taskana.ldap.userSearchFilterValue=person +taskana.ldap.userFirstnameAttribute=givenName +taskana.ldap.userLastnameAttribute=sn +taskana.ldap.userIdAttribute=uid +taskana.ldap.groupSearchBase=cn=groups +taskana.ldap.groupSearchFilterName=objectclass +taskana.ldap.groupSearchFilterValue=groupOfUniqueNames +taskana.ldap.groupNameAttribute=cn +taskana.ldap.minSearchForLength=3 +taskana.ldap.maxNumberOfReturnedAccessIds=50 +taskana.ldap.groupsOfUser=memberUid + +# Embedded Spring LDAP server +spring.ldap.embedded.base-dn= OU=Test,O=TASKANA +spring.ldap.embedded.credential.username= uid=admin +spring.ldap.embedded.credential.password= secret +spring.ldap.embedded.ldif=classpath:taskana-example.ldif +spring.ldap.embedded.port= 10389 +spring.ldap.embedded.validation.enabled=false diff --git a/rest/taskana-rest-spring-example-boot/src/main/resources/application-postgres.properties b/rest/taskana-rest-spring-example-boot/src/main/resources/application-postgres.properties index 60da30d73..b0af5e8dc 100644 --- a/rest/taskana-rest-spring-example-boot/src/main/resources/application-postgres.properties +++ b/rest/taskana-rest-spring-example-boot/src/main/resources/application-postgres.properties @@ -7,11 +7,19 @@ server.servlet.context-path=/taskana ########spring.datasource.driverClassName=org.h2.Driver ########spring.datasource.username=sa ########spring.datasource.password=sa -taskana.schemaName=taskana +taskana.schemaName=TASKANA + ######## h2 console configuration ######## ########spring.h2.console.enabled=true ########spring.h2.console.path=/h2-console +######## db2 configuration ######## +########spring.datasource.driverClassName=com.ibm.db2.jcc.DB2Driver +########spring.datasource.url=jdbc:db2://localhost:50000/tskdb +########spring.datasource.username=db2inst1 +########spring.datasource.password=db2inst1-pwd +########taskana.schemaName=TASKANA + ######## Postgres configuration ######## spring.datasource.url=jdbc:postgresql://localhost/postgres spring.datasource.driverClassName=org.postgresql.Driver diff --git a/rest/taskana-rest-spring-example-boot/src/main/resources/application.properties b/rest/taskana-rest-spring-example-boot/src/main/resources/application.properties index a8b493b1a..70477f14a 100644 --- a/rest/taskana-rest-spring-example-boot/src/main/resources/application.properties +++ b/rest/taskana-rest-spring-example-boot/src/main/resources/application.properties @@ -15,6 +15,12 @@ taskana.schemaName=TASKANA ########spring.h2.console.enabled=true ########spring.h2.console.path=/h2-console +######## db2 configuration ######## +########spring.datasource.driverClassName=com.ibm.db2.jcc.DB2Driver +########spring.datasource.url=jdbc:db2://localhost:50000/tskdb +########spring.datasource.username=db2inst1 +########spring.datasource.password=db2inst1-pwd + ######## Postgres configuration ######## ########spring.datasource.url=jdbc:postgresql://localhost/taskana ########spring.datasource.driverClassName=org.postgresql.Driver diff --git a/rest/taskana-rest-spring/src/main/java/pro/taskana/common/rest/Mapping.java b/rest/taskana-rest-spring/src/main/java/pro/taskana/common/rest/Mapping.java index a89f4a17c..b88cd7053 100644 --- a/rest/taskana-rest-spring/src/main/java/pro/taskana/common/rest/Mapping.java +++ b/rest/taskana-rest-spring/src/main/java/pro/taskana/common/rest/Mapping.java @@ -34,6 +34,7 @@ public final class Mapping { public static final String URL_TASK_COMMENTS = URL_TASKS + "/comments"; public static final String URL_TASK_COMMENT = URL_TASK_COMMENTS + "/{taskCommentId}"; public static final String URL_TASKS_ID_CLAIM = URL_TASKS_ID + "/claim"; + public static final String URL_TASKS_ID_SELECT_AND_CLAIM = URL_TASKS + "/select-and-claim"; public static final String URL_TASKS_ID_COMPLETE = URL_TASKS_ID + "/complete"; public static final String URL_TASKS_ID_TRANSFER_WORKBASKETID = URL_TASKS_ID + "/transfer/{workbasketId}"; diff --git a/rest/taskana-rest-spring/src/main/java/pro/taskana/task/rest/TaskController.java b/rest/taskana-rest-spring/src/main/java/pro/taskana/task/rest/TaskController.java index 59c20c7ec..116d4c5e7 100644 --- a/rest/taskana-rest-spring/src/main/java/pro/taskana/task/rest/TaskController.java +++ b/rest/taskana-rest-spring/src/main/java/pro/taskana/task/rest/TaskController.java @@ -85,6 +85,7 @@ public class TaskController extends AbstractPagingController { private static final String EXTERNAL_ID = "external-id"; private static final String WILDCARD_SEARCH_VALUE = "wildcard-search-value"; private static final String WILDCARD_SEARCH_FIELDS = "wildcard-search-fields"; + private static final String CUSTOM = "custom"; private static final String SORT_BY = "sort-by"; private static final String SORT_DIRECTION = "order"; @@ -166,6 +167,30 @@ public class TaskController extends AbstractPagingController { return result; } + @PostMapping(path = Mapping.URL_TASKS_ID_SELECT_AND_CLAIM) + @Transactional(rollbackFor = Exception.class) + public ResponseEntity selectAndClaimTask( + @RequestParam MultiValueMap params) + throws TaskNotFoundException, InvalidStateException, InvalidOwnerException, + NotAuthorizedException, InvalidArgumentException { + + LOGGER.debug("Entry to selectAndClaimTask"); + + TaskQuery query = taskService.createTaskQuery(); + query = applyFilterParams(query, params); + query = applySortingParams(query, params); + + Task selectedAndClaimedTask = taskService.selectAndClaim(query); + + ResponseEntity result = + ResponseEntity.ok(taskRepresentationModelAssembler.toModel(selectedAndClaimedTask)); + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Exit from selectAndClaimTask(), returning {}", result); + } + + return result; + } + @DeleteMapping(path = Mapping.URL_TASKS_ID_CLAIM) @Transactional(rollbackFor = Exception.class) public ResponseEntity cancelClaimTask(@PathVariable String taskId) @@ -414,8 +439,15 @@ public class TaskController extends AbstractPagingController { params.remove(EXTERNAL_ID); } - if (LOGGER.isDebugEnabled()) { - LOGGER.debug("Exit from applyFilterParams(), returning {}", taskQuery); + for (int i = 1; i < 17; i++) { + if (params.containsKey(CUSTOM + i)) { + String[] customValues = extractCommaSeparatedFields(params.get(CUSTOM + i)); + taskQuery.customAttributeIn(String.valueOf(i), customValues); + if (LOGGER.isDebugEnabled()) { + params.remove(CUSTOM + i); + LOGGER.debug("Exit from applyFilterParams(), returning {}", taskQuery); + } + } } return taskQuery; diff --git a/rest/taskana-rest-spring/src/test/java/pro/taskana/doc/api/TaskControllerRestDocumentation.java b/rest/taskana-rest-spring/src/test/java/pro/taskana/doc/api/TaskControllerRestDocumentation.java index 70318a911..be810fefc 100644 --- a/rest/taskana-rest-spring/src/test/java/pro/taskana/doc/api/TaskControllerRestDocumentation.java +++ b/rest/taskana-rest-spring/src/test/java/pro/taskana/doc/api/TaskControllerRestDocumentation.java @@ -539,6 +539,20 @@ class TaskControllerRestDocumentation extends BaseRestDocumentation { responseFields(taskFieldDescriptors))); } + @Test + void selectAndClaimTaskDocTest() throws Exception { + this.mockMvc + .perform( + RestDocumentationRequestBuilders.post( + restHelper.toUrl(Mapping.URL_TASKS_ID_SELECT_AND_CLAIM) + "?custom14=abc") + .accept("application/hal+json") + .header("Authorization", ADMIN_CREDENTIALS)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andDo( + MockMvcRestDocumentation.document( + "SelectAndClaimTaskDocTest", responseFields(taskFieldDescriptors))); + } + @Test void createAndDeleteTaskDocTest() throws Exception { diff --git a/rest/taskana-rest-spring/src/test/resources/asciidoc/rest-api.adoc b/rest/taskana-rest-spring/src/test/resources/asciidoc/rest-api.adoc index 7c99daed1..e48f7b866 100644 --- a/rest/taskana-rest-spring/src/test/resources/asciidoc/rest-api.adoc +++ b/rest/taskana-rest-spring/src/test/resources/asciidoc/rest-api.adoc @@ -230,6 +230,24 @@ include::{snippets}/CancelClaimTaskDocTest/http-response.adoc[] The response-body is essentially the same as for getting a single task. + Therefore for the response fields you can refer to the <>. +=== Select and Claim a task + +A `POST` request is used to select and claim a task + +==== Example Request + +Note the empty request-body in the example. + +include::{snippets}/SelectAndClaimTaskDocTest/http-request.adoc[] + +==== Example Response + +include::{snippets}/SelectAndClaimTaskDocTest/http-response.adoc[] + +==== Response Structure + +The response-body is essentially the same as for getting a single task. + +Therefore for the response fields you can refer to the <>. === Complete a task