feat(phase-30): add coverage snapshots, temporal comparison and auto re-testing (T-230 to T-232)
This commit is contained in:
@@ -16,11 +16,12 @@ from datetime import datetime
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
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
|
||||
from app.services.notification_service import notify_test_state_change, create_notification
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Valid transition map
|
||||
@@ -298,6 +299,131 @@ def check_dual_validation(db: Session, test: Test) -> Test:
|
||||
return test
|
||||
|
||||
|
||||
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``.
|
||||
|
||||
Returns the new retest or *None* if the limit was reached.
|
||||
"""
|
||||
# Always reference the original test, not an intermediate retest
|
||||
original_test_id = test.retest_of or test.id
|
||||
|
||||
if test.retest_count >= settings.MAX_RETEST_COUNT:
|
||||
# Max retests reached — notify and bail out
|
||||
if test.created_by:
|
||||
create_notification(
|
||||
db,
|
||||
user_id=test.created_by,
|
||||
type="max_retests_reached",
|
||||
title="Maximum retests reached",
|
||||
message=(
|
||||
f'Test "{test.name}" has reached the maximum of '
|
||||
f'{settings.MAX_RETEST_COUNT} retests. Manual review required.'
|
||||
),
|
||||
entity_type="test",
|
||||
entity_id=test.id,
|
||||
)
|
||||
|
||||
log_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
action="max_retests_reached",
|
||||
entity_type="test",
|
||||
entity_id=test.id,
|
||||
details={
|
||||
"retest_count": test.retest_count,
|
||||
"max_allowed": settings.MAX_RETEST_COUNT,
|
||||
"original_test_id": str(original_test_id),
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
retest = Test(
|
||||
technique_id=test.technique_id,
|
||||
name=f"[Retest #{test.retest_count + 1}] {test.name.replace(f'[Retest #{test.retest_count}] ', '')}",
|
||||
description=test.description,
|
||||
platform=test.platform,
|
||||
procedure_text=test.procedure_text,
|
||||
tool_used=test.tool_used,
|
||||
state=TestState.draft,
|
||||
created_by=test.created_by,
|
||||
retest_of=original_test_id,
|
||||
retest_count=test.retest_count + 1,
|
||||
)
|
||||
db.add(retest)
|
||||
db.flush()
|
||||
|
||||
log_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
action="create_retest",
|
||||
entity_type="test",
|
||||
entity_id=retest.id,
|
||||
details={
|
||||
"original_test_id": str(original_test_id),
|
||||
"retest_number": retest.retest_count,
|
||||
"source_test_id": str(test.id),
|
||||
},
|
||||
)
|
||||
|
||||
# Notify the test creator and any red_tech users
|
||||
if test.created_by:
|
||||
create_notification(
|
||||
db,
|
||||
user_id=test.created_by,
|
||||
type="retest_created",
|
||||
title="Re-test created",
|
||||
message=(
|
||||
f'A re-test has been automatically created for "{test.name}" '
|
||||
f'after remediation was completed.'
|
||||
),
|
||||
entity_type="test",
|
||||
entity_id=retest.id,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(retest)
|
||||
return retest
|
||||
|
||||
|
||||
def get_retest_chain(db: Session, test_id) -> list[Test]:
|
||||
"""Return the full chain of retests for a given test.
|
||||
|
||||
Includes the original test and all subsequent retests, ordered
|
||||
by retest_count.
|
||||
"""
|
||||
import uuid as _uuid
|
||||
|
||||
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()
|
||||
if not test:
|
||||
return []
|
||||
|
||||
original_id = test.retest_of or test.id
|
||||
|
||||
# Get original
|
||||
original = db.query(Test).filter(Test.id == original_id).first()
|
||||
if not original:
|
||||
return [test]
|
||||
|
||||
# Get all retests of the original
|
||||
retests = (
|
||||
db.query(Test)
|
||||
.filter(Test.retest_of == original_id)
|
||||
.order_by(Test.retest_count)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [original] + retests
|
||||
|
||||
|
||||
def reopen_test(db: Session, test: Test, user: User) -> Test:
|
||||
"""Move a ``rejected`` test back to ``draft``, clearing validation fields.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user