Files
Deal/archive/leadradar-legacy/backend/app/routers/settings_routes.py
T
Rustam Khalimov 713d554dc2 Нормализовать переводы строк в 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.
2026-09-11 19:01:42 +03:00

244 lines
11 KiB
Python

"""Настройки, AI-провайдеры, курсы валют (роутер /api)."""
from __future__ import annotations
import asyncio
import json
import httpx
from fastapi import APIRouter, Depends, HTTPException
from .. import constants as C
from ..auth import current_login
from ..crypto import decrypt_text, encrypt_text
from ..db import store
from ..services import ai as ai_svc
from ..services import rates as rates_svc
from ..services.telegram import tg
router = APIRouter(prefix="/api", tags=["settings"])
# Какие настройки видны наружу (без секретов). Значения секретов маскируются.
_PUBLIC_INT = {"archiveAfterDays", "archiveClearDays", "trashClearDays", "minLen", "discJoinLimit", "discJoinDelayMin", "discJoinDelayMax", "discEvalSample", "discEvalThreshold"}
_PUBLIC_BOOL = {"autoArchive", "aiFilterEnabled", "aiEnabled", "conversionOn", "remindersEnabled", "mlEnabled", "blockResumes", "budgetRequiredHire", "budgetRequiredOrder", "autoMonitorNew", "discPaused"}
_PUBLIC_STR = {"targetCurrency", "rateSource", "aiProvider", "aiPrompt", "aiFilterPrompt", "cardPrompt", "domainDescription", "wantedType", "hireLabel", "orderLabel"}
_PUBLIC_LIST = {"stopPhrases", "domainKeywords", "hireMarkers", "levelTerms", "resumeMarkers"}
_PUBLIC_DICT = {"colState"}
def mask(value: str) -> str:
if not value:
return ""
return value if len(value) <= 8 else f"{value[:4]}{value[-4:]}"
def public_settings() -> dict:
out: dict = {}
allset = store.all_settings()
for k in _PUBLIC_INT:
out[k] = int(allset.get(k, 0))
for k in _PUBLIC_BOOL:
out[k] = bool(allset.get(k))
for k in _PUBLIC_STR:
out[k] = allset.get(k, "")
for k in _PUBLIC_LIST:
out[k] = allset.get(k, [])
for k in _PUBLIC_DICT:
out[k] = allset.get(k, {})
out["myPrompts"] = allset.get("myPrompts", [])
tg_keys = store.get_setting("tgKeys") or {}
api_hash = str(tg_keys.get("apiHash", ""))
out["tgKeys"] = {
"apiId": mask(str(tg_keys.get("apiId", ""))),
"apiHashSet": bool(decrypt_text(api_hash)) if api_hash.startswith("enc:") else bool(api_hash),
}
cfg = store.get_setting("aiConfigs") or {}
out["aiConfigs"] = {}
for pid, conf in cfg.items():
raw_key = str(conf.get("apiKey", ""))
plain_key = decrypt_text(raw_key) if raw_key.startswith("enc:") else raw_key
out["aiConfigs"][pid] = {
"baseUrl": conf.get("baseUrl", ""),
"model": conf.get("model", ""),
"keySet": bool(plain_key),
"keyMasked": mask(plain_key),
}
out["providers"] = C.AI_PROVIDERS
return out
@router.get("/settings")
def get_settings(_: str = Depends(current_login)) -> dict:
return public_settings()
@router.patch("/settings")
async def patch_settings(body: dict, _: str = Depends(current_login)) -> dict:
current = store.all_settings()
# инвариант пауз авто-вступлений: если пришли оба конца интервала — клампы
# (5..600) и min <= max (если нет — меняем местами, как делает фронт); если
# пришёл один конец — клампим его относительно сохранённого другого конца
delay_min, delay_max = body.get("discJoinDelayMin"), body.get("discJoinDelayMax")
if delay_min is not None and delay_max is not None:
try:
dmin, dmax = int(delay_min), int(delay_max)
except (TypeError, ValueError):
pass
else:
dmin = max(5, min(600, dmin))
dmax = max(5, min(600, dmax))
if dmin > dmax:
dmin, dmax = dmax, dmin
body["discJoinDelayMin"] = dmin
body["discJoinDelayMax"] = dmax
elif delay_min is not None:
try:
cur_max = int(current.get("discJoinDelayMax") or 0)
value = max(5, min(600, int(delay_min)))
except (TypeError, ValueError):
pass
else:
body["discJoinDelayMin"] = min(value, cur_max) if cur_max >= 5 else value
elif delay_max is not None:
try:
cur_min = int(current.get("discJoinDelayMin") or 0)
value = max(5, min(600, int(delay_max)))
except (TypeError, ValueError):
pass
else:
body["discJoinDelayMax"] = max(value, cur_min) if cur_min <= 600 else value
for key, value in body.items():
if key in _PUBLIC_INT:
try:
value = int(value)
except (TypeError, ValueError):
continue
if key == "archiveAfterDays":
value = max(1, min(30, value))
if key == "minLen":
value = max(10, min(500, value))
if key == "discJoinLimit":
value = max(1, min(200, value))
if key in {"discJoinDelayMin", "discJoinDelayMax"}:
value = max(5, min(600, value))
if key == "discEvalSample":
value = max(3, min(30, value))
if key == "discEvalThreshold":
value = max(1, min(100, value))
store.set_setting(key, value)
elif key in _PUBLIC_BOOL:
store.set_setting(key, bool(value))
elif key in _PUBLIC_STR:
if key in {"targetCurrency"}:
value = str(value).upper()
if key == "aiProvider" and not any(p["id"] == value for p in C.AI_PROVIDERS):
continue
store.set_setting(key, str(value))
elif key in _PUBLIC_LIST:
if isinstance(value, list):
store.set_setting(key, [str(x) for x in value][:200])
elif key in _PUBLIC_DICT:
if isinstance(value, dict):
store.set_setting(key, value)
elif key == "myPrompts" and isinstance(value, list):
clean: list[dict] = []
for item in value[:100]:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()[:80]
prompt = str(item.get("prompt") or "").strip()[:8000]
if not name or not prompt:
continue
clean.append(
{
"id": str(item.get("id") or "")[:40] or store.uid("pp_"),
"name": name,
"description": str(item.get("description") or "").strip()[:300],
"prompt": prompt,
}
)
store.set_setting("myPrompts", clean)
elif key == "aiConfigs" and isinstance(value, dict):
cfg = current.get("aiConfigs") or {}
for pid, conf in value.items():
if pid not in cfg:
continue
entry = dict(cfg[pid])
if isinstance(conf, dict):
for fk in ("baseUrl", "model"):
if fk in conf and conf[fk] is not None:
entry[fk] = str(conf[fk])
new_key = conf.get("apiKey")
if new_key and not str(new_key).startswith(("enc:",)) and len(str(new_key)) >= 8:
entry["apiKey"] = encrypt_text(str(new_key))
cfg[pid] = entry
store.set_setting("aiConfigs", cfg)
elif key == "tgKeys" and isinstance(value, dict):
keys = current.get("tgKeys") or {}
if value.get("apiId") is not None:
api = str(value["apiId"]).strip()
if api.isdigit() and 5 < len(api) < 10:
keys["apiId"] = api
new_hash = value.get("apiHash")
if new_hash and len(str(new_hash)) >= 16 and not str(new_hash).startswith("enc:"):
keys["apiHash"] = encrypt_text(str(new_hash).strip())
store.set_setting("tgKeys", keys)
# смена источника курсов — обновляем в фоне; смена целевой валюты/конвертации —
# пересчёт старых карточек (кроме архива/корзины)
if body.get("rateSource"):
asyncio.get_running_loop().create_task(rates_svc.refresh_rates())
if body.get("targetCurrency") is not None or body.get("conversionOn") is not None:
rates_svc.recompute_conversions()
return public_settings()
@router.post("/ai/check")
async def ai_check(_: str = Depends(current_login)) -> dict:
status = ai_svc.provider_status()
provider_id, data = ai_svc._cfg()
meta = data["meta"]
if meta.get("local"):
return {**status, "ok": True, "message": f"Локальный сервер «{meta['name']}» (ping в проде)"}
key = data["cfg"].get("apiKey")
if not key:
return {**status, "ok": False, "message": "Не задан API-ключ"}
# Лёгкая проверка: GET /models (для OpenAI-совместимых) или /v1/models (Anthropic)
base = str(data["cfg"].get("baseUrl") or meta["base"]).rstrip("/")
url = base + ("/v1/models" if meta.get("api_style") == "anthropic" else "/models")
headers = {"Authorization": f"Bearer {key}"} if meta.get("api_style") != "anthropic" else {
"x-api-key": key, "anthropic-version": "2023-06-01"}
try:
async with httpx.AsyncClient(timeout=12) as client:
resp = await client.get(url, headers=headers)
if resp.status_code < 400:
return {**status, "ok": True, "message": "Подключение успешно"}
if resp.status_code in (401, 403):
return {**status, "ok": False, "message": f"Ключ не принят (HTTP {resp.status_code}) — проверьте ключ и доступ к модели"}
return {**status, "ok": False, "message": f"HTTP {resp.status_code} — проверьте Base URL и модель"}
except Exception as exc: # noqa: BLE001
return {**status, "ok": False, "message": f"Ошибка соединения: {exc}"}
# ─── Курсы ────────────────────────────────────────────────────────────────
@router.get("/rates")
def get_rates(_: str = Depends(current_login)) -> dict:
return rates_svc.get_rates()
@router.post("/rates/refresh")
async def refresh_rates(_: str = Depends(current_login)) -> dict:
ok = await rates_svc.refresh_rates()
return {"ok": ok, "rates": rates_svc.get_rates()}
# ─── Методанные для фронта ────────────────────────────────────────────────
@router.get("/meta/constants")
def meta_constants(_: str = Depends(current_login)) -> dict:
return {
"currencies": C.CURRENCIES,
"stages": C.PIPELINE_STAGES,
"palette": C.PALETTE,
}