diff --git a/custom/acme/loan/job/src/main/resources/db/custom-changelog/0001_acme_loan_job.xml b/custom/acme/loan/job/src/main/resources/db/custom-changelog/0001_acme_loan_job.xml index f466fdaf3..b3a8f5443 100644 --- a/custom/acme/loan/job/src/main/resources/db/custom-changelog/0001_acme_loan_job.xml +++ b/custom/acme/loan/job/src/main/resources/db/custom-changelog/0001_acme_loan_job.xml @@ -42,4 +42,10 @@ + + + + name='Acme Noop Job' + + diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/api/IdTypeResolver.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/api/IdTypeResolver.java new file mode 100644 index 000000000..28983324d --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/api/IdTypeResolver.java @@ -0,0 +1,74 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.core.api; + +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.service.DefaultOption; + +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public abstract class IdTypeResolver { + + public enum IdType implements DefaultOption { + + ID, // + EXTERNAL_ID, // + SHORT_NAME,; // + + @Override + public boolean isDefault() { + return this == ID; + } + + } + + @NotNull + public static IdType resolveDefault() { + return IdType.ID; + } + + public static IdType resolve(String idType) { + return resolve(IdType.class, idType); + } + + public static > T resolve(@NotNull Class clazz, String idType) { + if (idType == null) { + return clazz.isAssignableFrom(DefaultOption.class) ? (T) DefaultOption.getDefault((Class) clazz) : null; + } + idType = formatIdType(idType); + try { + return Enum.valueOf(clazz, idType); + } catch (IllegalArgumentException e) { + throw resolveFailed(idType, e); + } + } + + public static String formatIdType(String idType) { + return idType == null ? null : idType.replaceAll("-", "_").toUpperCase(); + } + + public static RuntimeException resolveFailed(String idType, Exception e) { + return new PlatformApiDataValidationException("error.msg.id.type.not.found", "Provided type " + idType + " is not supported", + "idType", e, idType); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/exception/MultiException.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/exception/MultiException.java index 17276de5a..b9fbab4dc 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/exception/MultiException.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/exception/MultiException.java @@ -51,14 +51,18 @@ public class MultiException extends Exception { private final List throwables; @SuppressFBWarnings("CT_CONSTRUCTOR_THROW") - public MultiException(List problems) { - super("MultiException with " + problems.size() + " contained causes (details available)"); + public MultiException(String message, List problems) { + super(message); if (problems.isEmpty()) { throw new IllegalArgumentException("List of Throwables must not be empty"); } this.throwables = new ArrayList<>(problems); } + public MultiException(List problems) { + this("MultiException with " + problems.size() + " contained causes (details available)", problems); + } + public List getCauses() { return Collections.unmodifiableList(throwables); } diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/exception/PlatformApiDataValidationException.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/exception/PlatformApiDataValidationException.java index f20b4cf34..6faf601db 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/exception/PlatformApiDataValidationException.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/exception/PlatformApiDataValidationException.java @@ -56,6 +56,18 @@ public class PlatformApiDataValidationException extends AbstractPlatformExceptio this.errors = errors; } + public PlatformApiDataValidationException(String messageCode, String userMessage, String parameterName, Throwable cause, + final Object... userMessageArgs) { + this("validation.msg.validation.errors.exist", "Validation errors exist.", + List.of(ApiParameterError.parameterError(messageCode, userMessage, parameterName, userMessageArgs)), cause); + } + + public PlatformApiDataValidationException(String messageCode, String userMessage, String parameterName, + final Object... userMessageArgs) { + this("validation.msg.validation.errors.exist", "Validation errors exist.", + List.of(ApiParameterError.parameterError(messageCode, userMessage, parameterName, userMessageArgs)), null); + } + public List getErrors() { return this.errors; } diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/DefaultOption.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/DefaultOption.java new file mode 100644 index 000000000..8249b961f --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/DefaultOption.java @@ -0,0 +1,33 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.core.service; + +public interface DefaultOption { + + boolean isDefault(); + + static & DefaultOption> T getDefault(Class clazz) { + for (T enumConstant : clazz.getEnumConstants()) { + if (enumConstant.isDefault()) { + return enumConstant; + } + } + return null; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/data/JobDetailData.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/data/JobDetailData.java index e11b03dae..3e2b63692 100755 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/data/JobDetailData.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/data/JobDetailData.java @@ -34,6 +34,9 @@ public class JobDetailData { @SuppressWarnings("unused") private String displayName; + @SuppressWarnings("unused") + private String shortName; + @SuppressWarnings("unused") private Date nextRunTime; @@ -50,4 +53,22 @@ public class JobDetailData { @SuppressWarnings("unused") private JobDetailHistoryData lastRunHistory; + + public JobDetailData(Long jobId, String displayName, String shortName, Date nextRunTime, String initializingError, + String cronExpression, boolean active, boolean currentlyRunning, Long version, Date jobRunStartTime, Date jobRunEndTime, + String status, String jobRunErrorMessage, String triggerType, String jobRunErrorLog) { + this.jobId = jobId; + this.displayName = displayName; + this.shortName = shortName; + this.nextRunTime = nextRunTime; + this.initializingError = initializingError; + this.cronExpression = cronExpression; + this.active = active; + this.currentlyRunning = currentlyRunning; + if (version != null) { + this.lastRunHistory = new JobDetailHistoryData().setVersion(version).setJobRunStartTime(jobRunStartTime) + .setJobRunEndTime(jobRunEndTime).setStatus(status).setJobRunErrorMessage(jobRunErrorMessage).setTriggerType(triggerType) + .setJobRunErrorLog(jobRunErrorLog); + } + } } diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/exception/JobExecutionException.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/exception/JobExecutionException.java index 02dc85776..bf7d29576 100755 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/exception/JobExecutionException.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/exception/JobExecutionException.java @@ -18,11 +18,23 @@ */ package org.apache.fineract.infrastructure.jobs.exception; +import static java.util.stream.Collectors.mapping; +import static java.util.stream.Collectors.toList; + +import jakarta.validation.constraints.NotNull; +import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.apache.fineract.infrastructure.core.exception.MultiException; public class JobExecutionException extends MultiException { + public JobExecutionException(String message, List problems) { + super(message, problems); + } + public JobExecutionException(List problems) { super(problems); } @@ -30,4 +42,27 @@ public class JobExecutionException extends MultiException { public JobExecutionException(MultiException multiException) { super(multiException.getCauses()); } + + public static void throwErrors(@NotNull Map> errorMap) throws JobExecutionException { + int size = errorMap.size(); + if (size == 0) { + return; + } + List errors; + StringBuilder msg = new StringBuilder("Job failed on "); + Stream>> entryStream = errorMap.entrySet().stream().filter(e -> e.getValue() != null); + if (size < 10) { + errors = new ArrayList<>(errorMap.keySet()); + Map>> errorTypes = entryStream + .collect(Collectors.groupingBy(e -> e.getKey().getClass().getSimpleName(), mapping(Map.Entry::getValue, toList()))); + errorTypes.forEach((key, value) -> msg.append(key).append(':').append(value.size()).append(':') + .append(value.stream().flatMap(List::stream).collect(Collectors.joining(","))).append(";\n")); + } else { + errors = List.of(errorMap.keySet().iterator().next()); + Map errorTypes = entryStream + .collect(Collectors.groupingBy(e -> e.getKey().getClass().getSimpleName(), Collectors.counting())); + errorTypes.forEach((key, value) -> msg.append(key).append(':').append(value).append(";\n")); + } + throw new JobExecutionException(msg.toString(), errors); + } } diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/SchedulerJobRunnerReadService.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/SchedulerJobRunnerReadService.java index f6567f717..ba40b7deb 100755 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/SchedulerJobRunnerReadService.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/SchedulerJobRunnerReadService.java @@ -18,7 +18,9 @@ */ package org.apache.fineract.infrastructure.jobs.service; +import jakarta.validation.constraints.NotNull; import java.util.List; +import org.apache.fineract.infrastructure.core.api.IdTypeResolver; import org.apache.fineract.infrastructure.core.service.Page; import org.apache.fineract.infrastructure.core.service.SearchParameters; import org.apache.fineract.infrastructure.jobs.data.JobDetailData; @@ -26,14 +28,15 @@ import org.apache.fineract.infrastructure.jobs.data.JobDetailHistoryData; public interface SchedulerJobRunnerReadService { - List findAllJobDeatils(); + List findAllJobDetails(); - JobDetailData retrieveOne(Long jobId); + JobDetailData retrieveOne(@NotNull IdTypeResolver.IdType idType, String identifier); - JobDetailData retrieveOneByName(String jobName); + Page retrieveJobHistory(@NotNull IdTypeResolver.IdType idType, String identifier, + SearchParameters searchParameters); - Page retrieveJobHistory(Long jobId, SearchParameters searchParameters); + @NotNull + Long retrieveId(@NotNull IdTypeResolver.IdType idType, String identifier); boolean isUpdatesAllowed(); - } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/config/CacheConfiguration.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/config/CacheConfiguration.java index 4465782c0..a9a3e485e 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/config/CacheConfiguration.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/config/CacheConfiguration.java @@ -34,7 +34,7 @@ public class CacheConfiguration { public CacheManager cacheManager() { SimpleCacheManager simpleCacheManager = new SimpleCacheManager(); simpleCacheManager.setCaches(List.of(new ConcurrentMapCache("paymentTypesByName"), // - new ConcurrentMapCache("jobsByName"), // + new ConcurrentMapCache("jobsByShortName"), // new ConcurrentMapCache("loanProductsByName"), // new ConcurrentMapCache("accountTypesByName")));// return simpleCacheManager; diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/job/DefaultJob.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/job/DefaultJob.java index cc80f2f3f..58d2a9100 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/job/DefaultJob.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/job/DefaultJob.java @@ -20,18 +20,28 @@ package org.apache.fineract.test.data.job; public enum DefaultJob implements Job { - ADD_ACCRUAL_TRANSACTIONS("Add Accrual Transactions"), ADD_PERIODIC_ACCRUAL_TRANSACTIONS( - "Add Periodic Accrual Transactions"), INCREASE_BUSINESS_DAY("Increase Business Date by 1 day"), LOAN_DELINQUENCY_CLASSIFICATION( - "Loan Delinquency Classification"), LOAN_COB("Loan COB"); + ADD_ACCRUAL_TRANSACTIONS("Add Accrual Transactions", "LA_AATR"), // + ADD_PERIODIC_ACCRUAL_TRANSACTIONS("Add Periodic Accrual Transactions", "ACC_APTR"), // + INCREASE_BUSINESS_DAY("Increase Business Date by 1 day", "BDT_INC1"), // + LOAN_DELINQUENCY_CLASSIFICATION("Loan Delinquency Classification", "LA_DECL"), // + LOAN_COB("Loan COB", "LA_ECOB"), // + ; private final String customName; + private final String shortName; - DefaultJob(String customName) { + DefaultJob(String customName, String shortName) { this.customName = customName; + this.shortName = shortName; } @Override public String getName() { return customName; } + + @Override + public String getShortName() { + return shortName; + } } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/job/Job.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/job/Job.java index 76354b282..54cc1fd4e 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/job/Job.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/job/Job.java @@ -21,4 +21,6 @@ package org.apache.fineract.test.data.job; public interface Job { String getName(); + + String getShortName(); } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/job/JobResolver.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/job/JobResolver.java index affecbc6f..4e562ce3a 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/job/JobResolver.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/job/JobResolver.java @@ -19,7 +19,6 @@ package org.apache.fineract.test.data.job; import java.io.IOException; -import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.client.models.GetJobsResponse; @@ -35,20 +34,16 @@ public class JobResolver { private final SchedulerJobApi schedulerJobApi; - @Cacheable(key = "#job.getName()", value = "jobsByName") + @Cacheable(key = "#job.getShortName()", value = "jobsByShortName") public long resolve(Job job) { try { - String jobName = job.getName(); - log.debug("Resolving job by name [{}]", jobName); - Response> response = schedulerJobApi.retrieveAll8().execute(); + String shortName = job.getShortName(); + log.debug("Resolving job by short-name [{}]", shortName); + Response response = schedulerJobApi.retrieveByShortName(shortName).execute(); if (!response.isSuccessful()) { - throw new IllegalStateException("Unable to get jobs list. Status code was HTTP " + response.code()); + throw new IllegalStateException("Unable to get job. Status code was HTTP " + response.code()); } - - List jobsResponses = response.body(); - GetJobsResponse foundJob = jobsResponses.stream().filter(j -> jobName.equals(j.getDisplayName())).findAny() - .orElseThrow(() -> new IllegalArgumentException("Job [%s] not found".formatted(jobName))); - return foundJob.getJobId(); + return response.body().getJobId(); } catch (IOException e) { throw new RuntimeException(e); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/api/SchedulerJobApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/api/SchedulerJobApiConstants.java index 0a09bce0b..cf4487aaa 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/api/SchedulerJobApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/api/SchedulerJobApiConstants.java @@ -54,6 +54,7 @@ public final class SchedulerJobApiConstants { public static final String COMMAND_START_SCHEDULER = "start"; public static final String COMMAND = "command"; public static final String JOB_ID = "jobId"; + public static final String SHORT_NAME_PARAM = "short-name"; public static final String JOB_RUN_HISTORY = "runhistory"; public static final String SCHEDULER_STATUS_PATH = "scheduler"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/api/SchedulerJobApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/api/SchedulerJobApiResource.java index efb9b06d0..7469dd70c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/api/SchedulerJobApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/api/SchedulerJobApiResource.java @@ -18,6 +18,11 @@ */ package org.apache.fineract.infrastructure.jobs.api; +import static org.apache.fineract.infrastructure.jobs.api.SchedulerJobApiConstants.JOB_DETAIL_RESPONSE_DATA_PARAMETERS; +import static org.apache.fineract.infrastructure.jobs.api.SchedulerJobApiConstants.JOB_HISTORY_RESPONSE_DATA_PARAMETERS; +import static org.apache.fineract.infrastructure.jobs.api.SchedulerJobApiConstants.SCHEDULER_RESOURCE_NAME; +import static org.apache.fineract.infrastructure.jobs.api.SchedulerJobApiConstants.SHORT_NAME_PARAM; + import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; @@ -27,6 +32,7 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotNull; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; @@ -41,12 +47,14 @@ import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; import jakarta.ws.rs.core.UriInfo; import java.util.List; +import java.util.Objects; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper; +import org.apache.fineract.infrastructure.core.api.IdTypeResolver; import org.apache.fineract.infrastructure.core.config.FineractProperties; import org.apache.fineract.infrastructure.core.data.ApiGlobalErrorResponse; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; @@ -88,10 +96,10 @@ public class SchedulerJobApiResource { @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = SchedulerJobApiResourceSwagger.GetJobsResponse.class)))) }) public String retrieveAll(@Context final UriInfo uriInfo) { - this.context.authenticatedUser().validateHasReadPermission(SchedulerJobApiConstants.SCHEDULER_RESOURCE_NAME); - final List jobDetailDatas = this.schedulerJobRunnerReadService.findAllJobDeatils(); + this.context.authenticatedUser().validateHasReadPermission(SCHEDULER_RESOURCE_NAME); + final List jobDetailDatas = this.schedulerJobRunnerReadService.findAllJobDetails(); final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); - return this.toApiJsonSerializer.serialize(settings, jobDetailDatas, SchedulerJobApiConstants.JOB_DETAIL_RESPONSE_DATA_PARAMETERS); + return this.toApiJsonSerializer.serialize(settings, jobDetailDatas, JOB_DETAIL_RESPONSE_DATA_PARAMETERS); } @GET @@ -101,10 +109,19 @@ public class SchedulerJobApiResource { @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = SchedulerJobApiResourceSwagger.GetJobsResponse.class))) }) public String retrieveOne(@PathParam(SchedulerJobApiConstants.JOB_ID) @Parameter(description = "jobId") final Long jobId, @Context final UriInfo uriInfo) { - this.context.authenticatedUser().validateHasReadPermission(SchedulerJobApiConstants.SCHEDULER_RESOURCE_NAME); - final JobDetailData jobDetailData = this.schedulerJobRunnerReadService.retrieveOne(jobId); - final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); - return this.toApiJsonSerializer.serialize(settings, jobDetailData, SchedulerJobApiConstants.JOB_DETAIL_RESPONSE_DATA_PARAMETERS); + return retrieveOne(IdTypeResolver.resolveDefault(), Objects.toString(jobId, null), uriInfo); + } + + @GET + @Path(SHORT_NAME_PARAM + "/{shortName}") + @Operation(summary = "Retrieve a Job", description = "Returns the details of a Job bu shortName.\n" + "\n" + "Example Requests:\n" + + "\n" + "jobs/short-name/SA_PINT") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = SchedulerJobApiResourceSwagger.GetJobsResponse.class))) }) + public String retrieveByShortName( + @PathParam("shortName") @Parameter(required = true, description = SchedulerJobApiConstants.SHORT_NAME_PARAM) final String shortName, + @Context final UriInfo uriInfo) { + return retrieveOne(IdTypeResolver.resolve(SHORT_NAME_PARAM), shortName, uriInfo); } @GET @@ -118,16 +135,22 @@ public class SchedulerJobApiResource { @QueryParam("limit") @Parameter(description = "limit") final Integer limit, @QueryParam("orderBy") @Parameter(description = "orderBy") final String orderBy, @QueryParam("sortOrder") @Parameter(description = "sortOrder") final String sortOrder) { - this.context.authenticatedUser().validateHasReadPermission(SchedulerJobApiConstants.SCHEDULER_RESOURCE_NAME); - sqlValidator.validate(orderBy); - sqlValidator.validate(sortOrder); - final SearchParameters searchParameters = SearchParameters.builder().limit(limit).offset(offset).orderBy(orderBy) - .sortOrder(sortOrder).build(); - final Page jobhistoryDetailData = this.schedulerJobRunnerReadService.retrieveJobHistory(jobId, - searchParameters); - final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); - return this.jobHistoryToApiJsonSerializer.serialize(settings, jobhistoryDetailData, - SchedulerJobApiConstants.JOB_HISTORY_RESPONSE_DATA_PARAMETERS); + return retrieveHistory(IdTypeResolver.resolveDefault(), Objects.toString(jobId, null), offset, limit, orderBy, sortOrder, uriInfo); + } + + @GET + @Path(SHORT_NAME_PARAM + "/{shortName}/" + SchedulerJobApiConstants.JOB_RUN_HISTORY) + @Operation(summary = "Retrieve Job Run History", description = "Example Requests:\n" + "\n" + + "jobs/short-name/SA_PINT/runhistory?offset=0&limit=200") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = SchedulerJobApiResourceSwagger.GetJobsJobIDJobRunHistoryResponse.class))) }) + public String retrieveHistoryByShortName(@Context final UriInfo uriInfo, + @PathParam("shortName") @Parameter(required = true, description = SchedulerJobApiConstants.SHORT_NAME_PARAM) final String shortName, + @QueryParam("offset") @Parameter(description = "offset") final Integer offset, + @QueryParam("limit") @Parameter(description = "limit") final Integer limit, + @QueryParam("orderBy") @Parameter(description = "orderBy") final String orderBy, + @QueryParam("sortOrder") @Parameter(description = "sortOrder") final String sortOrder) { + return retrieveHistory(IdTypeResolver.resolve(SHORT_NAME_PARAM), shortName, offset, limit, orderBy, sortOrder, uriInfo); } @POST @@ -138,17 +161,78 @@ public class SchedulerJobApiResource { public Response executeJob(@PathParam(SchedulerJobApiConstants.JOB_ID) @Parameter(description = "jobId") final Long jobId, @QueryParam(SchedulerJobApiConstants.COMMAND) @Parameter(description = "command") final String commandParam, @Parameter(hidden = true) final String jsonRequestBody) { - // check the logged in user have permissions to execute scheduler jobs + return executeJob(IdTypeResolver.resolveDefault(), Objects.toString(jobId, null), commandParam, jsonRequestBody); + } + + @POST + @Path(SHORT_NAME_PARAM + "/{shortName}") + @Operation(summary = "Run a Job", description = "Manually Execute Specific Job.") + @RequestBody(content = @Content(schema = @Schema(implementation = SchedulerJobApiResourceSwagger.ExecuteJobRequest.class))) + @ApiResponses({ @ApiResponse(responseCode = "200", description = "POST: jobs/short-name/SA_PINT?command=executeJob") }) + public Response executeJobByShortName( + @PathParam("shortName") @Parameter(required = true, description = SchedulerJobApiConstants.SHORT_NAME_PARAM) final String shortName, + @QueryParam(SchedulerJobApiConstants.COMMAND) @Parameter(description = "command") final String commandParam, + @Parameter(hidden = true) final String jsonRequestBody) { + return executeJob(IdTypeResolver.resolve(SHORT_NAME_PARAM), shortName, commandParam, jsonRequestBody); + } + + @PUT + @Path("{" + SchedulerJobApiConstants.JOB_ID + "}") + @Operation(summary = "Update a Job", description = "Updates the details of a job.") + @RequestBody(required = true, content = @Content(schema = @Schema(implementation = SchedulerJobApiResourceSwagger.PutJobsJobIDRequest.class))) + @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) + public String updateJobDetail(@PathParam(SchedulerJobApiConstants.JOB_ID) @Parameter(description = "jobId") final Long jobId, + @Parameter(hidden = true) final String jsonRequestBody) { + return updateJobDetail(IdTypeResolver.resolveDefault(), Objects.toString(jobId, null), jsonRequestBody); + } + + @PUT + @Path(SHORT_NAME_PARAM + "/{shortName}") + @Operation(summary = "Update a Job", description = "Updates the details of a job.") + @RequestBody(required = true, content = @Content(schema = @Schema(implementation = SchedulerJobApiResourceSwagger.PutJobsJobIDRequest.class))) + @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) + public String updateJobDetailByShortName( + @PathParam("shortName") @Parameter(required = true, description = SchedulerJobApiConstants.SHORT_NAME_PARAM) final String shortName, + @Parameter(hidden = true) final String jsonRequestBody) { + return updateJobDetail(IdTypeResolver.resolve(SHORT_NAME_PARAM), shortName, jsonRequestBody); + } + + private boolean is(final String commandParam, final String commandValue) { + return StringUtils.isNotBlank(commandParam) && commandParam.trim().equalsIgnoreCase(commandValue); + } + + private String retrieveOne(@NotNull IdTypeResolver.IdType idType, String identifier, UriInfo uriInfo) { + context.authenticatedUser().validateHasReadPermission(SCHEDULER_RESOURCE_NAME); + final JobDetailData jobDetailData = schedulerJobRunnerReadService.retrieveOne(idType, identifier); + final ApiRequestJsonSerializationSettings settings = apiRequestParameterHelper.process(uriInfo.getQueryParameters()); + return toApiJsonSerializer.serialize(settings, jobDetailData, JOB_DETAIL_RESPONSE_DATA_PARAMETERS); + } + + private String retrieveHistory(@NotNull IdTypeResolver.IdType idType, String identifier, Integer offset, Integer limit, String orderBy, + String sortOrder, UriInfo uriInfo) { + context.authenticatedUser().validateHasReadPermission(SCHEDULER_RESOURCE_NAME); + sqlValidator.validate(orderBy); + sqlValidator.validate(sortOrder); + final SearchParameters searchParameters = SearchParameters.builder().limit(limit).offset(offset).orderBy(orderBy) + .sortOrder(sortOrder).build(); + final Page jobHistoryData = schedulerJobRunnerReadService.retrieveJobHistory(idType, identifier, + searchParameters); + final ApiRequestJsonSerializationSettings settings = apiRequestParameterHelper.process(uriInfo.getQueryParameters()); + return jobHistoryToApiJsonSerializer.serialize(settings, jobHistoryData, JOB_HISTORY_RESPONSE_DATA_PARAMETERS); + } + + private Response executeJob(@NotNull IdTypeResolver.IdType idType, String identifier, String commandParam, String jsonRequestBody) { + // check the logged-in user have permissions to execute scheduler jobs Response response; if (fineractProperties.getMode().isBatchManagerEnabled()) { - final boolean hasNotPermission = this.context.authenticatedUser().hasNotPermissionForAnyOf("ALL_FUNCTIONS", - "EXECUTEJOB_SCHEDULER"); + final boolean hasNotPermission = context.authenticatedUser().hasNotPermissionForAnyOf("ALL_FUNCTIONS", "EXECUTEJOB_SCHEDULER"); if (hasNotPermission) { final String authorizationMessage = "User has no authority to execute scheduler jobs"; throw new NoAuthorizationException(authorizationMessage); } response = Response.status(400).build(); if (is(commandParam, SchedulerJobApiConstants.COMMAND_EXECUTE_JOB)) { + Long jobId = schedulerJobRunnerReadService.retrieveId(idType, identifier); jobRegisterService.executeJobWithParameters(jobId, jsonRequestBody); response = Response.status(202).build(); } else { @@ -161,14 +245,8 @@ public class SchedulerJobApiResource { return response; } - @PUT - @Path("{" + SchedulerJobApiConstants.JOB_ID + "}") - @Operation(summary = "Update a Job", description = "Updates the details of a job.") - @RequestBody(required = true, content = @Content(schema = @Schema(implementation = SchedulerJobApiResourceSwagger.PutJobsJobIDRequest.class))) - @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) - public String updateJobDetail(@PathParam(SchedulerJobApiConstants.JOB_ID) @Parameter(description = "jobId") final Long jobId, - @Parameter(hidden = true) final String jsonRequestBody) { - + private String updateJobDetail(@NotNull IdTypeResolver.IdType idType, String identifier, String jsonRequestBody) { + Long jobId = schedulerJobRunnerReadService.retrieveId(idType, identifier); final CommandWrapper commandRequest = new CommandWrapperBuilder() // .updateJobDetail(jobId) // .withJson(jsonRequestBody) // @@ -180,8 +258,4 @@ public class SchedulerJobApiResource { } return this.toApiJsonSerializer.serialize(result); } - - private boolean is(final String commandParam, final String commandValue) { - return StringUtils.isNotBlank(commandParam) && commandParam.trim().equalsIgnoreCase(commandValue); - } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/api/SchedulerJobApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/api/SchedulerJobApiResourceSwagger.java index 81bfec247..a981a4109 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/api/SchedulerJobApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/api/SchedulerJobApiResourceSwagger.java @@ -44,6 +44,8 @@ final class SchedulerJobApiResourceSwagger { public Long jobId; @Schema(example = "Update loan Summary") public String displayName; + @Schema(example = "LA_USUM") + public String shortName; @Schema(example = "") public Date nextRunTime; @Schema(example = "") diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/domain/ScheduledJobDetail.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/domain/ScheduledJobDetail.java index 036b8536b..a5b2d8d5a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/domain/ScheduledJobDetail.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/domain/ScheduledJobDetail.java @@ -23,6 +23,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.Table; import jakarta.persistence.Temporal; import jakarta.persistence.TemporalType; +import jakarta.persistence.UniqueConstraint; import java.util.Date; import java.util.LinkedHashMap; import java.util.Map; @@ -36,7 +37,7 @@ import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; import org.apache.fineract.infrastructure.jobs.api.SchedulerJobApiConstants; @Entity -@Table(name = "job") +@Table(name = "job", uniqueConstraints = { @UniqueConstraint(columnNames = { "short_name" }, name = "job_short_name_key") }) @Getter @Setter @NoArgsConstructor @@ -97,6 +98,9 @@ public class ScheduledJobDetail extends AbstractPersistableCustom { @Column(name = "is_misfired") private boolean triggerMisfired; + @Column(name = "short_name", nullable = false) + private String shortName; + public Map update(final JsonCommand command) { final Map actualChanges = new LinkedHashMap<>(9); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/domain/ScheduledJobDetailRepository.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/domain/ScheduledJobDetailRepository.java index 6440be79b..be3d4cc4b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/domain/ScheduledJobDetailRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/domain/ScheduledJobDetailRepository.java @@ -20,6 +20,8 @@ package org.apache.fineract.infrastructure.jobs.domain; import jakarta.persistence.LockModeType; import java.util.List; +import java.util.Optional; +import org.apache.fineract.infrastructure.jobs.data.JobDetailData; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Lock; @@ -47,4 +49,22 @@ public interface ScheduledJobDetailRepository ScheduledJobDetail findByJobName(String jobName); + String GET_DATA = "select new org.apache.fineract.infrastructure.jobs.data.JobDetailData(j.id, j.jobDisplayName, j.shortName, j.nextRunTime, " + + "j.errorLog, j.cronExpression, j.activeSchedular, j.currentlyRunning, " + + "jh.version, jh.startTime, jh.endTime, jh.status, jh.errorMessage, jh.triggerType, jh.errorLog) " + + "from ScheduledJobDetail j left join ScheduledJobRunHistory jh on jh.scheduledJobDetail = j and j.previousRunStartTime = jh.startTime "; + + @Query(GET_DATA + "where j.id = :jobId") + JobDetailData getDataById(@Param("jobId") Long jobId); + + @Query(GET_DATA + "where j.shortName = :shortName") + JobDetailData getDataByShortName(@Param("shortName") String shortName); + + @Query(GET_DATA + "order by j.id") + List getAllData(); + + boolean existsByShortName(String shortName); + + @Query("select j.id from ScheduledJobDetail j where j.shortName = :shortName") + Optional findIdByShortName(@Param("shortName") String shortName); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/exception/JobNotFoundException.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/exception/JobNotFoundException.java index becff1983..95c9b493e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/exception/JobNotFoundException.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/exception/JobNotFoundException.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.infrastructure.jobs.exception; +import org.apache.fineract.infrastructure.core.api.IdTypeResolver; import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException; import org.springframework.batch.core.launch.NoSuchJobException; import org.springframework.dao.EmptyResultDataAccessException; @@ -31,6 +32,10 @@ public class JobNotFoundException extends AbstractPlatformResourceNotFoundExcept super("error.msg.sheduler.job.id.invalid", "Job with identifier " + identifier + " does not exist", identifier); } + public JobNotFoundException(IdTypeResolver.IdType idType, String jobId) { + super("error.msg.sheduler.job.id.invalid", String.format("Job with %s: %s cannot be found", idType, jobId), idType, jobId); + } + public JobNotFoundException(final String identifier, NoSuchJobException e) { super("error.msg.sheduler.job.id.invalid", "Job with identifier " + identifier + " does not exist", identifier, e); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/SchedulerJobRunnerReadServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/SchedulerJobRunnerReadServiceImpl.java index 70ef390fe..8f811e6b8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/SchedulerJobRunnerReadServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/SchedulerJobRunnerReadServiceImpl.java @@ -18,22 +18,24 @@ */ package org.apache.fineract.infrastructure.jobs.service; +import jakarta.validation.constraints.NotNull; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Date; import java.util.List; import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.api.IdTypeResolver; import org.apache.fineract.infrastructure.core.service.Page; import org.apache.fineract.infrastructure.core.service.PaginationHelper; import org.apache.fineract.infrastructure.core.service.SearchParameters; import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; import org.apache.fineract.infrastructure.jobs.data.JobDetailData; import org.apache.fineract.infrastructure.jobs.data.JobDetailHistoryData; +import org.apache.fineract.infrastructure.jobs.domain.ScheduledJobDetailRepository; import org.apache.fineract.infrastructure.jobs.exception.JobNotFoundException; import org.apache.fineract.infrastructure.jobs.exception.OperationNotAllowedException; import org.apache.fineract.infrastructure.security.utils.ColumnValidator; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Service; @@ -47,59 +49,62 @@ public class SchedulerJobRunnerReadServiceImpl implements SchedulerJobRunnerRead private final JdbcTemplate jdbcTemplate; private final ColumnValidator columnValidator; private final DatabaseSpecificSQLGenerator sqlGenerator; + private final ScheduledJobDetailRepository jobDetailRepository; private final PaginationHelper paginationHelper; @Autowired public SchedulerJobRunnerReadServiceImpl(final JdbcTemplate jdbcTemplate, final ColumnValidator columnValidator, - DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) { + DatabaseSpecificSQLGenerator sqlGenerator, ScheduledJobDetailRepository jobDetailRepository, + PaginationHelper paginationHelper) { this.jdbcTemplate = jdbcTemplate; this.columnValidator = columnValidator; this.sqlGenerator = sqlGenerator; + this.jobDetailRepository = jobDetailRepository; this.paginationHelper = paginationHelper; } @Override - public List findAllJobDeatils() { - final JobDetailMapper detailMapper = new JobDetailMapper(sqlGenerator); - final String sql = detailMapper.schema(); - final List JobDeatils = this.jdbcTemplate.query(sql, detailMapper, new Object[] {}); - return JobDeatils; + public List findAllJobDetails() { + return jobDetailRepository.getAllData(); } @Override - public JobDetailData retrieveOne(final Long jobId) { - try { - final JobDetailMapper detailMapper = new JobDetailMapper(sqlGenerator); - final String sql = detailMapper.schema() + " where job.id=?"; - return this.jdbcTemplate.queryForObject(sql, detailMapper, new Object[] { jobId }); // NOSONAR - } catch (final EmptyResultDataAccessException e) { - throw new JobNotFoundException(String.valueOf(jobId), e); + public JobDetailData retrieveOne(@NotNull IdTypeResolver.IdType idType, String identifier) { + JobDetailData jobDetail = switch (idType) { + case ID -> jobDetailRepository.getDataById(Long.valueOf(identifier)); + case SHORT_NAME -> jobDetailRepository.getDataByShortName(identifier); + default -> null; + }; + if (jobDetail == null) { + throw new JobNotFoundException(idType, identifier); } + return jobDetail; } @Override - public JobDetailData retrieveOneByName(String jobName) { - try { - final JobDetailMapper detailMapper = new JobDetailMapper(sqlGenerator); - final String sql = detailMapper.schema() + " where job.name=?"; - return this.jdbcTemplate.queryForObject(sql, detailMapper, new Object[] { jobName }); // NOSONAR - } catch (final EmptyResultDataAccessException e) { - throw new JobNotFoundException(jobName, e); - } - } - - @Override - public Page retrieveJobHistory(final Long jobId, final SearchParameters searchParameters) { - if (!isJobExist(jobId)) { - throw new JobNotFoundException(String.valueOf(jobId)); + public Page retrieveJobHistory(@NotNull IdTypeResolver.IdType idType, String identifier, + SearchParameters searchParameters) { + if (!isJobExist(idType, identifier)) { + throw new JobNotFoundException(idType, identifier); } final JobHistoryMapper jobHistoryMapper = new JobHistoryMapper(sqlGenerator); - final StringBuilder sqlBuilder = new StringBuilder(200); - sqlBuilder.append("select " + sqlGenerator.calcFoundRows() + " "); - sqlBuilder.append(jobHistoryMapper.schema()); - sqlBuilder.append(" where job.id=?"); + final StringBuilder sqlBuilder = new StringBuilder("select " + sqlGenerator.calcFoundRows() + " ").append(jobHistoryMapper.schema()) + .append(" where job."); + Object idParam; + switch (idType) { + case ID -> { + sqlBuilder.append("id"); + idParam = Long.valueOf(identifier); + } + case SHORT_NAME -> { + sqlBuilder.append("short_name"); + idParam = identifier; + } + default -> throw new JobNotFoundException(idType, identifier); + } + sqlBuilder.append(" = ?"); if (searchParameters.hasOrderBy()) { sqlBuilder.append(" order by ").append(searchParameters.getOrderBy()); this.columnValidator.validateSqlInjection(sqlBuilder.toString(), searchParameters.getOrderBy()); @@ -118,7 +123,18 @@ public class SchedulerJobRunnerReadServiceImpl implements SchedulerJobRunnerRead } } - return this.paginationHelper.fetchPage(this.jdbcTemplate, sqlBuilder.toString(), new Object[] { jobId }, jobHistoryMapper); + return this.paginationHelper.fetchPage(this.jdbcTemplate, sqlBuilder.toString(), new Object[] { idParam }, jobHistoryMapper); + } + + @Override + @NotNull + public Long retrieveId(@NotNull IdTypeResolver.IdType idType, String identifier) { + return switch (idType) { + case ID -> Long.valueOf(identifier); + case SHORT_NAME -> + jobDetailRepository.findIdByShortName(identifier).orElseThrow(() -> new JobNotFoundException(idType, identifier)); + default -> throw new JobNotFoundException(idType, identifier); + }; } @Override @@ -133,84 +149,27 @@ public class SchedulerJobRunnerReadServiceImpl implements SchedulerJobRunnerRead return true; } - private boolean isJobExist(final Long jobId) { - boolean isJobPresent = false; - try { - final String sql = "select count(*) from job job where job.id= ?"; - final int count = this.jdbcTemplate.queryForObject(sql, Integer.class, new Object[] { jobId }); - if (count == 1) { - isJobPresent = true; - } - return isJobPresent; - } catch (EmptyResultDataAccessException e) { - return isJobPresent; - } - - } - - private static final class JobDetailMapper implements RowMapper { - - private final StringBuilder sqlBuilder; - - JobDetailMapper(DatabaseSpecificSQLGenerator sqlGenerator) { - sqlBuilder = new StringBuilder("select").append( - " job.id,job.display_name as displayName,job.next_run_time as nextRunTime,job.initializing_errorlog as initializingError,job.cron_expression as cronExpression,job.is_active as active,job.currently_running as currentlyRunning,") - .append(" runHistory.version,runHistory.start_time as lastRunStartTime,runHistory.end_time as lastRunEndTime,runHistory.") - .append(sqlGenerator.escape("status")) - .append(",runHistory.error_message as jobRunErrorMessage,runHistory.trigger_type as triggerType,runHistory.error_log as jobRunErrorLog ") - .append(" from job job left join job_run_history runHistory ON job.id=runHistory.job_id and job.previous_run_start_time=runHistory.start_time "); - } - - public String schema() { - return this.sqlBuilder.toString(); - } - - @Override - public JobDetailData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { - final Long id = rs.getLong("id"); - final String displayName = rs.getString("displayName"); - final Date nextRunTime = rs.getTimestamp("nextRunTime"); - final String initializingError = rs.getString("initializingError"); - final String cronExpression = rs.getString("cronExpression"); - final boolean active = rs.getBoolean("active"); - final boolean currentlyRunning = rs.getBoolean("currentlyRunning"); - - final Long version = rs.getLong("version"); - final Date jobRunStartTime = rs.getTimestamp("lastRunStartTime"); - final Date jobRunEndTime = rs.getTimestamp("lastRunEndTime"); - final String status = rs.getString("status"); - final String jobRunErrorMessage = rs.getString("jobRunErrorMessage"); - final String triggerType = rs.getString("triggerType"); - final String jobRunErrorLog = rs.getString("jobRunErrorLog"); - - JobDetailHistoryData lastRunHistory = null; - if (version > 0) { - lastRunHistory = new JobDetailHistoryData().setVersion(version).setJobRunStartTime(jobRunStartTime) - .setJobRunEndTime(jobRunEndTime).setStatus(status).setJobRunErrorMessage(jobRunErrorMessage) - .setTriggerType(triggerType).setJobRunErrorLog(jobRunErrorLog); - } - final JobDetailData jobDetail = new JobDetailData().setJobId(id).setDisplayName(displayName).setNextRunTime(nextRunTime) - .setInitializingError(initializingError).setCronExpression(cronExpression).setActive(active) - .setCurrentlyRunning(currentlyRunning).setLastRunHistory(lastRunHistory); - return jobDetail; - } - + private boolean isJobExist(@NotNull IdTypeResolver.IdType idType, @NotNull String jobId) { + return switch (idType) { + case ID -> jobDetailRepository.existsById(Long.valueOf(jobId)); + case SHORT_NAME -> jobDetailRepository.existsByShortName(jobId); + default -> false; + }; } private static final class JobHistoryMapper implements RowMapper { - private final StringBuilder sqlBuilder; + private final String sql; JobHistoryMapper(DatabaseSpecificSQLGenerator sqlGenerator) { - sqlBuilder = new StringBuilder(200) - .append(" runHistory.version,runHistory.start_time as runStartTime,runHistory.end_time as runEndTime,runHistory." - + sqlGenerator.escape("status") - + ",runHistory.error_message as jobRunErrorMessage,runHistory.trigger_type as triggerType,runHistory.error_log as jobRunErrorLog ") - .append(" from job job join job_run_history runHistory ON job.id=runHistory.job_id"); + sql = " runHistory.version, runHistory.start_time as runStartTime, runHistory.end_time as runEndTime, " + "runHistory." + + sqlGenerator.escape("status") + + ", runHistory.error_message as jobRunErrorMessage, runHistory.trigger_type as triggerType, runHistory.error_log as jobRunErrorLog " + + "from job job join job_run_history runHistory ON job.id=runHistory.job_id"; } public String schema() { - return this.sqlBuilder.toString(); + return sql; } @Override @@ -222,12 +181,9 @@ public class SchedulerJobRunnerReadServiceImpl implements SchedulerJobRunnerRead final String jobRunErrorMessage = rs.getString("jobRunErrorMessage"); final String triggerType = rs.getString("triggerType"); final String jobRunErrorLog = rs.getString("jobRunErrorLog"); - final JobDetailHistoryData jobDetailHistory = new JobDetailHistoryData().setVersion(version).setJobRunStartTime(jobRunStartTime) - .setJobRunEndTime(jobRunEndTime).setStatus(status).setJobRunErrorMessage(jobRunErrorMessage).setTriggerType(triggerType) + return new JobDetailHistoryData().setVersion(version).setJobRunStartTime(jobRunStartTime).setJobRunEndTime(jobRunEndTime) + .setStatus(status).setJobRunErrorMessage(jobRunErrorMessage).setTriggerType(triggerType) .setJobRunErrorLog(jobRunErrorLog); - return jobDetailHistory; } - } - } diff --git a/fineract-provider/src/main/resources/db/changelog/db.changelog-master.xml b/fineract-provider/src/main/resources/db/changelog/db.changelog-master.xml index 4edca24bf..fd9f869f1 100644 --- a/fineract-provider/src/main/resources/db/changelog/db.changelog-master.xml +++ b/fineract-provider/src/main/resources/db/changelog/db.changelog-master.xml @@ -31,6 +31,10 @@ - - + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index ea46e8546..cf753dc26 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -164,4 +164,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml new file mode 100644 index 000000000..27b3335ca --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0145_job_short_name.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0145_job_short_name.xml new file mode 100644 index 000000000..5f1f87472 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0145_job_short_name.xml @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + name='Update loan Summary' + + + + name='Update Loan Arrears Ageing' + + + + name='Update Loan Paid In Advance' + + + + name='Apply Holidays To Loans' + + + + name='Transfer Fee For Loans From Savings' + + + + name='Apply penalty to overdue loans' + + + + name='Update Non Performing Assets' + + + + name='Recalculate Interest For Loans' + + + + name='Generate Loan Loss Provisioning' + + + + name='Add Accrual Transactions For Loans With Income Posted As Transactions' + + + + name='Loan COB' + + + + name='Loan Delinquency Classification' + + + + name='Apply Annual Fee For Savings' + + + + name='Post Interest For Savings' + + + + name='Pay Due Savings Charges' + + + + name='Transfer Interest To Savings' + + + + name='Update Deposit Accounts Maturity details' + + + + name='Generate Mandatory Savings Schedule' + + + + name='Update Savings Dormant Accounts' + + + + name='Post Dividends For Shares' + + + + name='Update Accounting Running Balances' + + + + name='Add Accrual Transactions' + + + + name='Add Periodic Accrual Transactions' + + + + name='Execute Standing Instruction' + + + + name='Execute Report Mailing Jobs' + + + + name='Execute Email' + + + + name='Update Email Outbound with campaign message' + + + + name='Update SMS Outbound with Campaign Message' + + + + name='Send Messages to SMS Gateway' + + + + name='Get Delivery Reports from SMS Gateway' + + + + name='Generate AdhocClient Schedule' + + + + name='Update Trial Balance Details' + + + + name='Execute All Dirty Jobs' + + + + name='Increase Business Date by 1 day' + + + + name='Increase Business Date by 1 day' + + + + name='Increase COB Date by 1 day' + + + + name='Send Asynchronous Events' + + + + name='Purge External Events' + + + + name='Purge Processed Commands' + + + + + + name='Accrual Activity Posting' + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0146_add_final_constraints.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0146_add_final_constraints.xml new file mode 100644 index 000000000..e2f21abaa --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0146_add_final_constraints.xml @@ -0,0 +1,31 @@ + + + + + + + + + + diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/InstanceModeIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/InstanceModeIntegrationTest.java index 251490c4c..a1d327674 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/InstanceModeIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/InstanceModeIntegrationTest.java @@ -57,8 +57,8 @@ public class InstanceModeIntegrationTest { responseSpec405 = new ResponseSpecBuilder().expectStatusCode(405).build(); schedulerJobHelper = new SchedulerJobHelper(requestSpec); - String jobName = "Apply Annual Fee For Savings"; - jobId = schedulerJobHelper.getSchedulerJobIdByName(jobName); + // Apply Annual Fee For Savings" + jobId = schedulerJobHelper.getSchedulerJobIdByShortName("SA_AANF"); } @ConfigureInstanceMode(readEnabled = true, writeEnabled = false, batchWorkerEnabled = false, batchManagerEnabled = false) diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingJobIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingJobIntegrationTest.java index 61ee96503..2f77b7c1b 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingJobIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingJobIntegrationTest.java @@ -59,6 +59,7 @@ public class SavingsInterestPostingJobIntegrationTest { private static final Logger LOG = LoggerFactory.getLogger(SavingsInterestPostingJobIntegrationTest.class); public static final String ACCOUNT_TYPE_INDIVIDUAL = "INDIVIDUAL"; + public static final String POST_INTEREST_FOR_SAVINGS_JOB_SHORT_NAME = "SA_PINT"; private static ResponseSpecification responseSpec; private static RequestSpecification requestSpec; @@ -83,7 +84,6 @@ public class SavingsInterestPostingJobIntegrationTest { public void testSavingsBalanceCheckAfterDailyInterestPostingJob() { // client activation, savings activation and 1st transaction date final String startDate = "10 April 2022"; - final String jobName = "Post Interest For Savings"; final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); Assertions.assertNotNull(clientID); @@ -95,7 +95,7 @@ public class SavingsInterestPostingJobIntegrationTest { * Runs Post interest posting job and verify the new account created with accounting configuration set as none * is picked up by job */ - this.scheduleJobHelper.executeAndAwaitJob(jobName); + this.scheduleJobHelper.executeAndAwaitJobByShortName(POST_INTEREST_FOR_SAVINGS_JOB_SHORT_NAME); Object transactionObj = this.savingsAccountHelper.getSavingsDetails(savingsId, "transactions"); ArrayList> transactions = (ArrayList>) transactionObj; HashMap interestPostingTransaction = transactions.get(transactions.size() - 48); @@ -108,7 +108,6 @@ public class SavingsInterestPostingJobIntegrationTest { @Test public void testSavingsDailyInterestPostingJobWithAccountingNone() { final String startDate = "10 April 2022"; - final String jobName = "Post Interest For Savings"; final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); Assertions.assertNotNull(clientID); this.accountHelper = new AccountHelper(requestSpec, responseSpec); @@ -126,7 +125,6 @@ public class SavingsInterestPostingJobIntegrationTest { public void testDuplicateOverdraftInterestPostingJob() { // client activation, savings activation and 1st transaction date final String startDate = "01 July 2022"; - final String jobName = "Post Interest For Savings"; final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); Assertions.assertNotNull(clientID); @@ -134,7 +132,7 @@ public class SavingsInterestPostingJobIntegrationTest { this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, "1000", startDate, CommonConstants.RESPONSE_RESOURCE_ID); - this.scheduleJobHelper.executeAndAwaitJob(jobName); + this.scheduleJobHelper.executeAndAwaitJobByShortName(POST_INTEREST_FOR_SAVINGS_JOB_SHORT_NAME); this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, "1000", startDate, CommonConstants.RESPONSE_RESOURCE_ID); Object transactionObj = this.savingsAccountHelper.getSavingsDetails(savingsId, "transactions"); ArrayList> transactions = (ArrayList>) transactionObj; @@ -155,7 +153,6 @@ public class SavingsInterestPostingJobIntegrationTest { BusinessDateHelper.updateBusinessDate(requestSpec, responseSpec, BusinessDateType.BUSINESS_DATE, today); // client activation, savings activation and 1st transaction date final String startDate = "10 April 2022"; - final String jobName = "Post Interest For Savings"; final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); Assertions.assertNotNull(clientID); @@ -167,7 +164,7 @@ public class SavingsInterestPostingJobIntegrationTest { * Runs Post interest posting job and verify the new account created with accounting configuration set as * none is picked up by job */ - this.scheduleJobHelper.executeAndAwaitJob(jobName); + this.scheduleJobHelper.executeAndAwaitJobByShortName(POST_INTEREST_FOR_SAVINGS_JOB_SHORT_NAME); Object transactionObj = this.savingsAccountHelper.getSavingsDetails(savingsId, "transactions"); ArrayList> transactions = (ArrayList>) transactionObj; HashMap interestPostingTransaction = transactions.get(transactions.size() - 3); @@ -190,7 +187,6 @@ public class SavingsInterestPostingJobIntegrationTest { public void testSavingsDailyOverdraftInterestPostingJob() { // client activation, savings activation and 1st transaction date final String startDate = "10 April 2022"; - final String jobName = "Post Interest For Savings"; final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); Assertions.assertNotNull(clientID); @@ -199,7 +195,7 @@ public class SavingsInterestPostingJobIntegrationTest { this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsId, "10000", startDate, CommonConstants.RESPONSE_RESOURCE_ID); // Runs Post interest posting job and verify the new account created with Overdraft is posting negative interest - this.scheduleJobHelper.executeAndAwaitJob(jobName); + this.scheduleJobHelper.executeAndAwaitJobByShortName(POST_INTEREST_FOR_SAVINGS_JOB_SHORT_NAME); Object transactionObj = this.savingsAccountHelper.getSavingsDetails(savingsId, "transactions"); ArrayList> transactions = (ArrayList>) transactionObj; HashMap interestPostingTransaction = transactions.get(transactions.size() - 2); @@ -215,7 +211,6 @@ public class SavingsInterestPostingJobIntegrationTest { @Test public void testAccountBalanceWithWithdrawalFeeAfterInterestPostingJob() { final String startDate = "21 June 2022"; - final String jobName = "Post Interest For Savings"; final Integer clientID = ClientHelper.createClient(this.requestSpec, this.responseSpec, startDate); Assertions.assertNotNull(clientID); @@ -227,7 +222,7 @@ public class SavingsInterestPostingJobIntegrationTest { Float balance = Float.parseFloat("800.0"); assertEquals(balance, summary.get("accountBalance"), "Verifying account balance is 800"); - this.scheduleJobHelper.executeAndAwaitJob(jobName); + this.scheduleJobHelper.executeAndAwaitJobByShortName(POST_INTEREST_FOR_SAVINGS_JOB_SHORT_NAME); Object transactionObj = this.savingsAccountHelper.getSavingsDetails(savingsId, "transactions"); ArrayList> transactions = (ArrayList>) transactionObj; HashMap interestPostingTransaction = transactions.get(transactions.size() - 5); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/SchedulerJobHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/SchedulerJobHelper.java index 8f069ccf1..c343f25b2 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/SchedulerJobHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/SchedulerJobHelper.java @@ -19,6 +19,7 @@ package org.apache.fineract.integrationtests.common; import static java.time.Instant.now; +import static org.apache.fineract.infrastructure.jobs.api.SchedulerJobApiConstants.SHORT_NAME_PARAM; import static org.awaitility.Awaitility.await; import static org.hamcrest.core.Is.is; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -28,6 +29,7 @@ import com.google.gson.Gson; import io.restassured.builder.ResponseSpecBuilder; import io.restassured.specification.RequestSpecification; import io.restassured.specification.ResponseSpecification; +import java.io.Serializable; import java.time.Duration; import java.time.Instant; import java.time.LocalDate; @@ -37,7 +39,9 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Callable; +import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.Supplier; import java.util.stream.Collectors; import org.apache.fineract.client.models.PutJobsJobIDRequest; import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; @@ -89,6 +93,15 @@ public class SchedulerJobHelper extends IntegrationTest { return response; } + private Map getSchedulerJobByShortName(String shortName) { + final String GET_SCHEDULER_JOB_URL = "/fineract-provider/api/v1/jobs/" + SHORT_NAME_PARAM + "/" + shortName + "?" + + Utils.TENANT_IDENTIFIER; + LOG.info("------------------------ RETRIEVING SCHEDULER JOB BY SHORT NAME -------------------------"); + Map response = Utils.performServerGet(requestSpec, response200Spec, GET_SCHEDULER_JOB_URL, ""); + assertNotNull(response); + return response; + } + public Boolean getSchedulerStatus() { final String GET_SCHEDULER_STATUS_URL = "/fineract-provider/api/v1/scheduler?" + Utils.TENANT_IDENTIFIER; LOG.info("------------------------ RETRIEVING SCHEDULER STATUS -------------------------"); @@ -131,6 +144,18 @@ public class SchedulerJobHelper extends IntegrationTest { Utils.performServerPost(requestSpec, responseSpec, RUN_SCHEDULER_JOB_URL, runSchedulerJobAsJSON(), null); } + public void runSchedulerJobByShortName(String shortName) { + final ResponseSpecification responseSpec = new ResponseSpecBuilder().expectStatusCode(202).build(); + runSchedulerJobByShortName(shortName, responseSpec); + } + + public void runSchedulerJobByShortName(String shortName, ResponseSpecification responseSpec) { + final String RUN_SCHEDULER_JOB_URL = "/fineract-provider/api/v1/jobs/" + SHORT_NAME_PARAM + "/" + shortName + "?command=executeJob&" + + Utils.TENANT_IDENTIFIER; + LOG.info("------------------------ RUN SCHEDULER JOB -------------------------"); + Utils.performServerPost(requestSpec, responseSpec, RUN_SCHEDULER_JOB_URL, runSchedulerJobAsJSON(), null); + } + private static String runSchedulerJobAsJSON() { final Map map = new HashMap<>(); String runSchedulerJob = new Gson().toJson(map); @@ -149,6 +174,16 @@ public class SchedulerJobHelper extends IntegrationTest { "No such named Job (see org.apache.fineract.infrastructure.jobs.service.JobName enum):" + jobName); } + public int getSchedulerJobIdByShortName(String shortName) { + Map jobMap = getSchedulerJobByShortName(shortName); + final String GET_SCHEDULER_JOB_URL = "/fineract-provider/api/v1/jobs/" + SHORT_NAME_PARAM + "/" + shortName + "?" + + Utils.TENANT_IDENTIFIER; + LOG.info("------------------------ RETRIEVING SCHEDULER JOB ID BY SHORT NAME -------------------------"); + Integer response = (Integer) jobMap.get("jobId"); + assertNotNull(response); + return response; + } + /** * Launches a Job and awaits its completion. * @@ -158,23 +193,43 @@ public class SchedulerJobHelper extends IntegrationTest { * @author Michael Vorburger.ch */ public void executeAndAwaitJob(String jobName) { - final Duration timeout = Duration.ofMinutes(4); - final Duration pause = Duration.ofSeconds(2); - DateTimeFormatter df = DateTimeFormatter.ISO_INSTANT; // FINERACT-926 - Instant beforeExecuteTime = now().truncatedTo(ChronoUnit.SECONDS); + int jobId = getSchedulerJobIdByName(jobName); + executeAndAwaitJob(jobId, (a) -> runSchedulerJob(jobId), () -> jobLastRunHistorySupplier(jobId)); + } + /** + * Launches a Job and awaits its completion. + * + * @param shortName + * shortName of Scheduler Job + * + * @author Michael Vorburger.ch + */ + public void executeAndAwaitJobByShortName(String shortName) { + executeAndAwaitJob(shortName, (a) -> runSchedulerJobByShortName(shortName), () -> jobLastRunHistoryByShortName(shortName)); + } + + public void executeAndAwaitJob(T jobParam, Consumer runSchedulerJob, + Supplier>> retrievelastRunHistory) { // Stop the Scheduler while we manually trigger execution of job, to // avoid side effects and simplify debugging when readings logs updateSchedulerStatus(false); + Instant beforeExecuteTime = now().truncatedTo(ChronoUnit.SECONDS); // Executing Scheduler Job - int jobId = getSchedulerJobIdByName(jobName); - runSchedulerJob(jobId); + runSchedulerJob.accept(jobParam); + awaitJob(beforeExecuteTime, retrievelastRunHistory); + } + + private void awaitJob(Instant beforeExecuteTime, Supplier>> retrieveLastRunHistory) { + final Duration timeout = Duration.ofMinutes(4); + final Duration pause = Duration.ofSeconds(2); + DateTimeFormatter df = DateTimeFormatter.ISO_INSTANT; // FINERACT-926 // Await JobDetailData.lastRunHistory [JobDetailHistoryData] // jobRunStartTime >= beforeExecuteTime (or timeout) // jobRunEndTime to be both set and >= jobRunStartTime (or timeout) - Map finalLastRunHistory = await().atMost(timeout).pollInterval(pause).until(jobLastRunHistorySupplier(jobId), + Map finalLastRunHistory = await().atMost(timeout).pollInterval(pause).until(retrieveLastRunHistory.get(), lastRunHistory -> { String jobRunStartText = lastRunHistory.get("jobRunStartTime"); if (jobRunStartText == null) { @@ -223,4 +278,15 @@ public class SchedulerJobHelper extends IntegrationTest { return (Map) job.get("lastRunHistory"); }; } + + @SuppressWarnings("unchecked") + private Callable> jobLastRunHistoryByShortName(String shortName) { + return () -> { + Map job = getSchedulerJobByShortName(shortName); + if (job == null) { + return null; + } + return (Map) job.get("lastRunHistory"); + }; + } }