Migrate to Pydantic V2 (#1495)

* chore: update pydantic to version 2.10.2 and refactor model validators

* refactor: simplify field validators by removing unnecessary pre and always flags

* remove typing_extensions==4.9.0 from requirements.txt

* refactor: remove allow_reuse flag from status field validator

* refactor: simplify field validators by removing pre and always flags

* refactor: update user model imports and enhance account class with abstract method

* refactor: update model_config to use dictionary format in Admin and SubscriptionUserResponse classes

* fix typo in UserDataResetByNext

* change pre=True to mode="before"

* refactor: update validation methods and model configuration in User and Proxy classes

* change pre=False with mode="after"

* Migrated to Pydantic V2

* fix: custom subscriptions not workong

* some small changes

* add missing properties to example schema

* replace from_orm with model_validate

---------

Co-authored-by: MahdiButcher <madibutchercoding@gmail.com>
Co-authored-by: Mahdi Butcher <MahdiButcherCoding@gmail.com>
This commit is contained in:
Mohammad
2024-12-09 21:56:24 +03:30
committed by GitHub
parent afa6bc41c5
commit ea6a3d2eb6
24 changed files with 263 additions and 268 deletions

View File

@@ -613,7 +613,7 @@ def revoke_user_sub(db: Session, dbuser: User) -> User:
"""
dbuser.sub_revoked_at = datetime.utcnow()
user = UserResponse.from_orm(dbuser)
user = UserResponse.model_validate(dbuser)
for proxy_type, settings in user.proxies.copy().items():
settings.revoke()
user.proxies[proxy_type] = settings

View File

@@ -14,7 +14,7 @@ def validate_admin(db: Session, username: str, password: str) -> Optional[AdminV
return AdminValidationResult(username=username, is_sudo=True)
dbadmin = crud.get_admin(db, username)
if dbadmin and AdminInDB.from_orm(dbadmin).verify_password(password):
if dbadmin and AdminInDB.model_validate(dbadmin).verify_password(password):
return AdminValidationResult(username=dbadmin.username, is_sudo=dbadmin.is_sudo)
return None
@@ -40,7 +40,8 @@ def validate_dates(start: Optional[Union[str, datetime]], end: Optional[Union[st
"""Validate if start and end dates are correct and if end is after start."""
try:
if start:
start_date = start if isinstance(start, datetime) else datetime.fromisoformat(start).astimezone(timezone.utc)
start_date = start if isinstance(start, datetime) else datetime.fromisoformat(
start).astimezone(timezone.utc)
else:
start_date = datetime.now(timezone.utc) - timedelta(days=30)
if end:

View File

@@ -12,7 +12,7 @@ def remove_expired_users():
deleted_users = crud.autodelete_expired_users(db, USER_AUTODELETE_INCLUDE_LIMITED_ACCOUNTS)
for user in deleted_users:
report.user_deleted(user.username, SYSTEM_ADMIN, user_admin=Admin.from_orm(user.admin))
report.user_deleted(user.username, SYSTEM_ADMIN, user_admin=Admin.model_validate(user.admin))
logger.log(logging.INFO, "Expired user %s deleted." % user.username)

View File

@@ -25,7 +25,7 @@ def add_notification_reminders(db: Session, user: "User", now: datetime = dateti
if usage_percent >= percent:
if not get_notification_reminder(db, user.id, ReminderType.data_usage, threshold=percent):
report.data_usage_percent_reached(
db, usage_percent, UserResponse.from_orm(user),
db, usage_percent, UserResponse.model_validate(user),
user.id, user.expire, threshold=percent
)
break
@@ -37,17 +37,19 @@ def add_notification_reminders(db: Session, user: "User", now: datetime = dateti
if expire_days <= days_left:
if not get_notification_reminder(db, user.id, ReminderType.expiration_date, threshold=days_left):
report.expire_days_reached(
db, expire_days, UserResponse.from_orm(user),
db, expire_days, UserResponse.model_validate(user),
user.id, user.expire, threshold=days_left
)
break
def reset_user_by_next_report(db: Session, user: "User"):
user = reset_user_by_next(db, user)
xray.operations.update_user(user)
report.user_data_reset_by_next(user=UserResponse.from_orm(user), user_admin=user.admin)
report.user_data_reset_by_next(user=UserResponse.model_validate(user), user_admin=user.admin)
def review():
now = datetime.utcnow()
@@ -60,15 +62,15 @@ def review():
if (limited or expired) and user.next_plan is not None:
if user.next_plan is not None:
if user.next_plan.fire_on_either:
reset_user_by_next_report(db, user)
continue
elif limited and expired:
reset_user_by_next_report(db, user)
continue
if limited:
status = UserStatus.limited
elif expired:
@@ -82,7 +84,7 @@ def review():
update_user_status(db, user, status)
report.status_change(username=user.username, status=status,
user=UserResponse.from_orm(user), user_admin=user.admin)
user=UserResponse.model_validate(user), user_admin=user.admin)
logger.info(f"User \"{user.username}\" status changed to {status}")
@@ -108,7 +110,7 @@ def review():
start_user_expire(db, user)
report.status_change(username=user.username, status=status,
user=UserResponse.from_orm(user), user_admin=user.admin)
user=UserResponse.model_validate(user), user_admin=user.admin)
logger.info(f"User \"{user.username}\" status changed to {status}")

View File

@@ -3,7 +3,7 @@ from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from passlib.context import CryptContext
from pydantic import BaseModel, validator
from pydantic import ConfigDict, field_validator, BaseModel
from app.db import Session, crud, get_db
from app.utils.jwt import get_admin_payload
@@ -21,12 +21,10 @@ class Token(BaseModel):
class Admin(BaseModel):
username: str
is_sudo: bool
telegram_id: Optional[int]
discord_webhook: Optional[str]
users_usage: Optional[int]
class Config:
orm_mode = True
telegram_id: Optional[int] = None
discord_webhook: Optional[str] = None
users_usage: Optional[int] = None
model_config = ConfigDict(from_attributes=True)
@classmethod
def get_admin(cls, token: str, db: Session):
@@ -47,7 +45,7 @@ class Admin(BaseModel):
if dbadmin.password_reset_at > payload.get("created_at"):
return
return cls.from_orm(dbadmin)
return cls.model_validate(dbadmin)
@classmethod
def get_current(cls,
@@ -60,13 +58,12 @@ class Admin(BaseModel):
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
return admin
@classmethod
def check_sudo_admin(cls,
db: Session = Depends(get_db),
token: str = Depends(oauth2_scheme)):
db: Session = Depends(get_db),
token: str = Depends(oauth2_scheme)):
admin = cls.get_admin(token, db)
if not admin:
raise HTTPException(
@@ -81,16 +78,18 @@ class Admin(BaseModel):
)
return admin
class AdminCreate(Admin):
password: str
telegram_id: Optional[int]
discord_webhook: Optional[str]
telegram_id: Optional[int] = None
discord_webhook: Optional[str] = None
@property
def hashed_password(self):
return pwd_context.hash(self.password)
@validator("discord_webhook")
@field_validator("discord_webhook")
@classmethod
def validate_discord_webhook(cls, value):
if value and not value.startswith("https://discord.com"):
raise ValueError("Discord webhook must start with 'https://discord.com'")
@@ -98,17 +97,18 @@ class AdminCreate(Admin):
class AdminModify(BaseModel):
password: Optional[str]
password: Optional[str] = None
is_sudo: bool
telegram_id: Optional[int]
discord_webhook: Optional[str]
telegram_id: Optional[int] = None
discord_webhook: Optional[str] = None
@property
def hashed_password(self):
if self.password:
return pwd_context.hash(self.password)
@validator("discord_webhook")
@field_validator("discord_webhook")
@classmethod
def validate_discord_webhook(cls, value):
if value and not value.startswith("https://discord.com"):
raise ValueError("Discord webhook must start with 'https://discord.com'")
@@ -126,6 +126,7 @@ class AdminInDB(Admin):
def verify_password(self, plain_password):
return pwd_context.verify(plain_password, self.hashed_password)
class AdminValidationResult(BaseModel):
username: str
is_sudo: bool

View File

@@ -1,7 +1,7 @@
from enum import Enum
from typing import List, Optional
from pydantic import BaseModel, Field
from pydantic import ConfigDict, BaseModel, Field
class NodeStatus(str, Enum):
@@ -26,18 +26,16 @@ class Node(BaseModel):
class NodeCreate(Node):
add_as_new_host: bool = True
class Config:
schema_extra = {
"example": {
"name": "DE node",
"address": "192.168.1.1",
"port": 62050,
"api_port": 62051,
"add_as_new_host": True,
"usage_coefficient": 1
}
model_config = ConfigDict(json_schema_extra={
"example": {
"name": "DE node",
"address": "192.168.1.1",
"port": 62050,
"api_port": 62051,
"add_as_new_host": True,
"usage_coefficient": 1
}
})
class NodeModify(Node):
@@ -47,32 +45,28 @@ class NodeModify(Node):
api_port: Optional[int] = Field(None, nullable=True)
status: Optional[NodeStatus] = Field(None, nullable=True)
usage_coefficient: Optional[float] = Field(None, nullable=True)
class Config:
schema_extra = {
"example": {
"name": "DE node",
"address": "192.168.1.1",
"port": 62050,
"api_port": 62051,
"status": "disabled",
"usage_coefficient": 1.0
}
model_config = ConfigDict(json_schema_extra={
"example": {
"name": "DE node",
"address": "192.168.1.1",
"port": 62050,
"api_port": 62051,
"status": "disabled",
"usage_coefficient": 1.0
}
})
class NodeResponse(Node):
id: int
xray_version: Optional[str]
xray_version: Optional[str] = None
status: NodeStatus
message: Optional[str]
class Config:
orm_mode = True
message: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class NodeUsageResponse(BaseModel):
node_id: Optional[int]
node_id: Optional[int] = None
node_name: str
uplink: int
downlink: int

View File

@@ -4,7 +4,7 @@ from enum import Enum
from typing import Optional, Union
from uuid import UUID, uuid4
from pydantic import BaseModel, Field, validator
from pydantic import field_validator, ConfigDict, BaseModel, Field
from app.utils.system import random_password
from xray_api.types.account import (
@@ -53,10 +53,10 @@ class ProxyTypes(str, Enum):
return ShadowsocksSettings
class ProxySettings(BaseModel):
class ProxySettings(BaseModel, use_enum_values=True):
@classmethod
def from_dict(cls, proxy_type: ProxyTypes, _dict: dict):
return ProxyTypes(proxy_type).settings_model.parse_obj(_dict)
return ProxyTypes(proxy_type).settings_model.model_validate(_dict)
def dict(self, *, no_obj=False, **kwargs):
if no_obj:
@@ -154,11 +154,9 @@ class ProxyHost(BaseModel):
fragment_setting: Optional[str] = Field(None, nullable=True)
noise_setting: Optional[str] = Field(None, nullable=True)
random_user_agent: Union[bool, None] = None
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
@validator("remark", pre=False, always=True)
@field_validator("remark", mode="after")
def validate_remark(cls, v):
try:
v.format_map(FormatVariables())
@@ -167,7 +165,7 @@ class ProxyHost(BaseModel):
return v
@validator("address", pre=False, always=True)
@field_validator("address", mode="after")
def validate_address(cls, v):
try:
v.format_map(FormatVariables())
@@ -176,7 +174,8 @@ class ProxyHost(BaseModel):
return v
@validator("fragment_setting", check_fields=False)
@field_validator("fragment_setting", check_fields=False)
@classmethod
def validate_fragment(cls, v):
if v and not FRAGMENT_PATTERN.match(v):
raise ValueError(
@@ -184,7 +183,8 @@ class ProxyHost(BaseModel):
)
return v
@validator("noise_setting", check_fields=False)
@field_validator("noise_setting", check_fields=False)
@classmethod
def validate_noise(cls, v):
if v:
if not NOISE_PATTERN.match(v):

View File

@@ -1,17 +1,16 @@
import re
import secrets
from datetime import datetime
from enum import Enum
from typing import Dict, List, Optional, Union
import random
import secrets
from pydantic import BaseModel, Field, validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app import xray
from app.models.proxy import ProxySettings, ProxyTypes
from app.models.admin import Admin
from app.utils.jwt import create_subscription_token
from app.models.proxy import ProxySettings, ProxyTypes
from app.subscription.share import generate_v2ray_links
from app.utils.jwt import create_subscription_token
from config import XRAY_SUBSCRIPTION_PATH, XRAY_SUBSCRIPTION_URL_PREFIX
USERNAME_REGEXP = re.compile(r"^(?=\w{3,32}\b)[a-zA-Z0-9-_@.]+(?:_[a-zA-Z0-9-_@.]+)*$")
@@ -48,14 +47,14 @@ class UserDataLimitResetStrategy(str, Enum):
month = "month"
year = "year"
class NextPlanModel(BaseModel):
data_limit: Optional[int]
expire: Optional[int]
data_limit: Optional[int] = None
expire: Optional[int] = None
add_remaining_traffic: bool = False
fire_on_either: bool = True
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
class User(BaseModel):
proxies: Dict[ProxyTypes, ProxySettings] = {}
@@ -75,10 +74,10 @@ class User(BaseModel):
on_hold_timeout: Optional[Union[datetime, None]] = Field(None, nullable=True)
auto_delete_in_days: Optional[int] = Field(None, nullable=True)
next_plan: Optional[NextPlanModel] = Field(None, nullable=True)
@validator("proxies", pre=True, always=True)
@field_validator("proxies", mode="before")
def validate_proxies(cls, v, values, **kwargs):
if not v:
raise ValueError("Each user needs at least one proxy")
@@ -88,7 +87,8 @@ class User(BaseModel):
for proxy_type in v
}
@validator("username", check_fields=False)
@field_validator("username", check_fields=False)
@classmethod
def validate_username(cls, v):
if not USERNAME_REGEXP.match(v):
raise ValueError(
@@ -96,13 +96,14 @@ class User(BaseModel):
)
return v
@validator("note", check_fields=False)
@field_validator("note", check_fields=False)
@classmethod
def validate_note(cls, v):
if v and len(v) > 500:
raise ValueError("User's note can be a maximum of 500 character")
raise ValueError("User's note can be a maximum of 500 character", mode="before")
return v
@validator("on_hold_expire_duration", "on_hold_timeout", pre=True, always=True)
@field_validator("on_hold_expire_duration", "on_hold_timeout")
def validate_timeout(cls, v, values):
# Check if expire is 0 or None and timeout is not 0 or None
if (v in (0, None)):
@@ -113,34 +114,32 @@ class User(BaseModel):
class UserCreate(User):
username: str
status: UserStatusCreate = None
class Config:
schema_extra = {
"example": {
"username": "user1234",
"proxies": {
"vmess": {"id": "35e4e39c-7d5c-4f4b-8b71-558e4f37ff53"},
"vless": {},
},
"inbounds": {
"vmess": ["VMess TCP", "VMess Websocket"],
"vless": ["VLESS TCP REALITY", "VLESS GRPC REALITY"],
},
"next_plan": {
"data_limit": 0,
"expire": 0,
"add_remaining_traffic": False,
"fire_on_either": True
},
"expire": 0,
model_config = ConfigDict(json_schema_extra={
"example": {
"username": "user1234",
"proxies": {
"vmess": {"id": "35e4e39c-7d5c-4f4b-8b71-558e4f37ff53"},
"vless": {},
},
"inbounds": {
"vmess": ["VMess TCP", "VMess Websocket"],
"vless": ["VLESS TCP REALITY", "VLESS GRPC REALITY"],
},
"next_plan": {
"data_limit": 0,
"data_limit_reset_strategy": "no_reset",
"status": "active",
"note": "",
"on_hold_timeout": "2023-11-03T20:30:00",
"on_hold_expire_duration": 0,
}
"expire": 0,
"add_remaining_traffic": False,
"fire_on_either": True
},
"expire": 0,
"data_limit": 0,
"data_limit_reset_strategy": "no_reset",
"status": "active",
"note": "",
"on_hold_timeout": "2023-11-03T20:30:00",
"on_hold_expire_duration": 0,
}
})
@property
def excluded_inbounds(self):
@@ -153,9 +152,9 @@ class UserCreate(User):
return excluded
@validator("inbounds", pre=True, always=True)
@field_validator("inbounds", mode="before")
def validate_inbounds(cls, inbounds, values, **kwargs):
proxies = values.get("proxies", [])
proxies = values.data.get("proxies", [])
# delete inbounds that are for protocols not activated
for proxy_type in inbounds.copy():
@@ -182,16 +181,10 @@ class UserCreate(User):
return inbounds
@validator("status", pre=True, always=True)
def validate_status(cls, value):
if not value or value not in UserStatusCreate.__members__:
return UserStatusCreate.active # Set to the default if not valid
return value
@validator("status", pre=True, always=True, allow_reuse=True)
@field_validator("status", mode="before")
def validate_status(cls, status, values):
on_hold_expire = values.get("on_hold_expire_duration")
expire = values.get("expire")
on_hold_expire = values.data.get("on_hold_expire_duration")
expire = values.data.get("expire")
if status == UserStatusCreate.on_hold:
if (on_hold_expire == 0 or on_hold_expire is None):
raise ValueError("User cannot be on hold without a valid on_hold_expire_duration.")
@@ -203,33 +196,31 @@ class UserCreate(User):
class UserModify(User):
status: UserStatusModify = None
data_limit_reset_strategy: UserDataLimitResetStrategy = None
class Config:
schema_extra = {
"example": {
"proxies": {
"vmess": {"id": "35e4e39c-7d5c-4f4b-8b71-558e4f37ff53"},
"vless": {},
},
"inbounds": {
"vmess": ["VMess TCP", "VMess Websocket"],
"vless": ["VLESS TCP REALITY", "VLESS GRPC REALITY"],
},
"next_plan": {
"data_limit": 0,
"expire": 0,
"add_remaining_traffic": False,
"fire_on_either": True
},
"expire": 0,
model_config = ConfigDict(json_schema_extra={
"example": {
"proxies": {
"vmess": {"id": "35e4e39c-7d5c-4f4b-8b71-558e4f37ff53"},
"vless": {},
},
"inbounds": {
"vmess": ["VMess TCP", "VMess Websocket"],
"vless": ["VLESS TCP REALITY", "VLESS GRPC REALITY"],
},
"next_plan": {
"data_limit": 0,
"data_limit_reset_strategy": "no_reset",
"status": "active",
"note": "",
"on_hold_timeout": "2023-11-03T20:30:00",
"on_hold_expire_duration": 0,
}
"expire": 0,
"add_remaining_traffic": False,
"fire_on_either": True
},
"expire": 0,
"data_limit": 0,
"data_limit_reset_strategy": "no_reset",
"status": "active",
"note": "",
"on_hold_timeout": "2023-11-03T20:30:00",
"on_hold_expire_duration": 0,
}
})
@property
def excluded_inbounds(self):
@@ -242,7 +233,7 @@ class UserModify(User):
return excluded
@validator("inbounds", pre=True, always=True)
@field_validator("inbounds", mode="before")
def validate_inbounds(cls, inbounds, values, **kwargs):
# check with inbounds, "proxies" is optional on modifying
# so inbounds particularly can be modified
@@ -258,7 +249,7 @@ class UserModify(User):
return inbounds
@validator("proxies", pre=True, always=True)
@field_validator("proxies", mode="before")
def validate_proxies(cls, v):
return {
proxy_type: ProxySettings.from_dict(
@@ -266,10 +257,10 @@ class UserModify(User):
for proxy_type in v
}
@validator("status", pre=True, always=True, allow_reuse=True)
@field_validator("status", mode="before")
def validate_status(cls, status, values):
on_hold_expire = values.get("on_hold_expire_duration")
expire = values.get("expire")
on_hold_expire = values.data.get("on_hold_expire_duration")
expire = values.data.get("expire")
if status == UserStatusCreate.on_hold:
if (on_hold_expire == 0 or on_hold_expire is None):
raise ValueError("User cannot be on hold without a valid on_hold_expire_duration.")
@@ -289,29 +280,27 @@ class UserResponse(User):
proxies: dict
excluded_inbounds: Dict[ProxyTypes, List[str]] = {}
admin: Optional[Admin]
admin: Optional[Admin] = None
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
@validator("links", pre=False, always=True)
def validate_links(cls, v, values, **kwargs):
if not v:
return generate_v2ray_links(
values.get("proxies", {}), values.get("inbounds", {}), extra_data=values, reverse=False,
@model_validator(mode="after")
def validate_links(self):
if not self.links:
self.links = generate_v2ray_links(
self.proxies, self.inbounds, extra_data=self.model_dump(), reverse=False,
)
return v
return self
@validator("subscription_url", pre=False, always=True)
def validate_subscription_url(cls, v, values, **kwargs):
if not v:
@model_validator(mode="after")
def validate_subscription_url(self):
if not self.subscription_url:
salt = secrets.token_hex(8)
url_prefix = (XRAY_SUBSCRIPTION_URL_PREFIX).replace('*', salt)
token = create_subscription_token(values["username"])
return f"{url_prefix}/{XRAY_SUBSCRIPTION_PATH}/{token}"
return v
token = create_subscription_token(self.username)
self.subscription_url = f"{url_prefix}/{XRAY_SUBSCRIPTION_PATH}/{token}"
return self
@validator("proxies", pre=True, always=True)
@field_validator("proxies", mode="before")
def validate_proxies(cls, v, values, **kwargs):
if isinstance(v, list):
v = {p.type: p.settings for p in v}
@@ -319,28 +308,12 @@ class UserResponse(User):
class SubscriptionUserResponse(UserResponse):
class Config:
orm_mode = True
fields = {
field: {"include": True} for field in [
"username",
"status",
"expire",
"data_limit",
"data_limit_reset_strategy",
"used_traffic",
"lifetime_used_traffic",
"proxies",
"created_at",
"sub_updated_at",
"online_at",
"links",
"subscription_url",
"sub_updated_at",
"sub_last_user_agent",
"online_at",
]
}
admin: Admin | None = Field(default=None, exclude=True)
excluded_inbounds: Dict[ProxyTypes, List[str]] | None = Field(None, exclude=True)
note: str | None = Field(None, exclude=True)
inbounds: Dict[ProxyTypes, List[str]] | None = Field(None, exclude=True)
auto_delete_in_days: int | None = Field(None, exclude=True)
model_config = ConfigDict(from_attributes=True)
class UsersResponse(BaseModel):
@@ -349,7 +322,7 @@ class UsersResponse(BaseModel):
class UserUsageResponse(BaseModel):
node_id: Union[int, None]
node_id: Union[int, None] = None
node_name: str
used_traffic: int
@@ -358,5 +331,6 @@ class UserUsagesResponse(BaseModel):
username: str
usages: List[UserUsageResponse]
class UsersUsagesResponse(BaseModel):
usages: List[UserUsageResponse]

View File

@@ -1,6 +1,6 @@
from typing import Dict, List, Optional, Union
from typing import Dict, List, Optional
from pydantic import BaseModel, Field, validator
from pydantic import field_validator, ConfigDict, BaseModel, Field
from app import xray
from app.models.proxy import ProxyTypes
@@ -21,33 +21,36 @@ class UserTemplate(BaseModel):
class UserTemplateCreate(UserTemplate):
class Config:
schema_extra = {
"example": {
"name": "my template 1",
"inbounds": {"vmess": ["VMESS_INBOUND"], "vless": ["VLESS_INBOUND"]},
"data_limit": 0,
"expire_duration": 0,
}
model_config = ConfigDict(json_schema_extra={
"example": {
"name": "my template 1",
"username_prefix": None,
"username_suffix": None,
"inbounds": {"vmess": ["VMESS_INBOUND"], "vless": ["VLESS_INBOUND"]},
"data_limit": 0,
"expire_duration": 0,
}
})
class UserTemplateModify(UserTemplate):
class Config:
schema_extra = {
"example": {
"name": "my template 1",
"inbounds": {"vmess": ["VMESS_INBOUND"], "vless": ["VLESS_INBOUND"]},
"data_limit": 0,
"expire_duration": 0,
}
model_config = ConfigDict(json_schema_extra={
"example": {
"name": "my template 1",
"username_prefix": None,
"username_suffix": None,
"inbounds": {"vmess": ["VMESS_INBOUND"], "vless": ["VLESS_INBOUND"]},
"data_limit": 0,
"expire_duration": 0,
}
})
class UserTemplateResponse(UserTemplate):
id: int
@validator("inbounds", pre=True)
@field_validator("inbounds", mode="before")
@classmethod
def validate_inbounds(cls, v):
final = {}
inbound_tags = [i.tag for i in v]
@@ -59,6 +62,4 @@ class UserTemplateResponse(UserTemplate):
else:
final[protocol] = [inbound["tag"]]
return final
class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)

View File

@@ -53,7 +53,7 @@ def user_subscription(
user_agent: str = Header(default="")
):
"""Provides a subscription link based on the user agent (Clash, V2Ray, etc.)."""
user: UserResponse = UserResponse.from_orm(dbuser)
user: UserResponse = UserResponse.model_validate(dbuser)
accept_header = request.headers.get("Accept", "")
if "text/html" in accept_header:
@@ -159,7 +159,7 @@ def user_subscription_with_client_type(
user_agent: str = Header(default="")
):
"""Provides a subscription link based on the specified client type (e.g., Clash, V2Ray)."""
user: UserResponse = UserResponse.from_orm(dbuser)
user: UserResponse = UserResponse.model_validate(dbuser)
response_headers = {
"content-disposition": f'attachment; filename="{user.username}"',

View File

@@ -63,7 +63,7 @@ def add_user(
raise HTTPException(status_code=409, detail="User already exists")
bg.add_task(xray.operations.add_user, dbuser=dbuser)
user = UserResponse.from_orm(dbuser)
user = UserResponse.model_validate(dbuser)
report.user_created(user=user, user_id=dbuser.id, by=admin, user_admin=dbuser.admin)
logger.info(f'New user "{dbuser.username}" added')
return user
@@ -75,7 +75,7 @@ def get_user(dbuser: UserResponse = Depends(get_validated_user)):
return dbuser
@router.put("/user/{username}", response_model=UserResponse, responses={400: responses._400,403: responses._403, 404: responses._404})
@router.put("/user/{username}", response_model=UserResponse, responses={400: responses._400, 403: responses._403, 404: responses._404})
def modify_user(
modified_user: UserModify,
bg: BackgroundTasks,
@@ -97,7 +97,7 @@ def modify_user(
- **on_hold_timeout**: New UTC timestamp for when `on_hold` status should start or end. Only applicable if status is changed to 'on_hold'.
- **on_hold_expire_duration**: New duration (in seconds) for how long the user should stay in `on_hold` status. Only applicable if status is changed to 'on_hold'.
- **next_plan**: Next user plan (resets after use).
Note: Fields set to `null` or omitted will not be modified.
"""
@@ -110,7 +110,7 @@ def modify_user(
old_status = dbuser.status
dbuser = crud.update_user(db, dbuser, modified_user)
user = UserResponse.from_orm(dbuser)
user = UserResponse.model_validate(dbuser)
if user.status in [UserStatus.active, UserStatus.on_hold]:
bg.add_task(xray.operations.update_user, dbuser=dbuser)
@@ -168,7 +168,7 @@ def reset_user_data_usage(
if dbuser.status in [UserStatus.active, UserStatus.on_hold]:
bg.add_task(xray.operations.add_user, dbuser=dbuser)
user = UserResponse.from_orm(dbuser)
user = UserResponse.model_validate(dbuser)
bg.add_task(
report.user_data_usage_reset, user=user, user_admin=dbuser.admin, by=admin
)
@@ -189,7 +189,7 @@ def revoke_user_subscription(
if dbuser.status in [UserStatus.active, UserStatus.on_hold]:
bg.add_task(xray.operations.update_user, dbuser=dbuser)
user = UserResponse.from_orm(dbuser)
user = UserResponse.model_validate(dbuser)
bg.add_task(
report.user_subscription_revoked, user=user, user_admin=dbuser.admin, by=admin
)
@@ -253,7 +253,7 @@ def reset_users_data_usage(
return {"detail": "Users successfully reset."}
@router.get("/user/{username}/usage", response_model=UserUsagesResponse,responses={403: responses._403, 404: responses._404})
@router.get("/user/{username}/usage", response_model=UserUsagesResponse, responses={403: responses._403, 404: responses._404})
def get_user_usage(
dbuser: UserResponse = Depends(get_validated_user),
start: str = "",
@@ -268,7 +268,7 @@ def get_user_usage(
return {"usages": usages, "username": dbuser.username}
@router.post("/user/{username}/active-next", response_model=UserResponse,responses={403: responses._403, 404: responses._404})
@router.post("/user/{username}/active-next", response_model=UserResponse, responses={403: responses._403, 404: responses._404})
def active_next_plan(
bg: BackgroundTasks,
db: Session = Depends(get_db),
@@ -279,14 +279,14 @@ def active_next_plan(
if (dbuser is None or dbuser.next_plan is None):
raise HTTPException(
status_code=404,
detail=f"User doesn't have next plan",
)
status_code=404,
detail=f"User doesn't have next plan",
)
if dbuser.status in [UserStatus.active, UserStatus.on_hold]:
bg.add_task(xray.operations.add_user, dbuser=dbuser)
user = UserResponse.from_orm(dbuser)
user = UserResponse.model_validate(dbuser)
bg.add_task(
report.user_data_reset_by_next, user=user, user_admin=dbuser.admin,
)
@@ -326,7 +326,7 @@ def set_owner(
raise HTTPException(status_code=404, detail="Admin not found")
dbuser = crud.set_owner(db, dbuser, new_admin)
user = UserResponse.from_orm(dbuser)
user = UserResponse.model_validate(dbuser)
logger.info(f'{user.username}"owner successfully set to{admin.username}')

View File

@@ -1,6 +1,8 @@
import copy
import json
from random import choice
from uuid import UUID
from app.utils.helpers import yml_uuid_representer
import yaml
from jinja2.exceptions import TemplateNotFound
@@ -42,6 +44,8 @@ class ClashConfiguration(object):
def render(self, reverse=False):
if reverse:
self.data['proxies'].reverse()
yaml.add_representer(UUID, yml_uuid_representer)
return yaml.dump(
yaml.load(
render_template(
@@ -49,6 +53,7 @@ class ClashConfiguration(object):
{"conf": self.data, "proxy_remarks": self.proxy_remarks}
),
Loader=yaml.SafeLoader
),
sort_keys=False,
allow_unicode=True,

View File

@@ -314,7 +314,7 @@ def process_inbounds_and_tags(
remark=host["remark"].format_map(format_variables),
address=address.format_map(format_variables),
inbound=host_inbound,
settings=settings.dict(no_obj=True)
settings=settings.model_dump()
)
return conf.render(reverse=reverse)

View File

@@ -2,6 +2,7 @@ import copy
import json
from random import choice
from app.utils.helpers import UUIDEncoder
from jinja2.exceptions import TemplateNotFound
from app.subscription.funcs import get_grpc_gun
@@ -65,7 +66,7 @@ class SingBoxConfiguration(str):
if reverse:
self.config["outbounds"].reverse()
return json.dumps(self.config, indent=4)
return json.dumps(self.config, indent=4,cls=UUIDEncoder)
@staticmethod
def tls_config(sni=None, fp=None, tls=None, pbk=None,

View File

@@ -6,6 +6,7 @@ from random import choice
from typing import Union
from urllib.parse import quote
from uuid import UUID
from app.utils.helpers import UUIDEncoder
from jinja2.exceptions import TemplateNotFound
@@ -517,7 +518,7 @@ class V2rayJsonConfig(str):
def render(self, reverse=False):
if reverse:
self.config.reverse()
return json.dumps(self.config, indent=4)
return json.dumps(self.config, indent=4,cls=UUIDEncoder)
@staticmethod
def tls_config(sni=None, fp=None, alpn=None, ais: bool = False) -> dict:

View File

@@ -331,7 +331,7 @@ def edit_command(call: types.CallbackQuery):
'❌ User not found.',
show_alert=True
)
user = UserResponse.from_orm(db_user)
user = UserResponse.model_validate(db_user)
mem_store.set(f'{call.message.chat.id}:username', username)
mem_store.set(f'{call.message.chat.id}:data_limit', db_user.data_limit)
@@ -619,7 +619,7 @@ def edit_note_step(message: types.Message):
last_note = db_user.note
modify = UserModify(note=note)
db_user = crud.update_user(db, db_user, modify)
user = UserResponse.from_orm(db_user)
user = UserResponse.model_validate(db_user)
bot.reply_to(
message, get_user_info_text(db_user), parse_mode="html",
reply_markup=BotKeyboard.user_menu(user_info={'status': user.status, 'username': user.username}))
@@ -647,7 +647,7 @@ def user_command(call: types.CallbackQuery):
db_user = crud.get_user(db, username)
if not db_user:
return bot.answer_callback_query(call.id, '❌ User not found.', show_alert=True)
user = UserResponse.from_orm(db_user)
user = UserResponse.model_validate(db_user)
bot.edit_message_text(
get_user_info_text(db_user),
call.message.chat.id, call.message.message_id, parse_mode="HTML",
@@ -674,7 +674,7 @@ def links_command(call: types.CallbackQuery):
if not db_user:
return bot.answer_callback_query(call.id, "User not found!", show_alert=True)
user = UserResponse.from_orm(db_user)
user = UserResponse.model_validate(db_user)
text = f"<code>{user.subscription_url}</code>\n\n\n"
for link in user.links:
@@ -702,7 +702,7 @@ def genqr_command(call: types.CallbackQuery):
if not db_user:
return bot.answer_callback_query(call.id, "User not found!", show_alert=True)
user = UserResponse.from_orm(db_user)
user = UserResponse.model_validate(db_user)
bot.answer_callback_query(call.id, "Generating QR code...")
@@ -783,12 +783,12 @@ def template_charge_command(call: types.CallbackQuery):
template = crud.get_user_template(db, template_id)
if not template:
return bot.answer_callback_query(call.id, "Template not found!", show_alert=True)
template = UserTemplateResponse.from_orm(template)
template = UserTemplateResponse.model_validate(template)
db_user = crud.get_user(db, username)
if not db_user:
return bot.answer_callback_query(call.id, "User not found!", show_alert=True)
user = UserResponse.from_orm(db_user)
user = UserResponse.model_validate(db_user)
if (user.data_limit and not user.expire) or (not user.data_limit and user.expire):
expire = (datetime.fromtimestamp(db_user.expire) if db_user.expire else today)
expire += relativedelta(seconds=template.expire_duration)
@@ -917,7 +917,7 @@ def add_user_from_template(call: types.CallbackQuery):
template = crud.get_user_template(db, template_id)
if not template:
return bot.answer_callback_query(call.id, "Template not found!", show_alert=True)
template = UserTemplateResponse.from_orm(template)
template = UserTemplateResponse.model_validate(template)
text = get_template_info_text(template)
if template.username_prefix:
@@ -975,7 +975,7 @@ def random_username(call: types.CallbackQuery):
if template.username_suffix:
username += template.username_suffix
template = UserTemplateResponse.from_orm(template)
template = UserTemplateResponse.model_validate(template)
mem_store.set(f"{call.message.chat.id}:username", username)
mem_store.set(f"{call.message.chat.id}:data_limit", template.data_limit)
mem_store.set(f"{call.message.chat.id}:protocols", template.inbounds)
@@ -1066,7 +1066,7 @@ def add_user_from_template_username_step(message: types.Message):
wait_msg = bot.send_message(message.chat.id, '❌ Username already exists.')
schedule_delete_message(message.chat.id, wait_msg.message_id, message.message_id)
return bot.register_next_step_handler(wait_msg, add_user_from_template_username_step)
template = UserTemplateResponse.from_orm(template)
template = UserTemplateResponse.model_validate(template)
mem_store.set(f"{message.chat.id}:username", username)
mem_store.set(f"{message.chat.id}:data_limit", template.data_limit)
mem_store.set(f"{message.chat.id}:protocols", template.inbounds)
@@ -1570,7 +1570,7 @@ def confirm_user_command(call: types.CallbackQuery):
crud.reset_user_data_usage(db, db_user)
if db_user.status in [UserStatus.active, UserStatus.on_hold]:
xray.operations.add_user(db_user)
user = UserResponse.from_orm(db_user)
user = UserResponse.model_validate(db_user)
bot.edit_message_text(
get_user_info_text(db_user),
call.message.chat.id,
@@ -1608,12 +1608,12 @@ def confirm_user_command(call: types.CallbackQuery):
template = crud.get_user_template(db, template_id)
if not template:
return bot.answer_callback_query(call.id, "Template not found!", show_alert=True)
template = UserTemplateResponse.from_orm(template)
template = UserTemplateResponse.model_validate(template)
db_user = crud.get_user(db, username)
if not db_user:
return bot.answer_callback_query(call.id, "User not found!", show_alert=True)
user = UserResponse.from_orm(db_user)
user = UserResponse.model_validate(db_user)
inbounds = template.inbounds
proxies = {p.type.value: p.settings for p in db_user.proxies}
@@ -1731,10 +1731,10 @@ def confirm_user_command(call: types.CallbackQuery):
proxies=proxies,
inbounds=inbounds
)
last_user = UserResponse.from_orm(db_user)
last_user = UserResponse.model_validate(db_user)
db_user = crud.update_user(db, db_user, modify)
user = UserResponse.from_orm(db_user)
user = UserResponse.model_validate(db_user)
if user.status == UserStatus.active:
xray.operations.update_user(db_user)
@@ -1856,7 +1856,7 @@ def confirm_user_command(call: types.CallbackQuery):
with GetDB() as db:
db_user = crud.create_user(db, new_user)
proxies = db_user.proxies
user = UserResponse.from_orm(db_user)
user = UserResponse.model_validate(db_user)
xray.operations.add_user(db_user)
if mem_store.get(f"{call.message.chat.id}:is_bulk", False):
schedule_delete_message(call.message.chat.id, call.message.id)
@@ -2115,7 +2115,7 @@ def confirm_user_command(call: types.CallbackQuery):
if not db_user:
return bot.answer_callback_query(call.id, text=f"User not found!", show_alert=True)
db_user = crud.revoke_user_sub(db, db_user)
user = UserResponse.from_orm(db_user)
user = UserResponse.model_validate(db_user)
bot.answer_callback_query(call.id, "✅ Subscription Successfully Revoked!")
bot.edit_message_text(
get_user_info_text(db_user),
@@ -2156,7 +2156,7 @@ def search_user(message: types.Message):
if not db_user:
bot.reply_to(message, f'❌ User «{username}» not found.')
continue
user = UserResponse.from_orm(db_user)
user = UserResponse.model_validate(db_user)
bot.reply_to(
message,
get_user_info_text(db_user),

View File

@@ -21,7 +21,7 @@ def usage_command(message):
if not dbuser:
return bot.reply_to(message, "No user found with this username")
user = UserResponse.from_orm(dbuser)
user = UserResponse.model_validate(dbuser)
statuses = {
'active': '',

View File

@@ -47,7 +47,7 @@ def time_to_string(time: dt):
def get_user_info_text(db_user: User) -> str:
user: UserResponse = UserResponse.from_orm(db_user)
user: UserResponse = UserResponse.model_validate(db_user)
data_limit = readable_size(user.data_limit) if user.data_limit else "Unlimited"
used_traffic = readable_size(user.used_traffic) if user.used_traffic else "-"
data_left = readable_size(user.data_limit - user.used_traffic) if user.data_limit else "-"
@@ -58,7 +58,8 @@ def get_user_info_text(db_user: User) -> str:
online_at = time_to_string(user.online_at) if user.online_at else "-"
sub_updated_at = time_to_string(user.sub_updated_at) if user.sub_updated_at else "-"
if user.status == UserStatus.on_hold:
expiry_text = f"⏰ <b>On Hold Duration:</b> <code>{on_hold_duration} days</code> (auto start at <code>{on_hold_timeout}</code>)"
expiry_text = f"⏰ <b>On Hold Duration:</b> <code>{on_hold_duration} days</code> (auto start at <code>{
on_hold_timeout}</code>)"
else:
expiry_text = f"📅 <b>Expiry Date:</b> <code>{expiry_date}</code> ({time_left})"
return f"""\
@@ -71,7 +72,7 @@ def get_user_info_text(db_user: User) -> str:
{expiry_text}
🔌 <b>Online at:</b> {online_at}
🔄 <b>Subscription updated at:</b> {sub_updated_at}
🔄 <b>Subscription updated at:</b> {sub_updated_at}
📱 <b>Subscription last agent:</b> <blockquote>{user.sub_last_user_agent or "-"}</blockquote>
📝 <b>Note:</b> <blockquote expandable>{user.note or "empty"}</blockquote>

View File

@@ -1,4 +1,6 @@
from datetime import datetime as dt
import json
from uuid import UUID
def calculate_usage_percent(used_traffic: int, data_limit: int) -> float:
@@ -7,3 +9,15 @@ def calculate_usage_percent(used_traffic: int, data_limit: int) -> float:
def calculate_expiration_days(expire: int) -> int:
return (dt.fromtimestamp(expire) - dt.utcnow()).days
def yml_uuid_representer(dumper, data):
return dumper.represent_scalar('tag:yaml.org,2002:str', str(data))
class UUIDEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, UUID):
# if the obj is uuid, we simply return the value of uuid
return obj.hex
return json.JSONEncoder.default(self, obj)

View File

@@ -101,7 +101,7 @@ class UserDataResetByNext(UserNotification):
class UserSubscriptionRevoked(UserNotification):
action = Notification.Type = Notification.Type.subscription_revoked
action: Notification.Type = Notification.Type.subscription_revoked
by: Admin
user: UserResponse

View File

@@ -57,7 +57,7 @@ def _alter_inbound_user(api: XRayAPI, inbound_tag: str, account: Account):
def add_user(dbuser: "DBUser"):
user = UserResponse.from_orm(dbuser)
user = UserResponse.model_validate(dbuser)
email = f"{dbuser.id}.{dbuser.username}"
for proxy_type, inbound_tags in user.inbounds.items():
@@ -101,7 +101,7 @@ def remove_user(dbuser: "DBUser"):
def update_user(dbuser: "DBUser"):
user = UserResponse.from_orm(dbuser)
user = UserResponse.model_validate(dbuser)
email = f"{dbuser.id}.{dbuser.username}"
active_inbounds = []

View File

@@ -29,7 +29,7 @@ def get_link(
in order to work correctly.
"""
with GetDB() as db:
user: UserResponse = UserResponse.from_orm(utils.get_user(db, username))
user: UserResponse = UserResponse.model_validate(utils.get_user(db, username))
print(user.subscription_url)
@@ -53,7 +53,7 @@ def get_config(
otherwise will be shown in the terminal.
"""
with GetDB() as db:
user: UserResponse = UserResponse.from_orm(utils.get_user(db, username))
user: UserResponse = UserResponse.model_validate(utils.get_user(db, username))
conf: str = generate_subscription(
user=user, config_format=config_format.name, as_base64=as_base64
)

View File

@@ -24,7 +24,7 @@ psutil==5.9.4
pyOpenSSL==24.2.1
PySocks==1.7.1
pyTelegramBotAPI==4.9.0
pydantic==1.10.16
pydantic==2.10.3
python-dateutil==2.8.2
python-decouple==3.6
python-dotenv==0.21.1
@@ -37,8 +37,7 @@ rsa==4.9
sniffio==1.3.0
starlette==0.40.0
typer==0.7.0
typing_extensions==4.9.0
urllib3==1.26.19
uvicorn==0.27.0.post1
websocket-client==1.7.0
websockets==12.0
websockets==12.0

View File

@@ -1,4 +1,4 @@
from abc import ABC, abstractproperty
from abc import ABC, abstractmethod
from enum import Enum
from uuid import UUID
@@ -19,8 +19,9 @@ class Account(BaseModel, ABC):
email: str
level: int = 0
@abstractproperty
def message(self) -> TypedMessage:
@property
@abstractmethod
def message(self):
pass
def __repr__(self) -> str: