Repository: kestra-io/kestra
Issue: Add custom condition to respect holidays in the Schedule's timezone
Labels: enhancement

## Next steps

Add two new pebble expressions:
1. `isHoliday(date)` - returns True/False - naming TBD
2. `isWeekend(date)` - returns True/False - naming TBD

Add new Conditions (might be either 2 or 4 depending on whether NOT is a dedicated one):
1. Condition: for holidays, for NOT holidays
2. Condition: for the weekend, for NOT weekend

### Feature description

Request: "If I have scheduled a flow for the 1st day of the month, and according to my calendar, the 1st day falls on a holiday or a weekend, is there any way Kestra can automatically reschedule the flow to the next business day?"

We should add a holiday condition to make patterns like this applicable to business days only within a given country.

It would be best if we could use a database with current holidays per country like [this one](https://www.qppstudio.net/worldwide-public-holidays/presentation.htm)

Issue comments:

Pull request review comments:
Following Slack discussion, I'm ok with that but it should be consistent across all conditions so please do the change accordingly or stay with now()
```suggestion
    @PluginProperty(dynamic = true)
```
```suggestion
    @PluginProperty(dynamic = true)
```
```suggestion
        HolidayManager holidayManager = country != null ? HolidayManager.getInstance(ManagerParameters.create(conditionContext.getRunContext().render(country))) : HolidayManager.getInstance();
```
```suggestion
        return subDivision == null ? holidayManager.isHoliday(currentDate) : holidayManager.isHoliday(currentDate, conditionContext.getRunContext().render(subDivision));
```
Please also provide an example to revert the condition
It has been decided to align all date based conditions to the trigger date
I'm not sure it really have use cases but if you want ...
Done.

Patch that resolved the issue:
diff --git a/core/build.gradle b/core/build.gradle
index c151acc70b..ee984c103e 100644
--- a/core/build.gradle
+++ b/core/build.gradle
@@ -23,6 +23,8 @@ dependencies {
     implementation 'com.github.oshi:oshi-core:6.4.6'
     implementation 'io.pebbletemplates:pebble:3.2.1'
     implementation group: 'co.elastic.logging', name: 'logback-ecs-encoder', version: '1.5.0'
+    implementation group: 'de.focus-shift', name: 'jollyday-core', version: '0.22.0'
+    implementation group: 'de.focus-shift', name: 'jollyday-jaxb', version: '0.22.0'
 
     // scheduler
     implementation group: 'com.cronutils', name: 'cron-utils', version: '9.2.1'
diff --git a/core/src/main/java/io/kestra/core/models/conditions/types/DateTimeBetweenCondition.java b/core/src/main/java/io/kestra/core/models/conditions/types/DateTimeBetweenCondition.java
index e4dd2112be..30cbe3193d 100644
--- a/core/src/main/java/io/kestra/core/models/conditions/types/DateTimeBetweenCondition.java
+++ b/core/src/main/java/io/kestra/core/models/conditions/types/DateTimeBetweenCondition.java
@@ -40,11 +40,11 @@ public class DateTimeBetweenCondition extends Condition implements ScheduleCondi
     @NotNull
     @Schema(
         title = "The date to test",
-        description = "Can be any variable or any valid ISO 8601 datetime, default will use `{{ now() }}`"
+        description = "Can be any variable or any valid ISO 8601 datetime, default will use the trigger date"
     )
     @Builder.Default
     @PluginProperty(dynamic = true)
-    private final String date = "{{ now() }}";
+    private final String date = "{{ trigger.date }}";
 
     @Schema(
         title = "The date to test must be after this one",
diff --git a/core/src/main/java/io/kestra/core/models/conditions/types/DayWeekCondition.java b/core/src/main/java/io/kestra/core/models/conditions/types/DayWeekCondition.java
index fa42c77576..2da0c1b3f9 100644
--- a/core/src/main/java/io/kestra/core/models/conditions/types/DayWeekCondition.java
+++ b/core/src/main/java/io/kestra/core/models/conditions/types/DayWeekCondition.java
@@ -40,11 +40,11 @@ public class DayWeekCondition extends Condition implements ScheduleCondition {
     @NotNull
     @Schema(
         title = "The date to test",
-        description = "Can be any variable or any valid ISO 8601 datetime, default will use `{{ now(format=\"iso_local_date\") }}`"
+        description = "Can be any variable or any valid ISO 8601 datetime, default will use the trigger date"
     )
     @Builder.Default
     @PluginProperty(dynamic = true)
-    private final String date = "{{ now(format=\"iso_local_date\") }}";
+    private final String date = "{{ trigger.date }}";
 
     @NotNull
     @Schema(title = "The day of week")
diff --git a/core/src/main/java/io/kestra/core/models/conditions/types/DayWeekInMonthCondition.java b/core/src/main/java/io/kestra/core/models/conditions/types/DayWeekInMonthCondition.java
index 9e67158fb7..566c1b4132 100644
--- a/core/src/main/java/io/kestra/core/models/conditions/types/DayWeekInMonthCondition.java
+++ b/core/src/main/java/io/kestra/core/models/conditions/types/DayWeekInMonthCondition.java
@@ -42,11 +42,11 @@ public class DayWeekInMonthCondition extends Condition implements ScheduleCondit
     @NotNull
     @Schema(
         title = "The date to test",
-        description = "Can be any variable or any valid ISO 8601 datetime, default will use `{{ now(format=\"iso_local_date\") }}`"
+        description = "Can be any variable or any valid ISO 8601 datetime, default will use the trigger date`"
     )
     @Builder.Default
     @PluginProperty(dynamic = true)
-    private final String date = "{{ now(format=\"iso_local_date\") }}";
+    private final String date = "{{ trigger.date }}";
 
     @NotNull
     @Schema(title = "The day of week")
diff --git a/core/src/main/java/io/kestra/core/models/conditions/types/PublicHolidayCondition.java b/core/src/main/java/io/kestra/core/models/conditions/types/PublicHolidayCondition.java
new file mode 100644
index 0000000000..3816680633
--- /dev/null
+++ b/core/src/main/java/io/kestra/core/models/conditions/types/PublicHolidayCondition.java
@@ -0,0 +1,91 @@
+package io.kestra.core.models.conditions.types;
+
+import de.focus_shift.jollyday.core.HolidayManager;
+import de.focus_shift.jollyday.core.ManagerParameters;
+import io.kestra.core.exceptions.InternalException;
+import io.kestra.core.models.annotations.Example;
+import io.kestra.core.models.annotations.Plugin;
+import io.kestra.core.models.annotations.PluginProperty;
+import io.kestra.core.models.conditions.Condition;
+import io.kestra.core.models.conditions.ConditionContext;
+import io.kestra.core.models.conditions.ScheduleCondition;
+import io.kestra.core.utils.DateUtils;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.*;
+import lombok.experimental.SuperBuilder;
+
+import javax.validation.constraints.NotEmpty;
+import java.time.LocalDate;
+import java.util.Locale;
+
+@SuperBuilder
+@ToString
+@EqualsAndHashCode
+@Getter
+@NoArgsConstructor
+@Schema(
+    title = "Condition to allow events on public holidays"
+)
+@Plugin(
+    examples = {
+        @Example(
+            full = true,
+            title = "Condition to allow events on public holidays",
+            code = {
+                """
+                - conditions:
+                    - type: io.kestra.core.models.conditions.types.PublicHolidayCondition
+                      country: FR
+                """
+            }
+        ),
+        @Example(
+            full = true,
+            title = "Conditions to allow events on work days",
+            code = {
+                """
+                - conditions:
+                    - type: io.kestra.core.models.conditions.types.NotCondition
+                      conditions:
+                        - type: io.kestra.core.models.conditions.types.PublicHolidayCondition
+                          country: FR
+                        - type: io.kestra.core.models.conditions.types.WeekendCondition
+                """
+            }
+        )
+    }
+)
+public class PublicHolidayCondition extends Condition implements ScheduleCondition {
+    @NotEmpty
+    @Schema(
+        title = "The date to test",
+        description = "Can be any variable or any valid ISO 8601 datetime, default will use the trigger date"
+    )
+    @Builder.Default
+    @PluginProperty(dynamic = true)
+    private String date = "{{ trigger.date }}";
+
+    @Schema(
+        title = "[ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) country code. If not set it uses the country code from the default locale.",
+        description = "It uses the [Jollyday](https://github.com/focus-shift/jollyday) library for public holiday calendar that support more than 70 countries."
+    )
+    @PluginProperty(dynamic = true)
+    private String country;
+
+    @Schema(
+        title = "[ISO 3166-2](https://en.wikipedia.org/wiki/ISO_3166-2) country subdivision (e.g., provinces and states) code.",
+        description = "It uses the [Jollyday](https://github.com/focus-shift/jollyday) library for public holiday calendar that support more than 70 countries."
+    )
+    @PluginProperty(dynamic = true)
+    private String subDivision;
+
+    @Override
+    public boolean test(ConditionContext conditionContext) throws InternalException {
+        var renderedCountry = conditionContext.getRunContext().render(this.country);
+        var renderedSubDivision = conditionContext.getRunContext().render(this.subDivision);
+
+        HolidayManager holidayManager = renderedCountry != null ? HolidayManager.getInstance(ManagerParameters.create(renderedCountry)) : HolidayManager.getInstance();
+        LocalDate currentDate = DateUtils.parseLocalDate(conditionContext.getRunContext().render(date));
+        return renderedSubDivision == null ? holidayManager.isHoliday(currentDate) : holidayManager.isHoliday(currentDate, renderedSubDivision);
+    }
+}
diff --git a/core/src/main/java/io/kestra/core/models/conditions/types/TimeBetweenCondition.java b/core/src/main/java/io/kestra/core/models/conditions/types/TimeBetweenCondition.java
index 9e492d623e..08a595aa9b 100644
--- a/core/src/main/java/io/kestra/core/models/conditions/types/TimeBetweenCondition.java
+++ b/core/src/main/java/io/kestra/core/models/conditions/types/TimeBetweenCondition.java
@@ -40,11 +40,11 @@ public class TimeBetweenCondition extends Condition implements ScheduleCondition
     @NotNull
     @Schema(
         title = "The time to test",
-        description = "Can be any variable or any valid ISO 8601 time, default will use `{{ now(format='iso_offset_time') }}`"
+        description = "Can be any variable or any valid ISO 8601 time, default will use the trigger date"
     )
     @Builder.Default
     @PluginProperty(dynamic = true)
-    private final String date = "{{ now(format='iso_offset_time') }}";
+    private final String date = "{{ trigger.date }}";
 
     @Schema(
         title = "The time to test must be after this one",
diff --git a/core/src/main/java/io/kestra/core/models/conditions/types/WeekendCondition.java b/core/src/main/java/io/kestra/core/models/conditions/types/WeekendCondition.java
index 3c2c168ffd..82d1bc2fc6 100644
--- a/core/src/main/java/io/kestra/core/models/conditions/types/WeekendCondition.java
+++ b/core/src/main/java/io/kestra/core/models/conditions/types/WeekendCondition.java
@@ -22,7 +22,7 @@
 @Getter
 @NoArgsConstructor
 @Schema(
-    title = "Condition for allows events on weekend"
+    title = "Condition to allow events on weekend"
 )
 @Plugin(
     examples = {
@@ -39,11 +39,11 @@ public class WeekendCondition extends Condition implements ScheduleCondition {
     @NotNull
     @Schema(
         title = "The date to test",
-        description = "Can be any variable or any valid ISO 8601 datetime, default will use `{{ now(format=\"iso_local_date\") }}`"
+        description = "Can be any variable or any valid ISO 8601 datetime, default will use the trigger date"
     )
     @Builder.Default
     @PluginProperty(dynamic = true)
-    private final String date = "{{ now(format(\"iso_local_date\") }}";
+    private final String date = "{{ trigger.date }}";
 
     @Override
     public boolean test(ConditionContext conditionContext) throws InternalException {


Test patch:
diff --git a/core/src/test/java/io/kestra/core/models/conditions/types/PublicHolidayConditionTest.java b/core/src/test/java/io/kestra/core/models/conditions/types/PublicHolidayConditionTest.java
new file mode 100644
index 0000000000..f9c9a15234
--- /dev/null
+++ b/core/src/test/java/io/kestra/core/models/conditions/types/PublicHolidayConditionTest.java
@@ -0,0 +1,61 @@
+package io.kestra.core.models.conditions.types;
+
+import com.google.common.collect.ImmutableMap;
+import io.kestra.core.models.executions.Execution;
+import io.kestra.core.models.flows.Flow;
+import io.kestra.core.services.ConditionService;
+import io.kestra.core.utils.TestsUtils;
+import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
+import jakarta.inject.Inject;
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+@MicronautTest
+class PublicHolidayConditionTest {
+    @Inject
+    ConditionService conditionService;
+
+    @Test
+    void valid() {
+        Flow flow = TestsUtils.mockFlow();
+        Execution execution = TestsUtils.mockExecution(flow, ImmutableMap.of());
+
+        PublicHolidayCondition publicHoliday = PublicHolidayCondition.builder()
+            .date("2023-01-01")
+            .build();
+        assertThat(conditionService.isValid(publicHoliday, flow, execution), is(true));
+
+        publicHoliday = PublicHolidayCondition.builder()
+            .date("2023-07-14")
+            .country("FR")
+            .build();
+        assertThat(conditionService.isValid(publicHoliday, flow, execution), is(true));
+
+        publicHoliday = PublicHolidayCondition.builder()
+            .date("2023-03-08")
+            .country("DE")
+            .subDivision("BE")
+            .build();
+        assertThat(conditionService.isValid(publicHoliday, flow, execution), is(true));
+    }
+
+    @Test
+    void invalid() {
+        Flow flow = TestsUtils.mockFlow();
+        Execution execution = TestsUtils.mockExecution(flow, ImmutableMap.of());
+
+        PublicHolidayCondition publicHoliday = PublicHolidayCondition.builder()
+            .date("2023-01-02")
+            .country("FR")
+            .build();
+        assertThat(conditionService.isValid(publicHoliday, flow, execution), is(false));
+
+        publicHoliday = PublicHolidayCondition.builder()
+            .date("2023-03-08")
+            .country("DE")
+            .build();
+        assertThat(conditionService.isValid(publicHoliday, flow, execution), is(false));
+    }
+}
\ No newline at end of file