Repository: kestra-io/kestra (java)

Issue:
Add custom condition to respect holidays in the Schedule's timezone
## 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)

Pull request description:
feat(core): public holiday condition
Fixes #2629 

To QA it you can use the following flow and play with country and subdivision:

```yaml
id: schedule
namespace: company.team

triggers:
  - id: schedule
    type: io.kestra.core.models.triggers.types.Schedule
    cron: "* * * * *"
    conditions:
      - type: io.kestra.core.models.conditions.types.PublicHolidayCondition
        country: DE
        # subDivision: BE
        date: "2023-03-08"
    
tasks:
  - id: hello
    type: io.kestra.core.tasks.log.Log
    message: Kestra team wishes you a great day! 👋
```

In this example, 2023-03-08 will not trigger a flow for country DE but will trigger a flow for country DE and subDivision BE.

Patch:
diff --git a/core/build.gradle b/core/build.gradle
index c151acc70..ee984c103 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 e4dd2112b..30cbe3193 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 fa42c7757..2da0c1b3f 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 9e67158fb..566c1b413 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 000000000..381668063
--- /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 9e492d623..08a595aa9 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 3c2c168ff..82d1bc2fc 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 @@ import javax.validation.constraints.NotNull;
 @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 {
diff --git a/core/src/main/java/io/kestra/core/models/triggers/types/Flow.java b/core/src/main/java/io/kestra/core/models/triggers/types/Flow.java
index eca73ddd3..ff32e10ab 100644
--- a/core/src/main/java/io/kestra/core/models/triggers/types/Flow.java
+++ b/core/src/main/java/io/kestra/core/models/triggers/types/Flow.java
@@ -82,6 +82,7 @@ public class Flow extends AbstractTrigger implements TriggerOutput<Flow.Output>
 
         Execution.ExecutionBuilder builder = Execution.builder()
             .id(IdUtils.create())
+            .tenantId(flow.getTenantId())
             .namespace(flow.getNamespace())
             .flowId(flow.getId())
             .flowRevision(flow.getRevision())


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 000000000..f9c9a1523
--- /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
diff --git a/core/src/test/java/io/kestra/core/models/triggers/types/FlowTest.java b/core/src/test/java/io/kestra/core/models/triggers/types/FlowTest.java
index 657112d33..c4ff1d4ab 100644
--- a/core/src/test/java/io/kestra/core/models/triggers/types/FlowTest.java
+++ b/core/src/test/java/io/kestra/core/models/triggers/types/FlowTest.java
@@ -65,4 +65,49 @@ class FlowTest {
         assertThat(evaluate.get().getLabels(), hasItem(new Label("flow-label-1", "flow-label-1")));
         assertThat(evaluate.get().getLabels(), hasItem(new Label("flow-label-2", "flow-label-2")));
     }
+
+    @Test
+    void withTenant() {
+        var flow = io.kestra.core.models.flows.Flow.builder()
+            .id("flow-with-flow-trigger")
+            .tenantId("tenantId")
+            .namespace("io.kestra.unittest")
+            .revision(1)
+            .labels(
+                List.of(
+                    new Label("flow-label-1", "flow-label-1"),
+                    new Label("flow-label-2", "flow-label-2")
+                )
+            )
+            .tasks(Collections.singletonList(Return.builder()
+                .id("test")
+                .type(Return.class.getName())
+                .format("test")
+                .build()))
+            .build();
+        var execution = Execution.builder()
+            .id(IdUtils.create())
+            .tenantId("tenantId")
+            .namespace("io.kestra.unittest")
+            .flowId("flow-with-flow-trigger")
+            .flowRevision(1)
+            .state(State.of(State.Type.RUNNING, Collections.emptyList()))
+            .build();
+        var flowTrigger = Flow.builder()
+            .id("flow")
+            .type(Flow.class.getName())
+            .build();
+
+        Optional<Execution> evaluate = flowTrigger.evaluate(
+            runContextFactory.of(),
+            flow,
+            execution
+        );
+
+        assertThat(evaluate.isPresent(), is(true));
+        assertThat(evaluate.get().getFlowId(), is("flow-with-flow-trigger"));
+        assertThat(evaluate.get().getTenantId(), is("tenantId"));
+        assertThat(evaluate.get().getLabels(), hasItem(new Label("flow-label-1", "flow-label-1")));
+        assertThat(evaluate.get().getLabels(), hasItem(new Label("flow-label-2", "flow-label-2")));
+    }
 }
diff --git a/core/src/test/java/io/kestra/core/services/TaskDefaultServiceTest.java b/core/src/test/java/io/kestra/core/services/TaskDefaultServiceTest.java
index f56004d71..f1f4390da 100644
--- a/core/src/test/java/io/kestra/core/services/TaskDefaultServiceTest.java
+++ b/core/src/test/java/io/kestra/core/services/TaskDefaultServiceTest.java
@@ -85,6 +85,8 @@ class TaskDefaultServiceTest {
         assertThat(((DefaultTester) injected.getTasks().get(0)).getProperty().getLists().size(), is(1));
         assertThat(((DefaultTester) injected.getTasks().get(0)).getProperty().getLists().get(0).getVal().size(), is(1));
         assertThat(((DefaultTester) injected.getTasks().get(0)).getProperty().getLists().get(0).getVal().get("key"), is("test"));
+        assertThat(((DefaultTriggerTester) injected.getTriggers().get(0)).getSet(), is(123));
+        assertThat(((VariableCondition) injected.getTriggers().get(0).getConditions().get(0)).getExpression(), is("{{ test }}"));
     }
 
     @Test