"""«Выбранные»: проектные карточки, стадии, история, напоминания, вложения.""" from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, UploadFile from fastapi.responses import StreamingResponse from pydantic import BaseModel from ..auth import current_login from ..services import files as files_svc from ..services import object_store from ..services import projects as proj_svc router = APIRouter(prefix="/api/projects", tags=["projects"]) class TakeBody(BaseModel): leadId: str class CreateBody(BaseModel): title: str = "" summary: str = "" stack: list[str] | None = None budget: dict | None = None contact: str = "" tzText: str = "" stage: str | None = None class PatchBody(BaseModel): title: str | None = None summary: str | None = None stack: list[str] | None = None budget: dict | None = None contact: str | None = None tzText: str | None = None class StageBody(BaseModel): stage: str class CommentBody(BaseModel): text: str class LinkBody(BaseModel): name: str = "" url: str class ReminderBody(BaseModel): at: int # epoch ms def _card_or_404(card_id: str) -> dict: card = proj_svc.get_card(card_id) if not card: raise HTTPException(404, "Карточка не найдена") return card @router.get("") def list_cards(stage: str | None = None, _: str = Depends(current_login)) -> dict: return {"items": proj_svc.list_cards(stage)} @router.get("/reminders") def reminders(_: str = Depends(current_login)) -> dict: return {"items": proj_svc.active_reminders()} @router.get("/{card_id}") def get_card(card_id: str, _: str = Depends(current_login)) -> dict: return _card_or_404(card_id) @router.post("") def create_card(body: CreateBody, _: str = Depends(current_login)) -> dict: return proj_svc.create_local_card(body.model_dump()) @router.post("/take") def take(body: TakeBody, _: str = Depends(current_login)) -> dict: """«Взять в работу» — лид безвозвратно уходит с дашборда.""" try: return proj_svc.take_lead_to_projects(body.leadId) except KeyError: raise HTTPException(404, "Лид не найден") @router.post("/clear-rejected") def clear_rejected(_: str = Depends(current_login)) -> dict: """Полная очистка стадии «Отклонено» (безвозвратно).""" try: cleared = proj_svc.clear_stage("rejected") except ValueError as exc: raise HTTPException(400, str(exc)) from exc return {"ok": True, "cleared": cleared} @router.patch("/{card_id}") def patch(card_id: str, body: PatchBody, _: str = Depends(current_login)) -> dict: _card_or_404(card_id) return proj_svc.patch_card(card_id, body.model_dump(exclude_none=True)) @router.delete("/{card_id}") def delete(card_id: str, _: str = Depends(current_login)) -> dict: # По решению удаление проектных карточек не делаем — карточка живёт # до финального статуса «Выполнено»/«Отклонено». raise HTTPException(400, "Удаление проектных карточек отключено") @router.post("/{card_id}/move") def move(card_id: str, body: StageBody, _: str = Depends(current_login)) -> dict: _card_or_404(card_id) try: return proj_svc.move_stage(card_id, body.stage) except ValueError as exc: raise HTTPException(400, str(exc)) from exc @router.post("/{card_id}/comments") def comment(card_id: str, body: CommentBody, _: str = Depends(current_login)) -> dict: if not body.text.strip(): raise HTTPException(400, "Пустой комментарий") return {"comments": proj_svc.add_comment(card_id, body.text)} # ─── Ссылки ─────────────────────────────────────────────────────────────── @router.post("/{card_id}/links") def add_link(card_id: str, body: LinkBody, _: str = Depends(current_login)) -> dict: _card_or_404(card_id) url = body.url.strip() if not url: raise HTTPException(400, "Пустая ссылка") if not url.startswith(("http://", "https://")): url = "https://" + url card = _card_or_404(card_id) links = card["links"] + [{"id": f"pl_{card_id[:6]}_{len(card['links'])}", "name": body.name.strip() or url, "url": url}] return proj_svc.patch_card(card_id, {"links": links}) @router.delete("/{card_id}/links/{link_id}") def remove_link(card_id: str, link_id: str, _: str = Depends(current_login)) -> dict: card = _card_or_404(card_id) links = [l for l in card["links"] if l["id"] != link_id] return proj_svc.patch_card(card_id, {"links": links}) # ─── Вложения (мета; MinIO позже) ───────────────────────────────────────── @router.post("/{card_id}/files") async def upload_files(card_id: str, files: list[UploadFile], _: str = Depends(current_login)) -> dict: _card_or_404(card_id) added = [] for f in files: content = await f.read() added.append(files_svc.add_file(card_id, f.filename or "file", content, f.content_type or "")) return {"items": added} @router.get("/{card_id}/files/{file_id}/download") def download_file(card_id: str, file_id: str, _: str = Depends(current_login)): entry, _ = files_svc.get_file_entry(card_id, file_id) if not entry.get("objectKey"): raise HTTPException(410, "Файл не сохранён в объектном хранилище") try: stream = object_store.get(entry["objectKey"]) except Exception as exc: # noqa: BLE001 raise HTTPException(404, "Файл не найден в MinIO") from exc safe_name = entry["name"].replace('"', "") return StreamingResponse( stream, media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="{safe_name}"'}, ) @router.delete("/{card_id}/files/{file_id}") def remove_file(card_id: str, file_id: str, _: str = Depends(current_login)) -> dict: _card_or_404(card_id) files_svc.remove_file(card_id, file_id) return {"ok": True} # ─── Напоминания об «Отложено» ──────────────────────────────────────────── @router.post("/{card_id}/reminder") def set_reminder(card_id: str, body: ReminderBody, _: str = Depends(current_login)) -> dict: _card_or_404(card_id) try: return proj_svc.set_reminder(card_id, body.at) except PermissionError as exc: raise HTTPException(400, str(exc)) from exc @router.delete("/{card_id}/reminder") def clear_reminder(card_id: str, _: str = Depends(current_login)) -> dict: _card_or_404(card_id) proj_svc.clear_reminder(card_id) return {"ok": True} @router.post("/{card_id}/reminder/snooze") def snooze(card_id: str, _: str = Depends(current_login)) -> dict: _card_or_404(card_id) proj_svc.snooze(card_id) return {"ok": True}