From dbe91c057402eaec1c5424ec365118c7ec984cf1 Mon Sep 17 00:00:00 2001 From: norbiros Date: Thu, 19 Mar 2026 16:40:16 +0100 Subject: [PATCH] feat: introduce `name` and `stages` in event.yaml configuration Replace direct usage of `start-date` and `end-date` with a new `stages` list. Each stage encapsulates the previous date fields along with additional metadata (e.g., meal information), enabling richer event representation. This structure is more flexible and can be leveraged by the landing page and dashboard for improved display and organization. --- config/event.yaml | 36 +++++++-- toolbox/tests/test_verify.py | 153 ++++++++++++++++++++++++++++++++++- toolbox/utils/config.py | 61 +++++++++++++- 3 files changed, 240 insertions(+), 10 deletions(-) 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):