1153 lines
47 KiB
Python
1153 lines
47 KiB
Python
import csv
|
|
import re
|
|
|
|
from base64 import b64encode
|
|
from datetime import datetime
|
|
from io import BytesIO, StringIO
|
|
from urllib.parse import urlsplit
|
|
|
|
import qrcode
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
|
from fastapi.responses import JSONResponse, StreamingResponse
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from api.depends import get_request_actor, get_session, verify_identity_admin, verify_identity_token
|
|
from api.v2.schemas.web_public import (
|
|
PartnerApplyRequest,
|
|
PartnerApplyResponse,
|
|
PartnerConditionsResponse,
|
|
PartnerPayoutEntryResponse,
|
|
PartnerPayoutHistoryResponse,
|
|
PartnerPayoutRequestCreate,
|
|
PartnerPayoutRequestResponse,
|
|
PartnerQrResponse,
|
|
PartnerTopEntryResponse,
|
|
PartnerTopResponse,
|
|
)
|
|
from database import identities as idb
|
|
from utils.referral_codes import decode_partner_code, encode_partner_code
|
|
|
|
|
|
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:
|
|
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
|
|
|
|
|
|
def _row_dt_iso(value) -> str | None:
|
|
if isinstance(value, datetime):
|
|
return value.isoformat()
|
|
return None
|
|
|
|
|
|
def _resolve_public_base_url(request: Request) -> str:
|
|
origin = str(request.headers.get("origin") or "").strip()
|
|
if origin.startswith(("http://", "https://")):
|
|
return origin.rstrip("/")
|
|
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
|
|
if referer.startswith(("http://", "https://")):
|
|
parsed = urlsplit(referer)
|
|
if parsed.scheme and parsed.netloc:
|
|
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
|
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
|
|
host = forwarded_host or str(request.headers.get("host") or "").strip()
|
|
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
|
|
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
|
|
if host:
|
|
return f"{scheme}://{host}".rstrip("/")
|
|
return str(request.base_url).rstrip("/")
|
|
|
|
|
|
async def _ensure_partner_code(session: AsyncSession, user_id: int, raw_code: str | None) -> str:
|
|
code = str(raw_code or "").strip()
|
|
if code and not code.isdigit() and not code.startswith("r1_"):
|
|
return code
|
|
generated = encode_partner_code(int(user_id))
|
|
try:
|
|
await session.execute(
|
|
text("UPDATE users SET partner_code = :code WHERE id = :id"),
|
|
{"code": generated, "id": int(user_id)},
|
|
)
|
|
await session.flush()
|
|
except Exception:
|
|
pass
|
|
return generated
|
|
|
|
|
|
async def _resolve_partner_user(session: AsyncSession, request: Request, identity) -> tuple[int, int]:
|
|
actor = get_request_actor(request)
|
|
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
|
if billing_user_id is None:
|
|
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
|
row = (
|
|
await session.execute(
|
|
text("SELECT id, tg_id FROM users WHERE id = :user_id LIMIT 1"),
|
|
{"user_id": int(billing_user_id)},
|
|
)
|
|
).first()
|
|
if row is None or row[1] is None:
|
|
raise HTTPException(status_code=400, detail="Партнерский профиль недоступен")
|
|
return int(row[0]), int(row[1])
|
|
|
|
|
|
async def _resolve_referrer_by_partner_code(session: AsyncSession, partner_code: str) -> tuple[int, int] | None:
|
|
code = str(partner_code or "").strip()
|
|
if not code:
|
|
return None
|
|
by_code_row = (
|
|
await session.execute(
|
|
text(
|
|
"""
|
|
SELECT id, tg_id
|
|
FROM users
|
|
WHERE lower(COALESCE(partner_code, '')) = lower(:code)
|
|
LIMIT 1
|
|
"""
|
|
),
|
|
{"code": code},
|
|
)
|
|
).first()
|
|
if by_code_row is not None:
|
|
user_id = int(by_code_row[0])
|
|
tg_id = int(by_code_row[1] if by_code_row[1] is not None else by_code_row[0])
|
|
return user_id, tg_id
|
|
decoded = decode_partner_code(code)
|
|
if decoded is None:
|
|
return None
|
|
by_id_row = (
|
|
await session.execute(
|
|
text("SELECT id, tg_id FROM users WHERE id = :id LIMIT 1"),
|
|
{"id": int(decoded)},
|
|
)
|
|
).first()
|
|
if by_id_row is not None:
|
|
user_id = int(by_id_row[0])
|
|
tg_id = int(by_id_row[1] if by_id_row[1] is not None else by_id_row[0])
|
|
return user_id, tg_id
|
|
by_tg_row = (
|
|
await session.execute(
|
|
text("SELECT id, tg_id FROM users WHERE tg_id = :tg_id LIMIT 1"),
|
|
{"tg_id": int(decoded)},
|
|
)
|
|
).first()
|
|
if by_tg_row is not None:
|
|
user_id = int(by_tg_row[0])
|
|
tg_id = int(by_tg_row[1] if by_tg_row[1] is not None else by_tg_row[0])
|
|
return user_id, tg_id
|
|
return None
|
|
|
|
|
|
@router.post("/apply", response_model=PartnerApplyResponse)
|
|
async def partner_apply(
|
|
body: PartnerApplyRequest,
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_session),
|
|
identity=Depends(verify_identity_token),
|
|
):
|
|
joined_user_id, joined_tg_id = await _resolve_partner_user(session, request, identity)
|
|
code_value = str(body.partner_code or "").strip()
|
|
referrer_user_id: int | None = None
|
|
referrer_tg_id: int | None = None
|
|
if code_value:
|
|
resolved = await _resolve_referrer_by_partner_code(session, code_value)
|
|
if resolved is not None:
|
|
referrer_user_id, referrer_tg_id = resolved
|
|
if referrer_tg_id is None and body.partner_tg_id is not None:
|
|
referrer_tg_id = int(body.partner_tg_id)
|
|
referrer_user_id_row = (
|
|
await session.execute(
|
|
text("SELECT id FROM users WHERE tg_id = :tg_id LIMIT 1"),
|
|
{"tg_id": int(referrer_tg_id)},
|
|
)
|
|
).first()
|
|
if referrer_user_id_row is not None:
|
|
referrer_user_id = int(referrer_user_id_row[0])
|
|
if referrer_tg_id is None:
|
|
raise HTTPException(status_code=400, detail="Партнерский код не найден")
|
|
if int(referrer_tg_id) == int(joined_tg_id):
|
|
raise HTTPException(status_code=400, detail="Нельзя применить свой партнерский код")
|
|
already_row = (
|
|
await session.execute(
|
|
text("SELECT partner_tg_id FROM partners WHERE joined_tg_id = :joined_tg_id LIMIT 1"),
|
|
{"joined_tg_id": int(joined_tg_id)},
|
|
)
|
|
).first()
|
|
if already_row is not None and already_row[0] is not None:
|
|
raise HTTPException(status_code=409, detail="Партнер уже привязан")
|
|
await session.execute(
|
|
text(
|
|
"""
|
|
INSERT INTO partners (partner_tg_id, joined_tg_id)
|
|
VALUES (:partner_tg_id, :joined_tg_id)
|
|
"""
|
|
),
|
|
{"partner_tg_id": int(referrer_tg_id), "joined_tg_id": int(joined_tg_id)},
|
|
)
|
|
await session.commit()
|
|
return PartnerApplyResponse(
|
|
ok=True,
|
|
message="Партнерский код применен",
|
|
partner_code=code_value,
|
|
partner_user_id=int(referrer_user_id or 0),
|
|
partner_tg_id=int(referrer_tg_id),
|
|
joined_user_id=int(joined_user_id),
|
|
joined_tg_id=int(joined_tg_id),
|
|
)
|
|
|
|
|
|
@router.get("/qr", response_model=PartnerQrResponse)
|
|
async def partner_qr(
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_session),
|
|
identity=Depends(verify_identity_token),
|
|
):
|
|
user_id, _ = await _resolve_partner_user(session, request, identity)
|
|
code_row = (
|
|
await session.execute(
|
|
text("SELECT partner_code FROM users WHERE id = :id LIMIT 1"),
|
|
{"id": int(user_id)},
|
|
)
|
|
).first()
|
|
partner_code = await _ensure_partner_code(session, int(user_id), code_row[0] if code_row else None)
|
|
base_url = _resolve_public_base_url(request)
|
|
partner_link = f"{base_url}/partner/{partner_code}"
|
|
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
|
qr.add_data(partner_link)
|
|
qr.make(fit=True)
|
|
image = qr.make_image(fill_color="black", back_color="white")
|
|
png_buffer = BytesIO()
|
|
image.save(png_buffer, format="PNG")
|
|
image_data = b64encode(png_buffer.getvalue()).decode("ascii")
|
|
return PartnerQrResponse(
|
|
ok=True,
|
|
link=partner_link,
|
|
image_data_url=f"data:image/png;base64,{image_data}",
|
|
)
|
|
|
|
|
|
@router.get("/top", response_model=PartnerTopResponse)
|
|
async def partner_top(
|
|
request: Request,
|
|
limit: int = Query(5, ge=1, le=20),
|
|
session: AsyncSession = Depends(get_session),
|
|
identity=Depends(verify_identity_token),
|
|
):
|
|
_, joined_tg_id = await _resolve_partner_user(session, request, identity)
|
|
user_referred_count_row = (
|
|
await session.execute(
|
|
text(
|
|
"""
|
|
SELECT COUNT(DISTINCT joined_tg_id)
|
|
FROM partners
|
|
WHERE partner_tg_id = :partner_tg_id
|
|
"""
|
|
),
|
|
{"partner_tg_id": int(joined_tg_id)},
|
|
)
|
|
).first()
|
|
user_referred_count = int(user_referred_count_row[0] or 0) if user_referred_count_row else 0
|
|
user_position: int | None = None
|
|
if user_referred_count > 0:
|
|
user_position_row = (
|
|
await session.execute(
|
|
text(
|
|
"""
|
|
SELECT COUNT(*) + 1
|
|
FROM (
|
|
SELECT partner_tg_id, COUNT(DISTINCT joined_tg_id) AS referred_count
|
|
FROM partners
|
|
WHERE partner_tg_id IS NOT NULL
|
|
GROUP BY partner_tg_id
|
|
) ranked
|
|
WHERE ranked.referred_count > :referred_count
|
|
"""
|
|
),
|
|
{"referred_count": int(user_referred_count)},
|
|
)
|
|
).first()
|
|
user_position = int(user_position_row[0] or 1) if user_position_row else 1
|
|
top_rows = (
|
|
await session.execute(
|
|
text(
|
|
"""
|
|
SELECT
|
|
COALESCE(u.id, 0) AS partner_user_id,
|
|
p.partner_tg_id AS partner_tg_id,
|
|
COUNT(DISTINCT p.joined_tg_id) AS referred_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.id
|
|
ORDER BY referred_count DESC, p.partner_tg_id ASC
|
|
LIMIT :limit
|
|
"""
|
|
),
|
|
{"limit": int(limit)},
|
|
)
|
|
).all()
|
|
top: list[PartnerTopEntryResponse] = []
|
|
for index, row in enumerate(top_rows, 1):
|
|
partner_user_id = int(row[0] or 0)
|
|
partner_tg_id = int(row[1] or 0)
|
|
referred_count = int(row[2] or 0)
|
|
if partner_user_id > 0:
|
|
display_id = encode_partner_code(partner_user_id)
|
|
else:
|
|
tg_tail = str(partner_tg_id)
|
|
display_id = f"p_{tg_tail[:2]}***{tg_tail[-2:]}" if tg_tail else "p_***"
|
|
top.append(
|
|
PartnerTopEntryResponse(
|
|
position=index,
|
|
partner_user_id=partner_user_id,
|
|
referred_count=referred_count,
|
|
display_id=display_id,
|
|
)
|
|
)
|
|
return PartnerTopResponse(
|
|
user_referred_count=user_referred_count,
|
|
user_position=user_position,
|
|
top=top,
|
|
)
|
|
|
|
|
|
@router.get("/conditions", response_model=PartnerConditionsResponse)
|
|
async def partner_conditions(
|
|
session: AsyncSession = Depends(get_session),
|
|
identity=Depends(verify_identity_token),
|
|
):
|
|
try:
|
|
from modules.partner_program import settings as partner_settings
|
|
except Exception:
|
|
partner_settings = None
|
|
mode = str(getattr(partner_settings, "REFERRAL_REWARD_MODE", "percent_only") or "percent_only")
|
|
percent_levels_raw = getattr(partner_settings, "PARTNER_BONUS_PERCENTAGES", {}) or {}
|
|
flat_levels_raw = getattr(partner_settings, "PARTNER_FLAT_BONUSES", {}) or {}
|
|
min_payout = float(getattr(partner_settings, "MIN_PARTNER_PAYOUT", 0) or 0)
|
|
custom_amount_enabled = bool(getattr(partner_settings, "ENABLE_CUSTOM_WITHDRAW_AMOUNT", False))
|
|
method_map = [
|
|
("ENABLE_PAYOUT_CARD", "Карта"),
|
|
("ENABLE_PAYOUT_SBP", "СБП"),
|
|
("ENABLE_PAYOUT_USDT", "USDT"),
|
|
("ENABLE_PAYOUT_TON", "TON"),
|
|
]
|
|
payout_methods = (
|
|
[title for key, title in method_map if bool(getattr(partner_settings, key, False))] if partner_settings else []
|
|
)
|
|
level_lines: list[str] = []
|
|
all_levels = sorted({int(k) for k in [*percent_levels_raw.keys(), *flat_levels_raw.keys()] if str(k).isdigit()})
|
|
for level in all_levels:
|
|
parts: list[str] = []
|
|
if level in percent_levels_raw:
|
|
try:
|
|
parts.append(f"{float(percent_levels_raw[level]) * 100:.0f}%")
|
|
except Exception:
|
|
pass
|
|
if level in flat_levels_raw:
|
|
try:
|
|
parts.append(f"{float(flat_levels_raw[level]):.0f} RUB")
|
|
except Exception:
|
|
pass
|
|
if parts:
|
|
level_lines.append(f"{level} уровень: {' + '.join(parts)}")
|
|
if not level_lines:
|
|
level_lines = ["1 уровень: бонус определяется настройками проекта"]
|
|
mode_labels = {
|
|
"percent_only": "Процент с каждого пополнения приглашенного",
|
|
"flat_only": "Фиксированный бонус за первую оплату приглашенного",
|
|
"flat_plus_percent": "Фиксированный бонус за первую оплату и процент с пополнений",
|
|
}
|
|
rules = [
|
|
"Вознаграждение начисляется только после успешной оплаты приглашенного пользователя.",
|
|
"Самореферал и самопартнерство недоступны.",
|
|
f"Минимальная сумма вывода: {min_payout:.0f} RUB." if min_payout > 0 else "Вывод доступен по правилам проекта.",
|
|
]
|
|
if payout_methods:
|
|
rules.append(f"Доступные способы вывода: {', '.join(payout_methods)}.")
|
|
examples = [
|
|
"Пример: приглашенный пополнил на 1000 RUB, а ставка 15% — вы получаете 150 RUB.",
|
|
"Пример: приглашенный сделал несколько пополнений, бонус считается по каждой успешной операции.",
|
|
]
|
|
return PartnerConditionsResponse(
|
|
title="Условия партнерской программы",
|
|
summary="Актуальные условия и режим начислений для партнеров.",
|
|
bonus_mode=mode,
|
|
bonus_mode_label=mode_labels.get(mode, mode_labels["percent_only"]),
|
|
level_lines=level_lines,
|
|
rules=rules,
|
|
examples=examples,
|
|
min_payout_rub=min_payout,
|
|
payout_methods=payout_methods,
|
|
custom_amount_enabled=custom_amount_enabled,
|
|
)
|
|
|
|
|
|
@router.get("/payouts/me", response_model=PartnerPayoutHistoryResponse)
|
|
async def partner_payouts_me(
|
|
request: Request,
|
|
limit: int = Query(20, ge=1, le=100),
|
|
offset: int = Query(0, ge=0),
|
|
session: AsyncSession = Depends(get_session),
|
|
identity=Depends(verify_identity_token),
|
|
):
|
|
_, tg_id = await _resolve_partner_user(session, request, identity)
|
|
count_sql = text("SELECT COUNT(*) FROM payout_requests WHERE tg_id = :tg_id")
|
|
rows_sql = text(
|
|
"""
|
|
SELECT id, amount, status, created_at, method, destination
|
|
FROM payout_requests
|
|
WHERE tg_id = :tg_id
|
|
ORDER BY created_at DESC, id DESC
|
|
LIMIT :limit OFFSET :offset
|
|
"""
|
|
)
|
|
total = int((await session.scalar(count_sql, {"tg_id": tg_id})) or 0)
|
|
rows = (await session.execute(rows_sql, {"tg_id": tg_id, "limit": int(limit), "offset": int(offset)})).fetchall()
|
|
items = [
|
|
PartnerPayoutEntryResponse(
|
|
id=int(row[0]),
|
|
amount_rub=float(row[1] or 0.0),
|
|
status=str(row[2] or ""),
|
|
created_at=_row_dt_iso(row[3]),
|
|
method=row[4] or None,
|
|
destination=row[5] or None,
|
|
)
|
|
for row in rows
|
|
]
|
|
return PartnerPayoutHistoryResponse(total=total, items=items)
|
|
|
|
|
|
@router.post("/payouts/me", response_model=PartnerPayoutRequestResponse)
|
|
async def partner_create_payout_request(
|
|
body: PartnerPayoutRequestCreate,
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_session),
|
|
identity=Depends(verify_identity_token),
|
|
):
|
|
user_id, tg_id = await _resolve_partner_user(session, request, identity)
|
|
row = (
|
|
await session.execute(
|
|
text("SELECT COALESCE(partner_balance, 0), payout_method, card_number FROM users WHERE id = :id"),
|
|
{"id": user_id},
|
|
)
|
|
).first()
|
|
balance = float(row[0] or 0.0) if row else 0.0
|
|
requested = float(body.amount_rub)
|
|
if requested <= 0:
|
|
raise HTTPException(status_code=400, detail="Сумма должна быть больше нуля")
|
|
try:
|
|
from modules.partner_program.settings import ENABLE_CUSTOM_WITHDRAW_AMOUNT, MIN_PARTNER_PAYOUT
|
|
except Exception:
|
|
ENABLE_CUSTOM_WITHDRAW_AMOUNT = True
|
|
MIN_PARTNER_PAYOUT = 0
|
|
min_payout = float(MIN_PARTNER_PAYOUT or 0)
|
|
if requested < min_payout:
|
|
raise HTTPException(status_code=400, detail=f"Минимальная сумма вывода — {min_payout:.0f} RUB")
|
|
if not bool(ENABLE_CUSTOM_WITHDRAW_AMOUNT):
|
|
requested = balance
|
|
if requested > balance:
|
|
raise HTTPException(status_code=400, detail="Недостаточно партнерского баланса")
|
|
if requested <= 0:
|
|
raise HTTPException(status_code=400, detail="Недостаточно средств для заявки")
|
|
payout_method = (row[1] if row else None) or "card"
|
|
destination = (row[2] if row else None) or None
|
|
inserted = (
|
|
await session.execute(
|
|
text(
|
|
"""
|
|
INSERT INTO payout_requests (tg_id, amount, status, created_at, method, destination)
|
|
VALUES (:tg_id, :amount, 'pending', NOW(), :method, :destination)
|
|
RETURNING id
|
|
"""
|
|
),
|
|
{
|
|
"tg_id": int(tg_id),
|
|
"amount": float(requested),
|
|
"method": payout_method,
|
|
"destination": destination,
|
|
},
|
|
)
|
|
).scalar()
|
|
new_balance = balance - requested
|
|
await session.execute(
|
|
text("UPDATE users SET partner_balance = :balance WHERE id = :id"),
|
|
{"balance": new_balance, "id": int(user_id)},
|
|
)
|
|
await session.commit()
|
|
return PartnerPayoutRequestResponse(
|
|
ok=True,
|
|
message="Заявка на вывод создана",
|
|
request_id=int(inserted) if inserted is not None else None,
|
|
amount_rub=float(requested),
|
|
status="pending",
|
|
balance_rub=float(new_balance),
|
|
)
|
|
|
|
|
|
@router.get("/all")
|
|
async def get_all_partners(
|
|
limit: int = Query(1000, ge=1, le=10000, description="Лимит результатов"),
|
|
offset: int = Query(0, ge=0, description="Смещение"),
|
|
identity=Depends(verify_identity_admin),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Список всех партнёров со статистикой. Требуется админ (X-Identity-Id + X-Token)."""
|
|
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
|
|
default_percent = _default_partner_percent()
|
|
partners_list = []
|
|
for partner in partners:
|
|
percent_value = partner[2]
|
|
percent_custom = bool(partner[3])
|
|
percent = float(percent_value) if (percent_custom and percent_value is not None) else 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(
|
|
identity=Depends(verify_identity_admin),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Общая статистика партнёрской программы."""
|
|
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 partner_tg_id) FROM partners WHERE partner_tg_id IS NOT NULL AND DATE(created_at) = CURRENT_DATE) AS partners_today,
|
|
(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),
|
|
"partners_today": int(stats_row[1] or 0),
|
|
"total_referred": int(stats_row[2] or 0),
|
|
"total_balance": float(stats_row[3] or 0.0),
|
|
"top_partner_tg_id": int(stats_row[4] or 0),
|
|
"top_partner_refs": int(stats_row[5] or 0),
|
|
}
|
|
else:
|
|
stats = {
|
|
"total_partners": 0,
|
|
"partners_today": 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="Новый процент партнёра"),
|
|
identity=Depends(verify_identity_admin),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Обновляет данные партнёра (баланс и процент)."""
|
|
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
|
|
)
|
|
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 партнёра"),
|
|
identity=Depends(verify_identity_admin),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Партнёрские данные по tg_id и список приглашённых."""
|
|
meta_sql = text(
|
|
"""
|
|
SELECT COALESCE(u.partner_balance, 0), u.partner_percent, COALESCE(u.partner_percent_custom, false), 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),
|
|
(SELECT COUNT(*) FROM keys k WHERE k.tg_id = pr.joined_tg_id),
|
|
(SELECT COUNT(*) FROM payments pay WHERE pay.tg_id = pr.joined_tg_id AND lower(pay.status) = 'success')
|
|
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, percent_custom = meta_row[1], 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 приглашённого"),
|
|
identity=Depends(verify_identity_admin),
|
|
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 приглашённого"),
|
|
identity=Depends(verify_identity_admin),
|
|
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)"),
|
|
identity=Depends(verify_identity_admin),
|
|
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"),
|
|
identity=Depends(verify_identity_admin),
|
|
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 партнёра"),
|
|
identity=Depends(verify_identity_admin),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Список приглашённых партнёра."""
|
|
invited_sql = text(
|
|
"""
|
|
SELECT pr.joined_tg_id, pr.created_at, COALESCE(u.balance, 0),
|
|
(SELECT COUNT(*) FROM keys k WHERE k.tg_id = pr.joined_tg_id),
|
|
(SELECT COUNT(*) FROM payments pay WHERE pay.tg_id = pr.joined_tg_id AND lower(pay.status) = 'success')
|
|
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)
|
|
|
|
|
|
@router.get("/payouts/pending")
|
|
async def get_partner_payouts_pending(
|
|
limit: int = Query(50, ge=1, le=200),
|
|
offset: int = Query(0, ge=0),
|
|
partner_tg_id: int | None = Query(None),
|
|
identity=Depends(verify_identity_admin),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Ожидающие заявки на вывод."""
|
|
where_clause = "WHERE pr.status = 'pending'"
|
|
params = {"limit": limit, "offset": offset}
|
|
if partner_tg_id is not None:
|
|
where_clause += " AND pr.tg_id = :partner_tg_id"
|
|
params["partner_tg_id"] = partner_tg_id
|
|
count_sql = text(f"SELECT COUNT(*) FROM payout_requests pr {where_clause}")
|
|
rows_sql = text(
|
|
f"""
|
|
SELECT pr.id, pr.tg_id, pr.amount, pr.status, pr.created_at,
|
|
COALESCE(pr.method, u.payout_method) AS method, COALESCE(pr.destination, u.card_number) AS destination
|
|
FROM payout_requests pr
|
|
LEFT JOIN users u ON u.tg_id = pr.tg_id
|
|
{where_clause}
|
|
ORDER BY pr.created_at ASC, pr.id ASC
|
|
LIMIT :limit OFFSET :offset
|
|
"""
|
|
)
|
|
total = await session.scalar(count_sql) or 0
|
|
result = await session.execute(rows_sql, params)
|
|
items = [
|
|
{
|
|
"id": int(row[0]),
|
|
"tg_id": int(row[1]),
|
|
"amount": float(row[2] or 0.0),
|
|
"status": row[3] or "pending",
|
|
"created_at": _row_dt_iso(row[4]),
|
|
"method": row[5] or None,
|
|
"destination": row[6] or None,
|
|
}
|
|
for row in result.fetchall()
|
|
]
|
|
return JSONResponse(content={"total": int(total), "items": items})
|
|
|
|
|
|
@router.get("/payouts/history")
|
|
async def get_partner_payouts_history(
|
|
limit: int = Query(50, ge=1, le=200),
|
|
offset: int = Query(0, ge=0),
|
|
partner_tg_id: int | None = Query(None),
|
|
identity=Depends(verify_identity_admin),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""История выплат (approved/rejected)."""
|
|
where_clause = "WHERE pr.status IN ('approved','rejected')"
|
|
params = {"limit": limit, "offset": offset}
|
|
if partner_tg_id is not None:
|
|
where_clause += " AND pr.tg_id = :partner_tg_id"
|
|
params["partner_tg_id"] = partner_tg_id
|
|
count_sql = text(f"SELECT COUNT(*) FROM payout_requests pr {where_clause}")
|
|
rows_sql = text(
|
|
f"""
|
|
SELECT pr.id, pr.tg_id, pr.amount, pr.status, pr.created_at,
|
|
COALESCE(pr.method, u.payout_method) AS method, COALESCE(pr.destination, u.card_number) AS destination
|
|
FROM payout_requests pr
|
|
LEFT JOIN users u ON u.tg_id = pr.tg_id
|
|
{where_clause}
|
|
ORDER BY pr.created_at DESC, pr.id DESC
|
|
LIMIT :limit OFFSET :offset
|
|
"""
|
|
)
|
|
total = await session.scalar(count_sql) or 0
|
|
result = await session.execute(rows_sql, params)
|
|
items = [
|
|
{
|
|
"id": int(row[0]),
|
|
"tg_id": int(row[1]),
|
|
"amount": float(row[2] or 0.0),
|
|
"status": row[3] or "—",
|
|
"created_at": _row_dt_iso(row[4]),
|
|
"method": row[5] or None,
|
|
"destination": row[6] or None,
|
|
}
|
|
for row in result.fetchall()
|
|
]
|
|
return JSONResponse(content={"total": int(total), "items": items})
|
|
|
|
|
|
@router.post("/payouts/{payout_id}/approve")
|
|
async def approve_partner_payout(
|
|
payout_id: int = Path(..., description="ID заявки"),
|
|
identity=Depends(verify_identity_admin),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Одобряет заявку на вывод."""
|
|
req_row = await session.execute(
|
|
text("SELECT id, tg_id, amount FROM payout_requests WHERE id = :id AND status = 'pending'"),
|
|
{"id": payout_id},
|
|
)
|
|
req = req_row.fetchone()
|
|
if not req:
|
|
return JSONResponse(
|
|
content={"success": False, "message": "Заявка не найдена или уже обработана"}, status_code=404
|
|
)
|
|
user_row = await session.execute(
|
|
text("SELECT payout_method, card_number FROM users WHERE tg_id = :tg_id"), {"tg_id": req[1]}
|
|
)
|
|
user = user_row.fetchone()
|
|
payout_method = (user[0] if user else None) or "card"
|
|
destination = (user[1] if user else None) or None
|
|
destination = (destination or "").strip() or None
|
|
await session.execute(
|
|
text(
|
|
"UPDATE payout_requests SET status = 'approved', method = :method, destination = :destination WHERE id = :id"
|
|
),
|
|
{"id": payout_id, "method": payout_method, "destination": destination},
|
|
)
|
|
await session.commit()
|
|
return JSONResponse(content={"success": True, "message": "Заявка одобрена"}, status_code=200)
|
|
|
|
|
|
@router.post("/payouts/{payout_id}/reject")
|
|
async def reject_partner_payout(
|
|
payout_id: int = Path(..., description="ID заявки"),
|
|
identity=Depends(verify_identity_admin),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Отклоняет заявку на вывод и возвращает сумму на баланс."""
|
|
req_row = await session.execute(
|
|
text("SELECT id, tg_id, amount FROM payout_requests WHERE id = :id AND status = 'pending'"),
|
|
{"id": payout_id},
|
|
)
|
|
req = req_row.fetchone()
|
|
if not req:
|
|
return JSONResponse(
|
|
content={"success": False, "message": "Заявка не найдена или уже обработана"}, status_code=404
|
|
)
|
|
user_row = await session.execute(
|
|
text("SELECT payout_method, card_number, partner_balance FROM users WHERE tg_id = :tg_id"), {"tg_id": req[1]}
|
|
)
|
|
user = user_row.fetchone()
|
|
payout_method = (user[0] if user else None) or "card"
|
|
destination = (user[1] if user else None) or None
|
|
destination = (destination or "").strip() or None
|
|
await session.execute(
|
|
text(
|
|
"UPDATE payout_requests SET status = 'rejected', method = :method, destination = :destination WHERE id = :id"
|
|
),
|
|
{"id": payout_id, "method": payout_method, "destination": destination},
|
|
)
|
|
if user is not None:
|
|
current_balance = float(user[2] or 0.0)
|
|
await session.execute(
|
|
text("UPDATE users SET partner_balance = :balance WHERE tg_id = :tg_id"),
|
|
{"balance": current_balance + float(req[2] or 0.0), "tg_id": req[1]},
|
|
)
|
|
await session.commit()
|
|
return JSONResponse(content={"success": True, "message": "Заявка отклонена"}, status_code=200)
|
|
|
|
|
|
@router.patch("/{tg_id}/percent/reset")
|
|
async def reset_partner_percent(
|
|
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
|
identity=Depends(verify_identity_admin),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Сбрасывает персональный процент партнёра к дефолту."""
|
|
result = await session.execute(
|
|
text("UPDATE users SET partner_percent = NULL, partner_percent_custom = false WHERE tg_id = :tg_id"),
|
|
{"tg_id": tg_id},
|
|
)
|
|
await session.commit()
|
|
if result.rowcount > 0:
|
|
return JSONResponse(content={"success": True, "message": "Процент сброшен"}, status_code=200)
|
|
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
|
|
|
|
|
|
@router.patch("/{tg_id}/code")
|
|
async def update_partner_code(
|
|
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
|
code: str = Query(..., description="Новый код партнёра (латиница/цифры/_)"),
|
|
identity=Depends(verify_identity_admin),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Обновляет код партнёрской ссылки."""
|
|
raw = (code or "").strip().lower()
|
|
if not raw:
|
|
return JSONResponse(content={"success": False, "message": "Код не может быть пустым"}, status_code=400)
|
|
if not re.fullmatch(r"[a-z0-9_]{3,32}", raw):
|
|
return JSONResponse(
|
|
content={"success": False, "message": "Неверный код. Разрешены a-z, 0-9, _ (3-32 символа)"}, status_code=400
|
|
)
|
|
exists = await session.execute(
|
|
text("SELECT 1 FROM users WHERE partner_code = :code AND tg_id != :tg_id"), {"code": raw, "tg_id": tg_id}
|
|
)
|
|
if exists.first():
|
|
return JSONResponse(content={"success": False, "message": "Такой код уже занят"}, status_code=409)
|
|
result = await session.execute(
|
|
text("UPDATE users SET partner_code = :code WHERE tg_id = :tg_id"), {"code": raw, "tg_id": tg_id}
|
|
)
|
|
await session.commit()
|
|
if result.rowcount > 0:
|
|
return JSONResponse(content={"success": True, "message": "Код обновлён", "code": raw}, status_code=200)
|
|
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
|
|
|
|
|
|
@router.post("/reset-disabled-methods")
|
|
async def reset_disabled_payout_methods(
|
|
identity=Depends(verify_identity_admin),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Сбрасывает реквизиты для отключённых способов вывода."""
|
|
try:
|
|
from modules.partner_program import buttons as B
|
|
from modules.partner_program.settings import (
|
|
ENABLE_PAYOUT_CARD,
|
|
ENABLE_PAYOUT_SBP,
|
|
ENABLE_PAYOUT_TON,
|
|
ENABLE_PAYOUT_USDT,
|
|
)
|
|
except Exception:
|
|
ENABLE_PAYOUT_CARD = True
|
|
ENABLE_PAYOUT_USDT = True
|
|
ENABLE_PAYOUT_TON = True
|
|
ENABLE_PAYOUT_SBP = True
|
|
B = None
|
|
disabled = []
|
|
if not ENABLE_PAYOUT_CARD and B:
|
|
disabled.append(B.METHOD_CARD)
|
|
if not ENABLE_PAYOUT_USDT and B:
|
|
disabled.append(B.METHOD_USDT)
|
|
if not ENABLE_PAYOUT_TON and B:
|
|
disabled.append(B.METHOD_TON)
|
|
if not ENABLE_PAYOUT_SBP and B:
|
|
disabled.append(B.METHOD_SBP)
|
|
if not disabled:
|
|
return JSONResponse(content={"success": True, "message": "Отключённых методов нет"}, status_code=200)
|
|
await session.execute(
|
|
text("UPDATE users SET card_number = NULL WHERE payout_method = ANY(:methods)"), {"methods": disabled}
|
|
)
|
|
await session.commit()
|
|
return JSONResponse(content={"success": True, "message": "Отключённые методы сброшены"}, status_code=200)
|
|
|
|
|
|
@router.get("/{tg_id}/export")
|
|
async def export_partner_invites_csv(
|
|
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
|
identity=Depends(verify_identity_admin),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Экспорт приглашённых партнёром в CSV."""
|
|
rows = await session.execute(
|
|
text("SELECT joined_tg_id, created_at FROM partners WHERE partner_tg_id = :tg_id ORDER BY created_at ASC"),
|
|
{"tg_id": tg_id},
|
|
)
|
|
data = rows.fetchall()
|
|
if not data:
|
|
return JSONResponse(content={"success": False, "message": "Нет приглашённых"}, status_code=404)
|
|
buffer = StringIO()
|
|
writer = csv.writer(buffer, delimiter=";")
|
|
writer.writerow(["joined_tg_id", "created_at"])
|
|
for joined_tg_id, created_at in data:
|
|
writer.writerow([int(joined_tg_id), created_at.isoformat() if created_at else ""])
|
|
content = buffer.getvalue().encode("utf-8-sig")
|
|
filename = f"partner_invites_{tg_id}.csv"
|
|
return StreamingResponse(
|
|
iter([content]), media_type="text/csv", headers={"Content-Disposition": f"attachment; filename={filename}"}
|
|
)
|