Files
Solo_bot/api/routes/partners.py
T
2026-02-10 01:42:25 +03:00

596 lines
20 KiB
Python

from datetime import datetime
from fastapi import APIRouter, Depends, Path, Query
from fastapi.responses import JSONResponse
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
try:
from modules.partner_program.settings import PARTNER_BONUS_PERCENTAGES
except Exception:
PARTNER_BONUS_PERCENTAGES = {1: 0.0}
router = APIRouter()
def _parse_percent(value: float) -> float | None:
"""Normalize percent input to 0-100 range."""
try:
val = float(value)
except (TypeError, ValueError):
return None
if 0.0 <= val <= 1.0:
val *= 100.0
if 0.0 <= val <= 100.0:
return val
return None
def _default_partner_percent() -> float:
try:
return float(PARTNER_BONUS_PERCENTAGES.get(1, 0.0)) * 100.0
except Exception:
return 0.0
@router.get("/all")
async def get_all_partners(
limit: int = Query(1000, ge=1, le=10000, description="Лимит результатов"),
offset: int = Query(0, ge=0, description="Смещение"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает список всех партнёров со статистикой.
Структура ответа:
{
"total": int,
"items": [
{
"tg_id": int,
"balance": float,
"percent": float,
"code": str | None,
"method": str | None,
"referred_count": int
}
]
}
"""
partners_sql = text(
"""
SELECT
p.partner_tg_id AS tg_id,
COALESCE(u.partner_balance, 0) AS partner_balance,
u.partner_percent,
COALESCE(u.partner_percent_custom, false) AS partner_percent_custom,
u.partner_code,
u.payout_method,
COUNT(p.joined_tg_id) as joined_count
FROM partners p
LEFT JOIN users u ON u.tg_id = p.partner_tg_id
WHERE p.partner_tg_id IS NOT NULL
GROUP BY p.partner_tg_id, u.partner_balance, u.partner_percent, u.partner_percent_custom, u.partner_code, u.payout_method
ORDER BY partner_balance DESC
LIMIT :limit OFFSET :offset
"""
)
count_sql = text(
"""
SELECT COUNT(DISTINCT partner_tg_id) FROM partners
WHERE partner_tg_id IS NOT NULL
"""
)
result = await session.execute(partners_sql, {"limit": limit, "offset": offset})
partners = result.fetchall()
count_result = await session.execute(count_sql)
total = count_result.scalar() or 0
partners_list = []
default_percent = _default_partner_percent()
for partner in partners:
percent_value = partner[2]
percent_custom = bool(partner[3])
if percent_custom and percent_value is not None:
percent = float(percent_value)
else:
percent = float(default_percent)
partners_list.append(
{
"tg_id": int(partner[0]),
"balance": float(partner[1] or 0),
"percent": percent,
"code": partner[4] or None,
"method": partner[5] or None,
"referred_count": int(partner[6] or 0),
}
)
return JSONResponse(content={"total": total, "items": partners_list})
@router.get("/stats/all")
async def get_partners_stats(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает общую статистику партнёрской программы.
Структура ответа:
{
"total_partners": int,
"total_referred": int,
"total_balance": float,
"top_partner_tg_id": int,
"top_partner_refs": int
}
"""
stats_sql = text(
"""
WITH partner_refs AS (
SELECT partner_tg_id, COUNT(DISTINCT joined_tg_id) AS ref_count
FROM partners
WHERE partner_tg_id IS NOT NULL
GROUP BY partner_tg_id
)
SELECT
(SELECT COUNT(*) FROM partner_refs) AS total_partners,
(SELECT COUNT(DISTINCT joined_tg_id) FROM partners WHERE partner_tg_id IS NOT NULL) AS total_referred,
(
SELECT COALESCE(SUM(u.partner_balance), 0.0)
FROM users u
WHERE u.tg_id IN (SELECT partner_tg_id FROM partner_refs)
) AS total_balance,
(SELECT partner_tg_id FROM partner_refs ORDER BY ref_count DESC LIMIT 1) AS top_partner_tg_id,
(SELECT ref_count FROM partner_refs ORDER BY ref_count DESC LIMIT 1) AS top_partner_refs
"""
)
stats_result = await session.execute(stats_sql)
stats_row = stats_result.fetchone()
if stats_row:
stats = {
"total_partners": int(stats_row[0] or 0),
"total_referred": int(stats_row[1] or 0),
"total_balance": float(stats_row[2] or 0.0),
"top_partner_tg_id": int(stats_row[3] or 0),
"top_partner_refs": int(stats_row[4] or 0),
}
else:
stats = {
"total_partners": 0,
"total_referred": 0,
"total_balance": 0.0,
"top_partner_tg_id": 0,
"top_partner_refs": 0,
}
return JSONResponse(content=stats)
@router.patch("/{tg_id}")
async def update_partner(
tg_id: int = Path(..., description="Telegram ID партнёра"),
balance: float = Query(..., description="Новый баланс партнёра"),
percent: float = Query(..., description="Новый процент партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Обновляет данные партнёра (баланс и процент).
Структура ответа:
{
"success": bool,
"message": str
}
"""
try:
stmt = text(
"""
UPDATE users
SET partner_balance = :balance, partner_percent = :percent
WHERE tg_id = :tg_id
"""
)
result = await session.execute(stmt, {"tg_id": tg_id, "balance": balance, "percent": percent})
await session.commit()
if result.rowcount > 0:
return JSONResponse(
content={"success": True, "message": f"Партнёр {tg_id} успешно обновлён"},
status_code=200,
)
else:
return JSONResponse(
content={"success": False, "message": "Партнёр не найден"},
status_code=404,
)
except Exception as e:
await session.rollback()
return JSONResponse(
content={"success": False, "message": str(e)},
status_code=500,
)
@router.get("/{tg_id}")
async def get_partner_data(
tg_id: int = Path(..., description="Telegram ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает партнёрские данные для указанного `tg_id`.
Структура ответа:
{
"tg_id": int,
"partner_balance": float,
"partner_percent": float,
"partner_code": str | None,
"payout_method": str | None,
"invited": [
{ "tg_id": int, "joined_at": str | None, "balance": float, "subs_count": int, "payments_count": int }
]
}
"""
meta_sql = text(
"""
SELECT
COALESCE(u.partner_balance, 0) AS partner_balance,
u.partner_percent,
COALESCE(u.partner_percent_custom, false) AS partner_percent_custom,
u.partner_code,
u.payout_method
FROM users u
WHERE u.tg_id = :tg_id
"""
)
invited_sql = text(
"""
SELECT
pr.joined_tg_id,
pr.created_at,
COALESCE(u.balance, 0) AS user_balance,
(
SELECT COUNT(*) FROM keys k
WHERE k.tg_id = pr.joined_tg_id
) AS subs_count,
(
SELECT COUNT(*) FROM payments pay
WHERE pay.tg_id = pr.joined_tg_id
AND lower(pay.status) = 'success'
) AS payments_count
FROM partners pr
LEFT JOIN users u ON u.tg_id = pr.joined_tg_id
WHERE pr.partner_tg_id = :tg_id
ORDER BY pr.created_at DESC
"""
)
meta_res = await session.execute(meta_sql, {"tg_id": tg_id})
meta_row = meta_res.fetchone()
invited_res = await session.execute(invited_sql, {"tg_id": tg_id})
invited_rows = invited_res.fetchall()
default_percent = _default_partner_percent()
percent = default_percent
if meta_row:
percent_value = meta_row[1]
percent_custom = bool(meta_row[2])
if percent_custom and percent_value is not None:
percent = float(percent_value)
response = {
"tg_id": tg_id,
"partner_balance": float(meta_row[0] or 0) if meta_row else 0.0,
"partner_percent": percent,
"partner_code": meta_row[3] if meta_row else None,
"payout_method": meta_row[4] if meta_row else None,
"invited": [
{
"tg_id": row[0],
"joined_at": row[1].isoformat() if isinstance(row[1], datetime) else None,
"balance": float(row[2] or 0),
"subs_count": int(row[3] or 0),
"payments_count": int(row[4] or 0),
}
for row in invited_rows
],
}
return JSONResponse(content=response)
@router.post("/{tg_id}/invited")
async def add_partner_invited(
tg_id: int = Path(..., description="Telegram ID партнёра"),
joined_tg_id: int = Query(..., description="Telegram ID приглашённого"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Добавляет приглашённого пользователю партнёра."""
if joined_tg_id == tg_id:
return JSONResponse(
content={"success": False, "message": "Нельзя привязать пользователя к самому себе"},
status_code=400,
)
try:
partner_exists = await session.execute(
text("SELECT 1 FROM users WHERE tg_id = :tg_id"),
{"tg_id": tg_id},
)
if not partner_exists.scalar():
return JSONResponse(
content={"success": False, "message": "Партнёр не найден"},
status_code=404,
)
invited_exists = await session.execute(
text("SELECT 1 FROM users WHERE tg_id = :joined_tg_id"),
{"joined_tg_id": joined_tg_id},
)
if not invited_exists.scalar():
return JSONResponse(
content={"success": False, "message": "Приглашённый пользователь не найден"},
status_code=404,
)
existing = await session.execute(
text("SELECT partner_tg_id FROM partners WHERE joined_tg_id = :joined_tg_id"),
{"joined_tg_id": joined_tg_id},
)
existing_partner = existing.scalar()
if existing_partner is not None:
return JSONResponse(
content={
"success": False,
"message": f"Пользователь уже привязан к партнёру {existing_partner}",
},
status_code=409,
)
await session.execute(
text(
"""
INSERT INTO partners (partner_tg_id, joined_tg_id)
VALUES (:partner_tg_id, :joined_tg_id)
"""
),
{"partner_tg_id": tg_id, "joined_tg_id": joined_tg_id},
)
await session.commit()
return JSONResponse(
content={
"success": True,
"message": "Приглашённый добавлен",
"partner_tg_id": tg_id,
"joined_tg_id": joined_tg_id,
},
status_code=201,
)
except Exception as e:
await session.rollback()
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.delete("/{tg_id}/invited/{joined_tg_id}")
async def delete_partner_invited(
tg_id: int = Path(..., description="Telegram ID партнёра"),
joined_tg_id: int = Path(..., description="Telegram ID приглашённого"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Удаляет приглашённого у партнёра."""
try:
result = await session.execute(
text(
"""
DELETE FROM partners
WHERE partner_tg_id = :partner_tg_id
AND joined_tg_id = :joined_tg_id
"""
),
{"partner_tg_id": tg_id, "joined_tg_id": joined_tg_id},
)
await session.commit()
if result.rowcount > 0:
return JSONResponse(
content={
"success": True,
"message": "Приглашённый удалён",
"partner_tg_id": tg_id,
"joined_tg_id": joined_tg_id,
},
status_code=200,
)
return JSONResponse(
content={"success": False, "message": "Связка партнёр-приглашённый не найдена"},
status_code=404,
)
except Exception as e:
await session.rollback()
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.patch("/{tg_id}/percent")
async def update_partner_percent(
tg_id: int = Path(..., description="Telegram ID партнёра"),
percent: float = Query(..., description="Новый персональный процент (0-100 или 0.0-1.0)"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Обновляет персональный процент партнёра."""
normalized = _parse_percent(percent)
if normalized is None:
return JSONResponse(
content={"success": False, "message": "Неверный процент. Допустимо 0-100 или 0.0-1.0"},
status_code=400,
)
try:
result = await session.execute(
text(
"""
UPDATE users
SET partner_percent = :percent, partner_percent_custom = true
WHERE tg_id = :tg_id
"""
),
{"tg_id": tg_id, "percent": normalized},
)
await session.commit()
if result.rowcount > 0:
return JSONResponse(
content={"success": True, "message": "Процент обновлён", "percent": normalized},
status_code=200,
)
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
except Exception as e:
await session.rollback()
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.patch("/{tg_id}/balance")
async def update_partner_balance(
tg_id: int = Path(..., description="Telegram ID партнёра"),
amount: float = Query(..., description="Сумма операции"),
mode: str = Query("set", description="Режим: set, add, subtract"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Изменяет баланс партнёрской программы."""
mode_normalized = (mode or "set").strip().lower()
if mode_normalized not in {"set", "add", "subtract"}:
return JSONResponse(
content={"success": False, "message": "Неверный режим. Используйте set, add или subtract"},
status_code=400,
)
try:
amount_val = float(amount)
except (TypeError, ValueError):
return JSONResponse(
content={"success": False, "message": "Неверная сумма"},
status_code=400,
)
if amount_val < 0:
return JSONResponse(
content={"success": False, "message": "Сумма не может быть отрицательной"},
status_code=400,
)
try:
current_res = await session.execute(
text("SELECT partner_balance FROM users WHERE tg_id = :tg_id"),
{"tg_id": tg_id},
)
current_balance = current_res.scalar()
if current_balance is None:
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
current_balance = float(current_balance or 0.0)
if mode_normalized == "set":
new_balance = amount_val
elif mode_normalized == "add":
new_balance = current_balance + amount_val
else:
if current_balance < amount_val:
return JSONResponse(
content={"success": False, "message": "Недостаточно средств"},
status_code=400,
)
new_balance = current_balance - amount_val
await session.execute(
text("UPDATE users SET partner_balance = :balance WHERE tg_id = :tg_id"),
{"tg_id": tg_id, "balance": new_balance},
)
await session.commit()
return JSONResponse(
content={"success": True, "message": "Баланс обновлён", "balance": new_balance},
status_code=200,
)
except Exception as e:
await session.rollback()
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.get("/{tg_id}/invited")
async def get_partner_invited(
tg_id: int = Path(..., description="Telegram ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает список приглашённых пользователей конкретного партнёра.
Структура ответа:
[
{ "tg_id": int, "joined_at": str | None, "balance": float, "subs_count": int, "payments_count": int }
]
"""
invited_sql = text(
"""
SELECT
pr.joined_tg_id,
pr.created_at,
COALESCE(u.balance, 0) AS user_balance,
(
SELECT COUNT(*) FROM keys k
WHERE k.tg_id = pr.joined_tg_id
) AS subs_count,
(
SELECT COUNT(*) FROM payments pay
WHERE pay.tg_id = pr.joined_tg_id
AND lower(pay.status) = 'success'
) AS payments_count
FROM partners pr
LEFT JOIN users u ON u.tg_id = pr.joined_tg_id
WHERE pr.partner_tg_id = :tg_id
ORDER BY pr.created_at DESC
"""
)
invited_res = await session.execute(invited_sql, {"tg_id": tg_id})
invited_rows = invited_res.fetchall()
invited_list = [
{
"tg_id": row[0],
"joined_at": row[1].isoformat() if isinstance(row[1], datetime) else None,
"balance": float(row[2] or 0),
"subs_count": int(row[3] or 0),
"payments_count": int(row[4] or 0),
}
for row in invited_rows
]
return JSONResponse(content=invited_list)