diff --git a/build.gradle.kts b/build.gradle.kts index b758b03d9..1b5e3afdf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,7 +16,7 @@ java { allprojects { group = "fr.insee.eno" - version = "3.30.1-SNAPSHOT.1" + version = "3.30.1-SNAPSHOT.2" } subprojects { diff --git a/eno-core/src/main/java/fr/insee/eno/core/exceptions/business/InvalidValueException.java b/eno-core/src/main/java/fr/insee/eno/core/exceptions/business/InvalidValueException.java new file mode 100644 index 000000000..7eabec561 --- /dev/null +++ b/eno-core/src/main/java/fr/insee/eno/core/exceptions/business/InvalidValueException.java @@ -0,0 +1,9 @@ +package fr.insee.eno.core.exceptions.business; + +public class InvalidValueException extends RuntimeException { + + public InvalidValueException(String message) { + super(message); + } + +} diff --git a/eno-core/src/main/java/fr/insee/eno/core/exceptions/business/RequiredPropertyException.java b/eno-core/src/main/java/fr/insee/eno/core/exceptions/business/RequiredPropertyException.java new file mode 100644 index 000000000..cba22cc39 --- /dev/null +++ b/eno-core/src/main/java/fr/insee/eno/core/exceptions/business/RequiredPropertyException.java @@ -0,0 +1,9 @@ +package fr.insee.eno.core.exceptions.business; + +public class RequiredPropertyException extends RuntimeException { + + public RequiredPropertyException(String message) { + super(message); + } + +} diff --git a/eno-core/src/main/java/fr/insee/eno/core/i18n/date/DateFormatter.java b/eno-core/src/main/java/fr/insee/eno/core/i18n/date/DateFormatter.java new file mode 100644 index 000000000..4b4bdbc19 --- /dev/null +++ b/eno-core/src/main/java/fr/insee/eno/core/i18n/date/DateFormatter.java @@ -0,0 +1,54 @@ +package fr.insee.eno.core.i18n.date; + +import fr.insee.eno.core.parameter.EnoParameters; +import lombok.NonNull; + +import java.util.regex.Pattern; + +/** + * Interface to validate and format dates. + */ +public interface DateFormatter { + + class Factory { + + private Factory() {} + + public static DateFormatter forLanguage(EnoParameters.Language language) { + if (EnoParameters.Language.FR.equals(language)) + return new Iso8601ToFrench(); + // By default, return the implementation that returns date values in ISO-8601 format + return new Iso8601Formatter(); + } + } + + record Result( + boolean isValid, + String value, + String errorMessage) { + + public static Result success(String value) { + return new Result(true, value, null); + } + + public static Result failure(String errorMessage) { + return new Result(false, null, errorMessage); + } + } + + Pattern yearDatePattern = Pattern.compile("\\d{4}"); + + default Result convertYearDate(@NonNull String date) { + if (date.length() != 4 || !yearDatePattern.matcher(date).matches()) + return Result.failure(errorMessage(date, "YYYY")); + return Result.success(date); + } + + Result convertYearMontDate(@NonNull String date); + + Result convertYearMontDayDate(@NonNull String date); + + default String errorMessage(String date, String format) { + return "Date '" + date + "' is invalid or doesn't match format '" + format + "'."; + } +} diff --git a/eno-core/src/main/java/fr/insee/eno/core/i18n/date/Iso8601Formatter.java b/eno-core/src/main/java/fr/insee/eno/core/i18n/date/Iso8601Formatter.java new file mode 100644 index 000000000..df1e717be --- /dev/null +++ b/eno-core/src/main/java/fr/insee/eno/core/i18n/date/Iso8601Formatter.java @@ -0,0 +1,29 @@ +package fr.insee.eno.core.i18n.date; + +import lombok.NonNull; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; + +public class Iso8601Formatter implements DateFormatter { + + public Result convertYearMontDate(@NonNull String date) { + try { + return Result.success( + YearMonth.parse(date).format(DateTimeFormatter.ofPattern("yyyy-MM"))); + } catch (Exception e) { + return Result.failure(errorMessage(date, "YYYY-MM")); + } + } + + public Result convertYearMontDayDate(@NonNull String date) { + try { + return Result.success( + LocalDate.parse(date).format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))); + } catch (Exception e) { + return Result.failure(errorMessage(date, "YYYY-MM-DD")); + } + } + +} diff --git a/eno-core/src/main/java/fr/insee/eno/core/i18n/date/Iso8601ToFrench.java b/eno-core/src/main/java/fr/insee/eno/core/i18n/date/Iso8601ToFrench.java new file mode 100644 index 000000000..62b63f9f3 --- /dev/null +++ b/eno-core/src/main/java/fr/insee/eno/core/i18n/date/Iso8601ToFrench.java @@ -0,0 +1,29 @@ +package fr.insee.eno.core.i18n.date; + +import lombok.NonNull; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; + +public class Iso8601ToFrench implements DateFormatter { + + public Result convertYearMontDate(@NonNull String date) { + try { + return Result.success( + YearMonth.parse(date).format(DateTimeFormatter.ofPattern("MM/yyyy"))); + } catch (Exception e) { + return Result.failure(errorMessage(date, "YYYY-MM")); + } + } + + public Result convertYearMontDayDate(@NonNull String date) { + try { + return Result.success( + LocalDate.parse(date).format(DateTimeFormatter.ofPattern("dd/MM/yyyy"))); + } catch (Exception e) { + return Result.failure(errorMessage(date, "YYYY-MM-DD")); + } + } + +} diff --git a/eno-core/src/main/java/fr/insee/eno/core/model/question/DateQuestion.java b/eno-core/src/main/java/fr/insee/eno/core/model/question/DateQuestion.java index 66668d0b4..cbfdbfc1c 100644 --- a/eno-core/src/main/java/fr/insee/eno/core/model/question/DateQuestion.java +++ b/eno-core/src/main/java/fr/insee/eno/core/model/question/DateQuestion.java @@ -20,6 +20,10 @@ @Context(format = Format.LUNATIC, type = Datepicker.class) public class DateQuestion extends SingleResponseQuestion { + public static final String YEAR_FORMAT = "YYYY"; + public static final String YEAR_MONTH_FORMAT = "YYYY-MM"; + public static final String YEAR_MONTH_DAY_FORMAT = "YYYY-MM-DD"; + /** * Minimum date value allowed. */ diff --git a/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/LunaticAddControlFormat.java b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/LunaticAddControlFormat.java index b76bad85d..ec00fef5b 100644 --- a/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/LunaticAddControlFormat.java +++ b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/LunaticAddControlFormat.java @@ -1,15 +1,17 @@ package fr.insee.eno.core.processing.out.steps.lunatic; +import fr.insee.eno.core.exceptions.business.InvalidValueException; +import fr.insee.eno.core.exceptions.business.RequiredPropertyException; +import fr.insee.eno.core.i18n.date.DateFormatter; +import fr.insee.eno.core.model.question.DateQuestion; +import fr.insee.eno.core.parameter.EnoParameters; import fr.insee.eno.core.processing.ProcessingStep; import fr.insee.lunatic.model.flat.*; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.NonNull; import lombok.extern.slf4j.Slf4j; + import java.math.BigDecimal; import java.math.RoundingMode; -import java.time.LocalDate; -import java.time.YearMonth; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -21,6 +23,18 @@ */ @Slf4j public class LunaticAddControlFormat implements ProcessingStep { + + private final DateFormatter dateFormatter; + + /** Using french as default value for date formatting. */ + public LunaticAddControlFormat() { + dateFormatter = DateFormatter.Factory.forLanguage(EnoParameters.Language.FR); + } + + public LunaticAddControlFormat(EnoParameters.Language language) { + dateFormatter = DateFormatter.Factory.forLanguage(language); + } + /** * * @param lunaticQuestionnaire lunatic questionnaire to be processed. @@ -94,8 +108,10 @@ private List getFormatControlsForBodyCells(List bodyCells return controls; } + // TODO: internationalization of the generated messages + /** - * Create controls for a input number component + * Create controls for an input number component * @param number input number to process */ private void createFormatControlsForInputNumber(InputNumber number) { @@ -162,26 +178,16 @@ private void createFormatControlsForDatepicker(Datepicker datepicker) { // of the first control is displayed. } - private static final Logger logger = LoggerFactory.getLogger(LunaticAddControlFormat.class); - - String formatDateToFrench(String date) { - - if (date == null || date.isEmpty()) { - logger.warn("Date nulle ou vide reçue. Elle sera ignorée."); - return null; - } - if (date.matches("\\d{4}")) { - return date; - } else if (date.matches("\\d{4}-\\d{2}")) { - YearMonth parsedDate = YearMonth.parse(date); - return parsedDate.format(DateTimeFormatter.ofPattern("MM-yyyy")); - } else if (date.matches("\\d{4}-\\d{2}-\\d{2}")) { - LocalDate parsedDate = LocalDate.parse(date); - return parsedDate.format(DateTimeFormatter.ofPattern("dd-MM-yyyy")); - } else { - logger.warn("Format de date non pris en charge. Attendu : AAAA, AAAA-MM, ou AAAA-MM-JJ. Reçu : {}. La date sera ignorée.", date); - return null; - } + private Optional validateAndConvertDate(String date, @NonNull String format) { + if (date == null) + return Optional.empty(); // The min/max date properties can eventually be null (not 'required' in Pogues) + DateFormatter.Result result = switch (format) { + case DateQuestion.YEAR_MONTH_DAY_FORMAT -> dateFormatter.convertYearMontDayDate(date); + case DateQuestion.YEAR_MONTH_FORMAT -> dateFormatter.convertYearMontDate(date); + case DateQuestion.YEAR_FORMAT -> dateFormatter.convertYearDate(date); + default -> throw new InvalidValueException("Date format '" + format + "' is invalid."); + }; + return Optional.of(result); } /** @@ -192,20 +198,28 @@ String formatDateToFrench(String date) { * @param format format string * @param responseName date picker response attribute */ + private Optional getFormatControlFromDatepickerAttributes(String id, String minValue, String maxValue, String format, String responseName) { + if (format == null) + throw new RequiredPropertyException("Format is missing in date question '" + id + "'"); - Optional getFormatControlFromDatepickerAttributes(String id, String minValue, String maxValue, String format, String responseName) { String controlIdPrefix = id + "-format-date"; - String formattedMinValue = minValue != null ? formatDateToFrench(minValue) : null; - String formattedMaxValue = maxValue != null ? formatDateToFrench(maxValue) : null; - - if (minValue == null && maxValue == null) { - logger.warn("Aucune contrainte de date définie pour l'id : {}.", id); - return Optional.empty(); + Optional formattedMinValue = validateAndConvertDate(minValue, format); + if (formattedMinValue.isPresent() && !formattedMinValue.get().isValid()) { + String message = "Invalid value for min date of question '" + id + "': " + formattedMinValue.get().errorMessage(); + log.error(message); + throw new InvalidValueException(message); + } + Optional formattedMaxValue = validateAndConvertDate(maxValue, format); + if (formattedMaxValue.isPresent() && !formattedMaxValue.get().isValid()) { + // Due to a bug in the Pogues -> DDI transformation, an invalid max date doesn't throw an exception for now + log.warn("Invalid value for max date of question '" + id + "': " + maxValue); } - if (minValue != null && maxValue != null) { + boolean generateMin = formattedMinValue.isPresent(); // always valid when reached + boolean generateMax = formattedMaxValue.isPresent() && formattedMaxValue.get().isValid(); // can be invalid + if (generateMin && generateMax) { String controlExpression = String.format( "not(not(isnull(%s)) and " + "(cast(%s, date, \"%s\") getFormatControlFromDatepickerAttributes(String id, String ); String controlErrorMessage = String.format( "\"La date saisie doit être comprise entre %s et %s.\"", - formattedMinValue, formattedMaxValue + formattedMinValue.get().value(), formattedMaxValue.get().value() ); return Optional.of(createFormatControl(controlIdPrefix + "-borne-inf-sup", controlExpression, controlErrorMessage)); } - if (minValue == null && maxValue != null) { + if (generateMax) { String controlExpression = String.format( "not(not(isnull(%s)) and (cast(%s, date, \"%s\")>cast(\"%s\", date, \"%s\")))", responseName, responseName, format, maxValue, format ); String controlErrorMessage = String.format( "\"La date saisie doit être antérieure à %s.\"", - formattedMaxValue + formattedMaxValue.get().value() ); return Optional.of(createFormatControl(controlIdPrefix + "-borne-sup", controlExpression, controlErrorMessage)); } - if (minValue != null && maxValue == null) { + if (generateMin) { String controlExpression = String.format( "not(not(isnull(%s)) and (cast(%s, date, \"%s\") processing.apply(lunaticQuestionnaire)); } @Test @@ -195,11 +195,27 @@ void datepickerMinMaxFormatControl() { "cast(DATE_VAR, date, \"YYYY-MM-DD\")>cast(\"2023-01-01\", date, \"YYYY-MM-DD\")))"; assertEquals(expected, control.getControl().getValue()); assertEquals(LabelTypeEnum.VTL, control.getControl().getType()); + assertEquals("\"La date saisie doit être comprise entre 01/01/2020 et 01/01/2023.\"", + control.getErrorMessage().getValue()); assertEquals(LabelTypeEnum.VTL_MD, control.getErrorMessage().getType()); assertEquals(ControlTypeEnum.FORMAT, control.getTypeOfControl()); assertEquals(ControlCriticalityEnum.ERROR, control.getCriticality()); } + @Test + @DisplayName("Datepicker: invalid max value should be ignored") + void datepickerInvalidMaxValue() { + lunaticQuestionnaire = new Questionnaire(); + lunaticQuestionnaire.getComponents().add(datePicker); + datePicker.setMax("year-from-date(current-date())"); + datePicker.setDateFormat("YYYY-MM-DD"); + processing.apply(lunaticQuestionnaire); + + // Only year format control should be present + assertEquals(1, datePicker.getControls().size()); + assertNotEquals("datepicker-id-format-date-borne-sup", datePicker.getControls().getFirst().getId()); + } + @Test @DisplayName("Datepicker: min format control") void datepickerMinFormatControl() { @@ -218,11 +234,24 @@ void datepickerMinFormatControl() { "(cast(DATE_VAR, date, \"YYYY-MM-DD\") processing.apply(lunaticQuestionnaire)); + } + @Test @DisplayName("Datepicker: max format control") void datepickerMaxFormatControl() { @@ -241,13 +270,15 @@ void datepickerMaxFormatControl() { "and (cast(DATE_VAR, date, \"YYYY-MM-DD\")>cast(\"2023-01-01\", date, \"YYYY-MM-DD\")))"; assertEquals(expected, boundsControl.getControl().getValue()); assertEquals(LabelTypeEnum.VTL, boundsControl.getControl().getType()); + assertEquals("\"La date saisie doit être antérieure à 01/01/2023.\"", + boundsControl.getErrorMessage().getValue()); assertEquals(LabelTypeEnum.VTL_MD, boundsControl.getErrorMessage().getType()); assertEquals(ControlTypeEnum.FORMAT, boundsControl.getTypeOfControl()); assertEquals(ControlCriticalityEnum.ERROR, boundsControl.getCriticality()); } @Test - @DisplayName("Datepicker: year format control when min/max is set") + @DisplayName("Datepicker: year format control when max is set") void datepickerYearAndMinMaxFormatControl() { lunaticQuestionnaire = new Questionnaire(); lunaticQuestionnaire.getComponents().add(datePicker); @@ -367,64 +398,4 @@ private BodyLine buildBodyLine(List bodyCells) { return bodyLine; } - @Test - void testFormatDateToFrench_validDate() { - String input = "2024-12-10"; - String expected = "10-12-2024"; - String result = processing.formatDateToFrench(input); - assertEquals(expected, result, "La date doit être formatée correctement en JJ-MM-AAAA."); - } - - @Test - void shouldReturnEmptyWhenNoConstraints() { - Optional result = processing.getFormatControlFromDatepickerAttributes( - "datepicker-id", null, null, "YYYY-MM-DD", "DATEVAR"); - assertTrue(result.isEmpty(), "Aucun contrôle attendu lorsqu'aucune contrainte n'est fournie."); - } - - @Test - void shouldReturnControlWithMinOnly() { - Optional result = processing.getFormatControlFromDatepickerAttributes( - "datepicker-id", "2020-01-01", null, "YYYY-MM-DD", "DATEVAR"); - - assertTrue(result.isPresent()); - ControlType control = result.get(); - assertEquals("not(not(isnull(DATEVAR)) and (cast(DATEVAR, date, \"YYYY-MM-DD\") result = processing.getFormatControlFromDatepickerAttributes( - "datepicker-id", null, "2023-01-01", "YYYY-MM-DD", "DATEVAR"); - - assertTrue(result.isPresent()); - ControlType control = result.get(); - assertEquals("not(not(isnull(DATEVAR)) and (cast(DATEVAR, date, \"YYYY-MM-DD\")>cast(\"2023-01-01\", date, \"YYYY-MM-DD\")))", - control.getControl().getValue()); - assertEquals("\"La date saisie doit être antérieure à 01-01-2023.\"", - control.getErrorMessage().getValue()); - } - - @Test - void shouldReturnControlWithBothMinAndMax() { - Optional result = processing.getFormatControlFromDatepickerAttributes( - "datepicker-id", "2020-01-01", "2023-01-01", "YYYY-MM-DD", "DATEVAR"); - - assertTrue(result.isPresent()); - ControlType control = result.get(); - assertEquals("not(not(isnull(DATEVAR)) and (cast(DATEVAR, date, \"YYYY-MM-DD\")cast(\"2023-01-01\", date, \"YYYY-MM-DD\")))", - control.getControl().getValue()); - assertEquals("\"La date saisie doit être comprise entre 01-01-2020 et 01-01-2023.\"", - control.getErrorMessage().getValue()); - } - - @Test - void shouldLogWarningForUnknownFormat() { - String result = processing.formatDateToFrench("year-from-date(current-date())"); - assertNull(result); - } } -