API/ remnawave 1.6.12/ admins in database and more

This commit is contained in:
Vladless
2025-06-14 21:16:53 +03:00
parent cebdeb78fe
commit e06b0ef1bc
28 changed files with 1021 additions and 25 deletions
+33
View File
@@ -0,0 +1,33 @@
from typing import AsyncGenerator
import hashlib
from fastapi import Depends, HTTPException, Header, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from database import async_session_maker
from database.models import Admin
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
yield session
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def verify_admin_token(
admin_id: int = Query(..., alias="tg_id"),
token: str = Header(..., alias="X-Token"),
session: AsyncSession = Depends(get_session),
) -> Admin:
hashed = hash_token(token)
result = await session.execute(
select(Admin).where(Admin.tg_id == admin_id, Admin.token == hashed)
)
admin = result.scalar_one_or_none()
if not admin:
raise HTTPException(status_code=401, detail="Unauthorized")
return admin
+22
View File
@@ -0,0 +1,22 @@
from fastapi import FastAPI
from api.routes import users, keys, coupons, servers, tariffs
app = FastAPI(
title="SoloBot API (preAlpha)",
version="0.1",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json"
)
app.include_router(users.router, prefix="/api/users", tags=["Users"])
app.include_router(keys.router, prefix="/api/keys", tags=["Keys"])
app.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
app.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
app.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
@app.get("/api", include_in_schema=False)
async def root():
return {"message": "Welcome to SoloBot API. Docs: /api/docs"}
+124
View File
@@ -0,0 +1,124 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Path
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm.attributes import InstrumentedAttribute
from typing import Type, Union, Any
from api.depends import get_session, verify_admin_token
from database.models import Admin
def _cast_identifier_type(field: InstrumentedAttribute, value: Union[int, str]):
column_type = type(field.property.columns[0].type).__name__
if column_type in ("Integer", "BigInteger"):
return int(value)
return value
def generate_crud_router(
*,
model: Type,
schema_response: Type,
schema_create: Type,
schema_update: Type,
identifier_field: str = "tg_id",
parameter_name: str = "tg_id",
extra_get_by_email: bool = False,
enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete")
) -> APIRouter:
router = APIRouter()
if "get_all" in enabled_methods:
@router.get("/", response_model=list[schema_response])
async def get_all(
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(model))
return result.scalars().all()
if "get_by_email" in enabled_methods and extra_get_by_email:
@router.get("/by_email", response_model=schema_response)
async def get_by_email(
email: str = Query(...),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(model).where(model.email == email))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Not found by email")
return obj
if "get_one" in enabled_methods:
@router.get(f"/{{{parameter_name}}}", response_model=schema_response)
async def get_one(
value: Union[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)
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
if "create" in enabled_methods:
@router.post("/", response_model=schema_response)
async def create(
payload: schema_create, # type: ignore
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
data = payload.dict(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
if "update" in enabled_methods:
@router.patch(f"/{{{parameter_name}}}", response_model=schema_response)
async def update(
payload: schema_update, # type: ignore
value: Union[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)
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():
setattr(obj, k, v)
await session.commit()
await session.refresh(obj)
return obj
if "delete" in enabled_methods:
@router.delete(f"/{{{parameter_name}}}", response_model=dict)
async def delete(
value: Union[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)
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")
await session.delete(obj)
await session.commit()
return {"detail": f"{model.__name__} deleted"}
return router
+14
View File
@@ -0,0 +1,14 @@
from fastapi import APIRouter
from api.routes.base_crud import generate_crud_router
from api.schemas import CouponBase, CouponResponse, CouponUpdate
from database.models import Coupon
router: APIRouter = generate_crud_router(
model=Coupon,
schema_response=CouponResponse,
schema_create=CouponBase,
schema_update=CouponUpdate,
identifier_field="code",
parameter_name="code",
enabled_methods=["get_all", "get_one", "create", "update", "delete"]
)
+49
View File
@@ -0,0 +1,49 @@
from fastapi import Depends, HTTPException, Path
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from database.models import Key, Admin
from api.schemas.keys import KeyBase, KeyResponse, KeyUpdate
from api.routes.base_crud import generate_crud_router
from api.depends import get_session, verify_admin_token
from handlers.keys.key_utils import delete_key_from_cluster
from logger import logger
router = generate_crud_router(
model=Key,
schema_response=KeyResponse,
schema_create=KeyBase,
schema_update=KeyUpdate,
identifier_field="tg_id",
extra_get_by_email=True,
enabled_methods=["get_all", "get_one", "get_by_email"]
)
@router.delete("/by_email/{email}", response_model=dict)
async def delete_key_by_email(
email: str = Path(..., description="Email клиента"),
session: AsyncSession = Depends(get_session),
admin: Admin = Depends(verify_admin_token),
):
result = await session.execute(select(Key).where(Key.email == email))
db_key = result.scalar_one_or_none()
if not db_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
try:
await delete_key_from_cluster(
session=session,
email=db_key.email,
client_id=db_key.client_id,
cluster_id=db_key.server_id,
)
await session.delete(db_key)
await session.commit()
logger.info(f"[API] Ключ удалён: {db_key.client_id}")
return {"message": "Ключ успешно удалён"}
except Exception as e:
logger.error(f"[API] Ошибка при удалении ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при удалении ключа")
+14
View File
@@ -0,0 +1,14 @@
from fastapi import APIRouter
from api.routes.base_crud import generate_crud_router
from api.schemas import ServerBase, ServerResponse, ServerUpdate
from database.models import Server
router: APIRouter = generate_crud_router(
model=Server,
schema_response=ServerResponse,
schema_create=ServerBase,
schema_update=ServerUpdate,
identifier_field="server_name",
parameter_name="server_name",
enabled_methods=["get_all", "get_one", "create", "update", "delete"]
)
+14
View File
@@ -0,0 +1,14 @@
from fastapi import APIRouter
from api.routes.base_crud import generate_crud_router
from api.schemas import TariffBase, TariffResponse, TariffUpdate
from database.models import Tariff
router: APIRouter = generate_crud_router(
model=Tariff,
schema_response=TariffResponse,
schema_create=TariffBase,
schema_update=TariffUpdate,
identifier_field="name",
parameter_name="name",
enabled_methods=["get_all", "get_one", "create", "update", "delete"]
)
+54
View File
@@ -0,0 +1,54 @@
from fastapi import Depends, HTTPException, Path
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from api.routes.base_crud import generate_crud_router
from api.schemas.users import UserBase, UserResponse, UserUpdate
from database.models import User, Key
from api.depends import get_session, verify_admin_token
from handlers.keys.key_utils import delete_key_from_cluster
from database import get_servers, delete_user_data
from logger import logger
import asyncio
router = generate_crud_router(
model=User,
schema_response=UserResponse,
schema_create=UserBase,
schema_update=UserUpdate,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "get_by_email", "create", "update"]
)
@router.delete("/{tg_id}", response_model=dict)
async def delete_user(
tg_id: int = Path(..., description="Telegram ID пользователя"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
try:
result = await session.execute(
select(Key.email, Key.client_id).where(Key.tg_id == tg_id)
)
key_records = result.all()
async def delete_keys_from_servers():
try:
servers = await get_servers(session=session)
tasks = []
for email, client_id in key_records:
for cluster_id in servers:
tasks.append(delete_key_from_cluster(cluster_id, email, client_id, session))
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
logger.error(f"[DELETE] Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
await delete_keys_from_servers()
await delete_user_data(session, tg_id)
return {"detail": f"Пользователь {tg_id} и его ключи успешно удалены."}
except Exception as e:
logger.error(f"[DELETE] Ошибка при удалении пользователя {tg_id}: {e}")
raise HTTPException(status_code=500, detail="Ошибка при удалении пользователя")
+8
View File
@@ -0,0 +1,8 @@
from .users import UserBase, UserResponse, UserUpdate
from .keys import KeyResponse, KeyDetailsResponse
from .tariffs import TariffResponse, TariffUpdate, TariffBase
from .servers import ServerResponse, ServerBase, ServerUpdate
from .coupons import CouponResponse, CouponUsageResponse, CouponBase, CouponUpdate
from .referrals import ReferralResponse
from .gifts import GiftResponse, GiftUsageResponse
from .misc import ManualBanResponse, TemporaryDataResponse, BlockedUserResponse, TrackingSourceResponse, PaymentResponse, NotificationResponse
+54
View File
@@ -0,0 +1,54 @@
from pydantic import BaseModel, Field, model_validator
from typing import Optional
from datetime import datetime
class CouponBase(BaseModel):
code: str
amount: Optional[int] = Field(default=None)
usage_limit: int
usage_count: int = 0
is_used: bool = False
days: Optional[int] = Field(default=None)
@model_validator(mode="after")
def check_exactly_one_of_amount_or_days(self) -> "CouponBase":
has_amount = getattr(self, "amount", None) is not None
has_days = getattr(self, "days", None) is not None
if has_amount == has_days:
raise ValueError("Coupon must have exactly one of: 'amount' or 'days'")
return self
class CouponResponse(CouponBase):
id: int
class Config:
from_attributes = True
class CouponUpdate(BaseModel):
code: Optional[str] = None
amount: Optional[int] = None
usage_limit: Optional[int] = None
usage_count: Optional[int] = None
is_used: Optional[bool] = None
days: Optional[int] = Field(default=None)
@model_validator(mode="after")
def validate_amount_or_days(self) -> "CouponUpdate":
if self.amount is None and self.days is None:
return self
if self.amount is not None and self.days is not None:
raise ValueError("Specify only one of: 'amount' or 'days'")
return self
class CouponUsageResponse(BaseModel):
coupon_id: int
user_id: int
used_at: datetime
class Config:
from_attributes = True
+32
View File
@@ -0,0 +1,32 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class GiftBase(BaseModel):
sender_tg_id: int
recipient_tg_id: Optional[int] = None
selected_months: int
expiry_time: datetime
gift_link: str
is_used: bool = False
is_unlimited: bool = False
max_usages: Optional[int] = None
tariff_id: Optional[int] = None
class GiftResponse(GiftBase):
gift_id: str
created_at: datetime
class Config:
from_attributes = True
class GiftUsageResponse(BaseModel):
gift_id: str
tg_id: int
used_at: datetime
class Config:
from_attributes = True
+71
View File
@@ -0,0 +1,71 @@
from pydantic import BaseModel
from typing import Optional
class KeyBase(BaseModel):
tg_id: int
client_id: str
email: Optional[str] = None
created_at: Optional[int] = None
expiry_time: int
key: Optional[str] = None
server_id: Optional[str] = None
remnawave_link: Optional[str] = None
tariff_id: Optional[int] = None
is_frozen: Optional[bool] = False
alias: Optional[str] = None
notified: Optional[bool] = False
notified_24h: Optional[bool] = False
class KeyResponse(KeyBase):
class Config:
from_attributes = True
class KeyDetailsResponse(BaseModel):
key: Optional[str]
remnawave_link: Optional[str]
server_id: Optional[str]
created_at: Optional[int]
expiry_time: Optional[int]
client_id: str
tg_id: int
email: Optional[str]
is_frozen: bool
balance: float
alias: Optional[str]
expiry_date: str
days_left_message: str
link: Optional[str]
cluster_name: Optional[str]
location_name: Optional[str]
tariff_id: Optional[int]
class Config:
from_attributes = True
class KeyUpdate(BaseModel):
email: Optional[str] = None
expiry_time: Optional[int] = None
key: Optional[str] = None
server_id: Optional[str] = None
remnawave_link: Optional[str] = None
tariff_id: Optional[int] = None
is_frozen: Optional[bool] = None
alias: Optional[str] = None
notified: Optional[bool] = None
notified_24h: Optional[bool] = None
class Config:
from_attributes = True
class KeyCreateRequest(BaseModel):
tg_id: int
cluster_id: str
tariff_id: int
email: Optional[str] = None
alias: Optional[str] = None
remnawave_link: Optional[str] = None
+105
View File
@@ -0,0 +1,105 @@
from pydantic import BaseModel
from typing import Optional, Union, Literal
from datetime import datetime
class PaymentBase(BaseModel):
tg_id: int
amount: float
payment_system: str
status: Literal["success", "pending", "failed"]
class PaymentResponse(PaymentBase):
id: int
created_at: datetime
class Config:
from_attributes = True
class ReferralResponse(BaseModel):
referred_tg_id: int
referrer_tg_id: int
reward_issued: bool = False
class Config:
from_attributes = True
class NotificationResponse(BaseModel):
tg_id: int
notification_type: str
last_notification_time: datetime
class Config:
from_attributes = True
class GiftBase(BaseModel):
sender_tg_id: int
recipient_tg_id: Optional[int] = None
selected_months: int
expiry_time: datetime
gift_link: str
is_used: bool = False
is_unlimited: bool = False
max_usages: Optional[int] = None
tariff_id: Optional[int] = None
class GiftResponse(GiftBase):
gift_id: str
created_at: datetime
class Config:
from_attributes = True
class GiftUsageResponse(BaseModel):
gift_id: str
tg_id: int
used_at: datetime
class Config:
from_attributes = True
class ManualBanResponse(BaseModel):
tg_id: int
banned_at: datetime
reason: str
banned_by: int
until: Optional[datetime] = None
class Config:
from_attributes = True
class TemporaryDataResponse(BaseModel):
tg_id: int
state: str
data: dict
updated_at: datetime
class Config:
from_attributes = True
class BlockedUserResponse(BaseModel):
tg_id: int
class Config:
from_attributes = True
class TrackingSourceResponse(BaseModel):
id: int
name: str
code: str
type: str
created_by: int
created_at: datetime
class Config:
from_attributes = True
+10
View File
@@ -0,0 +1,10 @@
from pydantic import BaseModel
class ReferralResponse(BaseModel):
referred_tg_id: int
referrer_tg_id: int
reward_issued: bool = False
class Config:
from_attributes = True
+36
View File
@@ -0,0 +1,36 @@
from pydantic import BaseModel
from typing import Optional
class ServerBase(BaseModel):
cluster_name: str
server_name: str
api_url: str
subscription_url: Optional[str] = None
inbound_id: str
panel_type: str
max_keys: Optional[int] = None
tariff_group: str
enabled: bool = True
class ServerResponse(ServerBase):
id: int
class Config:
from_attributes = True
class ServerUpdate(BaseModel):
cluster_name: Optional[str] = None
server_name: Optional[str] = None
api_url: Optional[str] = None
subscription_url: Optional[str] = None
inbound_id: Optional[str] = None
panel_type: Optional[str] = None
max_keys: Optional[int] = None
tariff_group: Optional[str] = None
enabled: Optional[bool] = None
class Config:
from_attributes = True
+38
View File
@@ -0,0 +1,38 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class TariffBase(BaseModel):
name: str
group_code: str
duration_days: int
price_rub: int
traffic_limit: Optional[int] = None
device_limit: Optional[int] = None
is_active: bool = True
subgroup_title: Optional[str] = None
class TariffResponse(TariffBase):
id: int
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class TariffUpdate(BaseModel):
name: Optional[str] = None
group_code: Optional[str] = None
duration_days: Optional[int] = None
price_rub: Optional[int] = None
traffic_limit: Optional[int] = None
device_limit: Optional[int] = None
is_active: Optional[bool] = None
subgroup_title: Optional[str] = None
class Config:
from_attributes = True
+37
View File
@@ -0,0 +1,37 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class UserBase(BaseModel):
tg_id: int
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
language_code: Optional[str] = None
is_bot: Optional[bool] = False
balance: float = 0.0
trial: int = 0
source_code: Optional[str] = None
class UserResponse(UserBase):
created_at: Optional[datetime]
updated_at: Optional[datetime]
class Config:
from_attributes = True
class UserUpdate(BaseModel):
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
language_code: Optional[str] = None
is_bot: Optional[bool] = None
balance: Optional[float] = None
trial: Optional[int] = None
source_code: Optional[str] = None
class Config:
from_attributes = True