Нормализовать переводы строк в 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,243 +1,243 @@
|
||||
"""Настройки, 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,
|
||||
}
|
||||
"""Настройки, 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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user