Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions config/event.yaml
Original file line number Diff line number Diff line change
@@ -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
153 changes: 151 additions & 2 deletions toolbox/tests/test_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""


Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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):
Expand Down
61 changes: 59 additions & 2 deletions toolbox/utils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading