Newer
Older
bugtrail / server / app / routers / projects.py
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