feat(phase-37): timer pause/resume + professional reporting engine
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Pause/Resume timer:
- Add paused_at, red_paused_seconds, blue_paused_seconds fields to Test model
- Add pause_timer/resume_timer workflow functions with accumulated pause tracking
- Auto-resume on phase submit; subtract paused time from worklog duration
- Add POST /tests/{id}/pause-timer and resume-timer endpoints
- Update LiveTimer component with pause/resume button and paused visual state
- Wire pause/resume mutations through TestDetailPage and TestDetailHeader
Professional Reporting Engine - Fase 2:
- Add ReportEngine service with Jinja2 HTML rendering, WeasyPrint PDF, and docxtpl DOCX
- Add corporate CSS stylesheet with cover page, data tables, stats grid, findings
- Create purple_campaign, coverage_report, and executive_summary HTML templates
- Add report_generation_service collecting domain data for each report type
- Add professional_reports router: GET /reports/generate/purple-campaign/{id}, coverage-summary, executive-summary
- Add analytics router with flat JSON endpoints for PowerBI: /coverage, /tests, /trends, /operators
- Add advanced_metrics router: /coverage-by-tactic, /never-tested, /avg-validation-time, /detection-rate-trend
- Add weasyprint and docxtpl to requirements.txt
- Add REPORT_TEMPLATES_DIR, REPORT_OUTPUT_DIR, COMPANY_NAME, COMPANY_LOGO_PATH to config
This commit is contained in:
@@ -135,24 +135,32 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> Test:
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Auto-resume if paused
|
||||
paused_extra = 0
|
||||
if test.paused_at is not None:
|
||||
paused_extra = max(int((now - test.paused_at).total_seconds()), 0)
|
||||
test.paused_at = None
|
||||
|
||||
test = transition_state(
|
||||
db, test, TestState.blue_evaluating, user,
|
||||
action_name="submit_red_evidence",
|
||||
)
|
||||
|
||||
# Create automatic worklog for Red Team phase
|
||||
# Create automatic worklog for Red Team phase (subtract paused time)
|
||||
_create_phase_worklog(
|
||||
db,
|
||||
test=test,
|
||||
user=user,
|
||||
phase_started_at=test.red_started_at,
|
||||
phase_ended_at=now,
|
||||
paused_seconds=(test.red_paused_seconds or 0) + paused_extra,
|
||||
activity_type="red_team_execution",
|
||||
description=f"Red Team execution: {test.name}",
|
||||
)
|
||||
|
||||
# Start Blue Team timer
|
||||
test.blue_started_at = now
|
||||
test.blue_paused_seconds = 0
|
||||
db.commit()
|
||||
return test
|
||||
|
||||
@@ -165,18 +173,25 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> Test:
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Auto-resume if paused
|
||||
paused_extra = 0
|
||||
if test.paused_at is not None:
|
||||
paused_extra = max(int((now - test.paused_at).total_seconds()), 0)
|
||||
test.paused_at = None
|
||||
|
||||
test = transition_state(
|
||||
db, test, TestState.in_review, user,
|
||||
action_name="submit_blue_evidence",
|
||||
)
|
||||
|
||||
# Create automatic worklog for Blue Team phase
|
||||
# Create automatic worklog for Blue Team phase (subtract paused time)
|
||||
_create_phase_worklog(
|
||||
db,
|
||||
test=test,
|
||||
user=user,
|
||||
phase_started_at=test.blue_started_at,
|
||||
phase_ended_at=now,
|
||||
paused_seconds=(test.blue_paused_seconds or 0) + paused_extra,
|
||||
activity_type="blue_team_evaluation",
|
||||
description=f"Blue Team evaluation: {test.name}",
|
||||
)
|
||||
@@ -185,6 +200,62 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> Test:
|
||||
return test
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
if test.state not in (TestState.red_executing, TestState.blue_evaluating):
|
||||
raise InvalidOperationError(
|
||||
f"Cannot pause timer in '{test.state.value}' state"
|
||||
)
|
||||
if test.paused_at is not None:
|
||||
raise InvalidOperationError("Timer is already paused")
|
||||
|
||||
test.paused_at = datetime.utcnow()
|
||||
log_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
action="pause_timer",
|
||||
entity_type="test",
|
||||
entity_id=test.id,
|
||||
details={"state": test.state.value},
|
||||
)
|
||||
db.commit()
|
||||
return test
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
if test.paused_at is None:
|
||||
raise InvalidOperationError("Timer is not paused")
|
||||
|
||||
now = datetime.utcnow()
|
||||
paused_seconds = max(int((now - test.paused_at).total_seconds()), 0)
|
||||
|
||||
if test.state == TestState.red_executing:
|
||||
test.red_paused_seconds = (test.red_paused_seconds or 0) + paused_seconds
|
||||
elif test.state == TestState.blue_evaluating:
|
||||
test.blue_paused_seconds = (test.blue_paused_seconds or 0) + paused_seconds
|
||||
|
||||
test.paused_at = None
|
||||
log_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
action="resume_timer",
|
||||
entity_type="test",
|
||||
entity_id=test.id,
|
||||
details={"paused_seconds": paused_seconds, "state": test.state.value},
|
||||
)
|
||||
db.commit()
|
||||
return test
|
||||
|
||||
|
||||
def _create_phase_worklog(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -192,11 +263,14 @@ def _create_phase_worklog(
|
||||
user: User,
|
||||
phase_started_at: datetime | None,
|
||||
phase_ended_at: datetime,
|
||||
paused_seconds: int = 0,
|
||||
activity_type: str,
|
||||
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.
|
||||
"""
|
||||
if not phase_started_at:
|
||||
@@ -206,7 +280,8 @@ def _create_phase_worklog(
|
||||
)
|
||||
return
|
||||
|
||||
duration_seconds = max(int((phase_ended_at - phase_started_at).total_seconds()), 1)
|
||||
gross_seconds = int((phase_ended_at - phase_started_at).total_seconds())
|
||||
duration_seconds = max(gross_seconds - paused_seconds, 1)
|
||||
|
||||
try:
|
||||
from app.services.worklog_service import create_worklog
|
||||
@@ -520,6 +595,9 @@ def reopen_test(db: Session, test: Test, user: User) -> Test:
|
||||
# Clear phase timing fields
|
||||
test.red_started_at = None
|
||||
test.blue_started_at = None
|
||||
test.paused_at = None
|
||||
test.red_paused_seconds = 0
|
||||
test.blue_paused_seconds = 0
|
||||
|
||||
db.commit()
|
||||
return test
|
||||
|
||||
Reference in New Issue
Block a user