Skip to content

Commit 4b38516

Browse files
committed
FINERACT-2421: fix offsetdate handling for audit entry search
1 parent 8a61e30 commit 4b38516

File tree

12 files changed

+898
-186
lines changed

12 files changed

+898
-186
lines changed

fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/DateUtils.java

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020

2121
import static java.time.temporal.ChronoUnit.DAYS;
2222

23+
import java.time.DateTimeException;
24+
import java.time.Instant;
2325
import java.time.LocalDate;
2426
import java.time.LocalDateTime;
2527
import java.time.LocalTime;
@@ -36,6 +38,8 @@
3638
import java.util.List;
3739
import java.util.Locale;
3840
import java.util.Optional;
41+
import java.util.regex.Matcher;
42+
import java.util.regex.Pattern;
3943
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
4044
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
4145
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
@@ -442,9 +446,10 @@ public static LocalDateTime convertDateTimeStringToLocalDateTime(String dateTime
442446
if (dateTimeStr == null || dateTimeStr.isBlank()) {
443447
return null;
444448
}
449+
String dateTimeStrWithoutOffset = removeOffsetFromString(dateTimeStr);
445450
final Locale locale = localeStr == null ? null : JsonParserHelper.localeFromString(localeStr);
446451
DateTimeFormatter formatter = getDateFormatter(dateFormat, locale);
447-
TemporalAccessor parsed = formatter.parse(dateTimeStr);
452+
TemporalAccessor parsed = formatter.parse(dateTimeStrWithoutOffset);
448453

449454
boolean hasTime = parsed.isSupported(ChronoField.HOUR_OF_DAY) && parsed.isSupported(ChronoField.MINUTE_OF_HOUR);
450455

@@ -462,6 +467,146 @@ public static LocalDateTime convertDateTimeStringToLocalDateTime(String dateTime
462467
}
463468
}
464469

