"""Test workflow service — state-machine transitions for the Red/Blue validation flow. Controls which state transitions are valid and exposes high-level helpers for each step in the test lifecycle: draft → red_executing → blue_evaluating → in_review → validated / rejected ↓ rejected → draft Every public function validates the transition, mutates the test, and writes an audit-log entry. The caller (router) is responsible for committing the session via the Unit of Work pattern. """ # Import logging import logging # Import uuid import uuid # Import datetime from datetime from datetime import datetime # Import Session from sqlalchemy.orm from sqlalchemy.orm import Session # Import settings from app.config from app.config import settings # Import InvalidOperationError from app.domain.exceptions from app.domain.exceptions import InvalidOperationError # Import TestEntity from app.domain.test_entity from app.domain.test_entity import TestEntity # Import TestState from app.models.enums from app.models.enums import TestState # Import Test from app.models.test from app.models.test import Test # Import User from app.models.user from app.models.user import User # Import log_action from app.services.audit_service from app.services.audit_service import log_action # Import from app.services.notification_service from app.services.notification_service import ( create_notification, notify_test_state_change, ) # Assign logger = logging.getLogger(__name__) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Valid transition map # --------------------------------------------------------------------------- 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: [], # terminal state } # --------------------------------------------------------------------------- # Core helpers # --------------------------------------------------------------------------- def can_transition(test: Test, target_state: TestState) -> bool: """Return *True* if moving *test* to *target_state* is allowed. Args: test (Test): The test whose current state is being checked. target_state (TestState): The state to transition to. Returns: bool: ``True`` if the transition is permitted by ``VALID_TRANSITIONS``. """ # Assign current = test.state if isinstance(test.state, TestState) else TestState(test... current = test.state if isinstance(test.state, TestState) else TestState(test.state) # Return target_state in VALID_TRANSITIONS.get(current, []) return target_state in VALID_TRANSITIONS.get(current, []) # Define function transition_state def transition_state( # Entry: db db: Session, # Entry: test test: Test, # Entry: target_state target_state: TestState, # Entry: user user: User, *, # Entry: action_name action_name: str = "transition_state", # Entry: extra_details extra_details: dict | None = None, ) -> Test: """Validate and perform a state transition, log it, and flush. Delegates validation to :class:`TestEntity` which raises :class:`InvalidStateTransition` (aliased as ``InvalidTransitionError``) when the transition is illegal. The entity is authoritative for which transitions are valid; the module-level ``VALID_TRANSITIONS`` dict is kept temporarily for backward compatibility of ``can_transition()``. Args: db (Session): Active SQLAlchemy database session. test (Test): The test ORM object to transition. target_state (TestState): Desired next state. user (User): The user performing the transition (logged in audit). action_name (str): Audit log action label; defaults to ``"transition_state"``. extra_details (dict | None): Optional extra key-value pairs merged into the audit log details. Returns: Test: The mutated test ORM object (state updated, flushed). """ # Assign entity = TestEntity.from_orm(test) entity = TestEntity.from_orm(test) # Assign previous_state = entity.transition_to(target_state) previous_state = entity.transition_to(target_state) # Assign test.state = entity.state test.state = entity.state # Flush changes to DB without committing the transaction db.flush() # Assign details = { details: dict = { # Literal argument value "previous_state": previous_state, # Literal argument value "new_state": target_state.value, # Literal argument value "test_name": test.name, # Literal argument value "technique_id": str(test.technique_id), } # Check: extra_details if extra_details: # Call details.update() details.update(extra_details) # Call log_action() log_action( db, # Keyword argument: user_id user_id=user.id, # Keyword argument: action action=action_name, # Keyword argument: entity_type entity_type="test", # Keyword argument: entity_id entity_id=test.id, # Keyword argument: details details=details, ) # Attempt the following; catch errors below try: # Call notify_test_state_change() notify_test_state_change(db, test, target_state.value) # Handle Exception except Exception as e: # Log warning: "Notification failed for test %s: %s", test.id, e logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True) # Return test return test # --------------------------------------------------------------------------- # Lifecycle convenience functions # --------------------------------------------------------------------------- def start_execution(db: Session, test: Test, user: User) -> Test: """Move from ``draft`` → ``red_executing``. Typically called by a **red_tech** when they begin the attack. Delegates to :meth:`TestEntity.start_execution` which handles the state transition and sets ``execution_date`` / ``red_started_at``. Args: db (Session): Active SQLAlchemy database session. test (Test): The test to start executing. user (User): The red-team user initiating execution. Returns: Test: The mutated test with updated state and timestamps. """ # Assign entity = TestEntity.from_orm(test) entity = TestEntity.from_orm(test) # Call entity.start_execution() entity.start_execution() # Call entity.apply_to() entity.apply_to(test) # Flush changes to DB without committing the transaction db.flush() # Call log_action() log_action( db, # Keyword argument: user_id user_id=user.id, # Keyword argument: action action="start_execution", # Keyword argument: entity_type entity_type="test", # Keyword argument: entity_id entity_id=test.id, # Keyword argument: details details={ # Literal argument value "previous_state": "draft", # Literal argument value "new_state": test.state.value, # Literal argument value "test_name": test.name, # Literal argument value "technique_id": str(test.technique_id), }, ) # Attempt the following; catch errors below try: # Call notify_test_state_change() notify_test_state_change(db, test, test.state.value) # Handle Exception except Exception as e: # Log warning: "Notification failed for test %s: %s", test.id, e logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True) # Return test return test # Define function submit_red_evidence def submit_red_evidence(db: Session, test: Test, user: User) -> Test: """Move from ``red_executing`` → ``blue_evaluating``. Called by **red_tech** once they have finished documenting the attack. Stops the Red Team timer and creates an automatic worklog. Starts the Blue Team timer by recording ``blue_started_at``. Args: db (Session): Active SQLAlchemy database session. test (Test): The test whose red-team evidence is being submitted. user (User): The red-team user submitting the evidence. Returns: Test: The mutated test with state advanced and blue timer started. """ # Assign now = datetime.utcnow() now = datetime.utcnow() # Auto-resume if paused paused_extra = 0 # Check: test.paused_at is not None if test.paused_at is not None: # Assign paused_extra = max(int((now - test.paused_at).total_seconds()), 0) paused_extra = max(int((now - test.paused_at).total_seconds()), 0) # Assign test.paused_at = None test.paused_at = None # Assign test = transition_state( test = transition_state( db, test, TestState.blue_evaluating, user, # Keyword argument: action_name action_name="submit_red_evidence", ) # Create automatic worklog for Red Team phase (subtract paused time) _create_phase_worklog( db, # Keyword argument: test test=test, # Keyword argument: user user=user, # Keyword argument: phase_started_at phase_started_at=test.red_started_at, # Keyword argument: phase_ended_at phase_ended_at=now, # Keyword argument: paused_seconds paused_seconds=(test.red_paused_seconds or 0) + paused_extra, # Keyword argument: activity_type activity_type="red_team_execution", # Keyword argument: description description=f"Red Team execution: {test.name}", ) # Start Blue Team timer test.blue_started_at = now # Assign test.blue_paused_seconds = 0 test.blue_paused_seconds = 0 # Return test return test # Define function submit_blue_evidence def submit_blue_evidence(db: Session, test: Test, user: User) -> Test: """Move from ``blue_evaluating`` → ``in_review``. Called by **blue_tech** once they have finished documenting detection. Stops the Blue Team timer and creates an automatic worklog. Args: db (Session): Active SQLAlchemy database session. test (Test): The test whose blue-team evidence is being submitted. user (User): The blue-team user submitting the evidence. Returns: Test: The mutated test with state advanced to ``in_review``. """ # Assign now = datetime.utcnow() now = datetime.utcnow() # Auto-resume if paused paused_extra = 0 # Check: test.paused_at is not None if test.paused_at is not None: # Assign paused_extra = max(int((now - test.paused_at).total_seconds()), 0) paused_extra = max(int((now - test.paused_at).total_seconds()), 0) # Assign test.paused_at = None test.paused_at = None # Assign test = transition_state( test = transition_state( db, test, TestState.in_review, user, # Keyword argument: action_name action_name="submit_blue_evidence", ) # Create automatic worklog for Blue Team phase (subtract paused time) _create_phase_worklog( db, # Keyword argument: test test=test, # Keyword argument: user user=user, # Keyword argument: phase_started_at phase_started_at=test.blue_started_at, # Keyword argument: phase_ended_at phase_ended_at=now, # Keyword argument: paused_seconds paused_seconds=(test.blue_paused_seconds or 0) + paused_extra, # Keyword argument: activity_type activity_type="blue_team_evaluation", # Keyword argument: description description=f"Blue Team evaluation: {test.name}", ) # Return test return test # Define function pause_timer def pause_timer(db: Session, test: Test, user: User) -> Test: """Pause the active phase timer. Can only be called when the test is in ``red_executing`` or ``blue_evaluating`` and is not already paused. Args: db (Session): Active SQLAlchemy database session. test (Test): The currently active test. user (User): The user pausing the timer. Returns: Test: The mutated test with ``paused_at`` set to the current UTC time. """ # Check: test.state not in (TestState.red_executing, TestState.blue_evaluating) if test.state not in (TestState.red_executing, TestState.blue_evaluating): # Raise InvalidOperationError raise InvalidOperationError( f"Cannot pause timer in '{test.state.value}' state" ) # Check: test.paused_at is not None if test.paused_at is not None: # Raise InvalidOperationError raise InvalidOperationError("Timer is already paused") # Assign test.paused_at = datetime.utcnow() test.paused_at = datetime.utcnow() # Call log_action() log_action( db, # Keyword argument: user_id user_id=user.id, # Keyword argument: action action="pause_timer", # Keyword argument: entity_type entity_type="test", # Keyword argument: entity_id entity_id=test.id, # Keyword argument: details details={"state": test.state.value}, ) # Return test return test # Define function resume_timer def resume_timer(db: Session, test: Test, user: User) -> Test: """Resume a paused phase timer. Accumulates the paused duration into the appropriate counter so it is subtracted from the final worklog. Args: db (Session): Active SQLAlchemy database session. test (Test): The paused test to resume. user (User): The user resuming the timer. Returns: Test: The mutated test with ``paused_at`` cleared and accumulated pause seconds updated. """ # Check: test.paused_at is None if test.paused_at is None: # Raise InvalidOperationError raise InvalidOperationError("Timer is not paused") # Assign now = datetime.utcnow() now = datetime.utcnow() # Assign paused_seconds = max(int((now - test.paused_at).total_seconds()), 0) paused_seconds = max(int((now - test.paused_at).total_seconds()), 0) # Check: test.state == TestState.red_executing if test.state == TestState.red_executing: # Assign test.red_paused_seconds = (test.red_paused_seconds or 0) + paused_seconds test.red_paused_seconds = (test.red_paused_seconds or 0) + paused_seconds # Alternative: test.state == TestState.blue_evaluating elif test.state == TestState.blue_evaluating: # Assign test.blue_paused_seconds = (test.blue_paused_seconds or 0) + paused_seconds test.blue_paused_seconds = (test.blue_paused_seconds or 0) + paused_seconds # Assign test.paused_at = None test.paused_at = None # Call log_action() log_action( db, # Keyword argument: user_id user_id=user.id, # Keyword argument: action action="resume_timer", # Keyword argument: entity_type entity_type="test", # Keyword argument: entity_id entity_id=test.id, # Keyword argument: details details={"paused_seconds": paused_seconds, "state": test.state.value}, ) # Return test return test # Define function _create_phase_worklog def _create_phase_worklog( # Entry: db db: Session, *, # Entry: test test: Test, # Entry: user user: User, # Entry: phase_started_at phase_started_at: datetime | None, # Entry: phase_ended_at phase_ended_at: datetime, # Entry: paused_seconds paused_seconds: int = 0, # Entry: activity_type activity_type: str, # Entry: description description: str, ) -> None: """Create an automatic, integrity-hashed worklog for a completed phase. Subtracts accumulated *paused_seconds* from the gross elapsed time so the worklog reflects only active working time. Also triggers Tempo sync if the test has a Jira link. Args: db (Session): Active SQLAlchemy database session. test (Test): The test for which the worklog is being created. user (User): The user attributed to the worklog. phase_started_at (datetime | None): Timestamp when the phase began; if ``None`` the worklog is skipped with a warning. phase_ended_at (datetime): Timestamp when the phase ended. paused_seconds (int): Accumulated paused time in seconds to subtract from gross elapsed time. activity_type (str): Worklog activity type label (e.g. ``"red_team_execution"``). description (str): Human-readable description for the worklog. """ # Check: not phase_started_at if not phase_started_at: # Log warning: logger.warning( # Literal argument value "No phase start timestamp for test %s (%s), skipping worklog", test.id, activity_type, ) # Return control to caller return # Assign gross_seconds = int((phase_ended_at - phase_started_at).total_seconds()) gross_seconds = int((phase_ended_at - phase_started_at).total_seconds()) # Assign duration_seconds = max(gross_seconds - paused_seconds, 1) duration_seconds = max(gross_seconds - paused_seconds, 1) # Attempt the following; catch errors below try: # Import create_worklog from app.services.worklog_service from app.services.worklog_service import create_worklog # Assign wl = create_worklog( wl = create_worklog( db, # Keyword argument: entity_type entity_type="test", # Keyword argument: entity_id entity_id=test.id, # Keyword argument: user_id user_id=user.id, # Keyword argument: activity_type activity_type=activity_type, # Keyword argument: started_at started_at=phase_started_at, # Keyword argument: ended_at ended_at=phase_ended_at, # Keyword argument: duration_seconds duration_seconds=duration_seconds, # Keyword argument: description description=description, ) # Log info: logger.info( # Literal argument value "Auto-worklog created for test %s: %s, %ds (worklog %s)", test.id, activity_type, duration_seconds, wl.id, ) # Sync to Tempo if enabled try: # Import auto_log_test_worklog from app.services.tempo_service from app.services.tempo_service import auto_log_test_worklog # Call auto_log_test_worklog() auto_log_test_worklog(db, test, user, activity_type) # Handle Exception except Exception as e: # Log warning: "Tempo sync failed for worklog: %s", e, exc_info=T logger.warning("Tempo sync failed for worklog: %s", e, exc_info=True) # Handle Exception except Exception as e: # Log error: "Failed to create auto-worklog for test %s: %s", t logger.error("Failed to create auto-worklog for test %s: %s", test.id, e, exc_info=True) # Define function validate_as_red_lead def validate_as_red_lead( # Entry: db db: Session, # Entry: test test: Test, # Entry: user user: User, # Entry: validation_status validation_status: str, # Entry: notes notes: str | None = None, ) -> Test: """Record Red Lead's validation decision. Delegates validation rules and state mutation entirely to :meth:`TestEntity.validate_red`. If both leads have voted the entity will also advance the test to ``validated`` or ``rejected``. Args: db (Session): Active SQLAlchemy database session. test (Test): The test being reviewed. user (User): The red-lead user casting their vote. validation_status (str): Validation decision, e.g. ``"approved"`` or ``"rejected"``. notes (str | None): Optional freeform notes explaining the decision. Returns: Test: The mutated test with red-lead validation fields set. """ # Assign entity = TestEntity.from_orm(test) entity = TestEntity.from_orm(test) # Call entity.validate_red() entity.validate_red(validation_status, by=user.id, notes=notes) # Call entity.apply_to() entity.apply_to(test) # Flush changes to DB without committing the transaction db.flush() # Call log_action() log_action( db, # Keyword argument: user_id user_id=user.id, # Keyword argument: action action="validate_as_red_lead", # Keyword argument: entity_type entity_type="test", # Keyword argument: entity_id entity_id=test.id, # Keyword argument: details details={ # Literal argument value "validation_status": validation_status, # Literal argument value "notes": notes, # Literal argument value "technique_id": str(test.technique_id), }, ) # Call _dispatch_dual_validation_effects() _dispatch_dual_validation_effects(db, test, entity) # Return test return test # Define function validate_as_blue_lead def validate_as_blue_lead( # Entry: db db: Session, # Entry: test test: Test, # Entry: user user: User, # Entry: validation_status validation_status: str, # Entry: notes notes: str | None = None, ) -> Test: """Record Blue Lead's validation decision. Delegates validation rules and state mutation entirely to :meth:`TestEntity.validate_blue`. If both leads have voted the entity will also advance the test to ``validated`` or ``rejected``. Args: db (Session): Active SQLAlchemy database session. test (Test): The test being reviewed. user (User): The blue-lead user casting their vote. validation_status (str): Validation decision, e.g. ``"approved"`` or ``"rejected"``. notes (str | None): Optional freeform notes explaining the decision. Returns: Test: The mutated test with blue-lead validation fields set. """ # Assign entity = TestEntity.from_orm(test) entity = TestEntity.from_orm(test) # Call entity.validate_blue() entity.validate_blue(validation_status, by=user.id, notes=notes) # Call entity.apply_to() entity.apply_to(test) # Flush changes to DB without committing the transaction db.flush() # Call log_action() log_action( db, # Keyword argument: user_id user_id=user.id, # Keyword argument: action action="validate_as_blue_lead", # Keyword argument: entity_type entity_type="test", # Keyword argument: entity_id entity_id=test.id, # Keyword argument: details details={ # Literal argument value "validation_status": validation_status, # Literal argument value "notes": notes, # Literal argument value "technique_id": str(test.technique_id), }, ) # Call _dispatch_dual_validation_effects() _dispatch_dual_validation_effects(db, test, entity) # Return test return test # Define function check_dual_validation def check_dual_validation(db: Session, test: Test) -> Test: """Evaluate both leads' decisions and advance the test if both have voted. All state mutation is delegated to :meth:`TestEntity.check_dual_validation`. This function never assigns ``test.state`` directly. Args: db (Session): Active SQLAlchemy database session. test (Test): The test to evaluate. Returns: Test: The mutated test, potentially with state advanced to ``validated`` or ``rejected``. """ # Assign entity = TestEntity.from_orm(test) entity = TestEntity.from_orm(test) # Call entity.check_dual_validation() entity.check_dual_validation() # Call entity.apply_to() entity.apply_to(test) # Call _dispatch_dual_validation_effects() _dispatch_dual_validation_effects(db, test, entity) # Return test return test # Define function _dispatch_dual_validation_effects def _dispatch_dual_validation_effects( # Entry: db db: Session, test: Test, entity: TestEntity ) -> None: """Dispatch side effects (notifications, cache) based on domain events. Args: db (Session): Active SQLAlchemy database session. test (Test): The test whose domain events are being processed. entity (TestEntity): Domain entity carrying the pending event list. """ # Iterate over entity.events for event in entity.events: # Check: event.name == "dual_validation_approved" if event.name == "dual_validation_approved": # Attempt the following; catch errors below try: # Import invalidate from app.services.score_cache from app.services.score_cache import invalidate # Call invalidate() invalidate() # Handle Exception except Exception as e: # Log warning: "Score cache invalidation failed: %s", e, exc_info logger.warning("Score cache invalidation failed: %s", e, exc_info=True) # Attempt the following; catch errors below try: # Call notify_test_state_change() notify_test_state_change(db, test, "validated") # Handle Exception except Exception as e: # Log warning: logger.warning( # Literal argument value "Notification failed for test %s (validated): %s", test.id, e, exc_info=True, ) # Alternative: event.name == "dual_validation_rejected" elif event.name == "dual_validation_rejected": # Attempt the following; catch errors below try: # Call notify_test_state_change() notify_test_state_change(db, test, "rejected") # Handle Exception except Exception as e: # Log warning: logger.warning( # Literal argument value "Notification failed for test %s (rejected): %s", test.id, e, exc_info=True, ) # Define function handle_remediation_completed def handle_remediation_completed(db: Session, test: Test, user: User) -> Test | None: """Create a re-test when remediation is completed. When a test's remediation_status changes to 'completed', this function creates a new test (retest) with the same base data to verify that the fix was effective. Prevents infinite loops by enforcing ``MAX_RETEST_COUNT``. Args: db (Session): Active SQLAlchemy database session. test (Test): The test whose remediation was completed. user (User): The user triggering the remediation completion. Returns: Test | None: The newly created retest, or ``None`` if the maximum retest count has been reached. """ # Always reference the original test, not an intermediate retest original_test_id = test.retest_of or test.id # Check: test.retest_count >= settings.MAX_RETEST_COUNT if test.retest_count >= settings.MAX_RETEST_COUNT: # Max retests reached — notify and bail out if test.created_by: # Call create_notification() create_notification( db, # Keyword argument: user_id user_id=test.created_by, # Keyword argument: type type="max_retests_reached", # Keyword argument: title title="Maximum retests reached", # Keyword argument: message message=( f'Test "{test.name}" has reached the maximum of ' f'{settings.MAX_RETEST_COUNT} retests. Manual review required.' ), # Keyword argument: entity_type entity_type="test", # Keyword argument: entity_id entity_id=test.id, ) # Call log_action() log_action( db, # Keyword argument: user_id user_id=user.id, # Keyword argument: action action="max_retests_reached", # Keyword argument: entity_type entity_type="test", # Keyword argument: entity_id entity_id=test.id, # Keyword argument: details details={ # Literal argument value "retest_count": test.retest_count, # Literal argument value "max_allowed": settings.MAX_RETEST_COUNT, # Literal argument value "original_test_id": str(original_test_id), }, ) # Return None return None # Assign retest = Test( retest = Test( # Keyword argument: technique_id technique_id=test.technique_id, # Keyword argument: name name=f"[Retest #{test.retest_count + 1}] {test.name.replace(f'[Retest #{test.retest_count}] ', '')}", # Keyword argument: description description=test.description, # Keyword argument: platform platform=test.platform, # Keyword argument: procedure_text procedure_text=test.procedure_text, # Keyword argument: tool_used tool_used=test.tool_used, # Keyword argument: state state=TestState.draft, # Keyword argument: created_by created_by=test.created_by, # Keyword argument: retest_of retest_of=original_test_id, # Keyword argument: retest_count retest_count=test.retest_count + 1, ) # Stage new record(s) for database insertion db.add(retest) # Flush changes to DB without committing the transaction db.flush() # Call log_action() log_action( db, # Keyword argument: user_id user_id=user.id, # Keyword argument: action action="create_retest", # Keyword argument: entity_type entity_type="test", # Keyword argument: entity_id entity_id=retest.id, # Keyword argument: details details={ # Literal argument value "original_test_id": str(original_test_id), # Literal argument value "retest_number": retest.retest_count, # Literal argument value "source_test_id": str(test.id), }, ) # Notify the test creator and any red_tech users if test.created_by: # Call create_notification() create_notification( db, # Keyword argument: user_id user_id=test.created_by, # Keyword argument: type type="retest_created", # Keyword argument: title title="Re-test created", # Keyword argument: message message=( f'A re-test has been automatically created for "{test.name}" ' f'after remediation was completed.' ), # Keyword argument: entity_type entity_type="test", # Keyword argument: entity_id entity_id=retest.id, ) # Flush changes to DB without committing the transaction db.flush() # Return retest return retest # Define function get_retest_chain def get_retest_chain(db: Session, test_id: uuid.UUID) -> list[Test]: """Return the full chain of retests for a given test. Includes the original test and all subsequent retests, ordered by retest_count. Args: db (Session): Active SQLAlchemy database session. test_id (uuid.UUID): UUID of any test in the retest chain. Returns: list[Test]: The original test followed by all its retests in ascending retest-count order. Returns an empty list if the test is not found. """ # Import uuid import uuid as _uuid # Assign tid = _uuid.UUID(str(test_id)) if not isinstance(test_id, _uuid.UUID) els... tid = _uuid.UUID(str(test_id)) if not isinstance(test_id, _uuid.UUID) else test_id # Find the original test first test = db.query(Test).filter(Test.id == tid).first() # Check: not test if not test: # Return [] return [] # Assign original_id = test.retest_of or test.id original_id = test.retest_of or test.id # Get original original = db.query(Test).filter(Test.id == original_id).first() # Check: not original if not original: # Return [test] return [test] # Get all retests of the original retests = ( db.query(Test) # Chain .filter() call .filter(Test.retest_of == original_id) # Chain .order_by() call .order_by(Test.retest_count) # Chain .all() call .all() ) # Return [original] + retests return [original] + retests # Define function reopen_test def reopen_test(db: Session, test: Test, user: User) -> Test: """Move a ``rejected`` test back to ``draft``, clearing validation fields. This allows the teams to redo the test cycle. Args: db (Session): Active SQLAlchemy database session. test (Test): The rejected test to reopen. user (User): The user reopening the test. Returns: Test: The mutated test reset to ``draft`` with all validation and timing fields cleared. """ # Assign test = transition_state( test = transition_state( db, test, TestState.draft, user, # Keyword argument: action_name action_name="reopen_test", ) # Clear dual-validation fields test.red_validation_status = None # Assign test.red_validated_by = None test.red_validated_by = None # Assign test.red_validated_at = None test.red_validated_at = None # Assign test.red_validation_notes = None test.red_validation_notes = None # Assign test.blue_validation_status = None test.blue_validation_status = None # Assign test.blue_validated_by = None test.blue_validated_by = None # Assign test.blue_validated_at = None test.blue_validated_at = None # Assign test.blue_validation_notes = None test.blue_validation_notes = None # Clear phase timing fields test.red_started_at = None # Assign test.blue_started_at = None test.blue_started_at = None # Assign test.paused_at = None test.paused_at = None # Assign test.red_paused_seconds = 0 test.red_paused_seconds = 0 # Assign test.blue_paused_seconds = 0 test.blue_paused_seconds = 0 # Return test return test