Law/app/services/sms_service.py
2026-03-01 17:31:09 +03:00

250 lines
9.1 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.

from __future__ import annotations
import asyncio
import importlib.util
import logging
import os
import sys
from typing import Any
from app.core.config import settings
class SmsDeliveryError(Exception):
pass
logger = logging.getLogger("uvicorn.error")
def _otp_dev_mode_enabled() -> bool:
return bool(getattr(settings, "OTP_DEV_MODE", False))
def _is_automated_test_context() -> bool:
if str(os.getenv("PYTEST_CURRENT_TEST", "")).strip():
return True
if str(os.getenv("UNITTEST_CURRENT_TEST", "")).strip():
return True
if str(os.getenv("AUTOTEST_MODE", "")).strip().lower() in {"1", "true", "yes", "on"}:
return True
app_env = str(getattr(settings, "APP_ENV", "") or "").strip().lower()
if app_env in {"test", "ci"}:
return True
argv = " ".join(str(part) for part in sys.argv).lower()
if "pytest" in argv or "unittest" in argv:
return True
return False
def _autotest_force_mock_enabled() -> bool:
return bool(getattr(settings, "OTP_AUTOTEST_FORCE_MOCK_SMS", True)) and _is_automated_test_context()
def _module_available(module_name: str) -> bool:
return importlib.util.find_spec(module_name) is not None
def _normalize_phone_to_int(phone: str) -> int:
digits = "".join(ch for ch in str(phone or "") if ch.isdigit())
if not digits:
raise SmsDeliveryError("Некорректный номер телефона")
try:
return int(digits)
except ValueError as exc:
raise SmsDeliveryError("Некорректный номер телефона") from exc
def _build_otp_message(*, code: str, purpose: str, track_number: str | None) -> str:
template = str(settings.OTP_SMS_TEMPLATE or "").strip() or "Ваш код подтверждения: {code}"
try:
rendered = template.format(code=code, purpose=purpose, track_number=track_number or "")
except Exception:
rendered = f"Ваш код подтверждения: {code}"
return rendered
def _mock_sms_send(*, phone: str, code: str, purpose: str, track_number: str | None) -> dict[str, Any]:
line = f"[OTP MOCK] purpose={purpose} phone={phone} track={track_number or '-'} code={code}"
logger.warning(line)
return {
"provider": "mock_sms",
"status": "accepted",
"message": "SMS provider response mocked",
"sent": False,
"mocked": True,
}
async def _send_sms_aero_async(*, phone: int, message: str) -> dict[str, Any]:
try:
import smsaero
except Exception as exc: # pragma: no cover - runtime dependency branch
raise SmsDeliveryError("Библиотека smsaero-api-async не установлена") from exc
email = str(settings.SMSAERO_EMAIL or "").strip()
api_key = str(settings.SMSAERO_API_KEY or "").strip()
if not email or not api_key:
raise SmsDeliveryError("Не заданы SMSAERO_EMAIL и/или SMSAERO_API_KEY")
api = smsaero.SmsAero(email, api_key)
try:
result = await api.send_sms(phone, message)
except Exception as exc: # pragma: no cover - network/runtime branch
raise SmsDeliveryError(f"Ошибка отправки SMS через SMS Aero: {exc}") from exc
finally:
await api.close_session()
return {
"provider": "smsaero",
"status": "accepted",
"message": "SMS отправлено",
"sent": True,
"response": result,
}
def _send_sms_aero(*, phone: str, message: str) -> dict[str, Any]:
phone_int = _normalize_phone_to_int(phone)
return asyncio.run(_send_sms_aero_async(phone=phone_int, message=message))
async def _get_sms_aero_balance_async() -> dict[str, Any]:
try:
import smsaero
except Exception as exc: # pragma: no cover - runtime dependency branch
raise SmsDeliveryError("Библиотека smsaero-api-async не установлена") from exc
email = str(settings.SMSAERO_EMAIL or "").strip()
api_key = str(settings.SMSAERO_API_KEY or "").strip()
if not email or not api_key:
raise SmsDeliveryError("Не заданы SMSAERO_EMAIL и/или SMSAERO_API_KEY")
api = smsaero.SmsAero(email, api_key)
try:
result = await api.balance()
except Exception as exc: # pragma: no cover - network/runtime branch
raise SmsDeliveryError(f"Ошибка получения баланса SMS Aero: {exc}") from exc
finally:
await api.close_session()
return dict(result or {})
def _get_sms_aero_balance() -> tuple[float | None, dict[str, Any] | None, str | None]:
try:
raw = _get_sms_aero_balance_async()
data = asyncio.run(raw)
amount = data.get("balance")
number = float(amount)
return number, data, None
except Exception as exc:
return None, None, str(exc)
def sms_provider_health() -> dict[str, Any]:
provider = str(settings.SMS_PROVIDER or "dummy").strip().lower()
if _otp_dev_mode_enabled():
return {
"provider": provider or "dummy",
"effective_provider": "mock_sms",
"status": "ok",
"mode": "mock",
"dev_mode": True,
"can_send": True,
"balance_available": False,
"balance_amount": None,
"balance_currency": "RUB",
"checks": {"otp_dev_mode": True},
"issues": ["OTP_DEV_MODE включен: реальная SMS-рассылка отключена"],
}
if provider in {"", "dummy", "mock", "console"}:
return {
"provider": "dummy",
"status": "ok",
"mode": "mock",
"dev_mode": False,
"can_send": True,
"balance_available": False,
"balance_amount": None,
"balance_currency": "RUB",
"checks": {"mock_mode": True},
"issues": [],
}
if provider in {"smsaero", "sms_aero"}:
email = str(settings.SMSAERO_EMAIL or "").strip()
api_key = str(settings.SMSAERO_API_KEY or "").strip()
installed = _module_available("smsaero")
checks = {
"smsaero_installed": bool(installed),
"email_configured": bool(email),
"api_key_configured": bool(api_key),
}
issues: list[str] = []
if not checks["smsaero_installed"]:
issues.append("Не установлена библиотека smsaero-api-async")
if not checks["email_configured"]:
issues.append("Не задан SMSAERO_EMAIL")
if not checks["api_key_configured"]:
issues.append("Не задан SMSAERO_API_KEY")
can_send = all(checks.values())
balance_available = False
balance_amount: float | None = None
balance_raw: dict[str, Any] | None = None
if can_send:
amount, raw_balance, balance_error = _get_sms_aero_balance()
if amount is None:
issues.append(str(balance_error or "Не удалось получить баланс SMS Aero"))
else:
balance_available = True
balance_amount = amount
balance_raw = raw_balance
return {
"provider": "smsaero",
"status": "ok" if can_send and balance_available else "degraded",
"mode": "real",
"dev_mode": False,
"can_send": can_send,
"balance_available": balance_available,
"balance_amount": balance_amount,
"balance_currency": "RUB",
"balance_raw": balance_raw,
"checks": checks,
"issues": issues,
}
return {
"provider": provider,
"status": "error",
"mode": "unknown",
"dev_mode": False,
"can_send": False,
"balance_available": False,
"balance_amount": None,
"balance_currency": "RUB",
"checks": {"provider_supported": False},
"issues": [f"Неизвестный SMS_PROVIDER: {provider}"],
}
def send_otp_message(*, phone: str, code: str, purpose: str, track_number: str | None = None) -> dict[str, Any]:
provider = str(settings.SMS_PROVIDER or "dummy").strip().lower()
if _otp_dev_mode_enabled():
payload = _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number)
payload["dev_mode"] = True
payload["debug_code"] = str(code)
return payload
# Safety rail: automated tests must never call paid SMS providers.
if _autotest_force_mock_enabled() and provider in {"smsaero", "sms_aero"}:
payload = _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number)
payload["autotest_forced_mock"] = True
payload["debug_code"] = str(code)
return payload
if provider in {"", "dummy", "mock", "console"}:
return _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number)
if provider in {"smsaero", "sms_aero"}:
message = _build_otp_message(code=code, purpose=purpose, track_number=track_number)
return _send_sms_aero(phone=phone, message=message)
raise SmsDeliveryError(f"Неизвестный SMS_PROVIDER: {provider}")