Нормализовать переводы строк в 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:
Rustam Khalimov
2026-09-11 19:01:42 +03:00
parent 39c9bdc1b7
commit 713d554dc2
751 changed files with 94507 additions and 94486 deletions
@@ -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}