Нормализовать переводы строк в LF
Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF, CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения), .editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize. Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc.
This commit is contained in:
@@ -1,211 +1,211 @@
|
||||
"""«Выбранные»: проектные карточки, стадии, история, напоминания, вложения."""
|
||||
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}
|
||||
"""«Выбранные»: проектные карточки, стадии, история, напоминания, вложения."""
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user