From c06be7b5f2cf0484180298a4cb9eafd9418baab2 Mon Sep 17 00:00:00 2001 From: hexdev Date: Mon, 3 Aug 2026 11:15:11 +0700 Subject: [PATCH] feat(/me): subscription data + rw controls --- config.py | 1 + external/rw.py | 70 +++++++++++++++++++++---------------- routes/__init__.py | 4 +-- routes/auth.py | 1 - routes/users.py | 74 ++++++++++++++++++++++++++++++++++++++++ routes/users/__init__.py | 3 -- routes/users/me.py | 22 ------------ schemas/common.py | 5 +++ schemas/devices.py | 8 +++++ schemas/user.py | 15 +++++--- services/users.py | 12 +------ 11 files changed, 142 insertions(+), 73 deletions(-) create mode 100644 routes/users.py delete mode 100644 routes/users/__init__.py delete mode 100644 routes/users/me.py create mode 100644 schemas/common.py create mode 100644 schemas/devices.py diff --git a/config.py b/config.py index 5fad9ba..34d1893 100644 --- a/config.py +++ b/config.py @@ -22,6 +22,7 @@ class Settings(BaseSettings): pally_token: str = Field() remnawave_base_url: str = Field() + remnawave_sub_url: str = Field() remnawave_token: str = Field() remnawave_default_squads_raw: str = Field(alias="REMNAWAVE_DEFAULT_SQUADS_UUIDS") diff --git a/external/rw.py b/external/rw.py index 7d73246..e92ed99 100644 --- a/external/rw.py +++ b/external/rw.py @@ -431,6 +431,27 @@ def get_sdk() -> RemnawaveSDK | None: return RemnawaveSDK(base_url=cfg.remnawave_base_url, token=cfg.remnawave_token) +async def get_rw_user( + sdk: RemnawaveSDK, telegram_id: int | None = None, username: str | None = None +) -> RWUserInfo | None: + rw_user = None + if telegram_id is not None: + rw_user = await get_user_by_telegram_id(sdk, telegram_id) + + if rw_user is None: + rw_username = _build_rw_username(telegram_id=telegram_id, username=username) + if rw_username is None: + logger.warning( + "Cannot build username for telegram_id=%s username=%s", + telegram_id, + username, + ) + return None + + rw_user = await get_user_by_username(sdk, rw_username) + return rw_user + + async def sync_subscription_by_telegram_id( *, expires_at: datetime, @@ -443,38 +464,29 @@ async def sync_subscription_by_telegram_id( logger.info("Skipping RW subscription sync: RemnaWave is not configured") return False - rw_user = None - if telegram_id is not None: - rw_user = await get_user_by_telegram_id(sdk, telegram_id) - + rw_user = await get_rw_user(sdk, telegram_id=telegram_id, username=username) if rw_user is None: rw_username = _build_rw_username(telegram_id=telegram_id, username=username) - if rw_username is None: - logger.warning( - "Skipping RW subscription sync: cannot build username for telegram_id=%s username=%s", - telegram_id, - username, - ) - return False - - rw_user = await get_user_by_username(sdk, rw_username) - if rw_user is None: - rw_user = await create_user( - sdk=sdk, - username=rw_username, - telegram_id=telegram_id, - expire_at=expires_at, - hwid_device_limit=None if devices == 0 else devices, - squad_uuids=cfg.remnawave_default_squads, - ) - if rw_user is None: - logger.warning( - "Failed to create RW user for telegram_id=%s username=%s", - telegram_id, - username, - ) - return False + rw_user = await create_user( + sdk=sdk, + username=rw_username, + telegram_id=telegram_id, + expire_at=expires_at, + hwid_device_limit=None if devices == 0 else devices, + squad_uuids=cfg.remnawave_default_squads, + ) + if rw_user is None: + logger.warning( + "Failed to create RW user for telegram_id=%s username=%s", + telegram_id, + username, + ) + return False expire_synced = await update_expire_at(sdk, rw_user.uuid, expires_at) devices_synced = await set_hwid_limit(sdk, rw_user.uuid, devices) return expire_synced and devices_synced + + +def build_subscription_link(short_uuid: str): + return cfg.remnawave_sub_url.rstrip("/") + f"/{short_uuid}" diff --git a/routes/__init__.py b/routes/__init__.py index 9039afe..67ede13 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -4,12 +4,12 @@ from .auth import router as auth_router from .orders import router as orders_router from .payments import payment_routers from .plans import router as plans_router -from .users import users_routers +from .users import router as users_routers routers: list[APIRouter] = [ auth_router, plans_router, orders_router, + users_routers, *payment_routers, - *users_routers, ] diff --git a/routes/auth.py b/routes/auth.py index 5eb61b0..e54009f 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -40,7 +40,6 @@ async def signup(req: UserRegistration, session: AsyncSession = Depends(get_db)) UserInfo( username=user.username, telegram_id=user.telegram_id, - subscription=None, referal_code=user.referal_code, ).model_dump(), status_code=201, diff --git a/routes/users.py b/routes/users.py new file mode 100644 index 0000000..bddafd9 --- /dev/null +++ b/routes/users.py @@ -0,0 +1,74 @@ +from fastapi import APIRouter, Depends, HTTPException + +from core.deps import get_auth_context +from external.rw import build_subscription_link, delete_hwid, get_hwid_list, get_rw_user, get_sdk +from schemas.common import OperationData +from schemas.devices import Device +from schemas.dto import AuthContext +from schemas.user import SubscriptionData, UserInfo + +router = APIRouter(prefix="/users") + + +@router.get("/me", response_model=UserInfo) +async def get_me(ctx: AuthContext = Depends(get_auth_context)): + return UserInfo( + username=ctx.user.username, + telegram_id=ctx.user.telegram_id, + referal_code=ctx.user.referal_code, + ) + + +@router.get("/subscription") +async def get_subscription(ctx: AuthContext = Depends(get_auth_context)): + sub = ctx.user.subscription + if not sub: + return SubscriptionData( + devices=None, + expires_at=None, + addon_ids=[], + subscription_link=None, + ) + rw_user = await get_rw_user(get_sdk(), ctx.user.telegram_id, ctx.user.username) + + return SubscriptionData( + devices=sub.devices, + expires_at=sub.expires_at, + addon_ids=[a.id for a in sub.addons], + subscription_link=build_subscription_link(rw_user.short_uuid), + ) + + +@router.get("/subscription/hwid", response_model=list[Device]) +async def get_hwid(ctx: AuthContext = Depends(get_auth_context)): + rw_user = await get_rw_user(get_sdk(), ctx.user.telegram_id, ctx.user.username) + if not rw_user: + raise HTTPException(403, detail="RW user not found.") + hwid_list = await get_hwid_list(get_sdk(), user_uuid=rw_user.uuid) + devices = [] + + if not hwid_list: + return devices + + for device in hwid_list: + devices.append( + Device( + os=device.platform, + model=device.device_model, + client=device.user_agent.split("/")[0], + hwid=device.hwid, + ) + ) + + return devices + + +@router.delete("/subscription/hwid") +async def delete_hwid_endpoint(hwid: str, ctx: AuthContext = Depends(get_auth_context)): + rw_user = await get_rw_user(get_sdk(), ctx.user.telegram_id, ctx.user.username) + if not rw_user: + raise HTTPException(403, detail="RW user not found.") + + success = await delete_hwid(get_sdk(), rw_user.uuid, hwid) + + return OperationData(success=success) diff --git a/routes/users/__init__.py b/routes/users/__init__.py deleted file mode 100644 index f543f29..0000000 --- a/routes/users/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .me import router as me_router - -users_routers = [me_router] diff --git a/routes/users/me.py b/routes/users/me.py deleted file mode 100644 index 7478f83..0000000 --- a/routes/users/me.py +++ /dev/null @@ -1,22 +0,0 @@ -from fastapi import APIRouter, Depends - -from core.deps import get_auth_context -from schemas.dto import AuthContext -from schemas.user import SubscriptionData, UserInfo - -router = APIRouter(prefix="/users/me") - - -@router.get("", response_model=UserInfo) -async def get_me(ctx: AuthContext = Depends(get_auth_context)): - sub_data = SubscriptionData( - devices=ctx.user.subscription.devices, - expires_at=ctx.user.subscription.expires_at, - addon_ids=[a.id for a in ctx.user.subscription.addons], - ) - return UserInfo( - username=ctx.user.username, - telegram_id=ctx.user.telegram_id, - subscription=sub_data, - referal_code=ctx.user.referal_code, - ) diff --git a/schemas/common.py b/schemas/common.py new file mode 100644 index 0000000..6c41537 --- /dev/null +++ b/schemas/common.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, Field + + +class OperationData(BaseModel): + success: bool = Field(False) diff --git a/schemas/devices.py b/schemas/devices.py new file mode 100644 index 0000000..09b621b --- /dev/null +++ b/schemas/devices.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel, Field + + +class Device(BaseModel): + os: str | None = Field(None) + model: str | None = Field(None) + client: str | None = Field(None) + hwid: str = Field() diff --git a/schemas/user.py b/schemas/user.py index 45edb04..77963f1 100644 --- a/schemas/user.py +++ b/schemas/user.py @@ -1,16 +1,21 @@ from datetime import datetime -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, computed_field class SubscriptionData(BaseModel): - devices: int = Field() - expires_at: datetime = Field() - addon_ids: list[str] = Field() + devices: int | None = Field(None) + expires_at: datetime | None = Field(None) + addon_ids: list[str] = Field(default_factory=list) + subscription_link: str | None = Field(None) + + @property + @computed_field + def has_subscription(self) -> bool: + return all([self.devices, self.expires_at, self.subscription_link, self.addon_ids]) class UserInfo(BaseModel): username: str | None = Field(None) telegram_id: str | None = Field(None) - subscription: SubscriptionData | None = Field(None) referal_code: str = Field() diff --git a/services/users.py b/services/users.py index 12cfc81..6463692 100644 --- a/services/users.py +++ b/services/users.py @@ -3,7 +3,7 @@ from db.models.users import User from repositories.sessions import SessionsRepository from schemas.login import UserLogin from schemas.providers import ProvidersType -from schemas.user import SubscriptionData, UserInfo +from schemas.user import UserInfo async def authorize_user( @@ -15,15 +15,6 @@ async def authorize_user( await sessions_repo.create(user_id=user.id, refresh_token_hash=refresh_token_hash, iss=iss) - sub = ( - SubscriptionData( - devices=user.subscription.devices, - expires_at=user.subscription.expires_at, - addon_ids=[a.id for a in user.subscription.addons], - ) - if user.subscription - else None - ) return UserLogin( access_token=key_pair.access_token, refresh_token=key_pair.refresh_token, @@ -31,7 +22,6 @@ async def authorize_user( username=user.username, telegram_id=user.telegram_id, referal_code=user.referal_code, - subscription=sub, ), expires_at=key_pair.expires_at, )