diff --git a/config/event.yaml b/config/event.yaml index c403f99..a4caf1d 100644 --- a/config/event.yaml +++ b/config/event.yaml @@ -1,8 +1,32 @@ -# TasksTemplate configuration +# Event configuration # Unique identifier for the event -id: tasks-template -# Date and time when CTF starts (users have access to tasks etc.) -start-date: 2025-02-15T8:30:00+01:00 -# After this date users can't upload more flags -end-date: 2025-02-15T15:30:00+01:00 +id: h4k-test-edition +# Human-readable name of the event displayed in the UI +name: Hack4Krak Test Edition + +# Event stages shown on the timeline. +# Each stage is either a single point in time (only start-date) or a range (start-date + end-date). +# +# Supported stage types: +# - event-start: when flag submission opens (point in time); exactly one required +# - event-end: when flag submission closes (point in time); exactly one required +# - informative: info marker (point in time or range if end-date is provided) +stages: + - name: Breakfast + type: informative + start-date: 2025-02-15T09:00:00+01:00 + end-date: 2025-02-15T11:00:00+01:00 + + - name: Competition Start + type: event-start + start-date: 2025-02-15T10:00:00+01:00 + + - name: Lunch + type: informative + start-date: 2025-02-15T14:00:00+01:00 + end-date: 2025-02-15T15:00:00+01:00 + + - name: Event End + type: event-end + start-date: 2025-02-16T16:00:00+01:00 diff --git a/toolbox/tests/test_verify.py b/toolbox/tests/test_verify.py index 1a6d749..caf1346 100644 --- a/toolbox/tests/test_verify.py +++ b/toolbox/tests/test_verify.py @@ -14,8 +14,18 @@ def valid_event_config(): return """ id: tasks - end-date: 2025-02-15T15:30:00+01:00 - start-date: 2025-02-15T8:30:00 + name: Hack4Krak Test Edition + stages: + - name: Event start + type: event-start + start-date: 2025-02-15T08:30:00+01:00 + - name: Pizza break + type: informative + start-date: 2025-02-15T12:30:00+01:00 + description: Pizza served in the main hall + - name: Event end + type: event-end + start-date: 2025-02-15T15:30:00+01:00 """ @@ -82,6 +92,90 @@ def invalid_event_config(): return {"event_name": "Test Event"} +@pytest.fixture +def invalid_event_stage_config(): + return """ + id: tasks + name: Hack4Krak Test Edition + stages: + - name: Event start + type: event-start + start-date: 2025-02-15T08:30:00+01:00 + - name: Event end + type: event-end + start-date: 2025-02-15T15:30:00+01:00 + - name: Warm-up + type: normal + start-date: 2025-02-15T09:00:00+01:00 + """ + + +@pytest.fixture +def invalid_event_missing_start_stage_config(): + return """ + id: tasks + name: Hack4Krak Test Edition + stages: + - name: Lunch + type: informative + start-date: 2025-02-15T12:30:00+01:00 + - name: Event end + type: event-end + start-date: 2025-02-15T15:30:00+01:00 + """ + + +@pytest.fixture +def invalid_event_missing_end_stage_config(): + return """ + id: tasks + name: Hack4Krak Test Edition + stages: + - name: Event start + type: event-start + start-date: 2025-02-15T08:30:00+01:00 + - name: Lunch + type: informative + start-date: 2025-02-15T12:30:00+01:00 + """ + + +@pytest.fixture +def invalid_event_multiple_start_stages_config(): + return """ + id: tasks + name: Hack4Krak Test Edition + stages: + - name: Event start A + type: event-start + start-date: 2025-02-15T08:30:00+01:00 + - name: Event start B + type: event-start + start-date: 2025-02-15T08:45:00+01:00 + - name: Event end + type: event-end + start-date: 2025-02-15T15:30:00+01:00 + """ + + +@pytest.fixture +def invalid_event_multiple_end_stages_config(): + return """ + id: tasks + name: Hack4Krak Test Edition + stages: + - name: Event start + type: event-start + start-date: 2025-02-15T08:30:00+01:00 + - name: Event end A + type: event-end + start-date: 2025-02-15T15:30:00+01:00 + - name: Event end B + type: event-end + start-date: 2025-02-15T16:30:00+01:00 + """ + + @pytest.fixture def mock_context(): context = MagicMock() @@ -366,6 +460,61 @@ def test_config_registration_external_no_max_team_per_org( ) +@patch.object(Path, "read_text") +def test_config_invalid_event_stage( + mock_read_text, mock_context, invalid_event_stage_config, valid_registration_config_internal +): + mock_read_text.side_effect = [invalid_event_stage_config, valid_registration_config_internal] + + with patch.object(Console, "print") as mock_print: + config(mock_context) + assert "literal_error" in mock_print.call_args[0][0] + + +@patch.object(Path, "read_text") +def test_config_missing_event_start_stage( + mock_read_text, mock_context, invalid_event_missing_start_stage_config, valid_registration_config_internal +): + mock_read_text.side_effect = [invalid_event_missing_start_stage_config, valid_registration_config_internal] + + with patch.object(Console, "print") as mock_print: + config(mock_context) + assert "invalid_event_stage_count" in mock_print.call_args[0][0] + + +@patch.object(Path, "read_text") +def test_config_missing_event_end_stage( + mock_read_text, mock_context, invalid_event_missing_end_stage_config, valid_registration_config_internal +): + mock_read_text.side_effect = [invalid_event_missing_end_stage_config, valid_registration_config_internal] + + with patch.object(Console, "print") as mock_print: + config(mock_context) + assert "invalid_event_stage_count" in mock_print.call_args[0][0] + + +@patch.object(Path, "read_text") +def test_config_multiple_event_start_stages( + mock_read_text, mock_context, invalid_event_multiple_start_stages_config, valid_registration_config_internal +): + mock_read_text.side_effect = [invalid_event_multiple_start_stages_config, valid_registration_config_internal] + + with patch.object(Console, "print") as mock_print: + config(mock_context) + assert "invalid_event_stage_count" in mock_print.call_args[0][0] + + +@patch.object(Path, "read_text") +def test_config_multiple_event_end_stages( + mock_read_text, mock_context, invalid_event_multiple_end_stages_config, valid_registration_config_internal +): + mock_read_text.side_effect = [invalid_event_multiple_end_stages_config, valid_registration_config_internal] + + with patch.object(Console, "print") as mock_print: + config(mock_context) + assert "invalid_event_stage_count" in mock_print.call_args[0][0] + + @patch.object(Path, "read_text") @patch.object(Path, "iterdir") def test_labels_valid(mock_iterdir, mock_read_text, mock_context, valid_labels_config): diff --git a/toolbox/utils/config.py b/toolbox/utils/config.py index e6656d5..8783648 100644 --- a/toolbox/utils/config.py +++ b/toolbox/utils/config.py @@ -16,10 +16,67 @@ def from_config_directory(cls, config_directory: Path): return cls.model_validate(config) +class EventStage(BaseModel): + name: str + stage_type: Literal["event-start", "event-end", "informative"] = Field(alias="type") + start_date: datetime = Field(..., alias="start-date") + end_date: datetime | None = Field(default=None, alias="end-date") + description: str | None = None + + @model_validator(mode="after") + def check_stage_dates(self): + if self.end_date is not None: + try: + if self.end_date <= self.start_date: + raise PydanticCustomError( + "invalid_stage_dates", + "'end-date' must be later than 'start-date'", + ) + except TypeError as exception: + raise PydanticCustomError( + "invalid_stage_timezone", + "'start-date' and 'end-date' must use the same timezone format", + ) from exception + + return self + + class EventConfig(YamlConfig, BaseModel): id: str - start_date: datetime = Field(..., alias="start-date") - end_date: datetime = Field(..., alias="end-date") + name: str | None = None + stages: list[EventStage] = Field(default_factory=list) + + @model_validator(mode="after") + def check_stages(self): + def get_single_stage(stage_type: Literal["event-start", "event-end"]) -> EventStage: + matched_stages = [stage for stage in self.stages if stage.stage_type == stage_type] + if len(matched_stages) != 1: + raise PydanticCustomError( + "invalid_event_stage_count", + f"Exactly one {stage_type} stage is required", + ) + + return matched_stages[0] + + event_start_stage = get_single_stage("event-start") + event_end_stage = get_single_stage("event-end") + + event_start = event_start_stage.start_date + event_end = event_end_stage.start_date + + try: + if event_end <= event_start: + raise PydanticCustomError( + "invalid_event_boundaries", + "event-end stage must be later than event-start stage", + ) + except TypeError as exception: + raise PydanticCustomError( + "invalid_event_boundaries_timezone", + "event-start and event-end stages must use the same timezone format", + ) from exception + + return self class RegistrationConfig(YamlConfig, BaseModel):