d071269b8c
- Fix UUID resolution in monitoring and webhook services for multi-tariff mode - Update cabinet routes to properly resolve per-subscription UUIDs - Fix account merge service for multi-tariff subscription transfers - Update admin handlers (users, promo_offers, servers) for multi-subscription - Fix traffic, devices, servers, daily subscription modules - Update payment handlers (stars, yookassa) and purchase services - Fix broadcast, promocode, and subscription auto-purchase services - Add multi-subscription support to keyboards and localization - Fix CRUD operations for subscription queries - Resolve code quality issues (ruff linting)
758 lines
19 KiB
Python
758 lines
19 KiB
Python
"""Schemas for Admin Users management in cabinet."""
|
|
|
|
from datetime import datetime
|
|
from enum import StrEnum
|
|
from typing import Any
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class UserStatusEnum(StrEnum):
|
|
"""User status enum."""
|
|
|
|
ACTIVE = 'active'
|
|
BLOCKED = 'blocked'
|
|
DELETED = 'deleted'
|
|
|
|
|
|
class SubscriptionStatusEnum(StrEnum):
|
|
"""Subscription status enum."""
|
|
|
|
TRIAL = 'trial'
|
|
ACTIVE = 'active'
|
|
EXPIRED = 'expired'
|
|
DISABLED = 'disabled'
|
|
LIMITED = 'limited'
|
|
PENDING = 'pending'
|
|
|
|
|
|
class SortByEnum(StrEnum):
|
|
"""Sort options for users list."""
|
|
|
|
CREATED_AT = 'created_at'
|
|
BALANCE = 'balance'
|
|
TRAFFIC = 'traffic'
|
|
LAST_ACTIVITY = 'last_activity'
|
|
TOTAL_SPENT = 'total_spent'
|
|
PURCHASE_COUNT = 'purchase_count'
|
|
|
|
|
|
# === User Subscription Info ===
|
|
|
|
|
|
class TrafficPurchaseItem(BaseModel):
|
|
"""Individual traffic purchase record."""
|
|
|
|
id: int
|
|
traffic_gb: int
|
|
expires_at: datetime
|
|
created_at: datetime
|
|
days_remaining: int
|
|
is_expired: bool
|
|
|
|
|
|
class UserSubscriptionInfo(BaseModel):
|
|
"""User subscription information."""
|
|
|
|
id: int
|
|
status: str
|
|
is_trial: bool
|
|
start_date: datetime | None = None
|
|
end_date: datetime | None = None
|
|
traffic_limit_gb: int = 0
|
|
traffic_used_gb: float = 0.0
|
|
device_limit: int = 1
|
|
tariff_id: int | None = None
|
|
tariff_name: str | None = None
|
|
autopay_enabled: bool = False
|
|
is_active: bool = False
|
|
days_remaining: int = 0
|
|
purchased_traffic_gb: int = 0
|
|
traffic_purchases: list[TrafficPurchaseItem] = []
|
|
|
|
|
|
class UserPromoGroupInfo(BaseModel):
|
|
"""User promo group info."""
|
|
|
|
id: int
|
|
name: str
|
|
is_default: bool = False
|
|
|
|
|
|
# === User List ===
|
|
|
|
|
|
class UserListItem(BaseModel):
|
|
"""User item in list."""
|
|
|
|
id: int
|
|
telegram_id: int | None = None
|
|
username: str | None = None
|
|
first_name: str | None = None
|
|
last_name: str | None = None
|
|
full_name: str
|
|
status: str
|
|
balance_kopeks: int
|
|
balance_rubles: float
|
|
created_at: datetime
|
|
last_activity: datetime | None = None
|
|
|
|
# Subscription summary
|
|
has_subscription: bool = False
|
|
subscription_status: str | None = None
|
|
subscription_is_trial: bool = False
|
|
subscription_end_date: datetime | None = None
|
|
|
|
# Promo group
|
|
promo_group_id: int | None = None
|
|
promo_group_name: str | None = None
|
|
|
|
# Stats
|
|
total_spent_kopeks: int = 0
|
|
purchase_count: int = 0
|
|
|
|
# Restrictions
|
|
has_restrictions: bool = False
|
|
restriction_topup: bool = False
|
|
restriction_subscription: bool = False
|
|
|
|
|
|
class UsersListResponse(BaseModel):
|
|
"""Paginated list of users."""
|
|
|
|
users: list[UserListItem]
|
|
total: int
|
|
offset: int = 0
|
|
limit: int = 50
|
|
|
|
|
|
# === User Detail ===
|
|
|
|
|
|
class UserTransactionItem(BaseModel):
|
|
"""User transaction."""
|
|
|
|
id: int
|
|
type: str
|
|
amount_kopeks: int
|
|
amount_rubles: float
|
|
description: str | None = None
|
|
payment_method: str | None = None
|
|
is_completed: bool = True
|
|
created_at: datetime
|
|
|
|
|
|
class UserReferralInfo(BaseModel):
|
|
"""User referral info."""
|
|
|
|
referral_code: str
|
|
referrals_count: int = 0
|
|
total_earnings_kopeks: int = 0
|
|
commission_percent: int | None = None
|
|
referred_by_id: int | None = None
|
|
referred_by_username: str | None = None
|
|
|
|
|
|
class UserDetailResponse(BaseModel):
|
|
"""Detailed user information."""
|
|
|
|
id: int
|
|
telegram_id: int | None = None
|
|
username: str | None = None
|
|
first_name: str | None = None
|
|
last_name: str | None = None
|
|
full_name: str
|
|
status: str
|
|
language: str
|
|
balance_kopeks: int
|
|
balance_rubles: float
|
|
|
|
# Email (cabinet)
|
|
email: str | None = None
|
|
email_verified: bool = False
|
|
|
|
# Dates
|
|
created_at: datetime
|
|
updated_at: datetime | None = None
|
|
last_activity: datetime | None = None
|
|
cabinet_last_login: datetime | None = None
|
|
|
|
# Subscription (legacy single, kept for backward compat)
|
|
subscription: UserSubscriptionInfo | None = None
|
|
|
|
# All subscriptions (multi-tariff)
|
|
subscriptions: list[UserSubscriptionInfo] = []
|
|
|
|
# Promo group
|
|
promo_group: UserPromoGroupInfo | None = None
|
|
|
|
# Referral
|
|
referral: UserReferralInfo
|
|
|
|
# Stats
|
|
total_spent_kopeks: int = 0
|
|
purchase_count: int = 0
|
|
used_promocodes: int = 0
|
|
has_had_paid_subscription: bool = False
|
|
lifetime_used_traffic_bytes: int = 0
|
|
|
|
# Restrictions
|
|
restriction_topup: bool = False
|
|
restriction_subscription: bool = False
|
|
restriction_reason: str | None = None
|
|
|
|
# Promo offer
|
|
promo_offer_discount_percent: int = 0
|
|
promo_offer_discount_source: str | None = None
|
|
promo_offer_discount_expires_at: datetime | None = None
|
|
|
|
# Campaign
|
|
campaign_name: str | None = None
|
|
campaign_id: int | None = None
|
|
|
|
# Recent transactions
|
|
recent_transactions: list[UserTransactionItem] = []
|
|
|
|
# Remnawave UUID
|
|
remnawave_uuid: str | None = None
|
|
|
|
|
|
# === Panel Info ===
|
|
|
|
|
|
class UserPanelInfoResponse(BaseModel):
|
|
"""Panel info for user from Remnawave."""
|
|
|
|
found: bool = False
|
|
trojan_password: str | None = None
|
|
vless_uuid: str | None = None
|
|
ss_password: str | None = None
|
|
subscription_url: str | None = None
|
|
happ_link: str | None = None
|
|
used_traffic_bytes: int = 0
|
|
lifetime_used_traffic_bytes: int = 0
|
|
traffic_limit_bytes: int = 0
|
|
first_connected_at: datetime | None = None
|
|
online_at: datetime | None = None
|
|
last_connected_node_uuid: str | None = None
|
|
last_connected_node_name: str | None = None
|
|
|
|
|
|
# === Node Usage ===
|
|
|
|
|
|
class UserNodeUsageItem(BaseModel):
|
|
"""Per-node traffic usage item."""
|
|
|
|
node_uuid: str
|
|
node_name: str
|
|
country_code: str = ''
|
|
total_bytes: int
|
|
daily_bytes: list[int] = []
|
|
|
|
|
|
class UserNodeUsageResponse(BaseModel):
|
|
"""Node usage response with 30-day daily breakdown."""
|
|
|
|
items: list[UserNodeUsageItem]
|
|
categories: list[str] = []
|
|
period_days: int = 30
|
|
|
|
|
|
# === User Actions ===
|
|
|
|
|
|
class UpdateBalanceRequest(BaseModel):
|
|
"""Request to update user balance."""
|
|
|
|
amount_kopeks: int = Field(
|
|
..., ge=-2_000_000_000, le=2_000_000_000, description='Amount in kopeks (positive to add, negative to subtract)'
|
|
)
|
|
description: str = Field(default='Admin balance adjustment', max_length=500)
|
|
create_transaction: bool = Field(default=True, description='Create transaction record')
|
|
|
|
|
|
class UpdateBalanceResponse(BaseModel):
|
|
"""Response after balance update."""
|
|
|
|
success: bool
|
|
old_balance_kopeks: int
|
|
new_balance_kopeks: int
|
|
message: str
|
|
|
|
|
|
class UpdateSubscriptionRequest(BaseModel):
|
|
"""Request to update user subscription."""
|
|
|
|
action: str = Field(
|
|
..., description='Action: extend, shorten, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel'
|
|
)
|
|
|
|
# Target subscription (required in multi-tariff mode for non-create actions)
|
|
subscription_id: int | None = Field(None, description='Subscription ID to target (multi-tariff)')
|
|
|
|
# For extend action
|
|
days: int | None = Field(None, ge=1, le=3650, description='Days to extend')
|
|
|
|
# For set_end_date action
|
|
end_date: datetime | None = Field(None, description='New end date')
|
|
|
|
# For change_tariff action
|
|
tariff_id: int | None = Field(None, description='New tariff ID')
|
|
|
|
# For set_traffic action
|
|
traffic_limit_gb: int | None = Field(None, ge=0, description='New traffic limit in GB')
|
|
traffic_used_gb: float | None = Field(None, ge=0, description='Set traffic used in GB')
|
|
|
|
# For toggle_autopay
|
|
autopay_enabled: bool | None = Field(None, description='Enable/disable autopay')
|
|
|
|
# For add_traffic action
|
|
traffic_gb: int | None = Field(None, ge=1, description='Traffic GB to add')
|
|
|
|
# For remove_traffic action
|
|
traffic_purchase_id: int | None = Field(None, description='Traffic purchase ID to remove')
|
|
|
|
# For create new subscription
|
|
is_trial: bool | None = Field(None, description='Is trial subscription')
|
|
device_limit: int | None = Field(None, ge=1, description='Device limit')
|
|
|
|
|
|
class UpdateSubscriptionResponse(BaseModel):
|
|
"""Response after subscription update."""
|
|
|
|
success: bool
|
|
message: str
|
|
subscription: UserSubscriptionInfo | None = None
|
|
|
|
|
|
class UpdateUserStatusRequest(BaseModel):
|
|
"""Request to update user status."""
|
|
|
|
status: UserStatusEnum
|
|
reason: str | None = Field(None, max_length=500, description='Reason for status change')
|
|
|
|
|
|
class UpdateUserStatusResponse(BaseModel):
|
|
"""Response after status update."""
|
|
|
|
success: bool
|
|
old_status: str
|
|
new_status: str
|
|
message: str
|
|
|
|
|
|
class UpdateRestrictionsRequest(BaseModel):
|
|
"""Request to update user restrictions."""
|
|
|
|
restriction_topup: bool | None = Field(None, description='Block balance top-up')
|
|
restriction_subscription: bool | None = Field(None, description='Block subscription purchase/renewal')
|
|
restriction_reason: str | None = Field(None, max_length=500, description='Reason for restrictions')
|
|
|
|
|
|
class UpdateRestrictionsResponse(BaseModel):
|
|
"""Response after restrictions update."""
|
|
|
|
success: bool
|
|
restriction_topup: bool
|
|
restriction_subscription: bool
|
|
restriction_reason: str | None = None
|
|
message: str
|
|
|
|
|
|
class UpdatePromoGroupRequest(BaseModel):
|
|
"""Request to update user promo group."""
|
|
|
|
promo_group_id: int | None = Field(None, description='New promo group ID (null to remove)')
|
|
|
|
|
|
class UpdatePromoGroupResponse(BaseModel):
|
|
"""Response after promo group update."""
|
|
|
|
success: bool
|
|
old_promo_group_id: int | None = None
|
|
new_promo_group_id: int | None = None
|
|
promo_group_name: str | None = None
|
|
message: str
|
|
|
|
|
|
class UpdateReferralCommissionRequest(BaseModel):
|
|
"""Request to update user referral commission percent."""
|
|
|
|
commission_percent: int | None = Field(
|
|
None, ge=0, le=100, description='Referral commission percent (null for default)'
|
|
)
|
|
|
|
|
|
class UpdateReferralCommissionResponse(BaseModel):
|
|
"""Response after referral commission update."""
|
|
|
|
success: bool
|
|
old_commission_percent: int | None = None
|
|
new_commission_percent: int | None = None
|
|
message: str
|
|
|
|
|
|
class AssignReferrerRequest(BaseModel):
|
|
"""Request to manually assign a referrer to a user."""
|
|
|
|
referrer_id: int = Field(..., gt=0, description='ID of the referrer user')
|
|
|
|
|
|
class AssignReferrerResponse(BaseModel):
|
|
"""Response after referrer assignment."""
|
|
|
|
success: bool
|
|
old_referrer_id: int | None = None
|
|
new_referrer_id: int | None = None
|
|
message: str
|
|
|
|
|
|
class DeviceInfo(BaseModel):
|
|
"""Individual device info."""
|
|
|
|
hwid: str
|
|
platform: str = ''
|
|
device_model: str = ''
|
|
created_at: str | None = None
|
|
|
|
|
|
class UserDevicesResponse(BaseModel):
|
|
"""User devices from panel."""
|
|
|
|
devices: list[DeviceInfo] = []
|
|
total: int = 0
|
|
device_limit: int = 0
|
|
|
|
|
|
class DeleteDeviceResponse(BaseModel):
|
|
"""Response after device deletion."""
|
|
|
|
success: bool
|
|
message: str
|
|
deleted_hwid: str | None = None
|
|
|
|
|
|
class ResetDevicesResponse(BaseModel):
|
|
"""Response after resetting all devices."""
|
|
|
|
success: bool
|
|
message: str
|
|
deleted_count: int = 0
|
|
|
|
|
|
class DeleteUserRequest(BaseModel):
|
|
"""Request to delete user."""
|
|
|
|
soft_delete: bool = Field(default=True, description='Soft delete (mark as deleted) or hard delete')
|
|
reason: str | None = Field(None, max_length=500, description='Reason for deletion')
|
|
|
|
|
|
class DeleteUserResponse(BaseModel):
|
|
"""Response after user deletion."""
|
|
|
|
success: bool
|
|
message: str
|
|
|
|
|
|
# === Statistics ===
|
|
|
|
|
|
class UsersStatsResponse(BaseModel):
|
|
"""Users statistics."""
|
|
|
|
total_users: int = 0
|
|
active_users: int = 0
|
|
blocked_users: int = 0
|
|
deleted_users: int = 0
|
|
new_today: int = 0
|
|
new_week: int = 0
|
|
new_month: int = 0
|
|
|
|
# Subscription stats
|
|
users_with_subscription: int = 0
|
|
users_with_active_subscription: int = 0
|
|
users_with_trial: int = 0
|
|
users_with_expired_subscription: int = 0
|
|
|
|
# Financial stats
|
|
total_balance_kopeks: int = 0
|
|
total_balance_rubles: float = 0.0
|
|
avg_balance_kopeks: int = 0
|
|
|
|
# Activity stats
|
|
active_today: int = 0
|
|
active_week: int = 0
|
|
active_month: int = 0
|
|
|
|
|
|
# === Search ===
|
|
|
|
|
|
class UserSearchRequest(BaseModel):
|
|
"""Request for user search."""
|
|
|
|
query: str = Field(..., min_length=1, max_length=255)
|
|
search_by: list[str] = Field(
|
|
default=['telegram_id', 'username', 'first_name', 'last_name', 'email'], description='Fields to search in'
|
|
)
|
|
limit: int = Field(default=20, ge=1, le=100)
|
|
|
|
|
|
# === Tariffs for User ===
|
|
|
|
|
|
class PeriodPriceInfo(BaseModel):
|
|
"""Period price info."""
|
|
|
|
days: int
|
|
price_kopeks: int
|
|
price_rubles: float
|
|
|
|
|
|
class UserAvailableTariffItem(BaseModel):
|
|
"""Tariff available for user."""
|
|
|
|
id: int
|
|
name: str
|
|
description: str | None = None
|
|
is_active: bool = True
|
|
is_trial_available: bool = False
|
|
traffic_limit_gb: int = 0
|
|
device_limit: int = 1
|
|
tier_level: int = 1
|
|
display_order: int = 0
|
|
|
|
# Pricing
|
|
period_prices: list[PeriodPriceInfo] = []
|
|
is_daily: bool = False
|
|
daily_price_kopeks: int = 0
|
|
|
|
# Custom options
|
|
custom_days_enabled: bool = False
|
|
price_per_day_kopeks: int = 0
|
|
min_days: int = 1
|
|
max_days: int = 365
|
|
|
|
# Device limits
|
|
device_price_kopeks: int | None = None
|
|
max_device_limit: int | None = None
|
|
|
|
# Traffic topup
|
|
traffic_topup_enabled: bool = False
|
|
traffic_topup_packages: dict[str, int] = {}
|
|
max_topup_traffic_gb: int = 0
|
|
|
|
# Access info
|
|
is_available: bool = True # Available for this user's promo group
|
|
requires_promo_group: bool = False # Requires specific promo group
|
|
|
|
|
|
class UserAvailableTariffsResponse(BaseModel):
|
|
"""List of tariffs available for user."""
|
|
|
|
user_id: int
|
|
promo_group_id: int | None = None
|
|
promo_group_name: str | None = None
|
|
tariffs: list[UserAvailableTariffItem] = []
|
|
total: int = 0
|
|
|
|
# Current subscription tariff
|
|
current_tariff_id: int | None = None
|
|
current_tariff_name: str | None = None
|
|
|
|
|
|
# === Panel Sync ===
|
|
|
|
|
|
class PanelUserInfo(BaseModel):
|
|
"""User info from panel."""
|
|
|
|
uuid: str | None = None
|
|
short_uuid: str | None = None
|
|
username: str | None = None
|
|
status: str | None = None
|
|
expire_at: datetime | None = None
|
|
traffic_limit_gb: float = 0
|
|
traffic_used_gb: float = 0
|
|
device_limit: int = 1
|
|
subscription_url: str | None = None
|
|
active_squads: list[str] = []
|
|
|
|
|
|
class SyncFromPanelRequest(BaseModel):
|
|
"""Request to sync user from panel."""
|
|
|
|
update_subscription: bool = Field(default=True, description='Update subscription data')
|
|
update_traffic: bool = Field(default=True, description='Update traffic usage')
|
|
create_if_missing: bool = Field(
|
|
default=False, description='Create subscription if user exists in panel but not in bot'
|
|
)
|
|
|
|
|
|
class SyncFromPanelResponse(BaseModel):
|
|
"""Response after syncing from panel."""
|
|
|
|
success: bool
|
|
message: str
|
|
panel_user: PanelUserInfo | None = None
|
|
changes: dict[str, Any] = {}
|
|
errors: list[str] = []
|
|
|
|
|
|
class SyncToPanelRequest(BaseModel):
|
|
"""Request to sync user to panel."""
|
|
|
|
create_if_missing: bool = Field(default=True, description='Create user in panel if not exists')
|
|
update_status: bool = Field(default=True, description='Update user status in panel')
|
|
update_traffic_limit: bool = Field(default=True, description='Update traffic limit in panel')
|
|
update_expire_date: bool = Field(default=True, description='Update expire date in panel')
|
|
update_squads: bool = Field(default=True, description='Update connected squads in panel')
|
|
|
|
|
|
class SyncToPanelResponse(BaseModel):
|
|
"""Response after syncing to panel."""
|
|
|
|
success: bool
|
|
message: str
|
|
action: str = '' # created, updated, no_changes
|
|
panel_uuid: str | None = None
|
|
changes: dict[str, Any] = {}
|
|
errors: list[str] = []
|
|
|
|
|
|
class PanelSyncStatusResponse(BaseModel):
|
|
"""Panel sync status for user."""
|
|
|
|
user_id: int
|
|
telegram_id: int | None = None
|
|
remnawave_uuid: str | None = None
|
|
last_sync: datetime | None = None
|
|
|
|
# Bot data
|
|
bot_subscription_status: str | None = None
|
|
bot_subscription_end_date: datetime | None = None
|
|
bot_traffic_limit_gb: int = 0
|
|
bot_traffic_used_gb: float = 0
|
|
bot_device_limit: int = 0
|
|
bot_squads: list[str] = []
|
|
|
|
# Panel data (if available)
|
|
panel_found: bool = False
|
|
panel_status: str | None = None
|
|
panel_expire_at: datetime | None = None
|
|
panel_traffic_limit_gb: float = 0
|
|
panel_traffic_used_gb: float = 0
|
|
panel_device_limit: int = 0
|
|
panel_squads: list[str] = []
|
|
|
|
# Differences
|
|
has_differences: bool = False
|
|
differences: list[str] = []
|
|
|
|
|
|
# === Admin User Management Actions ===
|
|
|
|
|
|
class FullDeleteUserRequest(BaseModel):
|
|
"""Request for full user deletion (bot + panel)."""
|
|
|
|
delete_from_panel: bool = Field(default=True, description='Also delete user from Remnawave panel')
|
|
reason: str | None = Field(None, max_length=500, description='Reason for deletion')
|
|
|
|
|
|
class FullDeleteUserResponse(BaseModel):
|
|
"""Response after full user deletion."""
|
|
|
|
success: bool
|
|
message: str
|
|
deleted_from_bot: bool = False
|
|
deleted_from_panel: bool = False
|
|
panel_error: str | None = None
|
|
|
|
|
|
class ResetTrialRequest(BaseModel):
|
|
"""Request to reset user trial."""
|
|
|
|
reason: str | None = Field(None, max_length=500, description='Reason for trial reset')
|
|
|
|
|
|
class ResetTrialResponse(BaseModel):
|
|
"""Response after trial reset."""
|
|
|
|
success: bool
|
|
message: str
|
|
subscription_deleted: bool = False
|
|
has_used_trial_reset: bool = False
|
|
|
|
|
|
class ResetSubscriptionRequest(BaseModel):
|
|
"""Request to reset user subscription."""
|
|
|
|
deactivate_in_panel: bool = Field(default=True, description='Also deactivate in Remnawave panel')
|
|
reason: str | None = Field(None, max_length=500, description='Reason for subscription reset')
|
|
|
|
|
|
class ResetSubscriptionResponse(BaseModel):
|
|
"""Response after subscription reset."""
|
|
|
|
success: bool
|
|
message: str
|
|
subscription_deleted: bool = False
|
|
panel_deactivated: bool = False
|
|
panel_error: str | None = None
|
|
|
|
|
|
class DisableUserRequest(BaseModel):
|
|
"""Request to disable user."""
|
|
|
|
reason: str | None = Field(None, max_length=500, description='Reason for disabling')
|
|
|
|
|
|
class DisableUserResponse(BaseModel):
|
|
"""Response after user disable."""
|
|
|
|
success: bool
|
|
message: str
|
|
subscription_deactivated: bool = False
|
|
panel_deactivated: bool = False
|
|
user_blocked: bool = False
|
|
panel_error: str | None = None
|
|
|
|
|
|
# === Gifts ===
|
|
|
|
|
|
class AdminUserGiftItem(BaseModel):
|
|
"""Gift item for admin user detail view."""
|
|
|
|
id: int
|
|
token: str
|
|
status: str
|
|
tariff_name: str | None = None
|
|
period_days: int
|
|
device_limit: int = 1
|
|
amount_kopeks: int
|
|
payment_method: str | None = None
|
|
gift_recipient_type: str | None = None
|
|
gift_recipient_value: str | None = None
|
|
gift_message: str | None = None
|
|
buyer_user_id: int | None = None
|
|
buyer_username: str | None = None
|
|
buyer_full_name: str | None = None
|
|
receiver_user_id: int | None = None
|
|
receiver_username: str | None = None
|
|
receiver_full_name: str | None = None
|
|
created_at: datetime | None = None
|
|
paid_at: datetime | None = None
|
|
delivered_at: datetime | None = None
|
|
|
|
|
|
class AdminUserGiftsResponse(BaseModel):
|
|
"""Response with sent and received gifts for admin user detail."""
|
|
|
|
sent: list[AdminUserGiftItem] = []
|
|
received: list[AdminUserGiftItem] = []
|
|
sent_total: int = 0
|
|
received_total: int = 0
|