import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi.responses import FileResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..db import get_session
from ..deps import require_user
from ..models import Attachment, File, Project, RecordingStep, Report, User, gen_share_token
from ..schemas import (
AttachmentOut,
ReportCreateIn,
ReportDetailOut,
ReportListItemOut,
ReportUpdateIn,
StepIn,
StepOut,
)
router = APIRouter(prefix="/reports", tags=["reports"])
async def get_report_by_token(token: str, db: AsyncSession) -> Report:
result = await db.execute(select(Report).where(Report.share_token == token))
report = result.scalar_one_or_none()
if report is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Report not found")
return report
async def get_report_for_edit(
token: str, user: User = Depends(require_user), db: AsyncSession = Depends(get_session)
) -> Report:
"""Edit access: the report author or the project owner."""
report = await get_report_by_token(token, db)
project = await db.get(Project, report.project_id)
if report.author_user_id != user.id and (project is None or project.owner_user_id != user.id):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Not allowed to modify this report")
return report
@router.post("", response_model=ReportDetailOut, status_code=status.HTTP_201_CREATED)
async def create_report(
body: ReportCreateIn, user: User = Depends(require_user), db: AsyncSession = Depends(get_session)
):
project = await db.get(Project, body.project_id)
if project is None or project.owner_user_id != user.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Project not found")
# privacy enforcement server-side: never persist password inputs
if body.steps:
for step in body.steps:
if step.type == "input" and (step.data or {}).get("input_type") == "password":
step.data = {k: v for k, v in step.data.items() if k != "value"}
elif step.type == "input" and isinstance((step.data or {}).get("value"), str):
value = step.data["value"]
if len(value) > settings.max_input_value_length:
step.data["value"] = value[: settings.max_input_value_length]
step.data["value_truncated"] = True
report = Report(
project_id=body.project_id,
author_user_id=user.id,
type=body.type,
title=body.title,
description=body.description,
page_url=body.page_url,
page_title=body.page_title,
environment=body.environment.model_dump(mode="json") if body.environment else {},
element=body.element.model_dump(mode="json") if body.element else None,
mouse_track=body.mouse_track.model_dump(mode="json") if body.mouse_track else None,
)
db.add(report)
await db.flush()
# link uploaded files as attachments
if body.attachment_ids:
files = (
await db.execute(select(File).where(File.id.in_(body.attachment_ids)))
).scalars().all()
for file in files:
kind = "screenshot" if (file.mime or "").startswith("image/") else "document"
shapes = (body.annotation_shapes or {}).get(file.id)
db.add(
Attachment(
report_id=report.id,
file_id=file.id,
kind=kind,
filename=file.path.rsplit("/", 1)[-1],
mime=file.mime,
size=file.size,
annotation_shapes=shapes,
)
)
await db.flush()
# link step screenshots (must already be uploaded and attached)
if body.steps:
attachments = (await db.execute(
select(Attachment).where(Attachment.report_id == report.id)
)).scalars().all()
attachment_ids = {a.id for a in attachments}
# clients reference the uploaded file id; attachment rows wrap files
file_to_attachment = {a.file_id: a.id for a in attachments}
for index, step in enumerate(body.steps):
screenshot_id = None
if step.attachment_id is not None:
screenshot_id = (
step.attachment_id
if step.attachment_id in attachment_ids
else file_to_attachment.get(step.attachment_id)
)
db.add(
RecordingStep(
report_id=report.id,
step_index=index,
type=step.type,
offset_ms=step.offset_ms,
data=step.data,
screenshot_attachment_id=screenshot_id,
)
)
await db.commit()
await db.refresh(report)
return report
@router.get("/by-token/{token}/files/{file_id}")
async def download_report_file(token: str, file_id: uuid.UUID, db: AsyncSession = Depends(get_session)):
"""File download scoped under the report share token — the token IS the authorization."""
report = await get_report_by_token(token, db)
result = await db.execute(
select(Attachment).where(Attachment.report_id == report.id, Attachment.file_id == file_id)
)
attachment = result.scalar_one_or_none()
if attachment is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "File not found")
file = await db.get(File, attachment.file_id)
path = settings.files_dir / file.path
if not path.exists():
raise HTTPException(status.HTTP_404_NOT_FOUND, "File missing on disk")
return FileResponse(
path,
media_type=file.mime,
filename=attachment.filename,
headers={"Cache-Control": "private, max-age=3600"},
)
@router.get("/{token}", response_model=ReportDetailOut)
async def get_report(token: str, db: AsyncSession = Depends(get_session)):
report = await get_report_by_token(token, db)
return ReportDetailOut(
**ReportListItemOut.model_validate(report).model_dump(),
attachments=[AttachmentOut.model_validate(a) for a in report.attachments],
steps=[StepOut.model_validate(s) for s in report.steps],
)
@router.patch("/{token}", response_model=ReportDetailOut)
async def update_report(
body: ReportUpdateIn,
report: Report = Depends(get_report_for_edit),
db: AsyncSession = Depends(get_session),
):
if body.title is not None:
report.title = body.title
if body.description is not None:
report.description = body.description
if body.status is not None:
report.status = body.status
await db.commit()
await db.refresh(report)
return await get_report(report.share_token, db)
@router.delete("/{token}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_report(
report: Report = Depends(get_report_for_edit), db: AsyncSession = Depends(get_session)
):
await db.delete(report)
await db.commit()
@router.post("/{token}/share/rotate", response_model=ReportDetailOut)
async def rotate_report_share(
report: Report = Depends(get_report_for_edit), db: AsyncSession = Depends(get_session)
):
report.share_token = gen_share_token()
await db.commit()
await db.refresh(report)
return await get_report(report.share_token, db)
@router.post("/{token}/steps", response_model=StepOut, status_code=status.HTTP_201_CREATED)
async def append_step(
body: StepIn,
report: Report = Depends(get_report_for_edit),
db: AsyncSession = Depends(get_session),
):
data = body.data or {}
if body.type == "input" and data.get("input_type") == "password":
data = {k: v for k, v in data.items() if k != "value"}
elif body.type == "input" and isinstance(data.get("value"), str) and len(data["value"]) > settings.max_input_value_length:
data["value"] = data["value"][: settings.max_input_value_length]
data["value_truncated"] = True
screenshot_id = None
if body.attachment_id is not None:
attachment = await db.get(Attachment, body.attachment_id)
if attachment is not None and attachment.report_id == report.id:
screenshot_id = attachment.id
max_index = max((s.step_index for s in report.steps), default=-1)
step = RecordingStep(
report_id=report.id, step_index=max_index + 1, type=body.type, offset_ms=body.offset_ms,
data=data, screenshot_attachment_id=screenshot_id,
)
db.add(step)
await db.commit()
await db.refresh(step)
return step
@router.patch("/{token}/attachments/{attachment_id}", response_model=AttachmentOut)
async def update_attachment(
attachment_id: uuid.UUID,
annotation_shapes: list | None = None,
report: Report = Depends(get_report_for_edit),
db: AsyncSession = Depends(get_session),
):
"""Persist annotation vector shapes (they are re-rendered on the screenshot later)."""
attachment = next((a for a in report.attachments if a.id == attachment_id), None)
if attachment is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Attachment not found")
if annotation_shapes is not None:
attachment.annotation_shapes = annotation_shapes
await db.commit()
await db.refresh(attachment)
return attachment
@router.delete("/{token}/steps/{step_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_step(
token: str,
step_id: uuid.UUID,
report: Report = Depends(get_report_for_edit),
db: AsyncSession = Depends(get_session),
):
step = next((s for s in report.steps if s.id == step_id), None)
if step is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Step not found")
await db.delete(step)
await db.commit()