feat: add PSA ticket export format and Quick-Start landing page
PSA Export: - New "PSA / Ticket Note" export format optimized for ConnectWise - Structured output: Problem, Steps Taken, Resolution, Time Spent, Notes - Prominent "Copy for Ticket" button on session detail page - 24 unit tests for PSA export generator Quick-Start Landing: - New default landing page with search-first UX - Auto-focused search bar with debounced tree search - "Continue Session" cards for active sessions - "Recent Trees" section from session history - Home nav item and logo links updated Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ from app.models.user import User
|
||||
from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, ScratchpadUpdate, SaveAsTreeRequest, SaveAsTreeResponse
|
||||
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
|
||||
from app.services.export_service import generate_markdown_export, generate_text_export, generate_html_export, generate_psa_export
|
||||
|
||||
router = APIRouter(prefix="/sessions", tags=["sessions"])
|
||||
|
||||
@@ -288,6 +288,9 @@ async def export_session(
|
||||
elif export_options.format == "html":
|
||||
content = generate_html_export(session, export_options)
|
||||
media_type = "text/html"
|
||||
elif export_options.format == "psa":
|
||||
content = generate_psa_export(session, export_options)
|
||||
media_type = "text/plain"
|
||||
else: # text
|
||||
content = generate_text_export(session, export_options)
|
||||
media_type = "text/plain"
|
||||
|
||||
@@ -70,7 +70,7 @@ class SessionResponse(BaseModel):
|
||||
|
||||
|
||||
class SessionExport(BaseModel):
|
||||
format: str = Field(default="markdown", pattern="^(text|markdown|html)$")
|
||||
format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$")
|
||||
include_timestamps: bool = True
|
||||
include_tree_info: bool = True
|
||||
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
"""
|
||||
Session export generators for ResolutionFlow.
|
||||
|
||||
Provides markdown, plain text, and HTML export formatters
|
||||
Provides markdown, plain text, HTML, and PSA/ticket note export formatters
|
||||
for troubleshooting sessions.
|
||||
"""
|
||||
import html
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.models.session import Session
|
||||
from app.schemas.session import SessionExport
|
||||
|
||||
|
||||
def _format_duration(started_at: datetime, completed_at: datetime | None) -> str:
|
||||
"""Format duration between two datetimes as human-readable string."""
|
||||
if not completed_at:
|
||||
return "In progress"
|
||||
delta = completed_at - started_at
|
||||
total_seconds = int(delta.total_seconds())
|
||||
if total_seconds < 0:
|
||||
return "0 minutes"
|
||||
hours, remainder = divmod(total_seconds, 3600)
|
||||
minutes = remainder // 60
|
||||
if hours > 0:
|
||||
return f"{hours}h {minutes}m"
|
||||
return f"{minutes} minutes"
|
||||
|
||||
|
||||
def generate_markdown_export(session: Session, options: SessionExport) -> str:
|
||||
"""Generate markdown export."""
|
||||
lines = []
|
||||
@@ -160,3 +176,65 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
|
||||
|
||||
html_parts.extend(['</body>', '</html>'])
|
||||
return "\n".join(html_parts)
|
||||
|
||||
|
||||
def generate_psa_export(session: Session, options: SessionExport) -> str:
|
||||
"""Generate PSA/ticket note export optimized for ConnectWise and similar PSA tools."""
|
||||
lines = []
|
||||
|
||||
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
|
||||
tree_description = session.tree_snapshot.get("description", "")
|
||||
ticket_number = session.ticket_number or "N/A"
|
||||
client_name = session.client_name or "N/A"
|
||||
date_str = session.started_at.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
lines.append("=== TROUBLESHOOTING NOTES ===")
|
||||
lines.append(f"Ticket: {ticket_number} | Client: {client_name}")
|
||||
lines.append(f"Tree: {tree_name} | Date: {date_str}")
|
||||
lines.append("")
|
||||
|
||||
# Problem section
|
||||
lines.append("--- PROBLEM ---")
|
||||
lines.append(tree_description if tree_description else "No description provided.")
|
||||
lines.append("")
|
||||
|
||||
# Steps taken
|
||||
lines.append("--- STEPS TAKEN ---")
|
||||
if session.decisions:
|
||||
for i, decision in enumerate(session.decisions, 1):
|
||||
question = decision.get("question") or decision.get("action_performed", "Step")
|
||||
answer = decision.get("answer", "")
|
||||
notes = decision.get("notes", "")
|
||||
|
||||
line = f"{i}. {question}"
|
||||
if answer:
|
||||
line += f" -> {answer}"
|
||||
lines.append(line)
|
||||
if notes:
|
||||
lines.append(f" Notes: {notes}")
|
||||
else:
|
||||
lines.append("No steps recorded.")
|
||||
lines.append("")
|
||||
|
||||
# Resolution - last decision answer
|
||||
lines.append("--- RESOLUTION ---")
|
||||
if session.decisions:
|
||||
last_decision = session.decisions[-1]
|
||||
resolution = last_decision.get("answer") or last_decision.get("question", "No resolution recorded")
|
||||
lines.append(resolution)
|
||||
else:
|
||||
lines.append("No resolution recorded.")
|
||||
lines.append("")
|
||||
|
||||
# Time spent
|
||||
lines.append("--- TIME SPENT ---")
|
||||
duration = _format_duration(session.started_at, session.completed_at)
|
||||
lines.append(f"Duration: {duration}")
|
||||
lines.append("")
|
||||
|
||||
# Engineer notes (scratchpad)
|
||||
lines.append("--- ENGINEER NOTES ---")
|
||||
scratchpad = getattr(session, 'scratchpad', '') or ''
|
||||
lines.append(scratchpad.strip() if scratchpad.strip() else "None")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
233
backend/tests/test_psa_export.py
Normal file
233
backend/tests/test_psa_export.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Tests for PSA/ticket note export format.
|
||||
|
||||
Covers: all fields present, missing optional fields, duration calculation,
|
||||
incomplete sessions, and edge cases.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.schemas.session import SessionExport
|
||||
from app.services.export_service import generate_psa_export, _format_duration
|
||||
|
||||
|
||||
def _make_session(
|
||||
tree_name="Test Tree",
|
||||
tree_description="A test problem description",
|
||||
ticket_number=None,
|
||||
client_name=None,
|
||||
decisions=None,
|
||||
scratchpad="",
|
||||
started_at=None,
|
||||
completed_at=None,
|
||||
):
|
||||
"""Create a mock session object for PSA export testing."""
|
||||
session = MagicMock()
|
||||
session.tree_snapshot = {"name": tree_name, "description": tree_description}
|
||||
session.ticket_number = ticket_number
|
||||
session.client_name = client_name
|
||||
session.decisions = decisions or []
|
||||
session.scratchpad = scratchpad
|
||||
session.started_at = started_at or datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
|
||||
session.completed_at = completed_at or datetime(2026, 1, 15, 11, 30, tzinfo=timezone.utc)
|
||||
return session
|
||||
|
||||
|
||||
def _default_options():
|
||||
return SessionExport(format="psa", include_timestamps=True, include_tree_info=True)
|
||||
|
||||
|
||||
class TestPsaExportAllFields:
|
||||
"""Test PSA export with all fields populated."""
|
||||
|
||||
def test_includes_header(self):
|
||||
session = _make_session(
|
||||
ticket_number="TK-5001",
|
||||
client_name="Contoso Corp",
|
||||
)
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "=== TROUBLESHOOTING NOTES ===" in result
|
||||
assert "Ticket: TK-5001" in result
|
||||
assert "Client: Contoso Corp" in result
|
||||
|
||||
def test_includes_tree_name_and_date(self):
|
||||
session = _make_session(tree_name="DNS Troubleshooting")
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "Tree: DNS Troubleshooting" in result
|
||||
assert "Date: 2026-01-15 10:00" in result
|
||||
|
||||
def test_includes_problem_section(self):
|
||||
session = _make_session(tree_description="Client cannot resolve DNS names")
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "--- PROBLEM ---" in result
|
||||
assert "Client cannot resolve DNS names" in result
|
||||
|
||||
def test_includes_steps_taken(self):
|
||||
session = _make_session(decisions=[
|
||||
{"question": "Is the DNS service running?", "answer": "Yes", "notes": "Checked services"},
|
||||
{"question": "Can you ping the DNS server?", "answer": "No"},
|
||||
])
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "--- STEPS TAKEN ---" in result
|
||||
assert "1. Is the DNS service running? -> Yes" in result
|
||||
assert " Notes: Checked services" in result
|
||||
assert "2. Can you ping the DNS server? -> No" in result
|
||||
|
||||
def test_includes_resolution(self):
|
||||
session = _make_session(decisions=[
|
||||
{"question": "Check cable", "answer": "Connected"},
|
||||
{"question": "Restart service", "answer": "Service restarted successfully"},
|
||||
])
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "--- RESOLUTION ---" in result
|
||||
assert "Service restarted successfully" in result
|
||||
|
||||
def test_includes_duration(self):
|
||||
session = _make_session(
|
||||
started_at=datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc),
|
||||
completed_at=datetime(2026, 1, 15, 11, 30, tzinfo=timezone.utc),
|
||||
)
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "--- TIME SPENT ---" in result
|
||||
assert "Duration: 1h 30m" in result
|
||||
|
||||
def test_includes_engineer_notes(self):
|
||||
session = _make_session(scratchpad="Checked firewall rules, port 53 was blocked")
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "--- ENGINEER NOTES ---" in result
|
||||
assert "Checked firewall rules, port 53 was blocked" in result
|
||||
|
||||
|
||||
class TestPsaExportMissingFields:
|
||||
"""Test PSA export gracefully handles missing optional fields."""
|
||||
|
||||
def test_missing_ticket_number(self):
|
||||
session = _make_session(ticket_number=None)
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "Ticket: N/A" in result
|
||||
|
||||
def test_missing_client_name(self):
|
||||
session = _make_session(client_name=None)
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "Client: N/A" in result
|
||||
|
||||
def test_missing_description(self):
|
||||
session = _make_session(tree_description="")
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "No description provided." in result
|
||||
|
||||
def test_empty_scratchpad(self):
|
||||
session = _make_session(scratchpad="")
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "--- ENGINEER NOTES ---" in result
|
||||
lines = result.split("\n")
|
||||
notes_idx = lines.index("--- ENGINEER NOTES ---")
|
||||
assert lines[notes_idx + 1] == "None"
|
||||
|
||||
def test_whitespace_only_scratchpad(self):
|
||||
session = _make_session(scratchpad=" \n \n ")
|
||||
result = generate_psa_export(session, _default_options())
|
||||
lines = result.split("\n")
|
||||
notes_idx = lines.index("--- ENGINEER NOTES ---")
|
||||
assert lines[notes_idx + 1] == "None"
|
||||
|
||||
def test_no_decisions(self):
|
||||
session = _make_session(decisions=[])
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "No steps recorded." in result
|
||||
assert "No resolution recorded." in result
|
||||
|
||||
def test_decision_with_action_performed_fallback(self):
|
||||
session = _make_session(decisions=[
|
||||
{"action_performed": "Restarted the server", "answer": "Done"},
|
||||
])
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "1. Restarted the server -> Done" in result
|
||||
|
||||
def test_decision_without_answer(self):
|
||||
session = _make_session(decisions=[
|
||||
{"question": "Check logs"},
|
||||
])
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "1. Check logs" in result
|
||||
# No arrow when answer is missing
|
||||
assert "->" not in result
|
||||
|
||||
|
||||
class TestDurationCalculation:
|
||||
"""Test the _format_duration helper."""
|
||||
|
||||
def test_hours_and_minutes(self):
|
||||
start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 1, 15, 12, 45, tzinfo=timezone.utc)
|
||||
assert _format_duration(start, end) == "2h 45m"
|
||||
|
||||
def test_minutes_only(self):
|
||||
start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 1, 15, 10, 25, tzinfo=timezone.utc)
|
||||
assert _format_duration(start, end) == "25 minutes"
|
||||
|
||||
def test_zero_minutes(self):
|
||||
start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 1, 15, 10, 0, 30, tzinfo=timezone.utc)
|
||||
assert _format_duration(start, end) == "0 minutes"
|
||||
|
||||
def test_incomplete_session(self):
|
||||
start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
|
||||
assert _format_duration(start, None) == "In progress"
|
||||
|
||||
def test_exact_hour(self):
|
||||
start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 1, 15, 11, 0, tzinfo=timezone.utc)
|
||||
assert _format_duration(start, end) == "1h 0m"
|
||||
|
||||
|
||||
class TestPsaExportIncompleteSession:
|
||||
"""Test PSA export for sessions that are not yet completed."""
|
||||
|
||||
def test_incomplete_session_shows_in_progress_duration(self):
|
||||
session = _make_session(completed_at=None)
|
||||
# Override completed_at to None explicitly
|
||||
session.completed_at = None
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "Duration: In progress" in result
|
||||
|
||||
def test_incomplete_session_with_steps(self):
|
||||
session = _make_session(
|
||||
decisions=[{"question": "First step", "answer": "Done"}],
|
||||
completed_at=None,
|
||||
)
|
||||
session.completed_at = None
|
||||
result = generate_psa_export(session, _default_options())
|
||||
assert "1. First step -> Done" in result
|
||||
assert "Duration: In progress" in result
|
||||
|
||||
|
||||
class TestPsaExportFormat:
|
||||
"""Test the overall format structure of PSA export."""
|
||||
|
||||
def test_section_order(self):
|
||||
session = _make_session(
|
||||
ticket_number="TK-100",
|
||||
client_name="Acme",
|
||||
decisions=[{"question": "Step 1", "answer": "Yes"}],
|
||||
scratchpad="Some notes",
|
||||
)
|
||||
result = generate_psa_export(session, _default_options())
|
||||
sections = [
|
||||
"=== TROUBLESHOOTING NOTES ===",
|
||||
"--- PROBLEM ---",
|
||||
"--- STEPS TAKEN ---",
|
||||
"--- RESOLUTION ---",
|
||||
"--- TIME SPENT ---",
|
||||
"--- ENGINEER NOTES ---",
|
||||
]
|
||||
positions = [result.index(s) for s in sections]
|
||||
assert positions == sorted(positions), "Sections should appear in the expected order"
|
||||
|
||||
def test_format_validation_accepts_psa(self):
|
||||
"""Verify the schema accepts 'psa' as a valid format."""
|
||||
export = SessionExport(format="psa")
|
||||
assert export.format == "psa"
|
||||
Reference in New Issue
Block a user