Files
aiform_prod/backend/app/api/consultations.py
Fedor 66a0065df8 Consultations, CRM dashboard, Back button in support and consultations
- Consultations: list from DraftsContext, ticket-detail webhook, response card
- Back button in bar on consultations and in support chat (miniapp:goBack)
- BottomBar: back enabled on /support; Support: goBack subscription
- n8n: CRM normalize (n8n_CODE_CRM_NORMALIZE), flatten data (n8n_CODE_FLATTEN_DATA)
- Dashboard: filter by category for CRM items, draft card width
- Backend: consultations.py, ticket-detail, n8n_ticket_form_podrobnee_webhook
- CHANGELOG_MINIAPP.md: section 2026-02-25
2026-03-01 10:49:38 +03:00

214 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Консультации: тикеты из CRM (MySQL) через N8N_TICKET_FORM_CONSULTATION_WEBHOOK.
GET/POST /api/v1/consultations — верификация сессии, вызов webhook с тем же payload,
что и у других хуков (session_token, unified_id, contact_id, phone, chat_id, entry_channel, form_id).
Ответ webhook возвращается клиенту (список тикетов и т.д.).
"""
import logging
from typing import Any, Optional
import httpx
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from app.config import settings
from app.api.session import SessionVerifyRequest, verify_session
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/consultations", tags=["consultations"])
class ConsultationsPostBody(BaseModel):
"""Тело запроса: session_token обязателен для идентификации."""
session_token: str = Field(..., description="Токен сессии")
entry_channel: Optional[str] = Field("web", description="Канал входа: telegram | max | web")
class TicketDetailBody(BaseModel):
"""Тело запроса «подробнее по тикету»."""
session_token: str = Field(..., description="Токен сессии")
ticket_id: Any = Field(..., description="ID тикета в CRM (ticketid)")
entry_channel: Optional[str] = Field("web", description="Канал входа")
def _get_consultation_webhook_url() -> str:
url = (getattr(settings, "n8n_ticket_form_consultation_webhook", None) or "").strip()
if not url:
raise HTTPException(
status_code=503,
detail="N8N_TICKET_FORM_CONSULTATION_WEBHOOK не настроен",
)
return url
def _get_podrobnee_webhook_url() -> str:
url = (getattr(settings, "n8n_ticket_form_podrobnee_webhook", None) or "").strip()
if not url:
raise HTTPException(
status_code=503,
detail="N8N_TICKET_FORM_PODROBNEE_WEBHOOK не настроен",
)
return url
async def _call_consultation_webhook(
session_token: str,
entry_channel: str = "web",
) -> dict:
"""
Верифицировать сессию, собрать payload как у других хуков, POST в webhook, вернуть ответ.
"""
verify_res = await verify_session(SessionVerifyRequest(session_token=session_token))
if not getattr(verify_res, "valid", False):
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
unified_id = getattr(verify_res, "unified_id", None)
if not unified_id:
raise HTTPException(status_code=401, detail="Сессия не содержит unified_id")
contact_id = getattr(verify_res, "contact_id", None)
phone = getattr(verify_res, "phone", None)
chat_id = getattr(verify_res, "chat_id", None)
payload: dict[str, Any] = {
"form_id": "ticket_form",
"session_token": session_token,
"unified_id": unified_id,
"entry_channel": (entry_channel or "web").strip() or "web",
}
if contact_id is not None:
payload["contact_id"] = contact_id
if phone is not None:
payload["phone"] = phone
if chat_id is not None and str(chat_id).strip():
payload["chat_id"] = str(chat_id).strip()
webhook_url = _get_consultation_webhook_url()
logger.info("Consultation webhook: POST %s, keys=%s", webhook_url[:60], list(payload.keys()))
try:
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(
webhook_url,
json=payload,
headers={"Content-Type": "application/json"},
)
except httpx.TimeoutException:
logger.error("Таймаут вызова N8N_TICKET_FORM_CONSULTATION_WEBHOOK")
raise HTTPException(status_code=504, detail="Сервис консультаций временно недоступен")
except Exception as e:
logger.exception("Ошибка вызова N8N_TICKET_FORM_CONSULTATION_WEBHOOK: %s", e)
raise HTTPException(status_code=502, detail="Сервис консультаций временно недоступен")
if response.status_code != 200:
logger.warning(
"Consultation webhook вернул %s: %s",
response.status_code,
response.text[:500],
)
raise HTTPException(
status_code=502,
detail="Сервис консультаций вернул ошибку",
)
try:
return response.json()
except Exception:
return {"raw": response.text or ""}
@router.get("")
async def get_consultations(
session_token: Optional[str] = Query(None, description="Токен сессии"),
entry_channel: Optional[str] = Query("web", description="Канал входа: telegram | max | web"),
):
"""
Получить данные консультаций (тикеты из CRM) через n8n webhook.
Передаётся тот же payload, что и на другие хуки: session_token, unified_id, contact_id, phone, chat_id, entry_channel, form_id.
"""
if not session_token or not str(session_token).strip():
raise HTTPException(status_code=400, detail="Укажите session_token")
return await _call_consultation_webhook(
session_token=str(session_token).strip(),
entry_channel=entry_channel or "web",
)
@router.post("")
async def post_consultations(body: ConsultationsPostBody):
"""То же по телу запроса."""
return await _call_consultation_webhook(
session_token=body.session_token.strip(),
entry_channel=body.entry_channel or "web",
)
@router.post("/ticket-detail")
async def get_ticket_detail(body: TicketDetailBody):
"""
Подробнее по тикету: верификация сессии, вызов N8N_TICKET_FORM_PODROBNEE_WEBHOOK
с payload (session_token, unified_id, contact_id, phone, ticket_id, entry_channel, form_id).
Ответ вебхука возвращается клиенту как есть (HTML в поле html/body или весь JSON).
"""
session_token = str(body.session_token or "").strip()
if not session_token:
raise HTTPException(status_code=400, detail="Укажите session_token")
ticket_id = body.ticket_id
if ticket_id is None or (isinstance(ticket_id, str) and not str(ticket_id).strip()):
raise HTTPException(status_code=400, detail="Укажите ticket_id")
verify_res = await verify_session(SessionVerifyRequest(session_token=session_token))
if not getattr(verify_res, "valid", False):
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
unified_id = getattr(verify_res, "unified_id", None)
if not unified_id:
raise HTTPException(status_code=401, detail="Сессия не содержит unified_id")
contact_id = getattr(verify_res, "contact_id", None)
phone = getattr(verify_res, "phone", None)
chat_id = getattr(verify_res, "chat_id", None)
entry_channel = (body.entry_channel or "web").strip() or "web"
payload: dict[str, Any] = {
"form_id": "ticket_form",
"session_token": session_token,
"unified_id": unified_id,
"ticket_id": int(ticket_id) if isinstance(ticket_id, str) and ticket_id.isdigit() else ticket_id,
"entry_channel": entry_channel,
}
if contact_id is not None:
payload["contact_id"] = contact_id
if phone is not None:
payload["phone"] = phone
if chat_id is not None and str(chat_id).strip():
payload["chat_id"] = str(chat_id).strip()
webhook_url = _get_podrobnee_webhook_url()
logger.info("Podrobnee webhook: POST %s, ticket_id=%s", webhook_url[:60], payload.get("ticket_id"))
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
webhook_url,
json=payload,
headers={"Content-Type": "application/json"},
)
except httpx.TimeoutException:
logger.error("Таймаут вызова N8N_TICKET_FORM_PODROBNEE_WEBHOOK")
raise HTTPException(status_code=504, detail="Сервис временно недоступен")
except Exception as e:
logger.exception("Ошибка вызова N8N_TICKET_FORM_PODROBNEE_WEBHOOK: %s", e)
raise HTTPException(status_code=502, detail="Сервис временно недоступен")
if response.status_code != 200:
logger.warning("Podrobnee webhook вернул %s: %s", response.status_code, response.text[:500])
raise HTTPException(status_code=502, detail="Сервис вернул ошибку")
try:
return response.json()
except Exception:
return {"html": response.text or "", "raw": True}