Implement session outcomes, step timing, and live timer fixes
This commit is contained in:
31
backend/alembic/versions/029_add_session_outcomes.py
Normal file
31
backend/alembic/versions/029_add_session_outcomes.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""add outcome fields to sessions
|
||||
|
||||
Revision ID: 029
|
||||
Revises: 028
|
||||
Create Date: 2026-02-11
|
||||
|
||||
Adds outcome and outcome_notes columns for session completion tracking.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "029"
|
||||
down_revision: Union[str, None] = "028"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("sessions", sa.Column("outcome", sa.String(length=20), nullable=True))
|
||||
op.add_column("sessions", sa.Column("outcome_notes", sa.Text(), nullable=True))
|
||||
op.create_index(op.f("ix_sessions_outcome"), "sessions", ["outcome"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_sessions_outcome"), table_name="sessions")
|
||||
op.drop_column("sessions", "outcome_notes")
|
||||
op.drop_column("sessions", "outcome")
|
||||
@@ -10,7 +10,16 @@ from app.core.database import get_db
|
||||
from app.models.tree import Tree
|
||||
from app.models.session import Session
|
||||
from app.models.user import User
|
||||
from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, ScratchpadUpdate, SaveAsTreeRequest, SaveAsTreeResponse
|
||||
from app.schemas.session import (
|
||||
SessionCreate,
|
||||
SessionUpdate,
|
||||
SessionResponse,
|
||||
SessionExport,
|
||||
ScratchpadUpdate,
|
||||
SaveAsTreeRequest,
|
||||
SaveAsTreeResponse,
|
||||
SessionComplete,
|
||||
)
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.core.permissions import can_access_tree
|
||||
from app.services.export_service import generate_markdown_export, generate_text_export, generate_html_export, generate_psa_export
|
||||
@@ -198,6 +207,7 @@ async def update_session(
|
||||
@router.post("/{session_id}/complete", response_model=SessionResponse)
|
||||
async def complete_session(
|
||||
session_id: UUID,
|
||||
completion_data: SessionComplete,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
@@ -224,6 +234,8 @@ async def complete_session(
|
||||
)
|
||||
|
||||
session.completed_at = datetime.now(timezone.utc)
|
||||
session.outcome = completion_data.outcome
|
||||
session.outcome_notes = completion_data.outcome_notes
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
return session
|
||||
|
||||
@@ -45,6 +45,8 @@ class Session(Base):
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
outcome: Mapped[Optional[str]] = mapped_column(String(20), nullable=True, index=True)
|
||||
outcome_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
ticket_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
client_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
exported: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
@@ -3,6 +3,8 @@ from typing import Optional, Any, Literal
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved"]
|
||||
|
||||
|
||||
class CustomStepSchema(BaseModel):
|
||||
"""Enhanced custom step with source tracking.
|
||||
@@ -28,6 +30,9 @@ class DecisionRecord(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
automation_used: Optional[bool] = False
|
||||
timestamp: datetime
|
||||
entered_at: Optional[datetime] = None
|
||||
exited_at: Optional[datetime] = None
|
||||
duration_seconds: Optional[int] = Field(None, ge=0)
|
||||
attachments: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -57,6 +62,8 @@ class SessionResponse(BaseModel):
|
||||
custom_steps: list[dict[str, Any]] = Field(default_factory=list)
|
||||
started_at: datetime
|
||||
completed_at: Optional[datetime] = None
|
||||
outcome: Optional[SessionOutcome] = None
|
||||
outcome_notes: Optional[str] = None
|
||||
ticket_number: Optional[str] = None
|
||||
client_name: Optional[str] = None
|
||||
exported: bool
|
||||
@@ -77,6 +84,11 @@ class SessionExport(BaseModel):
|
||||
include_tree_info: bool = True
|
||||
|
||||
|
||||
class SessionComplete(BaseModel):
|
||||
outcome: SessionOutcome
|
||||
outcome_notes: Optional[str] = None
|
||||
|
||||
|
||||
class ScratchpadUpdate(BaseModel):
|
||||
scratchpad: str
|
||||
|
||||
|
||||
@@ -5,12 +5,21 @@ Provides markdown, plain text, HTML, and PSA/ticket note export formatters
|
||||
for troubleshooting sessions.
|
||||
"""
|
||||
import html
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from app.models.session import Session
|
||||
from app.schemas.session import SessionExport
|
||||
|
||||
|
||||
OUTCOME_LABELS = {
|
||||
"resolved": "Resolved",
|
||||
"escalated": "Escalated",
|
||||
"workaround": "Workaround Applied",
|
||||
"unresolved": "Unresolved",
|
||||
}
|
||||
|
||||
|
||||
def _format_duration(started_at: datetime, completed_at: datetime | None) -> str:
|
||||
"""Format duration between two datetimes as human-readable string."""
|
||||
if not completed_at:
|
||||
@@ -26,9 +35,64 @@ def _format_duration(started_at: datetime, completed_at: datetime | None) -> str
|
||||
return f"{minutes} minutes"
|
||||
|
||||
|
||||
def _format_step_duration(duration_seconds: int) -> str:
|
||||
"""Format step duration seconds as compact human-readable text."""
|
||||
if duration_seconds < 0:
|
||||
return "0s"
|
||||
if duration_seconds < 60:
|
||||
return f"{duration_seconds}s"
|
||||
hours, remainder = divmod(duration_seconds, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
if hours > 0:
|
||||
if seconds > 0:
|
||||
return f"{hours}h {minutes}m {seconds}s"
|
||||
return f"{hours}h {minutes}m"
|
||||
if seconds > 0:
|
||||
return f"{minutes}m {seconds}s"
|
||||
return f"{minutes}m"
|
||||
|
||||
|
||||
def _parse_iso_datetime(value: Any) -> datetime | None:
|
||||
"""Parse ISO datetime strings from JSONB with support for trailing Z."""
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _get_step_duration_seconds(decision: dict[str, Any]) -> int | None:
|
||||
"""Get step duration from explicit field or entered/exited timestamps."""
|
||||
explicit_duration = decision.get("duration_seconds")
|
||||
if isinstance(explicit_duration, (int, float)):
|
||||
duration = int(explicit_duration)
|
||||
if duration >= 0:
|
||||
return duration
|
||||
|
||||
entered_at = _parse_iso_datetime(decision.get("entered_at"))
|
||||
exited_at = _parse_iso_datetime(decision.get("exited_at"))
|
||||
if entered_at and exited_at:
|
||||
total_seconds = int((exited_at - entered_at).total_seconds())
|
||||
if total_seconds >= 0:
|
||||
return total_seconds
|
||||
return None
|
||||
|
||||
|
||||
def _get_outcome_label(session: Session) -> str | None:
|
||||
"""Map stored outcome enum to human-friendly label."""
|
||||
outcome = getattr(session, "outcome", None)
|
||||
if not outcome:
|
||||
return None
|
||||
return OUTCOME_LABELS.get(outcome, str(outcome).replace("_", " ").title())
|
||||
|
||||
|
||||
def generate_markdown_export(session: Session, options: SessionExport) -> str:
|
||||
"""Generate markdown export."""
|
||||
lines = []
|
||||
outcome_label = _get_outcome_label(session)
|
||||
|
||||
if options.include_tree_info:
|
||||
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
|
||||
@@ -42,6 +106,9 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
|
||||
lines.append(f"**Started:** {session.started_at.strftime('%Y-%m-%d %H:%M')}")
|
||||
if session.completed_at:
|
||||
lines.append(f"**Completed:** {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
|
||||
lines.append(f"**Duration:** {_format_duration(session.started_at, session.completed_at)}")
|
||||
if outcome_label:
|
||||
lines.append(f"**Outcome:** {outcome_label}")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
@@ -63,12 +130,15 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
|
||||
question = decision.get("question") or decision.get("action_performed", "Step")
|
||||
answer = decision.get("answer", "")
|
||||
notes = decision.get("notes", "")
|
||||
duration_seconds = _get_step_duration_seconds(decision)
|
||||
|
||||
lines.append(f"### Step {i}: {question}")
|
||||
if answer:
|
||||
lines.append(f"**Answer:** {answer}")
|
||||
if notes:
|
||||
lines.append(f"**Notes:** {notes}")
|
||||
if duration_seconds is not None:
|
||||
lines.append(f"**Duration:** {_format_step_duration(duration_seconds)}")
|
||||
if options.include_timestamps and decision.get("timestamp"):
|
||||
lines.append(f"*{decision['timestamp']}*")
|
||||
lines.append("")
|
||||
@@ -79,6 +149,7 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
|
||||
def generate_text_export(session: Session, options: SessionExport) -> str:
|
||||
"""Generate plain text export."""
|
||||
lines = []
|
||||
outcome_label = _get_outcome_label(session)
|
||||
|
||||
if options.include_tree_info:
|
||||
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
|
||||
@@ -92,6 +163,9 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
|
||||
lines.append(f"Started: {session.started_at.strftime('%Y-%m-%d %H:%M')}")
|
||||
if session.completed_at:
|
||||
lines.append(f"Completed: {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
|
||||
lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}")
|
||||
if outcome_label:
|
||||
lines.append(f"Outcome: {outcome_label}")
|
||||
lines.append("")
|
||||
|
||||
# Scratchpad / Evidence section
|
||||
@@ -109,12 +183,15 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
|
||||
question = decision.get("question") or decision.get("action_performed", "Step")
|
||||
answer = decision.get("answer", "")
|
||||
notes = decision.get("notes", "")
|
||||
duration_seconds = _get_step_duration_seconds(decision)
|
||||
|
||||
lines.append(f"\n{i}. {question}")
|
||||
if answer:
|
||||
lines.append(f" Answer: {answer}")
|
||||
if notes:
|
||||
lines.append(f" Notes: {notes}")
|
||||
if duration_seconds is not None:
|
||||
lines.append(f" Duration: {_format_step_duration(duration_seconds)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -122,6 +199,7 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
|
||||
def generate_html_export(session: Session, options: SessionExport) -> str:
|
||||
"""Generate HTML export."""
|
||||
tree_name = html.escape(session.tree_snapshot.get("name", "Troubleshooting Session"))
|
||||
outcome_label = _get_outcome_label(session)
|
||||
|
||||
html_parts = ['<!DOCTYPE html>', '<html>', '<head>',
|
||||
'<meta charset="UTF-8">',
|
||||
@@ -134,6 +212,7 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
|
||||
'.step h3 { margin: 0 0 10px 0; color: #444; }',
|
||||
'.answer { font-weight: bold; }',
|
||||
'.notes { font-style: italic; color: #555; }',
|
||||
'.duration { color: #444; margin-top: 6px; }',
|
||||
'.timestamp { font-size: 0.85em; color: #888; }',
|
||||
'</style>',
|
||||
'</head>', '<body>']
|
||||
@@ -149,6 +228,9 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
|
||||
html_parts.append(f'<p><strong>Started:</strong> {session.started_at.strftime("%Y-%m-%d %H:%M")}</p>')
|
||||
if session.completed_at:
|
||||
html_parts.append(f'<p><strong>Completed:</strong> {session.completed_at.strftime("%Y-%m-%d %H:%M")}</p>')
|
||||
html_parts.append(f'<p><strong>Duration:</strong> {_format_duration(session.started_at, session.completed_at)}</p>')
|
||||
if outcome_label:
|
||||
html_parts.append(f'<p><strong>Outcome:</strong> {html.escape(outcome_label)}</p>')
|
||||
html_parts.append('</div>')
|
||||
|
||||
# Scratchpad / Evidence section
|
||||
@@ -163,6 +245,7 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
|
||||
question = html.escape(decision.get("question") or decision.get("action_performed", "Step"))
|
||||
answer = html.escape(decision.get("answer", ""))
|
||||
notes = html.escape(decision.get("notes", ""))
|
||||
duration_seconds = _get_step_duration_seconds(decision)
|
||||
|
||||
html_parts.append('<div class="step">')
|
||||
html_parts.append(f'<h3>Step {i}: {question}</h3>')
|
||||
@@ -170,6 +253,8 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
|
||||
html_parts.append(f'<p class="answer">Answer: {answer}</p>')
|
||||
if notes:
|
||||
html_parts.append(f'<p class="notes">Notes: {notes}</p>')
|
||||
if duration_seconds is not None:
|
||||
html_parts.append(f'<p class="duration"><strong>Duration:</strong> {_format_step_duration(duration_seconds)}</p>')
|
||||
if options.include_timestamps and decision.get("timestamp"):
|
||||
html_parts.append(f'<p class="timestamp">{html.escape(str(decision["timestamp"]))}</p>')
|
||||
html_parts.append('</div>')
|
||||
@@ -181,6 +266,7 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
|
||||
def generate_psa_export(session: Session, options: SessionExport) -> str:
|
||||
"""Generate PSA/ticket note export optimized for ConnectWise and similar PSA tools."""
|
||||
lines = []
|
||||
outcome_label = _get_outcome_label(session)
|
||||
|
||||
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
|
||||
tree_description = session.tree_snapshot.get("description", "")
|
||||
@@ -191,6 +277,9 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
|
||||
lines.append("=== TROUBLESHOOTING NOTES ===")
|
||||
lines.append(f"Ticket: {ticket_number} | Client: {client_name}")
|
||||
lines.append(f"Tree: {tree_name} | Date: {date_str}")
|
||||
lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}")
|
||||
if outcome_label:
|
||||
lines.append(f"Outcome: {outcome_label}")
|
||||
lines.append("")
|
||||
|
||||
# Problem section
|
||||
@@ -205,10 +294,13 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
|
||||
question = decision.get("question") or decision.get("action_performed", "Step")
|
||||
answer = decision.get("answer", "")
|
||||
notes = decision.get("notes", "")
|
||||
duration_seconds = _get_step_duration_seconds(decision)
|
||||
|
||||
line = f"{i}. {question}"
|
||||
if answer:
|
||||
line += f" -> {answer}"
|
||||
if duration_seconds is not None:
|
||||
line += f" ({_format_step_duration(duration_seconds)})"
|
||||
lines.append(line)
|
||||
if notes:
|
||||
lines.append(f" Notes: {notes}")
|
||||
@@ -224,6 +316,8 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
|
||||
lines.append(resolution)
|
||||
else:
|
||||
lines.append("No resolution recorded.")
|
||||
if outcome_label:
|
||||
lines.append(f"Outcome: {outcome_label}")
|
||||
lines.append("")
|
||||
|
||||
# Time spent
|
||||
|
||||
@@ -153,12 +153,34 @@ class TestSessions:
|
||||
# Complete session
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/complete",
|
||||
json={"outcome": "resolved", "outcome_notes": "Issue fixed after restarting service"},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["completed_at"] is not None
|
||||
assert data["outcome"] == "resolved"
|
||||
assert data["outcome_notes"] == "Issue fixed after restarting service"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_session_requires_outcome(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Test that completion requires an outcome payload."""
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": test_tree["id"]},
|
||||
headers=auth_headers
|
||||
)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/complete",
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_already_completed_session(
|
||||
@@ -175,12 +197,14 @@ class TestSessions:
|
||||
|
||||
await client.post(
|
||||
f"/api/v1/sessions/{session_id}/complete",
|
||||
json={"outcome": "resolved"},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
# Try to complete again
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/complete",
|
||||
json={"outcome": "resolved"},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
@@ -217,6 +241,59 @@ class TestSessions:
|
||||
assert "EXP-001" in content # Should contain ticket number
|
||||
assert "#" in content # Markdown headers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_markdown_includes_outcome_and_step_duration(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Test markdown export includes session outcome and per-step duration."""
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": test_tree["id"], "ticket_number": "EXP-OUTCOME-001"},
|
||||
headers=auth_headers
|
||||
)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
step_timestamp = "2026-02-11T10:10:00Z"
|
||||
update_response = await client.put(
|
||||
f"/api/v1/sessions/{session_id}",
|
||||
json={
|
||||
"decisions": [{
|
||||
"node_id": "root",
|
||||
"question": "Is this a test?",
|
||||
"answer": "Yes",
|
||||
"action_performed": None,
|
||||
"notes": "Validated quickly",
|
||||
"automation_used": False,
|
||||
"timestamp": step_timestamp,
|
||||
"entered_at": "2026-02-11T10:08:30Z",
|
||||
"exited_at": step_timestamp,
|
||||
"duration_seconds": 90,
|
||||
"attachments": []
|
||||
}]
|
||||
},
|
||||
headers=auth_headers
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
|
||||
complete_response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/complete",
|
||||
json={"outcome": "workaround", "outcome_notes": "Temporary mitigation applied"},
|
||||
headers=auth_headers
|
||||
)
|
||||
assert complete_response.status_code == 200
|
||||
|
||||
export_response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/export",
|
||||
json={"format": "markdown", "include_timestamps": True, "include_tree_info": True},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert export_response.status_code == 200
|
||||
content = export_response.text
|
||||
assert "**Outcome:** Workaround Applied" in content
|
||||
assert "**Duration:**" in content
|
||||
assert "**Duration:** 1m 30s" in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_session_text(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
@@ -291,6 +368,7 @@ class TestSessions:
|
||||
# Complete first session
|
||||
await client.post(
|
||||
f"/api/v1/sessions/{session1_id}/complete",
|
||||
json={"outcome": "resolved"},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
@@ -449,6 +527,7 @@ class TestSessions:
|
||||
|
||||
await client.post(
|
||||
f"/api/v1/sessions/{session_id}/complete",
|
||||
json={"outcome": "resolved"},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
@@ -857,6 +936,7 @@ class TestSessions:
|
||||
|
||||
await client.post(
|
||||
f"/api/v1/sessions/{session_id}/complete",
|
||||
json={"outcome": "resolved"},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user