Newer
Older
gnexus-tasks / backend / app / api / projects.py
"""API проектов M1."""

from typing import Any, cast

from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Response
from pydantic import BaseModel
from sqlalchemy import select

from app.dependencies import DbDep, UserIdDep
from app.models import CoinEvent, Project
from app.realtime import publish
from app.schemas import ProjectCreate, ProjectOut, ProjectUpdate
from app.services import favicon, readme
from app.services.detailing import summarize_project
from app.services.xp import XP_CREATE_PROJECT, coins_for_create, grant_create_xp

router = APIRouter(prefix="/api/projects", tags=["projects"])


class ProjectReadmeOut(BaseModel):
    content: str

# «Закрыт» (archived) больше нет — вместо него архив (ТЗ 3.8.1)
VALID_RELEVANCE = {"active", "paused"}


def _get_project_or_404(db: Any, project_id: int, user_id: str) -> Project:
    project = cast(
        Project | None,
        db.scalar(select(Project).where(Project.id == project_id, Project.user_id == user_id)),
    )
    if project is None:
        raise HTTPException(status_code=404, detail="Project not found")
    return project


@router.post("", response_model=ProjectOut)
async def create_project(
    schema: ProjectCreate, db: DbDep, user_id: UserIdDep, response: Response,
    background: BackgroundTasks,
) -> Project:
    exists = db.scalars(
        select(Project).where(Project.name == schema.name, Project.user_id == user_id)
    ).first()
    if exists is not None:
        raise HTTPException(status_code=409, detail="Project name already exists")
    project = Project(
        user_id=user_id,
        name=schema.name,
        priority=schema.priority,
        note=schema.note,
        color=schema.color,
        site_url=schema.site_url,
        repository_url=schema.repository_url,
    )
    db.add(project)
    db.flush()
    # Микронаграда за создание (ТЗ 3.13): чуть больше XP, чем за задачу
    grant_create_xp(db, "create_project", user_id)
    db.add(
        CoinEvent(
            user_id=user_id, source="create_project", amount=coins_for_create("create_project")
        )
    )
    response.headers["X-Created-XP"] = str(XP_CREATE_PROJECT)
    response.headers["X-Created-Coins"] = str(coins_for_create("create_project"))
    db.commit()
    # суммаризация заметки для контекста LLM (ТЗ 3.11) — фон, коммит уже сделан
    if (schema.note or "").strip():
        background.add_task(summarize_project, project.id)
    publish(user_id, "project.changed", {"id": project.id})
    publish(user_id, "xp.changed", {"celebrate": False})
    return project


@router.get("", response_model=list[ProjectOut])
async def list_projects(
    db: DbDep, user_id: UserIdDep, archived: bool = Query(False)
) -> list[Project]:
    """Активные проекты; archived=true — список архива (история)."""
    return list(
        db.scalars(
            select(Project)
            .where(Project.is_archived == archived, Project.user_id == user_id)
            .order_by(Project.id)
        ).all()
    )


@router.get("/{project_id}", response_model=ProjectOut)
async def get_project(project_id: int, db: DbDep, user_id: UserIdDep) -> Project:
    return _get_project_or_404(db, project_id, user_id)


@router.patch("/{project_id}", response_model=ProjectOut)
async def update_project(
    project_id: int, schema: ProjectUpdate, db: DbDep, user_id: UserIdDep,
    background: BackgroundTasks,
) -> Project:
    project = _get_project_or_404(db, project_id, user_id)
    data = schema.model_dump(exclude_unset=True)
    if "relevance_status" in data and data["relevance_status"] not in VALID_RELEVANCE:
        raise HTTPException(
            status_code=422, detail=f"Unknown relevance_status: {data['relevance_status']}"
        )
    if "name" in data and data["name"] != project.name:
        exists = db.scalars(
            select(Project).where(Project.name == data["name"], Project.user_id == user_id)
        ).first()
        if exists is not None:
            raise HTTPException(status_code=409, detail="Project name already exists")
    note_changed = "note" in data and data["note"] != project.note
    for field, value in data.items():
        setattr(project, field, value)
    # Коммит до фоновой работы: summarize_project открывает свою сессию (см.
    # create_task про teardown-коммит get_db)
    db.commit()
    # заметка изменилась — суммаризация в контексте LLM устарела, перегенерим
    if note_changed:
        background.add_task(summarize_project, project.id)
    publish(user_id, "project.changed", {"id": project.id})
    return project


@router.post("/{project_id}/archive", response_model=ProjectOut)
async def archive_project(project_id: int, db: DbDep, user_id: UserIdDep) -> Project:
    """Отправить проект в архив — вместе со всеми его задачами (ТЗ 3.8.1)."""
    project = _get_project_or_404(db, project_id, user_id)
    project.is_archived = True
    db.commit()
    publish(user_id, "project.changed", {"id": project.id})
    return project


@router.post("/{project_id}/restore", response_model=ProjectOut)
async def restore_project(project_id: int, db: DbDep, user_id: UserIdDep) -> Project:
    """Вернуть проект из архива — со всеми задачами и историей."""
    project = _get_project_or_404(db, project_id, user_id)
    project.is_archived = False
    db.commit()
    publish(user_id, "project.changed", {"id": project.id})
    return project


@router.get("/{project_id}/favicon")
async def project_favicon(project_id: int, db: DbDep, user_id: UserIdDep) -> Response:
    """Иконка сайта проекта (0.63): тянет бэкенд-прокси с кэшем (ТЗ 3.11).

    Привязка к проекту, а не ?url= — иначе эндпоинт стал бы открытым прокси
    для сканирования произвольных адресов. Нет site_url или иконки — 404.
    """
    project = _get_project_or_404(db, project_id, user_id)
    if not project.site_url:
        raise HTTPException(status_code=404, detail="Project has no site_url")
    found = await favicon.fetch_favicon(project.site_url)
    if found is None:
        raise HTTPException(status_code=404, detail="Favicon not found")
    data, mime = found
    return Response(
        content=data,
        media_type=mime,
        headers={
            # Ресурс за сессией; nosniff — как у вложений (api/attachments.py)
            "Cache-Control": "private, max-age=86400",
            "X-Content-Type-Options": "nosniff",
        },
    )


@router.get("/{project_id}/readme", response_model=ProjectReadmeOut)
async def project_readme(project_id: int, db: DbDep, user_id: UserIdDep) -> ProjectReadmeOut:
    """README репозитория проекта (0.67) — второе описание рядом с заметкой.

    Привязка к проекту, а не `?url=` — как у фавикона: эндпоинт за сессией и не
    превращается в открытый прокси для чтения произвольных адресов. Нет
    repository_url или README не нашёлся — 404 (фронт показывает «README не найден»).
    """
    project = _get_project_or_404(db, project_id, user_id)
    if not project.repository_url:
        raise HTTPException(status_code=404, detail="Project has no repository_url")
    content = await readme.fetch_readme(project.repository_url)
    if content is None:
        raise HTTPException(status_code=404, detail="README not found")
    return ProjectReadmeOut(content=content)


@router.delete("/{project_id}")
async def delete_project(project_id: int, db: DbDep, user_id: UserIdDep) -> dict[str, bool]:
    project = _get_project_or_404(db, project_id, user_id)
    db.delete(project)
    db.commit()
    publish(user_id, "project.deleted", {"id": project_id})
    return {"ok": True}