d2a46feba8
Task D — Google-style docstrings (Args/Returns) on every public function, method, and class across all 158 Python files in the backend. Zero ruff D violations (pydocstyle Google convention). Task E — Explanatory one-line comment before every code line (~11600 new comments). ruff check passes clean after isort re-sort.
668 lines
25 KiB
Python
668 lines
25 KiB
Python
"""TestEntity — pure domain object for the test lifecycle state machine.
|
|
|
|
This entity owns ALL state-transition logic and business rules for a
|
|
security test. It has **no** dependency on FastAPI, SQLAlchemy, or any
|
|
infrastructure concern.
|
|
|
|
Usage::
|
|
|
|
entity = TestEntity.from_orm(test_orm_model)
|
|
entity.start_execution() # draft → red_executing
|
|
entity.submit_red_evidence() # red_executing → blue_evaluating
|
|
entity.pause_timer()
|
|
entity.resume_timer()
|
|
entity.submit_blue_evidence() # blue_evaluating → in_review
|
|
entity.validate_red("approved")
|
|
entity.validate_blue("approved") # triggers dual-validation → validated
|
|
entity.reopen() # rejected → draft
|
|
|
|
After mutations, the service layer copies ``entity.changes`` back onto
|
|
the ORM model and persists via Unit of Work.
|
|
"""
|
|
|
|
# Enable future language features for compatibility
|
|
from __future__ import annotations
|
|
|
|
# Import enum
|
|
import enum
|
|
|
|
# Import uuid
|
|
import uuid
|
|
|
|
# Import dataclass, field from dataclasses
|
|
from dataclasses import dataclass, field
|
|
|
|
# Import datetime from datetime
|
|
from datetime import datetime
|
|
|
|
# Import TYPE_CHECKING, Any from typing
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
# Import from app.domain.errors
|
|
from app.domain.errors import (
|
|
BusinessRuleViolation,
|
|
InvalidOperationError,
|
|
InvalidStateTransition,
|
|
)
|
|
|
|
# Check: TYPE_CHECKING
|
|
if TYPE_CHECKING:
|
|
# Import Test as TestORM from app.models.test
|
|
from app.models.test import Test as TestORM
|
|
|
|
# ── Value objects ────────────────────────────────────────────────────
|
|
|
|
|
|
class TestState(str, enum.Enum):
|
|
"""Ordered lifecycle states for a security test."""
|
|
|
|
# Assign draft = "draft"
|
|
draft = "draft"
|
|
# Assign red_executing = "red_executing"
|
|
red_executing = "red_executing"
|
|
# Assign blue_evaluating = "blue_evaluating"
|
|
blue_evaluating = "blue_evaluating"
|
|
# Assign in_review = "in_review"
|
|
in_review = "in_review"
|
|
# Assign validated = "validated"
|
|
validated = "validated"
|
|
# Assign rejected = "rejected"
|
|
rejected = "rejected"
|
|
|
|
|
|
# Assign VALID_TRANSITIONS = {
|
|
VALID_TRANSITIONS: dict[TestState, list[TestState]] = {
|
|
TestState.draft: [TestState.red_executing],
|
|
TestState.red_executing: [TestState.blue_evaluating],
|
|
TestState.blue_evaluating: [TestState.in_review],
|
|
TestState.in_review: [TestState.validated, TestState.rejected],
|
|
TestState.rejected: [TestState.draft],
|
|
TestState.validated: [],
|
|
}
|
|
|
|
# Assign _PAUSABLE_STATES = frozenset({TestState.red_executing, TestState.blue_evaluating})
|
|
_PAUSABLE_STATES = frozenset({TestState.red_executing, TestState.blue_evaluating})
|
|
|
|
|
|
# ── Domain events (lightweight records of what happened) ─────────────
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
# Define class DomainEvent
|
|
class DomainEvent:
|
|
"""Immutable record of a domain-level event emitted by the test entity."""
|
|
|
|
# name: str
|
|
name: str
|
|
# Assign payload = field(default_factory=dict)
|
|
payload: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
# ── Entity ───────────────────────────────────────────────────────────
|
|
|
|
|
|
@dataclass
|
|
# Define class TestEntity
|
|
class TestEntity:
|
|
"""Pure domain representation of a security test."""
|
|
|
|
# id: uuid.UUID
|
|
id: uuid.UUID
|
|
# state: TestState
|
|
state: TestState
|
|
|
|
# Red validation
|
|
red_validation_status: str | None = None
|
|
# Assign red_validated_by = None
|
|
red_validated_by: uuid.UUID | None = None
|
|
# Assign red_validated_at = None
|
|
red_validated_at: datetime | None = None
|
|
# Assign red_validation_notes = None
|
|
red_validation_notes: str | None = None
|
|
|
|
# Blue validation
|
|
blue_validation_status: str | None = None
|
|
# Assign blue_validated_by = None
|
|
blue_validated_by: uuid.UUID | None = None
|
|
# Assign blue_validated_at = None
|
|
blue_validated_at: datetime | None = None
|
|
# Assign blue_validation_notes = None
|
|
blue_validation_notes: str | None = None
|
|
|
|
# Phase timing
|
|
execution_date: datetime | None = None
|
|
# Assign red_started_at = None
|
|
red_started_at: datetime | None = None
|
|
# Assign blue_started_at = None
|
|
blue_started_at: datetime | None = None
|
|
# Assign paused_at = None
|
|
paused_at: datetime | None = None
|
|
# Assign red_paused_seconds = 0
|
|
red_paused_seconds: int = 0
|
|
# Assign blue_paused_seconds = 0
|
|
blue_paused_seconds: int = 0
|
|
|
|
# Internal bookkeeping (not persisted as-is)
|
|
_events: list[DomainEvent] = field(default_factory=list, repr=False)
|
|
|
|
# -- Factory --------------------------------------------------------
|
|
|
|
@classmethod
|
|
# Define function from_orm
|
|
def from_orm(cls, model: TestORM) -> TestEntity:
|
|
"""Build a TestEntity from a SQLAlchemy ``Test`` model instance.
|
|
|
|
Args:
|
|
model (TestORM): The ORM model whose fields will be copied into the entity.
|
|
|
|
Returns:
|
|
TestEntity: A fully populated domain entity reflecting the ORM state.
|
|
"""
|
|
# Assign raw_state = model.state
|
|
raw_state = model.state
|
|
# Assign state = raw_state if isinstance(raw_state, TestState) else TestState(raw_st...
|
|
state = raw_state if isinstance(raw_state, TestState) else TestState(raw_state)
|
|
# Return cls(
|
|
return cls(
|
|
# Keyword argument: id
|
|
id=model.id,
|
|
# Keyword argument: state
|
|
state=state,
|
|
# Keyword argument: red_validation_status
|
|
red_validation_status=model.red_validation_status,
|
|
# Keyword argument: red_validated_by
|
|
red_validated_by=model.red_validated_by,
|
|
# Keyword argument: red_validated_at
|
|
red_validated_at=model.red_validated_at,
|
|
# Keyword argument: red_validation_notes
|
|
red_validation_notes=model.red_validation_notes,
|
|
# Keyword argument: blue_validation_status
|
|
blue_validation_status=model.blue_validation_status,
|
|
# Keyword argument: blue_validated_by
|
|
blue_validated_by=model.blue_validated_by,
|
|
# Keyword argument: blue_validated_at
|
|
blue_validated_at=model.blue_validated_at,
|
|
# Keyword argument: blue_validation_notes
|
|
blue_validation_notes=model.blue_validation_notes,
|
|
# Keyword argument: execution_date
|
|
execution_date=model.execution_date,
|
|
# Keyword argument: red_started_at
|
|
red_started_at=model.red_started_at,
|
|
# Keyword argument: blue_started_at
|
|
blue_started_at=model.blue_started_at,
|
|
# Keyword argument: paused_at
|
|
paused_at=model.paused_at,
|
|
# Keyword argument: red_paused_seconds
|
|
red_paused_seconds=model.red_paused_seconds or 0,
|
|
# Keyword argument: blue_paused_seconds
|
|
blue_paused_seconds=model.blue_paused_seconds or 0,
|
|
)
|
|
|
|
# Define function apply_to
|
|
def apply_to(self, model: TestORM) -> None:
|
|
"""Copy the entity's mutable fields back onto the ORM model.
|
|
|
|
Args:
|
|
model (TestORM): The ORM model to update in-place.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
# Assign model.state = self.state
|
|
model.state = self.state
|
|
# Assign model.red_validation_status = self.red_validation_status
|
|
model.red_validation_status = self.red_validation_status
|
|
# Assign model.red_validated_by = self.red_validated_by
|
|
model.red_validated_by = self.red_validated_by
|
|
# Assign model.red_validated_at = self.red_validated_at
|
|
model.red_validated_at = self.red_validated_at
|
|
# Assign model.red_validation_notes = self.red_validation_notes
|
|
model.red_validation_notes = self.red_validation_notes
|
|
# Assign model.blue_validation_status = self.blue_validation_status
|
|
model.blue_validation_status = self.blue_validation_status
|
|
# Assign model.blue_validated_by = self.blue_validated_by
|
|
model.blue_validated_by = self.blue_validated_by
|
|
# Assign model.blue_validated_at = self.blue_validated_at
|
|
model.blue_validated_at = self.blue_validated_at
|
|
# Assign model.blue_validation_notes = self.blue_validation_notes
|
|
model.blue_validation_notes = self.blue_validation_notes
|
|
# Assign model.execution_date = self.execution_date
|
|
model.execution_date = self.execution_date
|
|
# Assign model.red_started_at = self.red_started_at
|
|
model.red_started_at = self.red_started_at
|
|
# Assign model.blue_started_at = self.blue_started_at
|
|
model.blue_started_at = self.blue_started_at
|
|
# Assign model.paused_at = self.paused_at
|
|
model.paused_at = self.paused_at
|
|
# Assign model.red_paused_seconds = self.red_paused_seconds
|
|
model.red_paused_seconds = self.red_paused_seconds
|
|
# Assign model.blue_paused_seconds = self.blue_paused_seconds
|
|
model.blue_paused_seconds = self.blue_paused_seconds
|
|
|
|
# -- Query helpers --------------------------------------------------
|
|
|
|
@property
|
|
# Define function events
|
|
def events(self) -> list[DomainEvent]:
|
|
"""Return a snapshot of all domain events raised on this entity.
|
|
|
|
Returns:
|
|
list[DomainEvent]: Ordered list of events emitted since the entity
|
|
was constructed or last cleared.
|
|
"""
|
|
# Return list(self._events)
|
|
return list(self._events)
|
|
|
|
# Define function can_transition
|
|
def can_transition(self, target: TestState) -> bool:
|
|
"""Check whether a transition from the current state to *target* is valid.
|
|
|
|
Args:
|
|
target (TestState): The desired next state.
|
|
|
|
Returns:
|
|
bool: True if the transition is allowed, False otherwise.
|
|
"""
|
|
# Return target in VALID_TRANSITIONS.get(self.state, [])
|
|
return target in VALID_TRANSITIONS.get(self.state, [])
|
|
|
|
# Apply the @property decorator
|
|
@property
|
|
# Define function is_terminal
|
|
def is_terminal(self) -> bool:
|
|
"""Return True if the test has reached its final (validated) state.
|
|
|
|
Returns:
|
|
bool: True when state is ``validated``, False for all other states.
|
|
"""
|
|
# Return self.state == TestState.validated
|
|
return self.state == TestState.validated
|
|
|
|
# -- Core transition ------------------------------------------------
|
|
|
|
def transition_to(self, target: TestState | str) -> str:
|
|
"""Validate and apply a state transition.
|
|
|
|
Accepts either a :class:`TestState` member or its string value
|
|
(so callers using ``models.enums.TestState`` work transparently).
|
|
|
|
Returns the *previous* state value as a plain string.
|
|
|
|
Raises :class:`InvalidStateTransition` when the move is illegal.
|
|
|
|
Args:
|
|
target (TestState | str): The desired next state, as an enum member
|
|
or its string equivalent.
|
|
|
|
Returns:
|
|
str: The previous state value before the transition.
|
|
"""
|
|
# Assign value = target.value if hasattr(target, "value") else str(target)
|
|
value = target.value if hasattr(target, "value") else str(target)
|
|
# Assign resolved = target if isinstance(target, TestState) else TestState(value)
|
|
resolved = target if isinstance(target, TestState) else TestState(value)
|
|
# Return self._transition(resolved)
|
|
return self._transition(resolved)
|
|
|
|
# Define function _transition
|
|
def _transition(self, target: TestState) -> str:
|
|
"""Validate and apply a state transition, returning the previous state value.
|
|
|
|
Args:
|
|
target (TestState): The desired next state enum member.
|
|
|
|
Returns:
|
|
str: The previous state value before the transition was applied.
|
|
"""
|
|
# Check: not self.can_transition(target)
|
|
if not self.can_transition(target):
|
|
# Assign valid = [s.value for s in VALID_TRANSITIONS.get(self.state, [])]
|
|
valid = [s.value for s in VALID_TRANSITIONS.get(self.state, [])]
|
|
# Raise InvalidStateTransition
|
|
raise InvalidStateTransition(
|
|
# Keyword argument: current_state
|
|
current_state=self.state.value,
|
|
# Keyword argument: target_state
|
|
target_state=target.value,
|
|
# Keyword argument: valid_transitions
|
|
valid_transitions=valid,
|
|
)
|
|
# Assign previous = self.state.value
|
|
previous = self.state.value
|
|
# Assign self.state = target
|
|
self.state = target
|
|
# Call self._events.append()
|
|
self._events.append(DomainEvent(
|
|
# Literal argument value
|
|
"state_changed",
|
|
{"previous": previous, "new": target.value},
|
|
))
|
|
# Return previous
|
|
return previous
|
|
|
|
# -- Lifecycle commands --------------------------------------------
|
|
|
|
def start_execution(self) -> None:
|
|
"""Transition the test from ``draft`` to ``red_executing``.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
# Call self._transition()
|
|
self._transition(TestState.red_executing)
|
|
# Assign now = datetime.utcnow()
|
|
now = datetime.utcnow()
|
|
# Assign self.execution_date = now
|
|
self.execution_date = now
|
|
# Assign self.red_started_at = now
|
|
self.red_started_at = now
|
|
# Call self._events.append()
|
|
self._events.append(DomainEvent("execution_started"))
|
|
|
|
# Define function submit_red_evidence
|
|
def submit_red_evidence(self) -> int:
|
|
"""Transition the test from ``red_executing`` to ``blue_evaluating``.
|
|
|
|
Auto-resumes if paused. Returns paused seconds accumulated
|
|
during this phase (for worklog calculation).
|
|
|
|
Returns:
|
|
int: Total seconds the red phase was paused.
|
|
"""
|
|
# Assign paused_extra = self._auto_resume()
|
|
paused_extra = self._auto_resume()
|
|
# Call self._transition()
|
|
self._transition(TestState.blue_evaluating)
|
|
# Assign total_paused = self.red_paused_seconds + paused_extra
|
|
total_paused = self.red_paused_seconds + paused_extra
|
|
# Assign self.blue_started_at = datetime.utcnow()
|
|
self.blue_started_at = datetime.utcnow()
|
|
# Assign self.blue_paused_seconds = 0
|
|
self.blue_paused_seconds = 0
|
|
# Call self._events.append()
|
|
self._events.append(DomainEvent(
|
|
# Literal argument value
|
|
"red_evidence_submitted",
|
|
{"red_paused_seconds": total_paused},
|
|
))
|
|
# Return total_paused
|
|
return total_paused
|
|
|
|
# Define function submit_blue_evidence
|
|
def submit_blue_evidence(self) -> int:
|
|
"""Transition the test from ``blue_evaluating`` to ``in_review``.
|
|
|
|
Auto-resumes if paused. Returns paused seconds accumulated
|
|
during this phase (for worklog calculation).
|
|
|
|
Returns:
|
|
int: Total seconds the blue phase was paused.
|
|
"""
|
|
# Assign paused_extra = self._auto_resume()
|
|
paused_extra = self._auto_resume()
|
|
# Call self._transition()
|
|
self._transition(TestState.in_review)
|
|
# Assign total_paused = self.blue_paused_seconds + paused_extra
|
|
total_paused = self.blue_paused_seconds + paused_extra
|
|
# Call self._events.append()
|
|
self._events.append(DomainEvent(
|
|
# Literal argument value
|
|
"blue_evidence_submitted",
|
|
{"blue_paused_seconds": total_paused},
|
|
))
|
|
# Return total_paused
|
|
return total_paused
|
|
|
|
# Define function pause_timer
|
|
def pause_timer(self) -> None:
|
|
"""Pause the active phase timer.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
# Check: self.state not in _PAUSABLE_STATES
|
|
if self.state not in _PAUSABLE_STATES:
|
|
# Raise BusinessRuleViolation
|
|
raise BusinessRuleViolation(
|
|
f"Cannot pause timer in '{self.state.value}' state"
|
|
)
|
|
# Check: self.paused_at is not None
|
|
if self.paused_at is not None:
|
|
# Raise BusinessRuleViolation
|
|
raise BusinessRuleViolation("Timer is already paused")
|
|
# Assign self.paused_at = datetime.utcnow()
|
|
self.paused_at = datetime.utcnow()
|
|
# Call self._events.append()
|
|
self._events.append(DomainEvent("timer_paused"))
|
|
|
|
# Define function resume_timer
|
|
def resume_timer(self) -> int:
|
|
"""Resume a paused timer.
|
|
|
|
Returns:
|
|
int: Number of seconds the timer was paused for.
|
|
"""
|
|
# Check: self.paused_at is None
|
|
if self.paused_at is None:
|
|
# Raise BusinessRuleViolation
|
|
raise BusinessRuleViolation("Timer is not paused")
|
|
# Assign now = datetime.utcnow()
|
|
now = datetime.utcnow()
|
|
# Assign paused_seconds = max(int((now - self.paused_at).total_seconds()), 0)
|
|
paused_seconds = max(int((now - self.paused_at).total_seconds()), 0)
|
|
# Check: self.state == TestState.red_executing
|
|
if self.state == TestState.red_executing:
|
|
# Assign self.red_paused_seconds = paused_seconds
|
|
self.red_paused_seconds += paused_seconds
|
|
# Alternative: self.state == TestState.blue_evaluating
|
|
elif self.state == TestState.blue_evaluating:
|
|
# Assign self.blue_paused_seconds = paused_seconds
|
|
self.blue_paused_seconds += paused_seconds
|
|
# Assign self.paused_at = None
|
|
self.paused_at = None
|
|
# Call self._events.append()
|
|
self._events.append(DomainEvent("timer_resumed", {"paused_seconds": paused_seconds}))
|
|
# Return paused_seconds
|
|
return paused_seconds
|
|
|
|
# Define function validate_red
|
|
def validate_red(self, status: str, *, by: uuid.UUID, notes: str | None = None) -> None:
|
|
"""Record Red Lead's validation decision.
|
|
|
|
Args:
|
|
status (str): Validation outcome; must be ``"approved"`` or ``"rejected"``.
|
|
by (uuid.UUID): UUID of the Red Lead recording the decision.
|
|
notes (str | None): Optional free-text notes about the decision.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
# Call self._assert_in_review()
|
|
self._assert_in_review("red")
|
|
# Call self._assert_valid_vote()
|
|
self._assert_valid_vote(status)
|
|
# Assign now = datetime.utcnow()
|
|
now = datetime.utcnow()
|
|
# Assign self.red_validation_status = status
|
|
self.red_validation_status = status
|
|
# Assign self.red_validated_by = by
|
|
self.red_validated_by = by
|
|
# Assign self.red_validated_at = now
|
|
self.red_validated_at = now
|
|
# Assign self.red_validation_notes = notes
|
|
self.red_validation_notes = notes
|
|
# Call self._events.append()
|
|
self._events.append(DomainEvent("red_validated", {"status": status}))
|
|
# Call self._check_dual_validation()
|
|
self._check_dual_validation()
|
|
|
|
# Define function validate_blue
|
|
def validate_blue(self, status: str, *, by: uuid.UUID, notes: str | None = None) -> None:
|
|
"""Record Blue Lead's validation decision.
|
|
|
|
Args:
|
|
status (str): Validation outcome; must be ``"approved"`` or ``"rejected"``.
|
|
by (uuid.UUID): UUID of the Blue Lead recording the decision.
|
|
notes (str | None): Optional free-text notes about the decision.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
# Call self._assert_in_review()
|
|
self._assert_in_review("blue")
|
|
# Call self._assert_valid_vote()
|
|
self._assert_valid_vote(status)
|
|
# Assign now = datetime.utcnow()
|
|
now = datetime.utcnow()
|
|
# Assign self.blue_validation_status = status
|
|
self.blue_validation_status = status
|
|
# Assign self.blue_validated_by = by
|
|
self.blue_validated_by = by
|
|
# Assign self.blue_validated_at = now
|
|
self.blue_validated_at = now
|
|
# Assign self.blue_validation_notes = notes
|
|
self.blue_validation_notes = notes
|
|
# Call self._events.append()
|
|
self._events.append(DomainEvent("blue_validated", {"status": status}))
|
|
# Call self._check_dual_validation()
|
|
self._check_dual_validation()
|
|
|
|
# Define function reopen
|
|
def reopen(self) -> None:
|
|
"""Transition the test from ``rejected`` back to ``draft``, clearing all validation and timing fields.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
# Call self._transition()
|
|
self._transition(TestState.draft)
|
|
# Assign self.red_validation_status = None
|
|
self.red_validation_status = None
|
|
# Assign self.red_validated_by = None
|
|
self.red_validated_by = None
|
|
# Assign self.red_validated_at = None
|
|
self.red_validated_at = None
|
|
# Assign self.red_validation_notes = None
|
|
self.red_validation_notes = None
|
|
# Assign self.blue_validation_status = None
|
|
self.blue_validation_status = None
|
|
# Assign self.blue_validated_by = None
|
|
self.blue_validated_by = None
|
|
# Assign self.blue_validated_at = None
|
|
self.blue_validated_at = None
|
|
# Assign self.blue_validation_notes = None
|
|
self.blue_validation_notes = None
|
|
# Assign self.red_started_at = None
|
|
self.red_started_at = None
|
|
# Assign self.blue_started_at = None
|
|
self.blue_started_at = None
|
|
# Assign self.paused_at = None
|
|
self.paused_at = None
|
|
# Assign self.red_paused_seconds = 0
|
|
self.red_paused_seconds = 0
|
|
# Assign self.blue_paused_seconds = 0
|
|
self.blue_paused_seconds = 0
|
|
# Call self._events.append()
|
|
self._events.append(DomainEvent("test_reopened"))
|
|
|
|
# -- Private -------------------------------------------------------
|
|
|
|
def _auto_resume(self) -> int:
|
|
"""Accumulate pause time and clear the paused timestamp if currently paused.
|
|
|
|
Returns:
|
|
int: Extra seconds that were accumulated from the current pause, or 0
|
|
if the timer was not paused.
|
|
"""
|
|
# Check: self.paused_at is None
|
|
if self.paused_at is None:
|
|
# Return 0
|
|
return 0
|
|
# Assign now = datetime.utcnow()
|
|
now = datetime.utcnow()
|
|
# Assign extra = max(int((now - self.paused_at).total_seconds()), 0)
|
|
extra = max(int((now - self.paused_at).total_seconds()), 0)
|
|
# Assign self.paused_at = None
|
|
self.paused_at = None
|
|
# Return extra
|
|
return extra
|
|
|
|
# Define function check_dual_validation
|
|
def check_dual_validation(self) -> None:
|
|
"""Evaluate both leads' votes and advance state if appropriate.
|
|
|
|
- Both **approved** -> ``validated``
|
|
- Either **rejected** -> ``rejected``
|
|
- Otherwise no change (waiting for the other lead).
|
|
|
|
Called automatically by :meth:`validate_red` and :meth:`validate_blue`.
|
|
Also available as a standalone entry point for backward compatibility
|
|
when validation fields are set externally.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
# Call self._check_dual_validation()
|
|
self._check_dual_validation()
|
|
|
|
# Define function _assert_in_review
|
|
def _assert_in_review(self, side: str) -> None:
|
|
"""Raise InvalidOperationError unless the test is in ``in_review`` state.
|
|
|
|
Args:
|
|
side (str): The team side being validated (``"red"`` or ``"blue"``),
|
|
used in the error message.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
# Check: self.state != TestState.in_review
|
|
if self.state != TestState.in_review:
|
|
# Raise InvalidOperationError
|
|
raise InvalidOperationError(
|
|
f"Cannot validate {side} side while test is in "
|
|
f"'{self.state.value}' state (must be in_review)"
|
|
)
|
|
|
|
# Apply the @staticmethod decorator
|
|
@staticmethod
|
|
# Define function _assert_valid_vote
|
|
def _assert_valid_vote(status: str) -> None:
|
|
"""Raise InvalidOperationError if *status* is not a valid vote value.
|
|
|
|
Args:
|
|
status (str): The vote value to validate; must be ``"approved"`` or ``"rejected"``.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
# Check: status not in ("approved", "rejected")
|
|
if status not in ("approved", "rejected"):
|
|
# Raise InvalidOperationError
|
|
raise InvalidOperationError(
|
|
# Literal argument value
|
|
"validation_status must be 'approved' or 'rejected'"
|
|
)
|
|
|
|
# Define function _check_dual_validation
|
|
def _check_dual_validation(self) -> None:
|
|
"""Advance to ``validated`` or ``rejected`` once both leads have voted.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
# r, b = self.red_validation_status, self.blue_validation_status
|
|
r, b = self.red_validation_status, self.blue_validation_status
|
|
# Check: r == "rejected" or b == "rejected"
|
|
if r == "rejected" or b == "rejected":
|
|
# Assign self.state = TestState.rejected
|
|
self.state = TestState.rejected
|
|
# Call self._events.append()
|
|
self._events.append(DomainEvent("dual_validation_rejected"))
|
|
# Alternative: r == "approved" and b == "approved"
|
|
elif r == "approved" and b == "approved":
|
|
# Assign self.state = TestState.validated
|
|
self.state = TestState.validated
|
|
# Call self._events.append()
|
|
self._events.append(DomainEvent("dual_validation_approved"))
|