from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..db import get_session
from ..deps import get_project_for_owner, require_user
from ..models import Project, Report, User, gen_share_token
from ..schemas import (
ProjectCreateIn,
ProjectDetailOut,
ProjectOut,
ProjectReportCounts,
ProjectUpdateIn,
ReportListOut,
)
router = APIRouter(prefix="/projects", tags=["projects"])
@router.get("", response_model=list[ProjectOut])
async def list_projects(user: User = Depends(require_user), db: AsyncSession = Depends(get_session)):
result = await db.execute(
select(Project).where(Project.owner_user_id == user.id).order_by(Project.created_at.desc())
)
return result.scalars().all()
@router.post("", response_model=ProjectOut, status_code=status.HTTP_201_CREATED)
async def create_project(
body: ProjectCreateIn, user: User = Depends(require_user), db: AsyncSession = Depends(get_session)
):
project = Project(owner_user_id=user.id, name=body.name, description=body.description)
db.add(project)
await db.commit()
await db.refresh(project)
return project
@router.patch("/{project_id}", response_model=ProjectOut)
async def update_project(
body: ProjectUpdateIn,
project: Project = Depends(get_project_for_owner),
db: AsyncSession = Depends(get_session),
):
if body.name is not None:
project.name = body.name
if body.description is not None:
project.description = body.description
await db.commit()
await db.refresh(project)
return project
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_project(
project: Project = Depends(get_project_for_owner), db: AsyncSession = Depends(get_session)
):
await db.delete(project)
await db.commit()
@router.post("/{project_id}/share/rotate", response_model=ProjectOut)
async def rotate_project_share(
project: Project = Depends(get_project_for_owner), db: AsyncSession = Depends(get_session)
):
project.share_token = gen_share_token()
await db.commit()
await db.refresh(project)
return project
async def _list_project_reports(
project: Project,
limit: int,
offset: int,
report_type: str | None,
report_status: str | None,
db: AsyncSession,
) -> ReportListOut:
limit = min(max(limit, 1), 200)
offset = max(offset, 0)
query = select(Report).where(Report.project_id == project.id)
if report_type in ("element_note", "recording"):
query = query.where(Report.type == report_type)
if report_status in ("open", "fixed", "wont_fix"):
query = query.where(Report.status == report_status)
total_result = await db.execute(
select(func.count()).select_from(Report).where(Report.project_id == project.id)
)
result = await db.execute(
query.order_by(Report.created_at.desc()).offset(offset).limit(limit)
)
return ReportListOut(items=result.scalars().all(), total=total_result.scalar_one(), limit=limit, offset=offset)
@router.get("/{project_id}/reports", response_model=ReportListOut)
async def list_project_reports(
project: Project = Depends(get_project_for_owner),
limit: int = 50,
offset: int = 0,
report_type: str | None = None,
report_status: str | None = None,
db: AsyncSession = Depends(get_session),
):
return await _list_project_reports(project, limit, offset, report_type, report_status, db)
# public counterpart of GET /{id}/reports: the project page is opened from a
# share link without a session, so the report list must be reachable by token
@router.get("/by-token/{token}/reports", response_model=ReportListOut)
async def list_project_reports_by_token(
token: str,
limit: int = 50,
offset: int = 0,
report_type: str | None = None,
report_status: str | None = None,
db: AsyncSession = Depends(get_session),
):
result = await db.execute(select(Project).where(Project.share_token == token))
project = result.scalar_one_or_none()
if project is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Project not found")
return await _list_project_reports(project, limit, offset, report_type, report_status, db)
@router.get("/by-token/{token}", response_model=ProjectDetailOut)
async def get_project_by_token(token: str, db: AsyncSession = Depends(get_session)):
result = await db.execute(select(Project).where(Project.share_token == token))
project = result.scalar_one_or_none()
if project is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Project not found")
owner = await db.get(User, project.owner_user_id)
counts_result = await db.execute(
select(Report.status, func.count()).where(Report.project_id == project.id).group_by(Report.status)
)
by_status = dict(counts_result.all())
total = sum(by_status.values())
return ProjectDetailOut(
**ProjectOut.model_validate(project).model_dump(),
owner_nickname=owner.nickname,
report_counts=ProjectReportCounts(total=total, open=by_status.get("open", 0)),
)
@router.get("/{project_id}/share", response_model=ProjectOut)
async def get_project(project: Project = Depends(get_project_for_owner)):
return project