feat(phase-34): resolve blocking tech debt — Redis, domain exceptions, indexes, CI
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Foundational changes required before any new feature work can begin. - 0.1 Redis infrastructure: add redis:7-alpine to docker-compose dev and prod, REDIS_URL config, singleton client in app/infrastructure/redis_client.py - 0.2 Token blacklist on Redis SEC-001: replace in-memory dict with Redis SETEX keyed by jti, auto-expiring TTL derived from token exp - 0.3 Database indexes SR-006: Alembic migration b019 with 5 composite indexes for scoring, MTTD/MTTR, remediation, and notification queries - 0.4 Domain exceptions TD-003: app/domain/exceptions.py with typed errors, error_handler middleware mapping them to HTTP, services decoupled from FastAPI - 0.5 Fix silenced exceptions TD-007: replace 4 bare except-pass blocks in test_workflow_service with logger.warning with exc_info - 0.6 CI pipeline TD-009: GitHub Actions workflow with Postgres and Redis service containers, ruff lint, pytest; ruff.toml for baseline config
This commit is contained in:
@@ -11,18 +11,21 @@ Every public function validates the transition, mutates the test, writes an
|
||||
audit-log entry, and commits the session.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.domain.exceptions import InvalidOperationError, InvalidTransitionError
|
||||
from app.models.enums import TestState
|
||||
from app.models.test import Test
|
||||
from app.models.user import User
|
||||
from app.services.audit_service import log_action
|
||||
from app.services.notification_service import notify_test_state_change, create_notification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Valid transition map
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -59,23 +62,15 @@ def transition_state(
|
||||
) -> Test:
|
||||
"""Validate and perform a state transition, log it, and commit.
|
||||
|
||||
Raises :class:`~fastapi.HTTPException` 400 when the transition is invalid.
|
||||
Raises :class:`InvalidTransitionError` when the transition is invalid.
|
||||
"""
|
||||
if not can_transition(test, target_state):
|
||||
current = test.state if isinstance(test.state, TestState) else TestState(test.state)
|
||||
valid = [s.value for s in VALID_TRANSITIONS.get(current, [])]
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"message": (
|
||||
f"Cannot transition from '{current.value}' to '{target_state.value}'. "
|
||||
f"Valid transitions: {valid}"
|
||||
),
|
||||
"code": "INVALID_TRANSITION",
|
||||
"current_state": current.value,
|
||||
"target_state": target_state.value,
|
||||
"valid_transitions": valid,
|
||||
},
|
||||
raise InvalidTransitionError(
|
||||
current_state=current.value,
|
||||
target_state=target_state.value,
|
||||
valid_transitions=valid,
|
||||
)
|
||||
|
||||
previous_state = test.state.value if isinstance(test.state, TestState) else test.state
|
||||
@@ -103,8 +98,8 @@ def transition_state(
|
||||
# Dispatch in-app notifications for the new state
|
||||
try:
|
||||
notify_test_state_change(db, test, target_state.value)
|
||||
except Exception:
|
||||
pass # Notifications are best-effort — don't block the workflow
|
||||
except Exception as e:
|
||||
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
return test
|
||||
|
||||
@@ -169,22 +164,13 @@ def validate_as_red_lead(
|
||||
"""
|
||||
current = test.state.value if isinstance(test.state, TestState) else test.state
|
||||
if test.state not in (TestState.in_review,):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"message": f"Cannot validate red side while test is in '{current}' state (must be in_review)",
|
||||
"code": "INVALID_STATE",
|
||||
"current_state": current,
|
||||
},
|
||||
raise InvalidOperationError(
|
||||
f"Cannot validate red side while test is in '{current}' state (must be in_review)"
|
||||
)
|
||||
|
||||
if validation_status not in ("approved", "rejected"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"message": "validation_status must be 'approved' or 'rejected'",
|
||||
"code": "INVALID_VALIDATION_STATUS",
|
||||
},
|
||||
raise InvalidOperationError(
|
||||
"validation_status must be 'approved' or 'rejected'"
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
@@ -225,22 +211,13 @@ def validate_as_blue_lead(
|
||||
"""
|
||||
current = test.state.value if isinstance(test.state, TestState) else test.state
|
||||
if test.state not in (TestState.in_review,):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"message": f"Cannot validate blue side while test is in '{current}' state (must be in_review)",
|
||||
"code": "INVALID_STATE",
|
||||
"current_state": current,
|
||||
},
|
||||
raise InvalidOperationError(
|
||||
f"Cannot validate blue side while test is in '{current}' state (must be in_review)"
|
||||
)
|
||||
|
||||
if validation_status not in ("approved", "rejected"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"message": "validation_status must be 'approved' or 'rejected'",
|
||||
"code": "INVALID_VALIDATION_STATUS",
|
||||
},
|
||||
raise InvalidOperationError(
|
||||
"validation_status must be 'approved' or 'rejected'"
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
@@ -283,8 +260,8 @@ def check_dual_validation(db: Session, test: Test) -> Test:
|
||||
db.commit()
|
||||
try:
|
||||
notify_test_state_change(db, test, "rejected")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("Notification failed for test %s (rejected): %s", test.id, e, exc_info=True)
|
||||
elif red_status == "approved" and blue_status == "approved":
|
||||
test.state = TestState.validated
|
||||
db.commit()
|
||||
@@ -292,12 +269,12 @@ def check_dual_validation(db: Session, test: Test) -> Test:
|
||||
try:
|
||||
from app.services.score_cache import invalidate
|
||||
invalidate()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("Score cache invalidation failed: %s", e, exc_info=True)
|
||||
try:
|
||||
notify_test_state_change(db, test, "validated")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("Notification failed for test %s (validated): %s", test.id, e, exc_info=True)
|
||||
else:
|
||||
# One side hasn't voted yet — stay in_review, just flush
|
||||
db.commit()
|
||||
|
||||
Reference in New Issue
Block a user