Docker/ API 0.5.0: Partner Statistics/Coupons for New Customers and More
This commit is contained in:
+3
-3
@@ -1,9 +1,9 @@
|
||||
from fastapi import FastAPI
|
||||
from api.routes import coupons, gifts, keys, misc, referrals, servers, settings, tariffs, users
|
||||
from api.routes import users, keys, coupons, servers, tariffs, gifts, referrals, misc, partners
|
||||
|
||||
app = FastAPI(
|
||||
title="SoloBot API (Alpha)",
|
||||
version="0.4.0",
|
||||
version="0.5.0",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json",
|
||||
@@ -16,7 +16,7 @@ app.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
|
||||
app.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
|
||||
app.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
|
||||
app.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
|
||||
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
|
||||
app.include_router(partners.router, prefix="/api/partners", tags=["Partners"])
|
||||
app.include_router(misc.router, prefix="/api")
|
||||
|
||||
|
||||
|
||||
+34
-15
@@ -1,3 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -7,13 +9,23 @@ from api.depends import get_session, verify_admin_token
|
||||
from database.models import Admin
|
||||
|
||||
|
||||
def _cast_identifier_type(field: InstrumentedAttribute, value: int | str):
|
||||
def cast_identifier_type(field: InstrumentedAttribute, value: int | str):
|
||||
column_type = type(field.property.columns[0].type).__name__
|
||||
if column_type in ("Integer", "BigInteger"):
|
||||
return int(value)
|
||||
return value
|
||||
|
||||
|
||||
def normalize_outgoing_object(obj: object) -> None:
|
||||
if hasattr(obj, "vless") and getattr(obj, "vless") is None:
|
||||
setattr(obj, "vless", False)
|
||||
|
||||
|
||||
def to_schema(schema_response: type, obj: object):
|
||||
normalize_outgoing_object(obj)
|
||||
return schema_response.model_validate(obj, from_attributes=True)
|
||||
|
||||
|
||||
def generate_crud_router(
|
||||
*,
|
||||
model: type,
|
||||
@@ -35,7 +47,10 @@ def generate_crud_router(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(model))
|
||||
return result.scalars().all()
|
||||
items = result.scalars().all()
|
||||
for item in items:
|
||||
normalize_outgoing_object(item)
|
||||
return [schema_response.model_validate(item, from_attributes=True) for item in items]
|
||||
|
||||
if "get_by_email" in enabled_methods and extra_get_by_email:
|
||||
|
||||
@@ -49,7 +64,7 @@ def generate_crud_router(
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Not found by email")
|
||||
return obj
|
||||
return to_schema(schema_response, obj)
|
||||
|
||||
if "get_one" in enabled_methods:
|
||||
|
||||
@@ -60,12 +75,12 @@ def generate_crud_router(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = _cast_identifier_type(field, value)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
return obj
|
||||
return to_schema(schema_response, obj)
|
||||
|
||||
if "get_all_by_field" in enabled_methods:
|
||||
|
||||
@@ -76,52 +91,56 @@ def generate_crud_router(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = _cast_identifier_type(field, value)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
objs = result.scalars().all()
|
||||
if not objs:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
return objs
|
||||
for obj in objs:
|
||||
normalize_outgoing_object(obj)
|
||||
return [schema_response.model_validate(obj, from_attributes=True) for obj in objs]
|
||||
|
||||
if "create" in enabled_methods:
|
||||
|
||||
@router.post("/", response_model=schema_response)
|
||||
async def create(
|
||||
payload: schema_create, # type: ignore
|
||||
payload: Any,
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
data = payload.dict(exclude_unset=True)
|
||||
validated = schema_create.model_validate(payload)
|
||||
data = validated.model_dump(exclude_unset=True)
|
||||
if "days" in data and data["days"] == 0:
|
||||
data["days"] = None
|
||||
obj = model(**data)
|
||||
session.add(obj)
|
||||
await session.commit()
|
||||
await session.refresh(obj)
|
||||
return obj
|
||||
return to_schema(schema_response, obj)
|
||||
|
||||
if "update" in enabled_methods:
|
||||
|
||||
@router.patch(f"/{{{parameter_name}}}", response_model=schema_response)
|
||||
async def update(
|
||||
payload: schema_update, # type: ignore
|
||||
payload: Any,
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = _cast_identifier_type(field, value)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
|
||||
for k, v in payload.dict(exclude_unset=True).items():
|
||||
validated = schema_update.model_validate(payload)
|
||||
for k, v in validated.model_dump(exclude_unset=True).items():
|
||||
setattr(obj, k, v)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(obj)
|
||||
return obj
|
||||
return to_schema(schema_response, obj)
|
||||
|
||||
if "delete" in enabled_methods:
|
||||
|
||||
@@ -132,7 +151,7 @@ def generate_crud_router(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = _cast_identifier_type(field, value)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
|
||||
+4
-20
@@ -93,31 +93,15 @@ async def edit_key_by_email(
|
||||
setattr(db_key, field, value)
|
||||
|
||||
try:
|
||||
tariff = None
|
||||
if db_key.tariff_id is not None:
|
||||
tariff_result = await session.execute(select(Tariff).where(Tariff.id == db_key.tariff_id))
|
||||
tariff = tariff_result.scalar_one_or_none()
|
||||
|
||||
total_gb = db_key.current_traffic_limit
|
||||
if total_gb is None:
|
||||
total_gb = db_key.selected_traffic_limit
|
||||
if total_gb is None and tariff is not None:
|
||||
total_gb = tariff.traffic_limit
|
||||
|
||||
hwid_device_limit = db_key.current_device_limit
|
||||
if hwid_device_limit is None:
|
||||
hwid_device_limit = db_key.selected_device_limit
|
||||
if hwid_device_limit is None and tariff is not None:
|
||||
hwid_device_limit = tariff.device_limit
|
||||
|
||||
new_expiry_time = db_key.expiry_time
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=db_key.server_id,
|
||||
email=db_key.email,
|
||||
client_id=db_key.client_id,
|
||||
new_expiry_time=db_key.expiry_time,
|
||||
total_gb=total_gb,
|
||||
new_expiry_time=new_expiry_time,
|
||||
total_gb=getattr(db_key, "traffic_limit", None),
|
||||
session=session,
|
||||
hwid_device_limit=hwid_device_limit,
|
||||
hwid_device_limit=getattr(db_key, "device_limit", None),
|
||||
reset_traffic=True,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
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
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@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,
|
||||
COALESCE(u.partner_percent, 0) AS partner_percent,
|
||||
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_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 = [
|
||||
{
|
||||
"tg_id": int(partner[0]),
|
||||
"balance": float(partner[1] or 0),
|
||||
"percent": float(partner[2] or 0),
|
||||
"code": partner[3] or None,
|
||||
"method": partner[4] or None,
|
||||
"referred_count": int(partner[5] or 0),
|
||||
}
|
||||
for partner in partners
|
||||
]
|
||||
|
||||
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,
|
||||
COALESCE(u.partner_percent, 0) AS partner_percent,
|
||||
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()
|
||||
|
||||
response = {
|
||||
"tg_id": tg_id,
|
||||
"partner_balance": float(meta_row[0] or 0) if meta_row else 0.0,
|
||||
"partner_percent": float(meta_row[1] or 0) if meta_row else 0.0,
|
||||
"partner_code": meta_row[2] if meta_row else None,
|
||||
"payout_method": meta_row[3] 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.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)
|
||||
Reference in New Issue
Block a user