470+
public static OffsetDateTime convertDateTimeStringToOffsetDateTime(String dateTimeStr, String dateFormat, String localeStr,
471+
LocalTime fallbackTime, ZoneOffset defaultOffset) {
472+
if (dateTimeStr == null || dateTimeStr.isBlank()) {
473+
return null;
474+
}
475+
String dateTimeStrWithoutOffset = removeOffsetFromString(dateTimeStr);
476+
ZoneOffset offset = extractOffsetFromString(dateTimeStr, defaultOffset);
477+
LocalDateTime localDateTime = convertDateTimeStringToLocalDateTime(dateTimeStrWithoutOffset, dateFormat, localeStr, fallbackTime);
478+
if (localDateTime == null) {
479+
return null;
480+
}
481+
return OffsetDateTime.of(localDateTime, offset);
482+
}
483+
484+
public static OffsetDateTime convertDateTimeStringToOffsetDateTime(String dateTimeStr, String dateFormat, String localeStr,
485+
LocalTime fallbackTime, String timeZone) {
486+
if (dateTimeStr == null || dateTimeStr.isBlank()) {
487+
return null;
488+
}
489+
String dateTimeStrWithoutOffset = removeOffsetFromString(dateTimeStr);
490+
LocalDateTime localDateTime = convertDateTimeStringToLocalDateTime(dateTimeStrWithoutOffset, dateFormat, localeStr, fallbackTime);
491+
if (localDateTime == null) {
492+
return null;
493+
}
494+
ZoneOffset inlineOffset = extractOffsetFromStringOrNull(dateTimeStr);
495+
if (inlineOffset != null) {
496+
return OffsetDateTime.of(localDateTime, inlineOffset);
497+
}
498+
ZoneOffset defaultOffset = resolveOffset(timeZone, localDateTime);
499+
return OffsetDateTime.of(localDateTime, defaultOffset);
500+
}
501+
502+
public static ZoneOffset resolveOffset(String timeZone) {
503+
return resolveOffset(timeZone, null);
504+
}
505+
506+
public static ZoneOffset resolveOffset(String timeZone, LocalDateTime dateTime) {
507+
if (timeZone == null || timeZone.isBlank()) {
508+
return ZoneOffset.UTC;
509+
}
510+
try {
511+
return ZoneOffset.of(timeZone);
512+
} catch (DateTimeException e) {
513+
try {
514+
ZoneId zoneId = ZoneId.of(timeZone);
515+
Instant instant = dateTime != null ? dateTime.atZone(zoneId).toInstant() : Instant.now();
516+
return zoneId.getRules().getOffset(instant);
517+
} catch (DateTimeException ex) {
518+
final List<ApiParameterError> errors = List.of(ApiParameterError.parameterError("validation.msg.invalid.timezone",
519+
"The parameter timeZone (" + timeZone + ") is invalid", "timeZone", timeZone));
520+
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors,
521+
ex);
522+
}
523+
}
524+
}
525+
526+
private static ZoneOffset extractOffsetFromStringOrNull(String dateTimeStr) {
527+
int offsetIndex = findOffsetIndex(dateTimeStr);
528+
if (offsetIndex < 0) {
529+
return null;
530+
}
531+
String offsetStr = dateTimeStr.substring(offsetIndex);
532+
try {
533+
return parseOffset(offsetStr);
534+
} catch (DateTimeException e) {
535+
final List<ApiParameterError> errors = List.of(ApiParameterError.parameterError("validation.msg.invalid.offset",
536+
"The inline offset (" + offsetStr + ") is invalid", "offset", offsetStr));
537+
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors, e);
538+
}
539+
}
540+
541+
private static final Pattern OFFSET_PATTERN = Pattern
542+
.compile("(?<=[:\\s]\\d{2}|\\s)(Z|[+-][\\w:]+)$|(?<=\\d{2}:\\d{2}:\\d{2})(Z|[+-][\\w:]+)$", Pattern.CASE_INSENSITIVE);
543+
544+
private static int findOffsetIndex(String str) {
545+
if (str == null || str.isEmpty()) {
546+
return -1;
547+
}
548+
Matcher matcher = OFFSET_PATTERN.matcher(str);
549+
if (matcher.find()) {
550+
return matcher.start();
551+
}
552+
return -1;
553+
}
554+
555+
private static ZoneOffset parseOffset(String offsetStr) {
556+
if (offsetStr == null || offsetStr.isBlank()) {
557+
return ZoneOffset.UTC;
558+
}
559+
offsetStr = offsetStr.trim();
560+
if ("Z".equalsIgnoreCase(offsetStr)) {
561+
return ZoneOffset.UTC;
562+
}
563+
char sign = offsetStr.charAt(0);
564+
if (sign != '+' && sign != '-') {
565+
throw new DateTimeException("Invalid offset format: " + offsetStr);
566+
}
567+
String numPart = offsetStr.substring(1);
568+
if (numPart.contains(":")) {
569+
return ZoneOffset.of(offsetStr);
570+
}
571+
if (!numPart.matches("\\d+")) {
572+
throw new DateTimeException("Invalid offset format: " + offsetStr);
573+
}
574+
int hours;
575+
int minutes = 0;
576+
if (numPart.length() <= 2) {
577+
hours = Integer.parseInt(numPart);
578+
} else if (numPart.length() == 4) {
579+
hours = Integer.parseInt(numPart.substring(0, 2));
580+
minutes = Integer.parseInt(numPart.substring(2, 4));
581+
} else {
582+
return ZoneOffset.of(offsetStr);
583+
}
584+
return ZoneOffset.ofHoursMinutes(sign == '-' ? -hours : hours, sign == '-' ? -minutes : minutes);
585+
}
586+
587+
private static ZoneOffset extractOffsetFromString(String dateTimeStr, ZoneOffset defaultOffset) {
588+
int offsetIndex = findOffsetIndex(dateTimeStr);
589+
if (offsetIndex < 0) {
590+
return defaultOffset;
591+
}
592+
String offsetStr = dateTimeStr.substring(offsetIndex);
593+
try {
594+
return parseOffset(offsetStr);
595+
} catch (DateTimeException e) {
596+
final List<ApiParameterError> errors = List.of(ApiParameterError.parameterError("validation.msg.invalid.offset",
597+
"The inline offset (" + offsetStr + ") is invalid", "offset", offsetStr));
598+
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors, e);
599+
}
600+
}
601+
602+
private static String removeOffsetFromString(String dateTimeStr) {
603+
int offsetIndex = findOffsetIndex(dateTimeStr);
604+
if (offsetIndex < 0) {
605+
return dateTimeStr;
606+
}
607+
return dateTimeStr.substring(0, offsetIndex).trim();
608+
}
609+
465610
/**
466611
* Returns the earlier date. If date1 is before date2 it return date1 otherwise date2.
467612
*

fineract-provider/src/main/java/org/apache/fineract/commands/api/AuditsApiResource.java

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020

2121
import io.swagger.v3.oas.annotations.Operation;
2222
import io.swagger.v3.oas.annotations.Parameter;
23+
import io.swagger.v3.oas.annotations.media.Content;
24+
import io.swagger.v3.oas.annotations.media.Schema;
25+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
2326
import io.swagger.v3.oas.annotations.tags.Tag;
2427
import jakarta.ws.rs.BeanParam;
2528
import jakarta.ws.rs.Consumes;
@@ -39,7 +42,7 @@
3942
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
4043
import org.apache.fineract.infrastructure.core.data.PaginationParameters;
4144
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
42-
import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer;
45+
import org.apache.fineract.infrastructure.core.service.Page;
4346
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
4447
import org.apache.fineract.infrastructure.security.utils.SQLBuilder;
4548
import org.springframework.stereotype.Component;
@@ -59,7 +62,6 @@ public class AuditsApiResource {
5962
private final PlatformSecurityContext context;
6063
private final AuditReadPlatformService auditReadPlatformService;
6164
private final ApiRequestParameterHelper apiRequestParameterHelper;
62-
private final ToApiJsonSerializer<String> toApiJsonSerializer;
6365

6466
@GET
6567
@Consumes({ MediaType.APPLICATION_JSON })
@@ -68,22 +70,20 @@ public class AuditsApiResource {
6870
+ "\n" + "Example Requests:\n" + "\n" + "audits\n" + "\n" + "audits?fields=madeOnDate,maker,processingResult\n" + "\n"
6971
+ "audits?makerDateTimeFrom=2013-03-25 08:00:00&makerDateTimeTo=2013-04-04 18:00:00\n" + "\n" + "audits?officeId=1\n" + "\n"
7072
+ "audits?officeId=1&includeJson=true")
71-
public String retrieveAuditEntries(@Context final UriInfo uriInfo, @BeanParam AuditRequest auditRequest,
73+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = AuditData.class)))
74+
public Page<AuditData> retrieveAuditEntries(@Context final UriInfo uriInfo, @BeanParam AuditRequest auditRequest,
7275
@QueryParam("offset") @Parameter(description = "offset") final Integer offset,
7376
@QueryParam("limit") @Parameter(description = "limit") final Integer limit,
7477
@QueryParam("orderBy") @Parameter(description = "orderBy") final String orderBy,
75-
@QueryParam("sortOrder") @Parameter(description = "sortOrder") final String sortOrder,
76-
@QueryParam("paged") @Parameter(description = "paged") final Boolean paged) {
78+
@QueryParam("sortOrder") @Parameter(description = "sortOrder") final String sortOrder) {
7779

7880
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
79-
final PaginationParameters parameters = PaginationParameters.builder().paged(Boolean.TRUE.equals(paged)).limit(limit).offset(offset)
80-
.orderBy(orderBy).sortOrder(sortOrder).build();
81+
final PaginationParameters parameters = PaginationParameters.builder().paged(true).limit(limit).offset(offset).orderBy(orderBy)
82+
.sortOrder(sortOrder).build();
8183
final SQLBuilder extraCriteria = getExtraCriteria(auditRequest);
8284
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
8385

84-
return toApiJsonSerializer.serialize(parameters.isPaged()
85-
? auditReadPlatformService.retrievePaginatedAuditEntries(extraCriteria, settings.isIncludeJson(), parameters)
86-
: auditReadPlatformService.retrieveAuditEntries(extraCriteria, settings.isIncludeJson()));
86+
return auditReadPlatformService.retrievePaginatedAuditEntries(extraCriteria, settings.isIncludeJson(), parameters);
8787
}
8888

8989
@GET
@@ -92,6 +92,7 @@ public String retrieveAuditEntries(@Context final UriInfo uriInfo, @BeanParam Au
9292
@Produces({ MediaType.APPLICATION_JSON })
9393
@Operation(summary = "Retrieve an Audit Entry", description = "Example Requests:\n" + "\n" + "audits/20\n"
9494
+ "audits/20?fields=madeOnDate,maker,processingResult")
95+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = AuditData.class)))
9596
public AuditData retrieveAuditEntry(@PathParam("auditId") @Parameter final Long auditId) {
9697
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
9798
return auditReadPlatformService.retrieveAuditEntry(auditId);
@@ -104,6 +105,7 @@ public AuditData retrieveAuditEntry(@PathParam("auditId") @Parameter final Long
104105
@Produces({ MediaType.APPLICATION_JSON })
105106
@Operation(summary = "Audit Search Template", description = "This is a convenience resource. It can be useful when building an Audit Search UI. \"appUsers\" are data scoped to the office/branch the requestor is associated with.\n"
106107
+ "\n" + "Example Requests:\n" + "\n" + "audits/searchtemplate\n" + "audits/searchtemplate?fields=actionNames")
108+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = AuditSearchData.class)))
107109
public AuditSearchData retrieveAuditSearchTemplate() {
108110
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
109111
return this.auditReadPlatformService.retrieveSearchTemplate("audit");
@@ -119,35 +121,35 @@ private SQLBuilder getExtraCriteria(AuditRequest auditRequest) {
119121
extraCriteria.addNonNullCriteria("aud.resource_id = ", auditRequest.getResourceId());
120122
extraCriteria.addNonNullCriteria("aud.maker_id = ", auditRequest.getMakerId());
121123
extraCriteria.addNonNullCriteria("aud.checker_id = ", auditRequest.getCheckerId());
122-
if (auditRequest.getMakerDateTimeFrom() != null) {
124+
if (auditRequest.hasMakerDateTimeFrom()) {
123125
extraCriteria.addSubOperation((SQLBuilder criteria) -> {
124126
criteria.addNonNullCriteria("aud.made_on_date >= ", auditRequest.getMakerDateTimeFrom(),
125127
SQLBuilder.WhereLogicalOperator.NONE);
126-
criteria.addNonNullCriteria("aud.made_on_date_utc >= ", auditRequest.getMakerDateTimeFrom(),
128+
criteria.addNonNullCriteria("aud.made_on_date_utc >= ", auditRequest.getMakerDateTimeFromOffset(),
127129
SQLBuilder.WhereLogicalOperator.OR);
128130
});
129131
}
130-
if (auditRequest.getMakerDateTimeTo() != null) {
132+
if (auditRequest.hasMakerDateTimeTo()) {
131133
extraCriteria.addSubOperation((SQLBuilder criteria) -> {
132134
criteria.addNonNullCriteria("aud.made_on_date <= ", auditRequest.getMakerDateTimeTo(),
133135
SQLBuilder.WhereLogicalOperator.NONE);
134-
criteria.addNonNullCriteria("aud.made_on_date_utc <= ", auditRequest.getMakerDateTimeTo(),
136+
criteria.addNonNullCriteria("aud.made_on_date_utc <= ", auditRequest.getMakerDateTimeToOffset(),
135137
SQLBuilder.WhereLogicalOperator.OR);
136138
});
137139
}
138-
if (auditRequest.getCheckerDateTimeFrom() != null) {
140+
if (auditRequest.hasCheckerDateTimeFrom()) {
139141
extraCriteria.addSubOperation((SQLBuilder criteria) -> {
140142
criteria.addNonNullCriteria("aud.checked_on_date >= ", auditRequest.getCheckerDateTimeFrom(),
141143
SQLBuilder.WhereLogicalOperator.NONE);
142-
criteria.addNonNullCriteria("aud.checked_on_date_utc >= ", auditRequest.getCheckerDateTimeFrom(),
144+
criteria.addNonNullCriteria("aud.checked_on_date_utc >= ", auditRequest.getCheckerDateTimeFromOffset(),
143145
SQLBuilder.WhereLogicalOperator.OR);
144146
});
145147
}
146-
if (auditRequest.getCheckerDateTimeTo() != null) {
148+
if (auditRequest.hasCheckerDateTimeTo()) {
147149
extraCriteria.addSubOperation((SQLBuilder criteria) -> {
148150
criteria.addNonNullCriteria("aud.checked_on_date <= ", auditRequest.getCheckerDateTimeTo(),
149151
SQLBuilder.WhereLogicalOperator.NONE);
150-
criteria.addNonNullCriteria("aud.checked_on_date_utc <= ", auditRequest.getCheckerDateTimeTo(),
152+
criteria.addNonNullCriteria("aud.checked_on_date_utc <= ", auditRequest.getCheckerDateTimeToOffset(),
151153
SQLBuilder.WhereLogicalOperator.OR);
152154
});
153155
}

fineract-provider/src/main/java/org/apache/fineract/commands/api/MakercheckersApiResource.java

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,22 @@ private SQLBuilder getExtraCriteria(MakerCheckerRequest makerCheckerRequest) {
134134
}
135135
extraCriteria.addNonNullCriteria("aud.resource_id = ", makerCheckerRequest.getResourceId());
136136
extraCriteria.addNonNullCriteria("aud.maker_id = ", makerCheckerRequest.getMakerId());
137-
extraCriteria.addNonNullCriteria("aud.made_on_date >= ", makerCheckerRequest.getMakerDateTimeFrom());
138-
extraCriteria.addNonNullCriteria("aud.made_on_date <= ", makerCheckerRequest.getMakerDateTimeTo());
137+
if (makerCheckerRequest.hasMakerDateTimeFrom()) {
138+
extraCriteria.addSubOperation((SQLBuilder criteria) -> {
139+
criteria.addNonNullCriteria("aud.made_on_date >= ", makerCheckerRequest.getMakerDateTimeFromLocal(),
140+
SQLBuilder.WhereLogicalOperator.NONE);
141+
criteria.addNonNullCriteria("aud.made_on_date_utc >= ", makerCheckerRequest.getMakerDateTimeFromOffset(),
142+
SQLBuilder.WhereLogicalOperator.OR);
143+
});
144+
}
145+
if (makerCheckerRequest.hasMakerDateTimeTo()) {
146+
extraCriteria.addSubOperation((SQLBuilder criteria) -> {
147+
criteria.addNonNullCriteria("aud.made_on_date <= ", makerCheckerRequest.getMakerDateTimeToLocal(),
148+
SQLBuilder.WhereLogicalOperator.NONE);
149+
criteria.addNonNullCriteria("aud.made_on_date_utc <= ", makerCheckerRequest.getMakerDateTimeToOffset(),
150+
SQLBuilder.WhereLogicalOperator.OR);
151+
});
152+
}
139153
extraCriteria.addNonNullCriteria("aud.office_id = ", makerCheckerRequest.getOfficeId());
140154
extraCriteria.addNonNullCriteria("aud.group_id = ", makerCheckerRequest.getGroupId());
141155
extraCriteria.addNonNullCriteria("aud.client_id = ", makerCheckerRequest.getClientId());

0 commit comments

Comments
 (0)