fix(tests): fix veto bug preventing disputed state, add manager notification and dispute-resolution routing
This commit is contained in:
@@ -33,7 +33,11 @@ from app.models.evidence import Evidence
|
||||
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
|
||||
from app.services.notification_service import (
|
||||
notify_test_state_change,
|
||||
create_notification,
|
||||
notify_role_with_email,
|
||||
)
|
||||
|
||||
# Assign logger = logging.getLogger(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1063,6 +1067,22 @@ def _dispatch_dual_validation_effects(
|
||||
elif event.name == "dual_validation_disputed":
|
||||
# Notify the lead who APPROVED asking them to review the rejection
|
||||
_notify_validation_conflict(db, test, actor)
|
||||
# Notify managers too, in case the dispute stalls and needs escalation
|
||||
try:
|
||||
notify_role_with_email(
|
||||
db,
|
||||
role="manager",
|
||||
type="validation_disputed",
|
||||
title="Validation dispute needs oversight",
|
||||
message=(
|
||||
f'Test "{test.name}" has a validation dispute — one lead approved, '
|
||||
f'the other rejected. Escalate if it does not resolve.'
|
||||
),
|
||||
entity_type="test",
|
||||
entity_id=test.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Manager dispute notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
|
||||
def _notify_validation_conflict(db: Session, test: Test, actor: User | None) -> None:
|
||||
@@ -1105,7 +1125,7 @@ def _notify_validation_conflict(db: Session, test: Test, actor: User | None) ->
|
||||
f"or contact {rejector_role} to resolve the disagreement."
|
||||
),
|
||||
entity_type="test",
|
||||
entity_id=str(test.id),
|
||||
entity_id=test.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
@@ -1114,6 +1134,70 @@ def _notify_validation_conflict(db: Session, test: Test, actor: User | None) ->
|
||||
)
|
||||
|
||||
|
||||
def resolve_dispute(db: Session, test: Test, user: User, target_team: str, notes: str | None = None) -> Test:
|
||||
"""Resolve a disputed test by flipping the approving vote to reject.
|
||||
|
||||
Called by the lead who originally approved, now agreeing with the other
|
||||
lead's rejection. Unlike a plain rejection, the flipping lead identifies
|
||||
WHICH team's work needs redoing — the test routes straight back to that
|
||||
team's active queue (red_executing or blue_evaluating) instead of the
|
||||
generic 'rejected' state that would force a full draft restart for both
|
||||
teams.
|
||||
"""
|
||||
if target_team not in ("red", "blue"):
|
||||
raise InvalidOperationError("target_team must be 'red' or 'blue'")
|
||||
|
||||
if user.role != "admin":
|
||||
is_red_approver = user.role == "red_lead" and test.red_validation_status == "approved"
|
||||
is_blue_approver = user.role == "blue_lead" and test.blue_validation_status == "approved"
|
||||
if not (is_red_approver or is_blue_approver):
|
||||
raise InvalidOperationError(
|
||||
"Only the lead who approved can flip their vote to resolve this dispute"
|
||||
)
|
||||
|
||||
entity = TestEntity.from_orm(test)
|
||||
entity.resolve_dispute_reject(target_team)
|
||||
entity.apply_to(test)
|
||||
|
||||
if target_team == "blue":
|
||||
test.blue_work_started_at = None # split responsibility: entity doesn't own this field
|
||||
|
||||
db.flush()
|
||||
|
||||
new_state = "red_executing" if target_team == "red" else "blue_evaluating"
|
||||
|
||||
log_action(
|
||||
db, user_id=user.id, action="resolve_dispute",
|
||||
entity_type="test", entity_id=test.id,
|
||||
details={"target_team": target_team, "notes": notes, "test_name": test.name},
|
||||
)
|
||||
|
||||
try:
|
||||
notify_test_state_change(db, test, new_state)
|
||||
except Exception as e:
|
||||
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
operator_id = test.red_tech_assignee if target_team == "red" else test.blue_tech_assignee
|
||||
if operator_id and notes:
|
||||
try:
|
||||
create_notification(
|
||||
db, user_id=operator_id, type="test_reopened",
|
||||
title="Test sent back for rework (validation dispute)",
|
||||
message=f'Test "{test.name}" needs rework: {notes[:200]}',
|
||||
entity_type="test", entity_id=test.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
try:
|
||||
from app.services.jira_service import push_test_event
|
||||
push_test_event(db, test, user, new_state)
|
||||
except Exception as e:
|
||||
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
return test
|
||||
|
||||
|
||||
# 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.
|
||||
|
||||
Reference in New Issue
Block a user