Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
212 lines
7.4 KiB
Python
212 lines
7.4 KiB
Python
"""«Выбранные»: проектные карточки, стадии, история, напоминания, вложения."""
|
|
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}
|