FINERACT-2119: Address jobs by name

This commit is contained in:
Marta Jankovics
2024-08-28 16:46:20 +02:00
committed by Adam Saghy
parent eef6a8da5e
commit 3aaa2ffe9c
27 changed files with 761 additions and 185 deletions
@@ -42,4 +42,10 @@
<column name="is_mismatched_job" valueBoolean="true"/>
</insert>
</changeSet>
<changeSet author="acme" id="2">
<update tableName="job">
<column name="short_name" value="ACM_NOOP"/>
<where>name='Acme Noop Job'</where>
</update>
</changeSet>
</databaseChangeLog>
@@ -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 extends Enum<T>> T resolve(@NotNull Class<T> 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);
}
}
@@ -51,14 +51,18 @@ public class MultiException extends Exception {
private final List<Throwable> throwables;
@SuppressFBWarnings("CT_CONSTRUCTOR_THROW")
public MultiException(List<Throwable> problems) {
super("MultiException with " + problems.size() + " contained causes (details available)");
public MultiException(String message, List<Throwable> problems) {
super(message);
if (problems.isEmpty()) {
throw new IllegalArgumentException("List of Throwables must not be empty");
}
this.throwables = new ArrayList<>(problems);
}
public MultiException(List<Throwable> problems) {
this("MultiException with " + problems.size() + " contained causes (details available)", problems);
}
public List<Throwable> getCauses() {
return Collections.unmodifiableList(throwables);
}
@@ -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<ApiParameterError> getErrors() {
return this.errors;
}
@@ -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 <T extends Enum<T> & DefaultOption> T getDefault(Class<T> clazz) {
for (T enumConstant : clazz.getEnumConstants()) {
if (enumConstant.isDefault()) {
return enumConstant;
}
}
return null;
}
}
@@ -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);
}
}
}
@@ -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<Throwable> problems) {
super(message, problems);
}
public JobExecutionException(List<Throwable> 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<Throwable, List<String>> errorMap) throws JobExecutionException {
int size = errorMap.size();
if (size == 0) {
return;
}
List<Throwable> errors;
StringBuilder msg = new StringBuilder("Job failed on ");
Stream<Map.Entry<Throwable, List<String>>> entryStream = errorMap.entrySet().stream().filter(e -> e.getValue() != null);
if (size < 10) {
errors = new ArrayList<>(errorMap.keySet());
Map<String, List<List<String>>> 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<String, Long> 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);
}
}
@@ -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<JobDetailData> findAllJobDeatils();
List<JobDetailData> findAllJobDetails();
JobDetailData retrieveOne(Long jobId);
JobDetailData retrieveOne(@NotNull IdTypeResolver.IdType idType, String identifier);
JobDetailData retrieveOneByName(String jobName);
Page<JobDetailHistoryData> retrieveJobHistory(@NotNull IdTypeResolver.IdType idType, String identifier,
SearchParameters searchParameters);
Page<JobDetailHistoryData> retrieveJobHistory(Long jobId, SearchParameters searchParameters);
@NotNull
Long retrieveId(@NotNull IdTypeResolver.IdType idType, String identifier);
boolean isUpdatesAllowed();
}
@@ -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;
@@ -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;
}
}
@@ -21,4 +21,6 @@ package org.apache.fineract.test.data.job;
public interface Job {
String getName();
String getShortName();
}
@@ -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<List<GetJobsResponse>> response = schedulerJobApi.retrieveAll8().execute();
String shortName = job.getShortName();
log.debug("Resolving job by short-name [{}]", shortName);
Response<GetJobsResponse> 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<GetJobsResponse> 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);
}
@@ -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";
@@ -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<JobDetailData> jobDetailDatas = this.schedulerJobRunnerReadService.findAllJobDeatils();
this.context.authenticatedUser().validateHasReadPermission(SCHEDULER_RESOURCE_NAME);
final List<JobDetailData> 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<JobDetailHistoryData> 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<JobDetailHistoryData> 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);
}
}
@@ -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 = "")
@@ -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<Long> {
@Column(name = "is_misfired")
private boolean triggerMisfired;
@Column(name = "short_name", nullable = false)
private String shortName;
public Map<String, Object> update(final JsonCommand command) {
final Map<String, Object> actualChanges = new LinkedHashMap<>(9);
@@ -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<JobDetailData> getAllData();
boolean existsByShortName(String shortName);
@Query("select j.id from ScheduledJobDetail j where j.shortName = :shortName")
Optional<Long> findIdByShortName(@Param("shortName") String shortName);
}
@@ -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);
}
@@ -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<JobDetailData> findAllJobDeatils() {
final JobDetailMapper detailMapper = new JobDetailMapper(sqlGenerator);
final String sql = detailMapper.schema();
final List<JobDetailData> JobDeatils = this.jdbcTemplate.query(sql, detailMapper, new Object[] {});
return JobDeatils;
public List<JobDetailData> 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<JobDetailHistoryData> retrieveJobHistory(final Long jobId, final SearchParameters searchParameters) {
if (!isJobExist(jobId)) {
throw new JobNotFoundException(String.valueOf(jobId));
public Page<JobDetailHistoryData> 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<JobDetailData> {
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<JobDetailHistoryData> {
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;
}
}
}
@@ -31,6 +31,10 @@
<include file="tenant-store/changelog-tenant-store.xml" relativeToChangelogFile="true" context="tenant_store_db AND !initial_switch"/>
<include file="tenant/initial-switch-changelog-tenant.xml" relativeToChangelogFile="true" context="tenant_db AND initial_switch"/>
<include file="tenant/changelog-tenant.xml" relativeToChangelogFile="true" context="tenant_db AND !initial_switch"/>
<includeAll path="db/changelog/tenant/module" errorIfMissingOrEmpty="false" context="tenant_db AND custom_changelog" filter="org.apache.fineract.infrastructure.core.service.migration.TenantModuleRootFilter" />
<includeAll path="db/custom-changelog" errorIfMissingOrEmpty="false" context="tenant_db AND custom_changelog" />
<!-- Add new module to the end of this modules list (to keep the existing auto-increment identifiers) -->
<include file="db/changelog/tenant/module/loan/module-changelog-master.xml" context="tenant_db AND !initial_switch"/>
<include file="db/changelog/tenant/module/investor/module-changelog-master.xml" context="tenant_db AND !initial_switch"/>
<includeAll path="db/custom-changelog" errorIfMissingOrEmpty="false" context="tenant_db AND !initial_switch AND custom_changelog"/>
<!-- Scripts to run after the modules were initialized -->
<include file="tenant/final-changelog-tenant.xml" relativeToChangelogFile="true" context="tenant_db AND !initial_switch"/>
</databaseChangeLog>
@@ -164,4 +164,5 @@
<include file="parts/0142_add_accrual_activity_transaction.xml" relativeToChangelogFile="true" />
<include file="parts/0143_add_accrual_activity_posting_job.xml" relativeToChangelogFile="true" />
<include file="parts/0144_transaction_summary_with_asset_owner_report_unc_allocation_fix.xml" relativeToChangelogFile="true" />
<include file="parts/0145_job_short_name.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<include file="parts/0146_add_final_constraints.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,196 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="fineract" id="1">
<addColumn tableName="job">
<column name="short_name" type="VARCHAR(8)">
<constraints unique="true" nullable="true"/>
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="2">
<update tableName="job">
<column name="short_name" value="LA_USUM"/>
<where>name='Update loan Summary'</where>
</update>
<update tableName="job">
<column name="short_name" value="LA_ARAG"/>
<where>name='Update Loan Arrears Ageing'</where>
</update>
<update tableName="job">
<column name="short_name" value="LA_PIAD"/>
<where>name='Update Loan Paid In Advance'</where>
</update>
<update tableName="job">
<column name="short_name" value="LA_AHOL"/>
<where>name='Apply Holidays To Loans'</where>
</update>
<update tableName="job">
<column name="short_name" value="LA_TFFS"/>
<where>name='Transfer Fee For Loans From Savings'</where>
</update>
<update tableName="job">
<column name="short_name" value="LA_OPEN"/>
<where>name='Apply penalty to overdue loans'</where>
</update>
<update tableName="job">
<column name="short_name" value="LA_UNPA"/>
<where>name='Update Non Performing Assets'</where>
</update>
<update tableName="job">
<column name="short_name" value="LA_RINT"/>
<where>name='Recalculate Interest For Loans'</where>
</update>
<update tableName="job">
<column name="short_name" value="LA_GLPR"/>
<where>name='Generate Loan Loss Provisioning'</where>
</update>
<update tableName="job">
<column name="short_name" value="LA_AATR"/>
<where>name='Add Accrual Transactions For Loans With Income Posted As Transactions'</where>
</update>
<update tableName="job">
<column name="short_name" value="LA_ECOB"/>
<where>name='Loan COB'</where>
</update>
<update tableName="job">
<column name="short_name" value="LA_DECL"/>
<where>name='Loan Delinquency Classification'</where>
</update>
<update tableName="job">
<column name="short_name" value="SA_AANF"/>
<where>name='Apply Annual Fee For Savings'</where>
</update>
<update tableName="job">
<column name="short_name" value="SA_PINT"/>
<where>name='Post Interest For Savings'</where>
</update>
<update tableName="job">
<column name="short_name" value="SA_PDCH"/>
<where>name='Pay Due Savings Charges'</where>
</update>
<update tableName="job">
<column name="short_name" value="SA_TINT"/>
<where>name='Transfer Interest To Savings'</where>
</update>
<update tableName="job">
<column name="short_name" value="SA_MATD"/>
<where>name='Update Deposit Accounts Maturity details'</where>
</update>
<update tableName="job">
<column name="short_name" value="SA_GSCH"/>
<where>name='Generate Mandatory Savings Schedule'</where>
</update>
<update tableName="job">
<column name="short_name" value="SA_UDOR"/>
<where>name='Update Savings Dormant Accounts'</where>
</update>
<update tableName="job">
<column name="short_name" value="SH_PDIV"/>
<where>name='Post Dividends For Shares'</where>
</update>
<update tableName="job">
<column name="short_name" value="ACC_RBAL"/>
<where>name='Update Accounting Running Balances'</where>
</update>
<update tableName="job">
<column name="short_name" value="ACC_AATR"/>
<where>name='Add Accrual Transactions'</where>
</update>
<update tableName="job">
<column name="short_name" value="ACC_APTR"/>
<where>name='Add Periodic Accrual Transactions'</where>
</update>
<update tableName="job">
<column name="short_name" value="STI_EXEC"/>
<where>name='Execute Standing Instruction'</where>
</update>
<update tableName="job">
<column name="short_name" value="RMJ_EXEC"/>
<where>name='Execute Report Mailing Jobs'</where>
</update>
<update tableName="job">
<column name="short_name" value="EM_EXEC"/>
<where>name='Execute Email'</where>
</update>
<update tableName="job">
<column name="short_name" value="EM_UOUT"/>
<where>name='Update Email Outbound with campaign message'</where>
</update>
<update tableName="job">
<column name="short_name" value="SMS_UOUT"/>
<where>name='Update SMS Outbound with Campaign Message'</where>
</update>
<update tableName="job">
<column name="short_name" value="SMS_SMSG"/>
<where>name='Send Messages to SMS Gateway'</where>
</update>
<update tableName="job">
<column name="short_name" value="SMS_DRPT"/>
<where>name='Get Delivery Reports from SMS Gateway'</where>
</update>
<update tableName="job">
<column name="short_name" value="ADH_GSCH"/>
<where>name='Generate AdhocClient Schedule'</where>
</update>
<update tableName="job">
<column name="short_name" value="TBL_UDET"/>
<where>name='Update Trial Balance Details'</where>
</update>
<update tableName="job">
<column name="short_name" value="JOB_EXEC"/>
<where>name='Execute All Dirty Jobs'</where>
</update>
<update tableName="job">
<column name="short_name" value="BDT_INC1"/>
<where>name='Increase Business Date by 1 day'</where>
</update>
<update tableName="job">
<column name="short_name" value="BDT_INC1"/>
<where>name='Increase Business Date by 1 day'</where>
</update>
<update tableName="job">
<column name="short_name" value="BDT_COB1"/>
<where>name='Increase COB Date by 1 day'</where>
</update>
<update tableName="job">
<column name="short_name" value="ASE_SEND"/>
<where>name='Send Asynchronous Events'</where>
</update>
<update tableName="job">
<column name="short_name" value="EXE_PURG"/>
<where>name='Purge External Events'</where>
</update>
<update tableName="job">
<column name="short_name" value="COM_PURG"/>
<where>name='Purge Processed Commands'</where>
</update>
</changeSet>
<changeSet author="fineract" id="3">
<update tableName="job">
<column name="short_name" value="ACC_ACPO"/>
<where>name='Accrual Activity Posting'</where>
</update>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="fineract" id="1" context="postgresql">
<addNotNullConstraint tableName="job" columnName="short_name"/>
</changeSet>
<changeSet author="fineract" id="2" context="mysql">
<addNotNullConstraint tableName="job" columnName="short_name" columnDataType="VARCHAR(8)"/>
</changeSet>
</databaseChangeLog>
@@ -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)
@@ -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<HashMap<String, Object>> transactions = (ArrayList<HashMap<String, Object>>) transactionObj;
HashMap<String, Object> 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<HashMap<String, Object>> transactions = (ArrayList<HashMap<String, Object>>) 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<HashMap<String, Object>> transactions = (ArrayList<HashMap<String, Object>>) transactionObj;
HashMap<String, Object> 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<HashMap<String, Object>> transactions = (ArrayList<HashMap<String, Object>>) transactionObj;
HashMap<String, Object> 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<HashMap<String, Object>> transactions = (ArrayList<HashMap<String, Object>>) transactionObj;
HashMap<String, Object> interestPostingTransaction = transactions.get(transactions.size() - 5);
@@ -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<String, Object> 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<String, Object> 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<String, String> 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<String, Object> 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 <T extends Serializable> void executeAndAwaitJob(T jobParam, Consumer<T> runSchedulerJob,
Supplier<Callable<Map<String, String>>> 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<Callable<Map<String, String>>> 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<String, String> finalLastRunHistory = await().atMost(timeout).pollInterval(pause).until(jobLastRunHistorySupplier(jobId),
Map<String, String> 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<String, String>) job.get("lastRunHistory");
};
}
@SuppressWarnings("unchecked")
private Callable<Map<String, String>> jobLastRunHistoryByShortName(String shortName) {
return () -> {
Map<String, Object> job = getSchedulerJobByShortName(shortName);
if (job == null) {
return null;
}
return (Map<String, String>) job.get("lastRunHistory");
};
}
}