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)