diff --git a/app/__init__.py b/app/__init__.py index 9750ee216..8524df615 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -3,7 +3,6 @@ from app.scheduler import scheduler from app.version import __version__ - __all__ = [ "__version__", "create_app", diff --git a/app/app_factory.py b/app/app_factory.py index 858913f3c..e7aba2c52 100644 --- a/app/app_factory.py +++ b/app/app_factory.py @@ -6,8 +6,8 @@ from sqlalchemy.exc import DBAPIError from app.lifecycle import on_shutdown, on_startup -from app.nats import is_nats_enabled from app.middlewares import setup_middleware +from app.nats import is_nats_enabled from app.nats.message import MessageTopic from app.nats.router import router from app.settings import handle_settings_message @@ -16,7 +16,6 @@ from app.version import __version__ from config import runtime_settings, subscription_env_settings - logger = get_logger("app-factory") @@ -164,7 +163,7 @@ def validation_exception_handler(request: Request, exc: RequestValidationError): app.add_exception_handler(DBAPIError, database_operational_error_handler) - from app.operation.permissions import LimitExceeded, PermissionDenied # noqa: F401 + from app.operation.permissions import LimitExceeded, PermissionDenied @app.exception_handler(PermissionDenied) async def permission_denied_handler(request: Request, exc: PermissionDenied): diff --git a/app/core/abstract_core.py b/app/core/abstract_core.py index ee1da4fb1..f1a59a74a 100644 --- a/app/core/abstract_core.py +++ b/app/core/abstract_core.py @@ -29,7 +29,7 @@ def to_json(self) -> dict: @classmethod @abstractmethod - def from_json(cls, data: dict) -> "AbstractCore": + def from_json(cls, data: dict) -> AbstractCore: """Reconstruct the core config from a dictionary.""" raise NotImplementedError diff --git a/app/core/manager.py b/app/core/manager.py index 33007d576..e52822b2d 100644 --- a/app/core/manager.py +++ b/app/core/manager.py @@ -1,6 +1,7 @@ import json from asyncio import Lock from copy import deepcopy +from typing import ClassVar import nats from aiocache import cached @@ -26,7 +27,7 @@ class CoreManager: STATE_CACHE_KEY = "state" KV_BUCKET_NAME = "core_manager_state" - CORE_CLASSES = { + CORE_CLASSES: ClassVar[dict] = { CoreType.xray: XRayConfig, CoreType.wg: WireGuardConfig, } diff --git a/app/core/wireguard.py b/app/core/wireguard.py index 111a81b60..1e97f4d1c 100644 --- a/app/core/wireguard.py +++ b/app/core/wireguard.py @@ -5,7 +5,6 @@ from copy import deepcopy from ipaddress import ip_interface from pathlib import PosixPath -from typing import Union import commentjson @@ -20,7 +19,7 @@ class WireGuardConfig(dict): def __init__( self, - config: Union[dict, str, PosixPath] | None = None, + config: dict | str | PosixPath | None = None, exclude_inbound_tags: set[str] | None = None, fallbacks_inbound_tags: set[str] | None = None, skip_validation: bool = False, @@ -83,7 +82,7 @@ def _validate(self): addresses = self.get("address") if not isinstance(addresses, list): - raise ValueError("address must be a list") + raise TypeError("address must be a list") normalized_addresses: list[str] = [] for cidr in addresses: @@ -135,7 +134,7 @@ def to_json(self) -> dict: } @classmethod - def from_json(cls, data: dict) -> "WireGuardConfig": + def from_json(cls, data: dict) -> WireGuardConfig: instance = cls(config=data.get("config", {}), skip_validation=True) if "inbounds" in data: instance._inbounds = data["inbounds"] diff --git a/app/core/xray.py b/app/core/xray.py index 907c0afb0..f44adf171 100644 --- a/app/core/xray.py +++ b/app/core/xray.py @@ -4,7 +4,6 @@ import json from copy import deepcopy from pathlib import PosixPath -from typing import Union import commentjson @@ -24,7 +23,7 @@ def _protocols_from_inbounds_by_tag(inbounds_by_tag: dict[str, dict]) -> frozens class XRayConfig(dict): def __init__( self, - config: Union[dict, str, PosixPath] | None = None, + config: dict | str | PosixPath | None = None, exclude_inbound_tags: set[str] | None = None, fallbacks_inbound_tags: set[str] | None = None, skip_validation: bool = False, @@ -150,7 +149,7 @@ def _create_base_settings(self, inbound: dict) -> dict: def _is_unix_socket(inbound: dict) -> bool: """Return True if the inbound listens on a Unix domain socket instead of a TCP/UDP port.""" listen = inbound.get("listen", "") - return isinstance(listen, str) and (listen.startswith("/") or listen.startswith("@")) + return isinstance(listen, str) and (listen.startswith(("/", "@"))) def _handle_port_settings(self, inbound: dict, settings: dict): """Handle port settings for an inbound.""" @@ -254,7 +253,7 @@ def _handle_tcp_raw_settings(self, net_settings: dict, settings: dict, inbound_t settings["header_type"] = header.get("type", "none") if isinstance(path, str) or isinstance(host, str): - raise ValueError( + raise TypeError( f"Settings of {inbound_tag} for path and host must be list, not str\n" "https://xtls.github.io/config/transports/tcp.html#httpheaderobject" ) @@ -273,7 +272,7 @@ def _handle_ws_settings(self, net_settings: dict, settings: dict, inbound_tag: s settings["header_type"] = "" if isinstance(path, list) or isinstance(host, list): - raise ValueError( + raise TypeError( "Settings for path and host must be str, not list\n" "https://xtls.github.io/config/transports/websocket.html#websocketobject" ) @@ -547,7 +546,7 @@ def to_json(self) -> dict: } @classmethod - def from_json(cls, data: dict) -> "XRayConfig": + def from_json(cls, data: dict) -> XRayConfig: """Reconstruct the config from a dictionary.""" fallback_tags = data.get("fallbacks_inbound_tags") if fallback_tags is None: diff --git a/app/db/__init__.py b/app/db/__init__.py index 5cc091f93..b3d1e9592 100644 --- a/app/db/__init__.py +++ b/app/db/__init__.py @@ -1,16 +1,14 @@ from sqlalchemy.ext.asyncio import AsyncSession -from .base import Base, GetDB, get_db # noqa - - -from .models import JWT, System, User # noqa +from .base import Base, GetDB, get_db +from .models import JWT, System, User __all__ = [ - "GetDB", - "get_db", - "User", - "System", "JWT", - "Base", "AsyncSession", + "Base", + "GetDB", + "System", + "User", + "get_db", ] diff --git a/app/db/base.py b/app/db/base.py index db3d5cf0c..4acabeaa2 100644 --- a/app/db/base.py +++ b/app/db/base.py @@ -1,6 +1,6 @@ +from sqlalchemy import MetaData from sqlalchemy.ext.asyncio import AsyncAttrs, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass -from sqlalchemy import MetaData from config import database_settings diff --git a/app/db/compiles_types.py b/app/db/compiles_types.py index c9b8f43a6..be22fd3c7 100644 --- a/app/db/compiles_types.py +++ b/app/db/compiles_types.py @@ -1,11 +1,11 @@ -from sqlalchemy import BigInteger, String, Numeric, TypeDecorator -from sqlalchemy.sql.expression import FunctionElement +from sqlalchemy import BigInteger, Numeric, String, TypeDecorator from sqlalchemy.ext.compiler import compiles +from sqlalchemy.sql.expression import FunctionElement class CaseSensitiveString(String): def __init__(self, length=None): - super(CaseSensitiveString, self).__init__(length) + super().__init__(length) class SqliteCompatibleBigInteger(BigInteger): @@ -39,7 +39,7 @@ class EnumArray(TypeDecorator): impl = String def __init__(self, enum_cls, length=255): - super(EnumArray, self).__init__(length=length) + super().__init__(length=length) self.enum_cls = enum_cls def process_bind_param(self, value, dialect): @@ -63,7 +63,7 @@ class StringArray(TypeDecorator): impl = String def __init__(self, length=255, **kwargs): - super(StringArray, self).__init__(length=length, **kwargs) + super().__init__(length=length, **kwargs) def process_bind_param(self, value, dialect): """Convert list to a comma-separated string for storage.""" @@ -76,7 +76,7 @@ def process_result_value(self, value, dialect): if value is None: return set() if isinstance(value, str): - return set(v for v in value.split(",") if v) + return {v for v in value.split(",") if v} return set(value) diff --git a/app/db/crud/__init__.py b/app/db/crud/__init__.py index f59f23678..03024c778 100644 --- a/app/db/crud/__init__.py +++ b/app/db/crud/__init__.py @@ -1,17 +1,16 @@ from .admin import get_admin -from .core import get_core_config_by_id from .client_template import get_client_template_by_id +from .core import get_core_config_by_id from .group import get_group_by_id from .host import get_host_by_id from .node import get_node_by_id from .user import get_user from .user_template import get_user_template - __all__ = [ "get_admin", - "get_core_config_by_id", "get_client_template_by_id", + "get_core_config_by_id", "get_group_by_id", "get_host_by_id", "get_node_by_id", diff --git a/app/db/crud/admin.py b/app/db/crud/admin.py index 0ca097d51..d4d15be6b 100644 --- a/app/db/crud/admin.py +++ b/app/db/crud/admin.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from sqlalchemy import and_, case, delete, func, not_, select, update from sqlalchemy.exc import InvalidRequestError @@ -13,11 +13,11 @@ to_utc_for_filter, ) from app.db.models import ( - APIKey, Admin, AdminNotificationReminder, AdminRole, AdminUsageLogs, + APIKey, NodeUserUsage, ReminderType, User, @@ -187,10 +187,9 @@ async def update_admin(db: AsyncSession, db_admin: Admin, modified_admin: AdminM Returns: Admin: The updated admin object. """ - if modified_admin.status is not None: - if modified_admin.status != db_admin.status: - db_admin.status = modified_admin.status - db_admin.last_status_change = datetime.now(timezone.utc) + if modified_admin.status is not None and modified_admin.status != db_admin.status: + db_admin.status = modified_admin.status + db_admin.last_status_change = datetime.now(UTC) if modified_admin.data_limit is not None: db_admin.data_limit = modified_admin.data_limit if modified_admin.data_limit > 0 else None # Recompute limited/active based on new data_limit — never touch disabled @@ -203,10 +202,10 @@ async def update_admin(db: AsyncSession, db_admin: Admin, modified_admin: AdminM new_status = AdminStatus.limited if should_be_limited else AdminStatus.active if db_admin.status != new_status: db_admin.status = new_status - db_admin.last_status_change = datetime.now(timezone.utc) + db_admin.last_status_change = datetime.now(UTC) if modified_admin.password is not None: db_admin.hashed_password = await hash_password(modified_admin.password) - db_admin.password_reset_at = datetime.now(timezone.utc) + db_admin.password_reset_at = datetime.now(UTC) if modified_admin.role_id is not None: db_admin.role_id = modified_admin.role_id if modified_admin.permission_overrides is not None: @@ -615,7 +614,7 @@ async def update_admin_status(db: AsyncSession, db_admin: Admin, new_status: Adm Admin: The updated admin object. """ db_admin.status = new_status - db_admin.last_status_change = datetime.now(timezone.utc) + db_admin.last_status_change = datetime.now(UTC) await db.commit() await db.refresh(db_admin) await load_admin_attrs(db_admin) @@ -644,7 +643,7 @@ async def reset_admin_usage(db: AsyncSession, db_admin: Admin) -> Admin: # After reset, used_traffic = 0 so the admin is no longer limited if db_admin.status == AdminStatus.limited: db_admin.status = AdminStatus.active - db_admin.last_status_change = datetime.now(timezone.utc) + db_admin.last_status_change = datetime.now(UTC) await db.commit() await db.refresh(db_admin) @@ -743,7 +742,7 @@ async def get_admin_usages( async def update_owner_password(db: AsyncSession, owner: Admin, new_password: str) -> Admin: """Reset the owner's password. All DB work stays in the CRUD layer.""" owner.hashed_password = await hash_password(new_password) - owner.password_reset_at = datetime.now(timezone.utc) + owner.password_reset_at = datetime.now(UTC) await db.commit() await db.refresh(owner) await load_admin_attrs(owner) diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index a78134387..bc7bb66a1 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -1,5 +1,5 @@ import uuid -from datetime import datetime as dt, timezone as tz +from datetime import UTC, datetime as dt from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -110,7 +110,7 @@ async def revoke_api_key(db: AsyncSession, db_key: APIKey) -> tuple[str, APIKey] raw_key = f"pg_key_{raw_uuid}" db_key.key_hash = hash_api_key(raw_key) db_key.api_key_trimmed = f"pg_key_{raw_uuid[:3]}***{raw_uuid[-3:]}" - db_key.revoked_at = dt.now(tz.utc) + db_key.revoked_at = dt.now(UTC) db_key.status = APIKeyStatus.active await db.flush() await db.refresh(db_key) diff --git a/app/db/crud/bulk.py b/app/db/crud/bulk.py index a5be01663..dd7258204 100644 --- a/app/db/crud/bulk.py +++ b/app/db/crud/bulk.py @@ -1,5 +1,4 @@ -from datetime import datetime as dt, timezone as tz -from typing import Optional +from datetime import UTC, datetime as dt from sqlalchemy import and_, case, cast, delete, func, or_, select, text, update from sqlalchemy.dialects.postgresql import JSONB @@ -24,7 +23,7 @@ async def reset_all_users_data_usage( db: AsyncSession, - admin: Optional[Admin] = None, + admin: Admin | None = None, *, clean_chart_data: bool = False, ): @@ -81,7 +80,7 @@ async def disable_all_active_users(db: AsyncSession, admin: Admin | None = None) await db.execute( query.values( - {User.status: UserStatus.disabled, User.last_status_change: dt.now(tz.utc)}, + {User.status: UserStatus.disabled, User.last_status_change: dt.now(UTC)}, ) ) @@ -111,12 +110,12 @@ async def activate_all_disabled_users(db: AsyncSession, admin: Admin | None = No await db.execute( query_for_on_hold_users.values( - {User.status: UserStatus.on_hold, User.last_status_change: dt.now(tz.utc)}, + {User.status: UserStatus.on_hold, User.last_status_change: dt.now(UTC)}, ) ) await db.execute( query_for_active_users.values( - {User.status: UserStatus.active, User.last_status_change: dt.now(tz.utc)}, + {User.status: UserStatus.active, User.last_status_change: dt.now(UTC)}, ) ) @@ -308,7 +307,7 @@ async def update_users_expire(db: AsyncSession, bulk_model: BulkUser) -> tuple[l ).scalar_one_or_none() or 0 # Get database-specific datetime addition expression new_expire = get_datetime_add_expression(db, User.expire, bulk_model.amount) - current_time = dt.now(tz.utc) + current_time = dt.now(UTC) # First, get the users that will have status changes BEFORE updating status_change_conditions = or_( diff --git a/app/db/crud/general.py b/app/db/crud/general.py index a0affc11f..0816611a4 100644 --- a/app/db/crud/general.py +++ b/app/db/crud/general.py @@ -1,5 +1,4 @@ -from datetime import datetime, timezone, timedelta -from typing import Optional +from datetime import UTC, datetime, timedelta from sqlalchemy import String, func, or_, select, text from sqlalchemy.ext.asyncio import AsyncSession @@ -26,7 +25,7 @@ def _build_trunc_expression( db: AsyncSession, period: Period, column, - start: Optional[datetime] = None, + start: datetime | None = None, ): """ Builds the appropriate truncation SQL expression based on dialect and period. @@ -159,7 +158,7 @@ def _get_next_period_boundary(dt: datetime, period: Period) -> datetime: return dt -def get_complete_period_start_for_filter(start: Optional[datetime], period: Period) -> Optional[datetime]: +def get_complete_period_start_for_filter(start: datetime | None, period: Period) -> datetime | None: """ Convert start datetime to the first complete period boundary in UTC for DB filtering. @@ -175,7 +174,7 @@ def get_complete_period_start_for_filter(start: Optional[datetime], period: Peri return to_utc_for_filter(start) -def attach_timezone_to_period_start(row_dict: dict, target_tz, dialect: str = None) -> None: +def attach_timezone_to_period_start(row_dict: dict, target_tz, dialect: str | None = None) -> None: """ Attach timezone info to period_start in the row dictionary. @@ -207,7 +206,7 @@ def attach_timezone_to_period_start(row_dict: dict, target_tz, dialect: str = No "%Y-01-01 00:00:00", ]: try: - period_start = datetime.strptime(clean_str, fmt) + period_start = datetime.strptime(clean_str, fmt).replace(tzinfo=UTC) break except ValueError: continue @@ -238,7 +237,7 @@ def attach_timezone_to_period_start(row_dict: dict, target_tz, dialect: str = No row_dict["period_start"] = period_start -def to_utc_for_filter(dt: Optional[datetime]) -> Optional[datetime]: +def to_utc_for_filter(dt: datetime | None) -> datetime | None: """ Convert a timezone-aware datetime to UTC for database filtering. @@ -262,7 +261,7 @@ def to_utc_for_filter(dt: Optional[datetime]) -> Optional[datetime]: # Convert to UTC if dt.tzinfo is not None: - utc_dt = dt.astimezone(timezone.utc) + utc_dt = dt.astimezone(UTC) # Return as naive datetime (remove tzinfo) for database comparison return utc_dt.replace(tzinfo=None) diff --git a/app/db/crud/host.py b/app/db/crud/host.py index 2327756fd..638153fa7 100644 --- a/app/db/crud/host.py +++ b/app/db/crud/host.py @@ -1,10 +1,8 @@ import asyncio -from typing import List -from sqlalchemy import bindparam, delete, select -from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy import bindparam, delete, insert, select from sqlalchemy.dialects.mysql import insert as mysql_insert -from sqlalchemy import insert +from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import ProxyHost, ProxyInbound @@ -76,7 +74,7 @@ async def get_or_create_inbound(db: AsyncSession, inbound_tag: str) -> ProxyInbo return result[inbound_tag] -async def get_inbounds_not_in_tags(db: AsyncSession, excluded_tags: List[str]) -> List[ProxyInbound]: +async def get_inbounds_not_in_tags(db: AsyncSession, excluded_tags: list[str]) -> list[ProxyInbound]: """ Get all inbounds where the tag is not in the provided list of tags. @@ -92,7 +90,7 @@ async def get_inbounds_not_in_tags(db: AsyncSession, excluded_tags: List[str]) - return result.scalars().all() -async def remove_inbounds(db: AsyncSession, inbounds: List[ProxyInbound]) -> None: +async def remove_inbounds(db: AsyncSession, inbounds: list[ProxyInbound]) -> None: """ Remove a list of inbounds from the database. diff --git a/app/db/crud/hwid.py b/app/db/crud/hwid.py index bded00dbb..1c26d2bd7 100644 --- a/app/db/crud/hwid.py +++ b/app/db/crud/hwid.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import cast from sqlalchemy import bindparam, delete, func, insert, select, update @@ -38,7 +38,7 @@ async def register_user_hwid( device_model: str | None = None, ) -> None: """Insert a new HWID or update last_used_at if it already exists.""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) params = { "user_id": user_id, "hwid": hwid, diff --git a/app/db/crud/node.py b/app/db/crud/node.py index a3083e376..2dac73408 100644 --- a/app/db/crud/node.py +++ b/app/db/crud/node.py @@ -1,5 +1,4 @@ -from datetime import datetime, timezone -from typing import Optional +from datetime import UTC, datetime from sqlalchemy import and_, bindparam, case, delete, func, literal_column, or_, select, update from sqlalchemy.exc import InvalidRequestError @@ -53,7 +52,7 @@ async def load_node_attrs(node: Node): pass -async def get_node(db: AsyncSession, name: str) -> Optional[Node]: +async def get_node(db: AsyncSession, name: str) -> Node | None: """ Retrieves a node by its name. @@ -70,7 +69,7 @@ async def get_node(db: AsyncSession, name: str) -> Optional[Node]: return node -async def get_node_by_id(db: AsyncSession, node_id: int) -> Optional[Node]: +async def get_node_by_id(db: AsyncSession, node_id: int) -> Node | None: """ Retrieves a node by its ID. @@ -453,9 +452,7 @@ async def modify_node(db: AsyncSession, db_node: Node, modify: NodeModify) -> No if db_node.is_limited: db_node.status = NodeStatus.limited - elif db_node.status == NodeStatus.limited: - db_node.status = NodeStatus.connecting - elif db_node.status not in (NodeStatus.disabled, NodeStatus.limited): + elif db_node.status == NodeStatus.limited or db_node.status not in (NodeStatus.disabled, NodeStatus.limited): db_node.status = NodeStatus.connecting await db.commit() @@ -493,7 +490,7 @@ async def update_node_status( message=message, xray_version=xray_version, node_version=node_version, - last_status_change=datetime.now(timezone.utc), + last_status_change=datetime.now(UTC), ) ) await db.execute(stmt) @@ -551,7 +548,7 @@ async def bulk_update_node_status( ) # Add timestamp to each update - now = datetime.now(timezone.utc) + now = datetime.now(UTC) for upd in updates: upd["now"] = now @@ -566,9 +563,9 @@ async def clear_usage_data( ): filters = [] if start: - filters.append(getattr(_table_model(table), "created_at") >= start.replace(tzinfo=timezone.utc)) + filters.append(_table_model(table).created_at >= start.replace(tzinfo=UTC)) if end: - filters.append(getattr(_table_model(table), "created_at") < end.replace(tzinfo=timezone.utc)) + filters.append(_table_model(table).created_at < end.replace(tzinfo=UTC)) stmt = delete(_table_model(table)) if filters: @@ -642,7 +639,7 @@ async def get_nodes_to_reset_usage(db: AsyncSession) -> list[Node]: filtered_nodes.append(node) else: # Time-based reset: check if current time matches the schedule - now = datetime.now(timezone.utc) + now = datetime.now(UTC) # Get last reset time if node.usage_logs: @@ -695,24 +692,21 @@ async def get_nodes_to_reset_usage(db: AsyncSession) -> list[Node]: # Check if we're past the target day and time in current month # and last reset was before this month's target time - if current_day > target_day or (current_day == target_day and current_seconds >= target_seconds): - # Check if last reset was in a previous month or before target time this month - if ( - now.year > last_reset.year - or now.month > last_reset.month - or ( - now.month == last_reset.month - and ( - last_reset.day < target_day - or ( - last_reset.day == target_day - and last_reset.hour * 3600 + last_reset.minute * 60 + last_reset.second - < target_seconds - ) + if (current_day > target_day or (current_day == target_day and current_seconds >= target_seconds)) and ( + now.year > last_reset.year + or now.month > last_reset.month + or ( + now.month == last_reset.month + and ( + last_reset.day < target_day + or ( + last_reset.day == target_day + and last_reset.hour * 3600 + last_reset.minute * 60 + last_reset.second < target_seconds ) ) - ): - should_reset = True + ) + ): + should_reset = True elif node.data_limit_reset_strategy == DataLimitResetStrategy.year: # reset_time is day_of_year * 86400 + seconds @@ -726,13 +720,14 @@ async def get_nodes_to_reset_usage(db: AsyncSession) -> list[Node]: # Check if we're past the target day in current year # and last reset was before this year's target time - if current_day_of_year > target_day_of_year or ( - current_day_of_year == target_day_of_year and current_seconds >= target_seconds + if ( + current_day_of_year > target_day_of_year + or (current_day_of_year == target_day_of_year and current_seconds >= target_seconds) + ) and ( + now.year > last_reset.year + or (now.year == last_reset.year and last_reset_day_of_year < target_day_of_year) ): - if now.year > last_reset.year or ( - now.year == last_reset.year and last_reset_day_of_year < target_day_of_year - ): - should_reset = True + should_reset = True if should_reset: filtered_nodes.append(node) diff --git a/app/db/crud/temp_key.py b/app/db/crud/temp_key.py index 5928788b1..3c79e0359 100644 --- a/app/db/crud/temp_key.py +++ b/app/db/crud/temp_key.py @@ -1,5 +1,5 @@ import uuid -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from sqlalchemy import or_, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -20,7 +20,7 @@ async def create_temp_key(db: AsyncSession) -> TempKey: key = TempKey( key=str(uuid.uuid4()), action="pending", # updated to the actual action when consumed - expires_at=datetime.now(timezone.utc) + timedelta(minutes=KEY_TTL_MINUTES), + expires_at=datetime.now(UTC) + timedelta(minutes=KEY_TTL_MINUTES), ) db.add(key) await db.commit() @@ -36,13 +36,13 @@ def _normalize_utc(value: datetime | None) -> datetime | None: if value is None: return None if value.tzinfo is None: - return value.replace(tzinfo=timezone.utc) - return value.astimezone(timezone.utc) + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) async def consume_temp_key(db: AsyncSession, key: str, action: str, ip: str) -> None: """Atomically validate and mark a temp key as used.""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) result = await db.execute( update(TempKey) .where( @@ -71,6 +71,6 @@ async def consume_temp_key(db: AsyncSession, key: str, action: str, ip: str) -> async def mark_temp_key_used(db: AsyncSession, key: TempKey, action: str, ip: str) -> None: """Backward-compatible helper for code paths that already own a locked TempKey instance.""" key.action = action - key.used_at = datetime.now(timezone.utc) + key.used_at = datetime.now(UTC) key.used_by_ip = ip await db.commit() diff --git a/app/db/crud/user.py b/app/db/crud/user.py index 753f4f7af..f21f77a7b 100644 --- a/app/db/crud/user.py +++ b/app/db/crud/user.py @@ -1,6 +1,7 @@ +from collections.abc import Sequence from copy import deepcopy -from datetime import UTC, datetime, timedelta, timezone -from typing import List, Literal, Optional, Sequence +from datetime import UTC, datetime, timedelta +from typing import Literal from sqlalchemy import and_, case, delete, desc, func, literal, not_, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -48,13 +49,6 @@ from app.models.validators import MAX_ON_HOLD_EXPIRE_DURATION_SECONDS from config import user_cleanup_settings -from .wireguard import ( - release_allocations_by_user_ids, - release_users_allocations, - sync_user_allocations, - sync_users_allocations, - tags_from_groups, -) from .general import ( _build_trunc_expression, attach_timezone_to_period_start, @@ -63,6 +57,13 @@ to_utc_for_filter, ) from .group import get_groups_by_ids +from .wireguard import ( + release_allocations_by_user_ids, + release_users_allocations, + sync_user_allocations, + sync_users_allocations, + tags_from_groups, +) _USER_AGENT_MAX_LEN = UserSubscriptionUpdate.__table__.columns.user_agent.type.length or 512 _SUBSCRIPTION_UPDATE_IP_MAX_LEN = UserSubscriptionUpdate.__table__.columns.ip.type.length or 64 @@ -76,8 +77,8 @@ def _safe_on_hold_expire_duration(duration: int | None) -> int | None: def _resolve_enabled_user_status(user: User) -> UserStatus: - now = datetime.now(timezone.utc) - if user.expire is not None and user.expire.replace(tzinfo=timezone.utc) <= now: + now = datetime.now(UTC) + if user.expire is not None and user.expire.replace(tzinfo=UTC) <= now: return UserStatus.expired if user.data_limit is not None and user.data_limit > 0 and user.used_traffic >= user.data_limit: return UserStatus.limited @@ -165,7 +166,7 @@ async def get_user( load_usage_logs: bool = True, load_groups: bool = True, admin_id: int | None = None, -) -> Optional[User]: +) -> User | None: """ Retrieves a user by username. @@ -384,9 +385,7 @@ async def get_users( if query.online_before is not None: filters.append(and_(User.online_at.is_not(None), User.online_at <= query.online_before)) if query.online: - filters.append( - and_(User.online_at.is_not(None), User.online_at >= datetime.now(timezone.utc) - _ONLINE_USERS_WINDOW) - ) + filters.append(and_(User.online_at.is_not(None), User.online_at >= datetime.now(UTC) - _ONLINE_USERS_WINDOW)) if query.group_ids: filters.append(User.groups.any(Group.id.in_(query.group_ids))) @@ -788,7 +787,7 @@ async def get_users_by_ids( return [users_by_id[user_id] for user_id in user_ids if user_id in users_by_id] -async def get_users_count(db: AsyncSession, status: UserStatus = None, admin_id: int = None) -> int: +async def get_users_count(db: AsyncSession, status: UserStatus = None, admin_id: int | None = None) -> int: """ Gets the total count of users with optional filters. @@ -815,7 +814,7 @@ async def get_users_count(db: AsyncSession, status: UserStatus = None, admin_id: async def get_users_count_by_status( - db: AsyncSession, statuses: list[UserStatus], admin_id: int = None + db: AsyncSession, statuses: list[UserStatus], admin_id: int | None = None ) -> dict[str, int]: """ Gets count of users grouped by status in a single query. @@ -1024,7 +1023,7 @@ async def modify_user( elif modify.expire is not None: db_user.expire = modify.expire if db_user.status in [UserStatus.active, UserStatus.expired]: - if not db_user.expire or db_user.expire.replace(tzinfo=timezone.utc) > datetime.now(timezone.utc): + if not db_user.expire or db_user.expire.replace(tzinfo=UTC) > datetime.now(UTC): db_user.status = UserStatus.active remove_expiration_reminder = True @@ -1070,7 +1069,7 @@ async def modify_user( elif db_user.next_plan is not None: await db.delete(db_user.next_plan) - db_user.edit_at = datetime.now(timezone.utc) + db_user.edit_at = datetime.now(UTC) if remove_usage_reminder or remove_expiration_reminder: id = db_user.id @@ -1245,7 +1244,7 @@ async def revoke_user_sub(db: AsyncSession, db_user: User, *, proxy_settings: di Returns: User: The updated user object. """ - db_user.sub_revoked_at = datetime.now(timezone.utc) + db_user.sub_revoked_at = datetime.now(UTC) db_user.proxy_settings = proxy_settings if proxy_settings is not None else build_revoked_proxy_settings(db_user) await db.commit() await refresh_and_load_user(db, db_user) @@ -1265,7 +1264,7 @@ async def bulk_revoke_user_sub( Returns: list[User]: The refreshed users. """ - revoked_at = datetime.now(timezone.utc) + revoked_at = datetime.now(UTC) for user in users: user.sub_revoked_at = revoked_at user.proxy_settings = ( @@ -1378,8 +1377,7 @@ async def autodelete_expired_users( expired_users = [ user for (user, auto_delete) in (await db.execute(query)).unique() - if user.last_status_change.replace(tzinfo=timezone.utc) + timedelta(days=auto_delete) - <= datetime.now(timezone.utc) + if user.last_status_change.replace(tzinfo=UTC) + timedelta(days=auto_delete) <= datetime.now(UTC) ] result: list[UserNotificationResponse] = [] @@ -1606,7 +1604,7 @@ async def update_users_status(db: AsyncSession, users: list[User], status: UserS User: The updated user object. """ user_ids = [user.id for user in users] - changed_at = datetime.now(timezone.utc) + changed_at = datetime.now(UTC) stmt = update(User).where(User.id.in_(user_ids)).values(status=status, last_status_change=changed_at) await db.execute(stmt) await db.commit() @@ -1696,7 +1694,7 @@ async def start_users_expire(db: AsyncSession, users: list[User]) -> list[User]: Returns: list[User]: The updated users list. """ - now = datetime.now(timezone.utc) + now = datetime.now(UTC) for user in users: duration = _safe_on_hold_expire_duration(user.on_hold_expire_duration) expire_time = now + timedelta(seconds=duration) if duration is not None else None @@ -1741,7 +1739,7 @@ async def create_notification_reminder( return reminder -async def bulk_create_notification_reminders(db: AsyncSession, reminder_data: List[dict]) -> None: +async def bulk_create_notification_reminders(db: AsyncSession, reminder_data: list[dict]) -> None: """ Bulk creates notification reminders. @@ -1798,7 +1796,7 @@ async def count_online_users(db: AsyncSession, time_delta: timedelta, admin_id: Returns: int: The number of users who have been online within the specified time period. """ - twenty_four_hours_ago = datetime.now(timezone.utc) - time_delta + twenty_four_hours_ago = datetime.now(UTC) - time_delta query = select(func.count(User.id)).where(User.online_at.isnot(None), User.online_at >= twenty_four_hours_ago) if admin_id: query = query.where(User.admin_id == admin_id) diff --git a/app/db/crud/user_template.py b/app/db/crud/user_template.py index 1e1d889c0..d43a58402 100644 --- a/app/db/crud/user_template.py +++ b/app/db/crud/user_template.py @@ -1,5 +1,3 @@ -from typing import List - from sqlalchemy import delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -144,7 +142,7 @@ async def get_user_template(db: AsyncSession, user_template_id: int) -> UserTemp return user_template -async def get_user_templates(db: AsyncSession, query: UserTemplateListQuery) -> List[UserTemplate]: +async def get_user_templates(db: AsyncSession, query: UserTemplateListQuery) -> list[UserTemplate]: """ Retrieves a list of user templates with optional pagination. diff --git a/app/db/crud/wireguard.py b/app/db/crud/wireguard.py index 25deba88c..9afd24437 100644 --- a/app/db/crud/wireguard.py +++ b/app/db/crud/wireguard.py @@ -1,9 +1,9 @@ from __future__ import annotations import json +from collections.abc import Iterable from dataclasses import dataclass from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_interface, ip_network -from typing import Iterable from sqlalchemy import and_, delete, insert, select from sqlalchemy.dialects.mysql import insert as mysql_insert diff --git a/app/db/models.py b/app/db/models.py index f17b295af..88e433723 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -1,7 +1,7 @@ import os -from datetime import datetime as dt, timezone as tz +from datetime import UTC, datetime as dt from enum import Enum -from typing import Any, Dict, List, Optional +from typing import Any from sqlalchemy import ( JSON, @@ -70,45 +70,45 @@ class IdMixin: class CreatedAtUTCMixin(IdMixin): - created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False) + created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(UTC), init=False) class Admin(Base, CreatedAtUTCMixin): __tablename__ = "admins" username: Mapped[str] = mapped_column(String(34), unique=True, index=True) hashed_password: Mapped[str] = mapped_column(String(128)) - users: Mapped[List["User"]] = relationship(back_populates="admin", init=False, default_factory=list) - usage_logs: Mapped[List["AdminUsageLogs"]] = relationship( + users: Mapped[list[User]] = relationship(back_populates="admin", init=False, default_factory=list) + usage_logs: Mapped[list[AdminUsageLogs]] = relationship( back_populates="admin", init=False, default_factory=list, cascade="all, delete-orphan" ) - notification_reminders: Mapped[List["AdminNotificationReminder"]] = relationship( + notification_reminders: Mapped[list[AdminNotificationReminder]] = relationship( back_populates="admin", init=False, default_factory=list, cascade="all, delete-orphan" ) - api_keys: Mapped[List["APIKey"]] = relationship( + api_keys: Mapped[list[APIKey]] = relationship( back_populates="admin", init=False, default_factory=list, cascade="all, delete-orphan" ) - password_reset_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) - telegram_id: Mapped[Optional[int]] = mapped_column(BigInteger, default=None) - discord_webhook: Mapped[Optional[str]] = mapped_column(String(1024), default=None) + password_reset_at: Mapped[dt | None] = mapped_column(DateTime(timezone=True), default=None) + telegram_id: Mapped[int | None] = mapped_column(BigInteger, default=None) + discord_webhook: Mapped[str | None] = mapped_column(String(1024), default=None) used_traffic: Mapped[int] = mapped_column(BigInteger, default=0) - data_limit: Mapped[Optional[int]] = mapped_column(BigInteger, default=None) + data_limit: Mapped[int | None] = mapped_column(BigInteger, default=None) status: Mapped[AdminStatus] = mapped_column( SQLEnum(AdminStatus, name="adminstatus", create_constraint=True), default=AdminStatus.active, server_default="active", ) - last_status_change: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) - sub_template: Mapped[Optional[str]] = mapped_column(String(1024), default=None) - sub_domain: Mapped[Optional[str]] = mapped_column(String(256), default=None) - profile_title: Mapped[Optional[str]] = mapped_column(String(512), default=None) - support_url: Mapped[Optional[str]] = mapped_column(String(1024), default=None) - custom_variables: Mapped[Optional[list[dict[str, str]]]] = mapped_column(PostgresJSONB, default=None) - notification_enable: Mapped[Optional[Dict]] = mapped_column(PostgresJSONB, default=None) - note: Mapped[Optional[str]] = mapped_column(String(500), default=None) + last_status_change: Mapped[dt | None] = mapped_column(DateTime(timezone=True), default=None) + sub_template: Mapped[str | None] = mapped_column(String(1024), default=None) + sub_domain: Mapped[str | None] = mapped_column(String(256), default=None) + profile_title: Mapped[str | None] = mapped_column(String(512), default=None) + support_url: Mapped[str | None] = mapped_column(String(1024), default=None) + custom_variables: Mapped[list[dict[str, str]] | None] = mapped_column(PostgresJSONB, default=None) + notification_enable: Mapped[dict | None] = mapped_column(PostgresJSONB, default=None) + note: Mapped[str | None] = mapped_column(String(500), default=None) role_id: Mapped[int] = fk_id_column("admin_roles.id", default=0) - role: Mapped[Optional[AdminRole]] = relationship(back_populates="admins", init=False, lazy="select") - permission_overrides: Mapped[Optional[Dict]] = mapped_column(PostgresJSONB, default=None) + role: Mapped[AdminRole | None] = relationship(back_populates="admins", init=False, lazy="select") + permission_overrides: Mapped[dict | None] = mapped_column(PostgresJSONB, default=None) @hybrid_property def is_disabled(self) -> bool: @@ -164,9 +164,9 @@ def has_api_keys(self) -> bool: class AdminUsageLogs(Base, IdMixin): __tablename__ = "admin_usage_logs" admin_id: Mapped[int] = fk_id_column("admins.id") - admin: Mapped["Admin"] = relationship(back_populates="usage_logs", init=False) + admin: Mapped[Admin] = relationship(back_populates="usage_logs", init=False) used_traffic_at_reset: Mapped[int] = mapped_column(BigInteger, nullable=False) - reset_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default=lambda: dt.now(tz.utc), init=False) + reset_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default=lambda: dt.now(UTC), init=False) class ReminderType(str, Enum): @@ -198,50 +198,50 @@ class User(Base, CreatedAtUTCMixin): Index("idx_users_admin_created", "admin_id", "created_at"), ) username: Mapped[str] = mapped_column(CaseSensitiveString(128), unique=True, index=True) - node_usages: Mapped[List["NodeUserUsage"]] = relationship( + node_usages: Mapped[list[NodeUserUsage]] = relationship( back_populates="user", cascade="all, delete-orphan", init=False, ) - notification_reminders: Mapped[List["NotificationReminder"]] = relationship( + notification_reminders: Mapped[list[NotificationReminder]] = relationship( back_populates="user", cascade="all, delete-orphan", init=False ) - subscription_updates: Mapped[List["UserSubscriptionUpdate"]] = relationship( + subscription_updates: Mapped[list[UserSubscriptionUpdate]] = relationship( back_populates="user", cascade="all, delete-orphan", init=False ) - usage_logs: Mapped[List["UserUsageResetLogs"]] = relationship(back_populates="user", init=False) - admin: Mapped["Admin"] = relationship(back_populates="users", init=False) - next_plan: Mapped[Optional["NextPlan"]] = relationship( + usage_logs: Mapped[list[UserUsageResetLogs]] = relationship(back_populates="user", init=False) + admin: Mapped[Admin] = relationship(back_populates="users", init=False) + next_plan: Mapped[NextPlan | None] = relationship( uselist=False, back_populates="user", cascade="all, delete-orphan", init=False ) - hwids: Mapped[List["UserHWID"]] = relationship(back_populates="user", cascade="all, delete-orphan", init=False) - groups: Mapped[List["Group"]] = relationship(secondary=users_groups_association, back_populates="users", init=False) - proxy_settings: Mapped[Dict[str, Any]] = mapped_column( + hwids: Mapped[list[UserHWID]] = relationship(back_populates="user", cascade="all, delete-orphan", init=False) + groups: Mapped[list[Group]] = relationship(secondary=users_groups_association, back_populates="users", init=False) + proxy_settings: Mapped[dict[str, Any]] = mapped_column( JSON(True), server_default=text("'{}'"), default_factory=dict ) status: Mapped[UserStatus] = mapped_column(SQLEnum(UserStatus), default=UserStatus.active) used_traffic: Mapped[int] = mapped_column(BigInteger, default=0) - data_limit: Mapped[Optional[int]] = mapped_column(BigInteger, default=None) + data_limit: Mapped[int | None] = mapped_column(BigInteger, default=None) data_limit_reset_strategy: Mapped[DataLimitResetStrategy] = mapped_column( SQLEnum(DataLimitResetStrategy), default=DataLimitResetStrategy.no_reset, ) - _expire: Mapped[Optional[dt]] = mapped_column("expire", DateTime(timezone=True), default=None, init=False) - admin_id: Mapped[Optional[int]] = fk_id_column("admins.id", default=None) - sub_revoked_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) - note: Mapped[Optional[str]] = mapped_column(String(500), default=None) - online_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) - on_hold_expire_duration: Mapped[Optional[int]] = mapped_column(BigInteger, default=None) - on_hold_timeout: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) - auto_delete_in_days: Mapped[Optional[int]] = mapped_column(default=None) - hwid_limit: Mapped[Optional[int]] = mapped_column(BigInteger, default=None) - edit_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) - last_status_change: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) + _expire: Mapped[dt | None] = mapped_column("expire", DateTime(timezone=True), default=None, init=False) + admin_id: Mapped[int | None] = fk_id_column("admins.id", default=None) + sub_revoked_at: Mapped[dt | None] = mapped_column(DateTime(timezone=True), default=None) + note: Mapped[str | None] = mapped_column(String(500), default=None) + online_at: Mapped[dt | None] = mapped_column(DateTime(timezone=True), default=None) + on_hold_expire_duration: Mapped[int | None] = mapped_column(BigInteger, default=None) + on_hold_timeout: Mapped[dt | None] = mapped_column(DateTime(timezone=True), default=None) + auto_delete_in_days: Mapped[int | None] = mapped_column(default=None) + hwid_limit: Mapped[int | None] = mapped_column(BigInteger, default=None) + edit_at: Mapped[dt | None] = mapped_column(DateTime(timezone=True), default=None) + last_status_change: Mapped[dt | None] = mapped_column(DateTime(timezone=True), default=None) @hybrid_property - def expire(self) -> Optional[dt]: + def expire(self) -> dt | None: if self._expire and self._expire.tzinfo is None: - return self._expire.replace(tzinfo=tz.utc) + return self._expire.replace(tzinfo=UTC) return self._expire @expire.inplace.expression @@ -249,14 +249,14 @@ def expire(cls): return cls._expire @expire.setter - def expire(self, value: Optional[dt]): + def expire(self, value: dt | None): if value is None: self._expire = None return if value.tzinfo is None: - self._expire = value.replace(tzinfo=tz.utc) + self._expire = value.replace(tzinfo=UTC) return - self._expire = value.astimezone(tz.utc) + self._expire = value.astimezone(UTC) @hybrid_property def reseted_usage(self) -> int: @@ -313,7 +313,7 @@ def group_names(self): @hybrid_property def is_expired(self) -> bool: - return self.expire is not None and self.expire <= dt.now(tz.utc) + return self.expire is not None and self.expire <= dt.now(UTC) @is_expired.expression def is_expired(cls): @@ -329,18 +329,15 @@ def is_limited(cls): @hybrid_property def become_online(self) -> bool: - now = dt.now(tz.utc) + now = dt.now(UTC) # Check if online_at is set and greater than or equal to base time if self.online_at: - base_time = (self.edit_at or self.created_at).replace(tzinfo=tz.utc) - return self.online_at.replace(tzinfo=tz.utc) >= base_time + base_time = (self.edit_at or self.created_at).replace(tzinfo=UTC) + return self.online_at.replace(tzinfo=UTC) >= base_time # Check if on_hold_timeout has passed - if self.on_hold_timeout and self.on_hold_timeout.replace(tzinfo=tz.utc) <= now: - return True - - return False + return bool(self.on_hold_timeout and self.on_hold_timeout.replace(tzinfo=UTC) <= now) @become_online.expression def become_online(cls): @@ -371,7 +368,7 @@ def usage_percentage(cls): def days_left(self) -> int: if not self.expire: return 0 - remaining_days = (self.expire.replace(tzinfo=tz.utc) - dt.now(tz.utc)).days + remaining_days = (self.expire.replace(tzinfo=UTC) - dt.now(UTC)).days return max(remaining_days, 0) @days_left.expression @@ -383,10 +380,10 @@ class UserSubscriptionUpdate(Base, CreatedAtUTCMixin): __tablename__ = "user_subscription_updates" __table_args__ = (Index("idx_user_subscription_updates_user_id", "user_id"),) user_id: Mapped[int] = fk_id_column("users.id", ondelete="CASCADE") - user: Mapped["User"] = relationship(back_populates="subscription_updates", init=False) + user: Mapped[User] = relationship(back_populates="subscription_updates", init=False) user_agent: Mapped[str] = mapped_column(String(512)) - ip: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, default=None) - hwid: Mapped[Optional[str]] = mapped_column(String(256), nullable=True, default=None) + ip: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None) + hwid: Mapped[str | None] = mapped_column(String(256), nullable=True, default=None) class UserHWID(Base, CreatedAtUTCMixin): @@ -399,14 +396,12 @@ class UserHWID(Base, CreatedAtUTCMixin): Index("ix_user_hwids_last_used_at", "last_used_at"), ) user_id: Mapped[int] = fk_id_column("users.id", ondelete="CASCADE") - user: Mapped["User"] = relationship(back_populates="hwids", init=False) + user: Mapped[User] = relationship(back_populates="hwids", init=False) hwid: Mapped[str] = mapped_column(String(256), nullable=False) - device_os: Mapped[Optional[str]] = mapped_column(String(256), default=None) - os_version: Mapped[Optional[str]] = mapped_column(String(128), default=None) - device_model: Mapped[Optional[str]] = mapped_column(String(256), default=None) - last_used_at: Mapped[dt] = mapped_column( - DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False - ) + device_os: Mapped[str | None] = mapped_column(String(256), default=None) + os_version: Mapped[str | None] = mapped_column(String(128), default=None) + device_model: Mapped[str | None] = mapped_column(String(256), default=None) + last_used_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(UTC), init=False) template_group_association = Table( @@ -425,11 +420,11 @@ class NextPlan(Base, IdMixin): Index("ix_next_plans_user_template_id", "user_template_id"), ) user_id: Mapped[int] = fk_id_column("users.id", ondelete="CASCADE") - user_template_id: Mapped[Optional[int]] = fk_id_column("user_templates.id", ondelete="SET NULL") - user: Mapped["User"] = relationship(back_populates="next_plan", init=False) - user_template: Mapped[Optional["UserTemplate"]] = relationship(back_populates="next_plans", init=False) + user_template_id: Mapped[int | None] = fk_id_column("user_templates.id", ondelete="SET NULL") + user: Mapped[User] = relationship(back_populates="next_plan", init=False) + user_template: Mapped[UserTemplate | None] = relationship(back_populates="next_plans", init=False) data_limit: Mapped[int] = mapped_column(BigInteger, default=0) - expire: Mapped[Optional[int]] = mapped_column(default=None) + expire: Mapped[int | None] = mapped_column(default=None) add_remaining_traffic: Mapped[bool] = mapped_column(default=False, server_default="0") @@ -441,17 +436,17 @@ class UserStatusCreate(str, Enum): class UserTemplate(Base, IdMixin): __tablename__ = "user_templates" name: Mapped[str] = mapped_column(String(64), unique=True) - username_prefix: Mapped[Optional[str]] = mapped_column(String(20)) - username_suffix: Mapped[Optional[str]] = mapped_column(String(20)) - extra_settings: Mapped[Optional[Dict]] = mapped_column(JSON(True)) - next_plans: Mapped[List["NextPlan"]] = relationship( + username_prefix: Mapped[str | None] = mapped_column(String(20)) + username_suffix: Mapped[str | None] = mapped_column(String(20)) + extra_settings: Mapped[dict | None] = mapped_column(JSON(True)) + next_plans: Mapped[list[NextPlan]] = relationship( back_populates="user_template", cascade="all, delete-orphan", init=False ) - groups: Mapped[List["Group"]] = relationship(secondary=template_group_association, back_populates="templates") + groups: Mapped[list[Group]] = relationship(secondary=template_group_association, back_populates="templates") data_limit: Mapped[int] = mapped_column(BigInteger, default=0) - hwid_limit: Mapped[Optional[int]] = mapped_column(BigInteger, default=None) + hwid_limit: Mapped[int | None] = mapped_column(BigInteger, default=None) expire_duration: Mapped[int] = mapped_column(BigInteger, default=0) # in seconds - on_hold_timeout: Mapped[Optional[int]] = mapped_column(default=None) + on_hold_timeout: Mapped[int | None] = mapped_column(default=None) status: Mapped[UserStatusCreate] = mapped_column(SQLEnum(UserStatusCreate), default=UserStatusCreate.active) reset_usages: Mapped[bool] = mapped_column(default=False, server_default="0") data_limit_reset_strategy: Mapped[DataLimitResetStrategy] = mapped_column( @@ -472,17 +467,17 @@ class UserUsageResetLogs(Base, IdMixin): # Index for user-specific queries sorted by time Index("ix_user_usage_logs_user_id_reset_at", "user_id", "reset_at"), ) - user_id: Mapped[Optional[int]] = fk_id_column("users.id", ondelete="CASCADE", nullable=True) - user: Mapped["User"] = relationship(back_populates="usage_logs", init=False) + user_id: Mapped[int | None] = fk_id_column("users.id", ondelete="CASCADE", nullable=True) + user: Mapped[User] = relationship(back_populates="usage_logs", init=False) used_traffic_at_reset: Mapped[int] = mapped_column(BigInteger, nullable=False) - reset_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default=lambda: dt.now(tz.utc), init=False) + reset_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default=lambda: dt.now(UTC), init=False) class ProxyInbound(Base, IdMixin): __tablename__ = "inbounds" tag: Mapped[str] = mapped_column(String(256), unique=True, index=True) - hosts: Mapped[List["ProxyHost"]] = relationship(back_populates="inbound", init=False) - groups: Mapped[List["Group"]] = relationship( + hosts: Mapped[list[ProxyHost]] = relationship(back_populates="inbound", init=False) + groups: Mapped[list[Group]] = relationship( secondary=inbounds_groups_association, back_populates="inbounds", init=False ) @@ -529,52 +524,48 @@ class ProxyHostALPN(str, Enum): class ProxyHost(Base, IdMixin): __tablename__ = "hosts" remark: Mapped[str] = mapped_column(String(256), unique=False, nullable=False) - port: Mapped[Optional[int]] = mapped_column(nullable=True) - path: Mapped[Optional[str]] = mapped_column(String(256), unique=False, nullable=True) + port: Mapped[int | None] = mapped_column(nullable=True) + path: Mapped[str | None] = mapped_column(String(256), unique=False, nullable=True) priority: Mapped[int] = mapped_column(nullable=False) - allowinsecure: Mapped[Optional[bool]] = mapped_column(nullable=True) + allowinsecure: Mapped[bool | None] = mapped_column(nullable=True) address: Mapped[set[str]] = mapped_column(StringArray(256), default_factory=set, unique=False, nullable=False) - sni: Mapped[Optional[set[str]]] = mapped_column(StringArray(1000), default_factory=set, unique=False, nullable=True) - host: Mapped[Optional[set[str]]] = mapped_column( - StringArray(1000), default_factory=set, unique=False, nullable=True - ) - inbound_tag: Mapped[Optional[str]] = mapped_column( + sni: Mapped[set[str] | None] = mapped_column(StringArray(1000), default_factory=set, unique=False, nullable=True) + host: Mapped[set[str] | None] = mapped_column(StringArray(1000), default_factory=set, unique=False, nullable=True) + inbound_tag: Mapped[str | None] = mapped_column( String(256), ForeignKey("inbounds.tag", ondelete="SET NULL", onupdate="CASCADE"), nullable=True, init=False ) - inbound: Mapped[Optional["ProxyInbound"]] = relationship(back_populates="hosts", init=False) + inbound: Mapped[ProxyInbound | None] = relationship(back_populates="hosts", init=False) security: Mapped[ProxyHostSecurity] = mapped_column( SQLEnum(ProxyHostSecurity), unique=False, default=ProxyHostSecurity.inbound_default, ) - alpn: Mapped[Optional[list[ProxyHostALPN]]] = mapped_column(EnumArray(ProxyHostALPN, 14), default=list) + alpn: Mapped[list[ProxyHostALPN] | None] = mapped_column(EnumArray(ProxyHostALPN, 14), default=list) fingerprint: Mapped[ProxyHostFingerprint] = mapped_column( SQLEnum(ProxyHostFingerprint), unique=False, default=ProxyHostSecurity.none, server_default=ProxyHostSecurity.none.name, ) - is_disabled: Mapped[Optional[bool]] = mapped_column(default=False) - fragment_settings: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None) - noise_settings: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None) + is_disabled: Mapped[bool | None] = mapped_column(default=False) + fragment_settings: Mapped[dict[str, Any] | None] = mapped_column(JSON(none_as_null=True), default=None) + noise_settings: Mapped[dict[str, Any] | None] = mapped_column(JSON(none_as_null=True), default=None) random_user_agent: Mapped[bool] = mapped_column(default=False, server_default="0") use_sni_as_host: Mapped[bool] = mapped_column(default=False, server_default="0") - http_headers: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None) - transport_settings: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None) - mux_settings: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None) - status: Mapped[Optional[list[UserStatus]]] = mapped_column( - EnumArray(UserStatus, 60), default=list, server_default="" - ) - ech_config_list: Mapped[Optional[str]] = mapped_column(String(512), default=None) - ech_query_strategy: Mapped[Optional[str]] = mapped_column(String(8), default=None) - vless_route: Mapped[Optional[str]] = mapped_column(String(4), default=None) - pinned_peer_cert_sha256: Mapped[Optional[str]] = mapped_column(String(128), default=None) - verify_peer_cert_by_name: Mapped[Optional[set[str]]] = mapped_column( + http_headers: Mapped[dict[str, Any] | None] = mapped_column(JSON(none_as_null=True), default=None) + transport_settings: Mapped[dict[str, Any] | None] = mapped_column(JSON(none_as_null=True), default=None) + mux_settings: Mapped[dict[str, Any] | None] = mapped_column(JSON(none_as_null=True), default=None) + status: Mapped[list[UserStatus] | None] = mapped_column(EnumArray(UserStatus, 60), default=list, server_default="") + ech_config_list: Mapped[str | None] = mapped_column(String(512), default=None) + ech_query_strategy: Mapped[str | None] = mapped_column(String(8), default=None) + vless_route: Mapped[str | None] = mapped_column(String(4), default=None) + pinned_peer_cert_sha256: Mapped[str | None] = mapped_column(String(128), default=None) + verify_peer_cert_by_name: Mapped[set[str] | None] = mapped_column( StringArray(1000), default_factory=set, unique=False, nullable=True ) - wireguard_overrides: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None) - subscription_templates: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None) - final_mask_settings: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None) + wireguard_overrides: Mapped[dict[str, Any] | None] = mapped_column(JSON(none_as_null=True), default=None) + subscription_templates: Mapped[dict[str, Any] | None] = mapped_column(JSON(none_as_null=True), default=None) + final_mask_settings: Mapped[dict[str, Any] | None] = mapped_column(JSON(none_as_null=True), default=None) class System(Base, IdMixin): @@ -609,23 +600,23 @@ class Node(Base, CreatedAtUTCMixin): address: Mapped[str] = mapped_column(String(256), unique=False, nullable=False) port: Mapped[int] = mapped_column(unique=False, nullable=False) api_port: Mapped[int] = mapped_column(unique=False, nullable=False, server_default="62051") - xray_version: Mapped[Optional[str]] = mapped_column(String(32), nullable=True, init=False) - message: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True, init=False) + xray_version: Mapped[str | None] = mapped_column(String(32), nullable=True, init=False) + message: Mapped[str | None] = mapped_column(String(1024), nullable=True, init=False) server_ca: Mapped[str] = mapped_column(String(2048), nullable=False) api_key: Mapped[str | None] = mapped_column(String(36)) - node_version: Mapped[Optional[str]] = mapped_column(String(32), nullable=True, init=False) - core_config_id: Mapped[Optional[int]] = fk_id_column("core_configs.id", ondelete="SET NULL", nullable=True) - user_usages: Mapped[List["NodeUserUsage"]] = relationship( + node_version: Mapped[str | None] = mapped_column(String(32), nullable=True, init=False) + core_config_id: Mapped[int | None] = fk_id_column("core_configs.id", ondelete="SET NULL", nullable=True) + user_usages: Mapped[list[NodeUserUsage]] = relationship( back_populates="node", cascade="all, delete-orphan", init=False ) - usages: Mapped[List["NodeUsage"]] = relationship(back_populates="node", cascade="all, delete-orphan", init=False) - usage_logs: Mapped[List["NodeUsageResetLogs"]] = relationship( + usages: Mapped[list[NodeUsage]] = relationship(back_populates="node", cascade="all, delete-orphan", init=False) + usage_logs: Mapped[list[NodeUsageResetLogs]] = relationship( back_populates="node", cascade="all, delete-orphan", init=False ) - core_config: Mapped[Optional["CoreConfig"]] = relationship("CoreConfig", init=False) - stats: Mapped[List["NodeStat"]] = relationship(back_populates="node", cascade="all, delete-orphan", init=False) + core_config: Mapped[CoreConfig | None] = relationship("CoreConfig", init=False) + stats: Mapped[list[NodeStat]] = relationship(back_populates="node", cascade="all, delete-orphan", init=False) status: Mapped[NodeStatus] = mapped_column(SQLEnum(NodeStatus), default=NodeStatus.connecting) - last_status_change: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), init=False) + last_status_change: Mapped[dt | None] = mapped_column(DateTime(timezone=True), init=False) uplink: Mapped[int] = mapped_column(BigInteger, default=0) downlink: Mapped[int] = mapped_column(BigInteger, default=0) data_limit: Mapped[int] = mapped_column(BigInteger, default=0) @@ -714,9 +705,9 @@ class NodeUserUsage(Base, IdMixin): ) created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), unique=False) # 10 minute per record user_id: Mapped[int] = fk_id_column("users.id", ondelete="CASCADE") - user: Mapped["User"] = relationship(back_populates="node_usages", init=False) - node_id: Mapped[Optional[int]] = fk_id_column("nodes.id", ondelete="CASCADE") - node: Mapped["Node"] = relationship(back_populates="user_usages", init=False) + user: Mapped[User] = relationship(back_populates="node_usages", init=False) + node_id: Mapped[int | None] = fk_id_column("nodes.id", ondelete="CASCADE") + node: Mapped[Node] = relationship(back_populates="user_usages", init=False) used_traffic: Mapped[int] = mapped_column(BigInteger, default=0) @@ -729,8 +720,8 @@ class NodeUsage(Base, IdMixin): # The unique constraint already creates an index on (created_at, node_id) ) created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), unique=False) # 10 minute per record - node_id: Mapped[Optional[int]] = fk_id_column("nodes.id", ondelete="CASCADE") - node: Mapped["Node"] = relationship(back_populates="usages", init=False) + node_id: Mapped[int | None] = fk_id_column("nodes.id", ondelete="CASCADE") + node: Mapped[Node] = relationship(back_populates="usages", init=False) uplink: Mapped[int] = mapped_column(BigInteger, default=0) downlink: Mapped[int] = mapped_column(BigInteger, default=0) @@ -742,7 +733,7 @@ class NodeUsageResetLogs(Base, CreatedAtUTCMixin): Index("ix_node_usage_reset_logs_node_id_created_at", "node_id", "created_at"), ) node_id: Mapped[int] = fk_id_column("nodes.id", ondelete="CASCADE") - node: Mapped["Node"] = relationship(back_populates="usage_logs", init=False) + node: Mapped[Node] = relationship(back_populates="usage_logs", init=False) uplink: Mapped[int] = mapped_column(BigInteger, nullable=False) downlink: Mapped[int] = mapped_column(BigInteger, nullable=False) @@ -750,29 +741,27 @@ class NodeUsageResetLogs(Base, CreatedAtUTCMixin): class NotificationReminder(Base, CreatedAtUTCMixin): __tablename__ = "notification_reminders" user_id: Mapped[int] = fk_id_column("users.id", ondelete="CASCADE") - user: Mapped["User"] = relationship(back_populates="notification_reminders", init=False) + user: Mapped[User] = relationship(back_populates="notification_reminders", init=False) type: Mapped[ReminderType] = mapped_column(SQLEnum(ReminderType)) - threshold: Mapped[Optional[int]] = mapped_column(default=None) - expires_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) + threshold: Mapped[int | None] = mapped_column(default=None) + expires_at: Mapped[dt | None] = mapped_column(DateTime(timezone=True), default=None) class AdminNotificationReminder(Base, CreatedAtUTCMixin): __tablename__ = "admin_notification_reminders" __table_args__ = (Index("ix_admin_notification_reminders_admin_id_type", "admin_id", "type"),) admin_id: Mapped[int] = fk_id_column("admins.id", ondelete="CASCADE") - admin: Mapped["Admin"] = relationship(back_populates="notification_reminders", init=False) + admin: Mapped[Admin] = relationship(back_populates="notification_reminders", init=False) type: Mapped[ReminderType] = mapped_column(SQLEnum(ReminderType)) - threshold: Mapped[Optional[int]] = mapped_column(default=None) + threshold: Mapped[int | None] = mapped_column(default=None) class Group(Base, IdMixin): __tablename__ = "groups" name: Mapped[str] = mapped_column(String(64)) - users: Mapped[List["User"]] = relationship(secondary=users_groups_association, back_populates="groups", init=False) - inbounds: Mapped[List["ProxyInbound"]] = relationship( - secondary=inbounds_groups_association, back_populates="groups" - ) - templates: Mapped[List["UserTemplate"]] = relationship( + users: Mapped[list[User]] = relationship(secondary=users_groups_association, back_populates="groups", init=False) + inbounds: Mapped[list[ProxyInbound]] = relationship(secondary=inbounds_groups_association, back_populates="groups") + templates: Mapped[list[UserTemplate]] = relationship( secondary=template_group_association, back_populates="groups", init=False ) is_disabled: Mapped[bool] = mapped_column(server_default="0", default=False) @@ -831,10 +820,10 @@ class CoreType(str, Enum): class CoreConfig(Base, CreatedAtUTCMixin): __tablename__ = "core_configs" name: Mapped[str] = mapped_column(String(256)) - config: Mapped[Dict[str, Any]] = mapped_column(JSON(False)) + config: Mapped[dict[str, Any]] = mapped_column(JSON(False)) type: Mapped[CoreType] = mapped_column(SQLEnum(CoreType), default=CoreType.xray, server_default=CoreType.xray) - exclude_inbound_tags: Mapped[Optional[set[str]]] = mapped_column(StringArray(2048), default_factory=set) - fallbacks_inbound_tags: Mapped[Optional[set[str]]] = mapped_column(StringArray(2048), default_factory=set) + exclude_inbound_tags: Mapped[set[str] | None] = mapped_column(StringArray(2048), default_factory=set) + fallbacks_inbound_tags: Mapped[set[str] | None] = mapped_column(StringArray(2048), default_factory=set) class WireGuardSubnet(Base, IdMixin): @@ -869,7 +858,7 @@ class ClientTemplate(Base): class NodeStat(Base, CreatedAtUTCMixin): __tablename__ = "node_stats" node_id: Mapped[int] = fk_id_column("nodes.id") - node: Mapped["Node"] = relationship(back_populates="stats", init=False) + node: Mapped[Node] = relationship(back_populates="stats", init=False) mem_total: Mapped[int] = mapped_column(BigInteger, unique=False, nullable=False) mem_used: Mapped[int] = mapped_column(BigInteger, unique=False, nullable=False) cpu_cores: Mapped[int] = mapped_column(unique=False, nullable=False) @@ -893,15 +882,15 @@ class AdminRole(Base, CreatedAtUTCMixin): __tablename__ = "admin_roles" name: Mapped[str] = mapped_column(String(64), unique=True) is_owner: Mapped[bool] = mapped_column(default=False, server_default="0") - permissions: Mapped[Dict] = mapped_column(PostgresJSONB, default_factory=dict) - limits: Mapped[Dict] = mapped_column(PostgresJSONB, default_factory=dict) - features: Mapped[Dict] = mapped_column(PostgresJSONB, default_factory=dict) - access: Mapped[Dict] = mapped_column(PostgresJSONB, default_factory=dict) - hwid: Mapped[Dict] = mapped_column(PostgresJSONB, default_factory=dict) + permissions: Mapped[dict] = mapped_column(PostgresJSONB, default_factory=dict) + limits: Mapped[dict] = mapped_column(PostgresJSONB, default_factory=dict) + features: Mapped[dict] = mapped_column(PostgresJSONB, default_factory=dict) + access: Mapped[dict] = mapped_column(PostgresJSONB, default_factory=dict) + hwid: Mapped[dict] = mapped_column(PostgresJSONB, default_factory=dict) disabled_when_limited: Mapped[bool] = mapped_column(default=False, server_default="0") disconnect_users_when_limited: Mapped[bool] = mapped_column(default=True, server_default="1") disconnect_users_when_disabled: Mapped[bool] = mapped_column(default=True, server_default="1") - admins: Mapped[List["Admin"]] = relationship(back_populates="role", init=False, viewonly=True, lazy="noload") + admins: Mapped[list[Admin]] = relationship(back_populates="role", init=False, viewonly=True, lazy="noload") @hybrid_property def is_builtin(self) -> bool: @@ -929,15 +918,15 @@ class APIKey(Base, CreatedAtUTCMixin): ) admin_id: Mapped[int] = fk_id_column("admins.id", ondelete="CASCADE") - admin: Mapped["Admin"] = relationship(back_populates="api_keys", init=False) + admin: Mapped[Admin] = relationship(back_populates="api_keys", init=False) name: Mapped[str] = mapped_column(String(128), nullable=False) key_hash: Mapped[str] = mapped_column(String(128), nullable=False) api_key_trimmed: Mapped[str] = mapped_column(String(16)) - permissions: Mapped[Dict] = mapped_column(PostgresJSONB, default_factory=dict) + permissions: Mapped[dict] = mapped_column(PostgresJSONB, default_factory=dict) inherit_permissions: Mapped[bool] = mapped_column(default=True, server_default="1") - note: Mapped[Optional[str]] = mapped_column(String(512), default=None) - expire_date: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) - revoked_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) + note: Mapped[str | None] = mapped_column(String(512), default=None) + expire_date: Mapped[dt | None] = mapped_column(DateTime(timezone=True), default=None) + revoked_at: Mapped[dt | None] = mapped_column(DateTime(timezone=True), default=None) status: Mapped[APIKeyStatus] = mapped_column( SQLEnum(APIKeyStatus, name="apikeystatus", create_constraint=True), default=APIKeyStatus.active, @@ -949,8 +938,8 @@ def is_expired(self) -> bool: """True when expire_date is set and is in the past.""" if self.expire_date is None: return False - expire_at = self.expire_date if self.expire_date.tzinfo else self.expire_date.replace(tzinfo=tz.utc) - return expire_at <= dt.now(tz.utc) + expire_at = self.expire_date if self.expire_date.tzinfo else self.expire_date.replace(tzinfo=UTC) + return expire_at <= dt.now(UTC) @is_expired.expression def is_expired(cls): @@ -972,5 +961,5 @@ class TempKey(Base): key: Mapped[str] = mapped_column(String(36), primary_key=True, init=True) action: Mapped[str] = mapped_column(String(32)) expires_at: Mapped[dt] = mapped_column(DateTime(timezone=True)) - used_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) - used_by_ip: Mapped[Optional[str]] = mapped_column(String(45), default=None) + used_at: Mapped[dt | None] = mapped_column(DateTime(timezone=True), default=None) + used_by_ip: Mapped[str | None] = mapped_column(String(45), default=None) diff --git a/app/jobs/cleanup_subscription_updates.py b/app/jobs/cleanup_subscription_updates.py index 1168559e1..0865a7585 100644 --- a/app/jobs/cleanup_subscription_updates.py +++ b/app/jobs/cleanup_subscription_updates.py @@ -1,4 +1,4 @@ -from sqlalchemy import func, select, delete +from sqlalchemy import delete, func, select from app import scheduler from app.db import GetDB diff --git a/app/jobs/inbound.py b/app/jobs/inbound.py index 62530810a..9e294e1d3 100644 --- a/app/jobs/inbound.py +++ b/app/jobs/inbound.py @@ -1,11 +1,10 @@ from app import scheduler +from app.core.manager import core_manager from app.db import GetDB from app.db.crud.host import get_inbounds_not_in_tags, remove_inbounds -from app.core.manager import core_manager from app.utils.logger import get_logger from config import job_settings, runtime_settings - logger = get_logger("jobs") diff --git a/app/jobs/node_checker.py b/app/jobs/node_checker.py index a149bc38c..1334bd4a4 100644 --- a/app/jobs/node_checker.py +++ b/app/jobs/node_checker.py @@ -4,14 +4,13 @@ from app import notification, on_shutdown, on_startup, scheduler from app.db import GetDB +from app.db.crud.node import get_limited_nodes, get_nodes from app.db.models import Node, NodeStatus from app.models.node import NodeListQuery, NodeNotification from app.node import node_manager from app.operation import OperatorType -from app.db.crud.node import get_limited_nodes, get_nodes -from app.utils.logger import get_logger from app.operation.node import NodeOperation - +from app.utils.logger import get_logger from config import feature_settings, job_settings, runtime_settings node_operator = NodeOperation(operator_type=OperatorType.SYSTEM) @@ -62,22 +61,18 @@ async def verify_node_backend_health(node: PasarGuardNode, node_name: str) -> tu return Health.BROKEN, e.code, e.detail except Exception as e_set_health: error_type_set = type(e_set_health).__name__ - logger.error( - f"[{node_name}] Failed to set health to BROKEN | Error: {error_type_set} - {str(e_set_health)}" - ) + logger.error(f"[{node_name}] Failed to set health to BROKEN | Error: {error_type_set} - {e_set_health!s}") return current_health, e.code, e.detail except Exception as e: error_type = type(e).__name__ - error_message = f"{error_type}: {str(e)}" + error_message = f"{error_type}: {e!s}" logger.error(f"[{node_name}] Health check failed, setting health to BROKEN | Error: {error_message}") try: await node.set_health(Health.BROKEN) return Health.BROKEN, None, error_message except Exception as e_set_health: error_type_set = type(e_set_health).__name__ - logger.error( - f"[{node_name}] Failed to set health to BROKEN | Error: {error_type_set} - {str(e_set_health)}" - ) + logger.error(f"[{node_name}] Failed to set health to BROKEN | Error: {error_type_set} - {e_set_health!s}") return current_health, None, error_message @@ -107,7 +102,7 @@ async def process_node_health_check(db_node: Node, node: PasarGuardNode): try: health, error_code, error_message = await verify_node_backend_health(node, db_node.name) - except asyncio.TimeoutError: + except TimeoutError: # Record timeout error in database but don't reconnect logger.warning(f"[{db_node.name}] Health check timed out") async with GetDB() as db: diff --git a/app/jobs/node_stats.py b/app/jobs/node_stats.py index d3e149f8b..f56b510ac 100644 --- a/app/jobs/node_stats.py +++ b/app/jobs/node_stats.py @@ -9,7 +9,6 @@ from app.utils.logger import get_logger from config import job_settings, runtime_settings, usage_settings - logger = get_logger("jobs") diff --git a/app/jobs/record_usages.py b/app/jobs/record_usages.py index 290241c5f..7a38e11d6 100644 --- a/app/jobs/record_usages.py +++ b/app/jobs/record_usages.py @@ -4,7 +4,7 @@ import time from collections import defaultdict from concurrent.futures import ThreadPoolExecutor -from datetime import datetime as dt, timedelta as td, timezone as tz +from datetime import UTC, datetime as dt, timedelta as td from operator import attrgetter from PasarGuardNodeBridge import NodeAPIError, PasarGuardNode @@ -73,7 +73,7 @@ def _process_node_chunk(chunk_data: tuple) -> dict: Process a chunk of node data - lightweight CPU operation. Uses simple arithmetic and dict operations that release GIL, perfect for threads. """ - node_id, params, coeff = chunk_data + _node_id, params, coeff = chunk_data users_usage = defaultdict(int) for param in params: uid = int(param["uid"]) @@ -313,10 +313,13 @@ async def safe_execute(stmt, params=None, max_retries: int = 2): # Get dialect once before retry loop to avoid repeated DB calls dialect = await get_dialect() - if dialect == "mysql" and isinstance(stmt, Insert): + if ( + dialect == "mysql" + and isinstance(stmt, Insert) + and (not hasattr(stmt, "_post_values_clause") or stmt._post_values_clause is None) + ): # MySQL-specific IGNORE prefix - but skip if using ON DUPLICATE KEY UPDATE - if not hasattr(stmt, "_post_values_clause") or stmt._post_values_clause is None: - statement = stmt.prefix_with("IGNORE") + statement = stmt.prefix_with("IGNORE") for attempt in range(max_retries): try: @@ -365,7 +368,7 @@ async def safe_execute(stmt, params=None, max_retries: int = 2): raise -def _get_time_bucket(now: dt = None) -> dt: +def _get_time_bucket(now: dt | None = None) -> dt: """ Get 10-minute time bucket instead of hourly to reduce hot row contention. This reduces lock contention by 6x (60 minutes / 10 minutes = 6). @@ -377,7 +380,7 @@ def _get_time_bucket(now: dt = None) -> dt: datetime rounded down to 10-minute bucket """ if now is None: - now = dt.now(tz.utc) + now = dt.now(UTC) # Round down to 10-minute bucket: minute // 10 * 10 return now.replace(minute=(now.minute // 10) * 10, second=0, microsecond=0) @@ -715,7 +718,7 @@ async def _record_user_usages_impl(): user_stmt = ( update(User) .where(User.id == bindparam("uid")) - .values(used_traffic=User.used_traffic + bindparam("value"), online_at=dt.now(tz.utc)) + .values(used_traffic=User.used_traffic + bindparam("value"), online_at=dt.now(UTC)) .execution_options(synchronize_session=False) ) async with JOB_SEM: @@ -761,9 +764,9 @@ async def _record_user_usages_impl(): f"{len(filtered_node_params)} nodes" ) - except Exception as e: + except Exception: job_duration = time.time() - job_start_time - logger.error(f"User usage recording failed after {job_duration:.2f}s: {e}", exc_info=True) + logger.exception(f"User usage recording failed after {job_duration:.2f}s") raise @@ -774,7 +777,7 @@ async def record_user_usages(): """ try: await asyncio.wait_for(_record_user_usages_impl(), timeout=120) - except asyncio.TimeoutError: + except TimeoutError: logger.warning("record_user_usages killed after 120s timeout") except asyncio.CancelledError: logger.warning("record_user_usages was cancelled") @@ -860,9 +863,9 @@ async def _record_node_usages_impl(): f"{len(node_update_params)} nodes, total: {total_up + total_down} bytes" ) - except Exception as e: + except Exception: job_duration = time.time() - job_start_time - logger.error(f"Node usage recording failed after {job_duration:.2f}s: {e}", exc_info=True) + logger.exception(f"Node usage recording failed after {job_duration:.2f}s") raise @@ -873,7 +876,7 @@ async def record_node_usages(): """ try: await asyncio.wait_for(_record_node_usages_impl(), timeout=120) - except asyncio.TimeoutError: + except TimeoutError: logger.warning("record_node_usages killed after 120s timeout") except asyncio.CancelledError: logger.warning("record_node_usages was cancelled") @@ -884,7 +887,7 @@ async def record_node_usages(): record_user_usages, "interval", seconds=job_settings.record_user_usages_interval, - start_date=dt.now(tz.utc) + td(seconds=30), + start_date=dt.now(UTC) + td(seconds=30), id="record_user_usages", ) @@ -892,6 +895,6 @@ async def record_node_usages(): record_node_usages, "interval", seconds=job_settings.record_node_usages_interval, - start_date=dt.now(tz.utc) + td(seconds=15), + start_date=dt.now(UTC) + td(seconds=15), id="record_node_usages", ) diff --git a/app/jobs/remove_expired_users.py b/app/jobs/remove_expired_users.py index 48b99bc2c..caaa131df 100644 --- a/app/jobs/remove_expired_users.py +++ b/app/jobs/remove_expired_users.py @@ -1,14 +1,12 @@ import asyncio -from app import scheduler +from app import notification, scheduler from app.db import GetDB from app.db.crud.user import autodelete_expired_users -from app import notification from app.jobs.dependencies import SYSTEM_ADMIN from app.utils.logger import get_logger from config import job_settings, runtime_settings, user_cleanup_settings - logger = get_logger("jobs") diff --git a/app/jobs/reset_node_usage.py b/app/jobs/reset_node_usage.py index a38212492..b49e48c32 100644 --- a/app/jobs/reset_node_usage.py +++ b/app/jobs/reset_node_usage.py @@ -1,15 +1,14 @@ import asyncio -from datetime import datetime as dt, timedelta as td, timezone as tz +from datetime import UTC, datetime as dt, timedelta as td -from app import scheduler +from app import notification, scheduler from app.db import GetDB +from app.db.crud.node import bulk_reset_node_usage, get_nodes_to_reset_usage from app.db.models import NodeStatus -from app.db.crud.node import get_nodes_to_reset_usage, bulk_reset_node_usage +from app.jobs.dependencies import SYSTEM_ADMIN from app.models.node import NodeResponse from app.operation import OperatorType from app.operation.node import NodeOperation -from app import notification -from app.jobs.dependencies import SYSTEM_ADMIN from app.utils.logger import get_logger from config import job_settings, runtime_settings @@ -49,7 +48,7 @@ async def reset_node_data_usage(): "interval", seconds=job_settings.reset_node_usage_interval, coalesce=True, - start_date=dt.now(tz.utc) + td(minutes=1), + start_date=dt.now(UTC) + td(minutes=1), max_instances=1, id="reset_node_usage", replace_existing=True, diff --git a/app/jobs/reset_user_data_usage.py b/app/jobs/reset_user_data_usage.py index 2d496d1b6..28e1998c2 100644 --- a/app/jobs/reset_user_data_usage.py +++ b/app/jobs/reset_user_data_usage.py @@ -1,13 +1,12 @@ import asyncio -from datetime import datetime as dt, timedelta as td, timezone as tz +from datetime import UTC, datetime as dt, timedelta as td -from app import scheduler +from app import notification, scheduler from app.db import GetDB -from app.db.crud.user import get_users_to_reset_data_usage, bulk_reset_user_data_usage +from app.db.crud.user import bulk_reset_user_data_usage, get_users_to_reset_data_usage +from app.jobs.dependencies import SYSTEM_ADMIN from app.operation import OperatorType from app.operation.user import UserOperation -from app import notification -from app.jobs.dependencies import SYSTEM_ADMIN from app.utils.logger import get_logger from config import job_settings, runtime_settings, usage_settings @@ -42,7 +41,7 @@ async def reset_data_usage(): "interval", seconds=job_settings.reset_user_data_usage_interval, coalesce=True, - start_date=dt.now(tz.utc) + td(minutes=1), + start_date=dt.now(UTC) + td(minutes=1), max_instances=1, id="reset_user_data_usage", replace_existing=True, diff --git a/app/jobs/review_admins.py b/app/jobs/review_admins.py index 4c66ac80a..d79507ceb 100644 --- a/app/jobs/review_admins.py +++ b/app/jobs/review_admins.py @@ -9,7 +9,7 @@ handles the active to limited transition and removes affected users from nodes. """ -from datetime import datetime as dt, timezone as tz +from datetime import UTC, datetime as dt from app import notification, scheduler from app.db import GetDB @@ -99,7 +99,7 @@ async def limit_admins_job(): seconds=job_settings.review_admin_limits_interval, coalesce=True, max_instances=1, - start_date=dt.now(tz.utc), + start_date=dt.now(UTC), id="limit_admins", replace_existing=True, ) diff --git a/app/jobs/review_users.py b/app/jobs/review_users.py index 86a1b81fa..cb8bacbd0 100644 --- a/app/jobs/review_users.py +++ b/app/jobs/review_users.py @@ -1,12 +1,12 @@ import asyncio -from datetime import datetime as dt, timedelta as td, timezone as tz +from datetime import UTC, datetime as dt, timedelta as td from sqlalchemy.ext.asyncio import AsyncSession from app import notification, scheduler from app.db import GetDB -from app.db.models import User, UserStatus, ReminderType from app.db.crud.user import ( + bulk_create_notification_reminders, get_active_to_expire_users, get_active_to_limited_users, get_days_left_reached_users, @@ -15,14 +15,13 @@ reset_user_by_next, start_users_expire, update_users_status, - bulk_create_notification_reminders, ) -from app.operation import OperatorType -from app.operation.user import UserOperation +from app.db.models import ReminderType, User, UserStatus from app.jobs.dependencies import SYSTEM_ADMIN from app.models.settings import Webhook from app.models.user import UserNotificationResponse -from app.node import node_manager as node_manager +from app.operation import OperatorType +from app.operation.user import UserOperation from app.settings import webhook_settings from app.utils.logger import get_logger from config import job_settings, runtime_settings, usage_settings @@ -155,7 +154,7 @@ async def days_left_notification_job(): if runtime_settings.role.runs_scheduler: - now = dt.now(tz.utc) + now = dt.now(UTC) interval = int(job_settings.review_users_interval / 5) # Register each job separately diff --git a/app/jobs/send_notifications.py b/app/jobs/send_notifications.py index 373375579..f6c6b7309 100644 --- a/app/jobs/send_notifications.py +++ b/app/jobs/send_notifications.py @@ -1,5 +1,5 @@ import asyncio -from datetime import datetime as dt, timedelta as td, timezone as tz +from datetime import UTC, datetime as dt, timedelta as td import aiohttp from sqlalchemy import delete @@ -58,7 +58,7 @@ async def send_notifications(): processed = 0 failed_to_requeue = [] ready_notifications = [] - current_time = dt.now(tz.utc).timestamp() + current_time = dt.now(UTC).timestamp() should_requeue = settings.enable try: @@ -103,7 +103,7 @@ async def send_notifications(): success = await send_to_all_webhooks(client, payloads, settings.webhooks) if not success: - retry_at = dt.now(tz.utc).timestamp() + retry_at = dt.now(UTC).timestamp() for notification in batch: notification.tries += 1 if notification.tries < settings.recurrent: @@ -125,7 +125,7 @@ async def send_notifications(): async def delete_expired_reminders() -> None: async with GetDB() as db: # Get current UTC time and convert to naive datetime - now_utc = dt.now(tz=tz.utc) + now_utc = dt.now(tz=UTC) now_naive = now_utc.replace(tzinfo=None) result = await db.execute(delete(NotificationReminder).where(NotificationReminder.expires_at < now_naive)) @@ -151,7 +151,7 @@ async def send_pending_notifications_before_shutdown(): delete_expired_reminders, "interval", hours=6, - start_date=dt.now(tz.utc) + td(minutes=5), + start_date=dt.now(UTC) + td(minutes=5), id="delete_expired_notification_reminders", replace_existing=True, ) diff --git a/app/lifecycle.py b/app/lifecycle.py index 2142ff53b..37113591b 100644 --- a/app/lifecycle.py +++ b/app/lifecycle.py @@ -2,7 +2,6 @@ import inspect from contextlib import asynccontextmanager - startup_functions = [] shutdown_functions = [] diff --git a/app/models/api_key.py b/app/models/api_key.py index 5266704ca..0bb2f1818 100644 --- a/app/models/api_key.py +++ b/app/models/api_key.py @@ -1,4 +1,4 @@ -from datetime import datetime as dt, timezone as tz +from datetime import UTC, datetime as dt from typing import Annotated from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -34,7 +34,7 @@ def validate_expire_date(cls, value): if value is None: return None parsed = fix_datetime_timezone(value) - if parsed <= dt.now(tz.utc): + if parsed <= dt.now(UTC): raise ValueError("expire_date must be in the future") return parsed @@ -54,7 +54,7 @@ def validate_expire_date(cls, value): if value is None: return None parsed = fix_datetime_timezone(value) - if parsed <= dt.now(tz.utc): + if parsed <= dt.now(UTC): raise ValueError("expire_date must be in the future") return parsed diff --git a/app/models/host.py b/app/models/host.py index b03216773..74a80afc0 100644 --- a/app/models/host.py +++ b/app/models/host.py @@ -465,7 +465,7 @@ def normalize_allowed_ips(cls, value): if value in (None, "", []): return None if not isinstance(value, list): - raise ValueError("allowed_ips must be a list of CIDR strings") + raise TypeError("allowed_ips must be a list of CIDR strings") normalized: list[str] = [] for cidr in value: if not isinstance(cidr, str) or not cidr.strip(): diff --git a/app/models/node.py b/app/models/node.py index fd3c2c442..a3c0f732c 100644 --- a/app/models/node.py +++ b/app/models/node.py @@ -120,7 +120,6 @@ def validate_certificate(cls, v: str | None) -> str | None: try: load_pem_x509_certificate(v.encode("utf-8")) - pass except Exception: raise ValueError("Invalid certificate structure") diff --git a/app/models/protocol.py b/app/models/protocol.py index e5866f132..c981cb170 100644 --- a/app/models/protocol.py +++ b/app/models/protocol.py @@ -10,7 +10,7 @@ class ProxyProtocol(IntEnum): hysteria = 6 @classmethod - def from_value(cls, value: str) -> "ProxyProtocol" | None: + def from_value(cls, value: str) -> ProxyProtocol | None: try: return _PROXY_PROTOCOL_BY_NAME[value] except KeyError: diff --git a/app/models/settings.py b/app/models/settings.py index ae9ddb2a5..f549017c4 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -269,7 +269,7 @@ class CustomVariable(BaseModel): @classmethod def normalize_key(cls, value): if not isinstance(value, str): - raise ValueError("Variable key must be a string") + raise TypeError("Variable key must be a string") value = value.strip() if value.startswith("{") and value.endswith("}"): value = value[1:-1].strip() diff --git a/app/models/validators.py b/app/models/validators.py index cc1127300..cbef936b4 100644 --- a/app/models/validators.py +++ b/app/models/validators.py @@ -1,7 +1,7 @@ import re -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from decimal import Decimal -from typing import Any, Optional +from typing import Any from urllib.parse import urlparse from app.db.models import UserStatus, UserStatusCreate @@ -26,7 +26,7 @@ def cast_to_int(v): """ if v is None: # Allow None values return v - elif isinstance(v, float) or isinstance(v, Decimal): # Allow float or Decimal to int conversion + elif isinstance(v, (float, Decimal)): # Allow float or Decimal to int conversion return int(v) elif isinstance(v, int): # Allow integers directly return v @@ -49,7 +49,7 @@ def cast_to_float(v): """ if v is None: return v - elif isinstance(v, int) or isinstance(v, Decimal): + elif isinstance(v, (int, Decimal)): return float(v) elif isinstance(v, float): return v @@ -200,7 +200,7 @@ def validator_on_hold_timeout(value): if value == 0 or value is None: return None if isinstance(value, int): - return datetime.now(timezone.utc) + timedelta(seconds=value) + return datetime.now(UTC) + timedelta(seconds=value) if isinstance(value, datetime): return value raise ValueError("on_hold_timeout can be datetime or int (seconds)") @@ -258,7 +258,7 @@ def validate_webhook(value: str | None): class URLValidator: @staticmethod - def validate_url(value: Optional[str]) -> Optional[str]: + def validate_url(value: str | None) -> str | None: """ Validates a general-purpose URL. Examples: diff --git a/app/nats/__init__.py b/app/nats/__init__.py index 5c97154b6..03b736130 100644 --- a/app/nats/__init__.py +++ b/app/nats/__init__.py @@ -19,4 +19,4 @@ def require_nats_if_multiworker(multi_worker: bool): ) -__all__ = ["is_nats_enabled", "get_nats_config", "require_nats_if_multiworker"] +__all__ = ["get_nats_config", "is_nats_enabled", "require_nats_if_multiworker"] diff --git a/app/nats/router.py b/app/nats/router.py index 32c94e4c8..65f46e980 100644 --- a/app/nats/router.py +++ b/app/nats/router.py @@ -1,6 +1,6 @@ import asyncio import json -from typing import Awaitable, Callable +from collections.abc import Awaitable, Callable import nats @@ -56,15 +56,15 @@ async def _listen(self): if handler: try: await handler(message.data) - except Exception as exc: - logger.error(f"Handler error for topic {message.topic.value}: {exc}", exc_info=True) + except Exception: + logger.exception(f"Handler error for topic {message.topic.value}") else: logger.warning(f"No handler registered for topic: {message.topic.value}") except asyncio.CancelledError: raise - except Exception as exc: - logger.error(f"NATS router listener stopped: {exc}", exc_info=True) + except Exception: + logger.exception("NATS router listener stopped") finally: self._running = False logger.info("NATS message router stopped") @@ -89,7 +89,7 @@ async def stop(self): self._listener_task.cancel() try: await asyncio.wait_for(self._listener_task, timeout=2.0) - except asyncio.CancelledError, asyncio.TimeoutError: + except TimeoutError, asyncio.CancelledError: pass if self._nc: diff --git a/app/nats/rpc_service.py b/app/nats/rpc_service.py index e02de11ab..921173108 100644 --- a/app/nats/rpc_service.py +++ b/app/nats/rpc_service.py @@ -1,6 +1,6 @@ import asyncio import json -from typing import Awaitable, Callable +from collections.abc import Awaitable, Callable import nats from nats.aio.subscription import Subscription diff --git a/app/node/sync.py b/app/node/sync.py index 676b6d051..75d383f57 100644 --- a/app/node/sync.py +++ b/app/node/sync.py @@ -3,8 +3,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import async_object_session -from app.db.models import Admin, AdminRole, AdminStatus -from app.db.models import User +from app.db.models import Admin, AdminRole, AdminStatus, User from app.models.user import UserNotificationResponse from app.nats.node_rpc import encode_node_command, node_nats_client from app.nats.proto_utils import serialize_proto_message, serialize_proto_messages diff --git a/app/node/user.py b/app/node/user.py index 0da8a8a06..1a0ca1000 100644 --- a/app/node/user.py +++ b/app/node/user.py @@ -57,7 +57,7 @@ async def serialize_user(user: User, allowed_protocols: frozenset[ProxyProtocol] def _serialize_user_for_node( id: int, user_settings: dict, - inbounds: list[str] = None, + inbounds: list[str] | None = None, allowed_protocols: frozenset[ProxyProtocol] | None = None, ) -> ProtoUser: allowed_protocols = allowed_protocols or _ALL_PROXY_PROTOCOLS diff --git a/app/node/worker.py b/app/node/worker.py index 134faeb92..6a8fb66d5 100644 --- a/app/node/worker.py +++ b/app/node/worker.py @@ -13,8 +13,8 @@ from app.db.crud.node import get_node_by_id, get_nodes from app.db.models import NodeStatus from app.models.node import NodeCoreUpdate, NodeGeoFilesUpdate, NodeListQuery -from app.nats.rpc_service import BaseRpcService from app.nats.proto_utils import deserialize_proto_message, deserialize_proto_messages +from app.nats.rpc_service import BaseRpcService from app.node import node_manager from app.operation import OperatorType from app.operation.node import NodeOperation @@ -109,8 +109,8 @@ async def _run_command(self, action: str | None, data: dict): async with self._command_semaphore: try: await self._dispatch_command(action, data) - except Exception as exc: - logger.error(f"Node command failed: {action} - {exc}", exc_info=True) + except Exception: + logger.exception(f"Node command failed: {action}") async def _run_rpc(self, msg, action: str | None, data: dict): async with self._rpc_semaphore: @@ -214,7 +214,7 @@ async def _get_node_system_stats(self, data: dict) -> dict: stats = await self._node_operator.get_node_system_stats(node_id) return stats.model_dump() - async def _get_nodes_system_stats(self, data: dict = None) -> dict: + async def _get_nodes_system_stats(self, data: dict | None = None) -> dict: stats = await self._node_operator.get_nodes_system_stats() return {node_id: value.model_dump() if value else None for node_id, value in stats.items()} diff --git a/app/notification/__init__.py b/app/notification/__init__.py index 95c9eaa6b..9de726c2b 100644 --- a/app/notification/__init__.py +++ b/app/notification/__init__.py @@ -3,11 +3,11 @@ from app.models.admin import AdminDetails from app.models.admin_role import AdminRoleResponse +from app.models.api_key import APIKeyResponse from app.models.core import CoreResponse from app.models.group import GroupResponse from app.models.host import BaseHost from app.models.node import NodeNotification, NodeResponse -from app.models.api_key import APIKeyResponse from app.models.user import UserNotificationResponse from app.models.user_template import UserTemplateResponse from app.settings import notification_enable diff --git a/app/notification/client.py b/app/notification/client.py index e36ad4c8b..0a60e2183 100644 --- a/app/notification/client.py +++ b/app/notification/client.py @@ -68,7 +68,7 @@ async def _send_discord_webhook_direct(json_data, webhook, max_retries: int) -> logger.error(f"Discord webhook failed: {response.status} - {response_text}") return False except Exception as err: - logger.error(f"Discord webhook failed Exception: {str(err)}") + logger.error(f"Discord webhook failed Exception: {err!s}") return False logger.error(f"Discord webhook failed after {max_retries} retries") @@ -129,7 +129,7 @@ async def _send_telegram_message_direct( logger.error(f"Telegram message failed: {response.status} - {response_text}") return False except Exception as err: - logger.error(f"Telegram message failed: {str(err)}") + logger.error(f"Telegram message failed: {err!s}") return False logger.error(f"Telegram message failed after {max_retries} retries") diff --git a/app/notification/discord/__init__.py b/app/notification/discord/__init__.py index ea98eab0c..f1275711a 100644 --- a/app/notification/discord/__init__.py +++ b/app/notification/discord/__init__.py @@ -30,44 +30,44 @@ from .user_template import create_user_template, modify_user_template, remove_user_template __all__ = [ + "admin_login", + "admin_reset_usage", + "admin_usage_limit_reached", + "connect_node", + "create_admin", "create_admin_role", - "modify_admin_role", - "remove_admin_role", "create_api_key", - "modify_api_key", - "remove_api_key", + "create_core", + "create_group", "create_host", - "modify_host", - "remove_host", - "modify_hosts", - "create_user_template", - "modify_user_template", - "remove_user_template", "create_node", - "modify_node", - "remove_node", - "connect_node", - "recovered_node", + "create_user", + "create_user_template", "error_node", "limited_node", - "create_group", - "modify_group", - "remove_group", - "create_core", - "modify_core", - "remove_core", - "create_admin", "modify_admin", - "remove_admin", - "admin_reset_usage", - "admin_usage_limit_reached", - "admin_login", - "user_status_change", - "create_user", + "modify_admin_role", + "modify_api_key", + "modify_core", + "modify_group", + "modify_host", + "modify_hosts", + "modify_node", "modify_user", + "modify_user_template", + "recovered_node", + "remove_admin", + "remove_admin_role", + "remove_api_key", + "remove_core", + "remove_group", + "remove_host", + "remove_node", "remove_user", + "remove_user_template", + "reset_node_usage", "reset_user_data_usage", "user_data_reset_by_next", + "user_status_change", "user_subscription_revoked", - "reset_node_usage", ] diff --git a/app/notification/discord/admin_role.py b/app/notification/discord/admin_role.py index 44ff4d10c..3a0191565 100644 --- a/app/notification/discord/admin_role.py +++ b/app/notification/discord/admin_role.py @@ -1,7 +1,7 @@ -from app.notification.client import send_discord_webhook -from app.notification.helpers import get_discord_webhook from app.models.admin_role import AdminRoleResponse from app.models.settings import NotificationSettings +from app.notification.client import send_discord_webhook +from app.notification.helpers import get_discord_webhook from app.settings import notification_settings from app.utils.helpers import escape_ds_markdown_list diff --git a/app/notification/discord/core.py b/app/notification/discord/core.py index c0f152c52..9115bb931 100644 --- a/app/notification/discord/core.py +++ b/app/notification/discord/core.py @@ -1,7 +1,7 @@ -from app.notification.client import send_discord_webhook -from app.notification.helpers import get_discord_webhook from app.models.core import CoreResponse from app.models.settings import NotificationSettings +from app.notification.client import send_discord_webhook +from app.notification.helpers import get_discord_webhook from app.settings import notification_settings from app.utils.helpers import escape_ds_markdown diff --git a/app/notification/discord/group.py b/app/notification/discord/group.py index 165e30aee..eb723a5d1 100644 --- a/app/notification/discord/group.py +++ b/app/notification/discord/group.py @@ -1,9 +1,9 @@ -from app.notification.client import send_discord_webhook -from app.notification.helpers import get_discord_webhook from app.models.group import GroupResponse from app.models.settings import NotificationSettings +from app.notification.client import send_discord_webhook +from app.notification.helpers import get_discord_webhook from app.settings import notification_settings -from app.utils.helpers import escape_ds_markdown_list, escape_ds_markdown +from app.utils.helpers import escape_ds_markdown, escape_ds_markdown_list from . import colors, messages diff --git a/app/notification/discord/host.py b/app/notification/discord/host.py index 64887455a..58552ccad 100644 --- a/app/notification/discord/host.py +++ b/app/notification/discord/host.py @@ -1,12 +1,12 @@ -from app.notification.client import send_discord_webhook -from app.notification.helpers import get_discord_webhook from app.models.host import BaseHost from app.models.settings import NotificationSettings +from app.notification.client import send_discord_webhook +from app.notification.helpers import get_discord_webhook from app.settings import notification_settings -from app.utils.helpers import escape_ds_markdown_list, escape_ds_markdown +from app.utils.helpers import escape_ds_markdown, escape_ds_markdown_list -from .utils import escape_md_host from . import colors, messages +from .utils import escape_md_host ENTITY = "host" diff --git a/app/notification/discord/node.py b/app/notification/discord/node.py index e69c997b8..adf9fbe1a 100644 --- a/app/notification/discord/node.py +++ b/app/notification/discord/node.py @@ -1,9 +1,9 @@ -from app.notification.client import send_discord_webhook -from app.notification.helpers import get_discord_webhook from app.models.node import NodeNotification, NodeResponse from app.models.settings import NotificationSettings +from app.notification.client import send_discord_webhook +from app.notification.helpers import get_discord_webhook from app.settings import notification_settings -from app.utils.helpers import escape_ds_markdown_list, escape_ds_markdown +from app.utils.helpers import escape_ds_markdown, escape_ds_markdown_list from app.utils.system import readable_size from . import colors, messages diff --git a/app/notification/discord/user.py b/app/notification/discord/user.py index 8383d309e..3ef73fb18 100644 --- a/app/notification/discord/user.py +++ b/app/notification/discord/user.py @@ -41,9 +41,8 @@ async def user_status_change(user: UserNotificationResponse, by: str): if settings.notify_discord: webhook = get_discord_webhook(settings, ENTITY) await send_discord_webhook(data, webhook) - if user.admin and user.admin.discord_webhook: - if should_send_admin_notification(user.admin, "status_change"): - await send_discord_webhook(data, user.admin.discord_webhook) + if user.admin and user.admin.discord_webhook and should_send_admin_notification(user.admin, "status_change"): + await send_discord_webhook(data, user.admin.discord_webhook) async def create_user(user: UserNotificationResponse, by: str): @@ -67,9 +66,8 @@ async def create_user(user: UserNotificationResponse, by: str): if settings.notify_discord: webhook = get_discord_webhook(settings, ENTITY) await send_discord_webhook(data, webhook) - if user.admin and user.admin.discord_webhook: - if should_send_admin_notification(user.admin, "create"): - await send_discord_webhook(data, user.admin.discord_webhook) + if user.admin and user.admin.discord_webhook and should_send_admin_notification(user.admin, "create"): + await send_discord_webhook(data, user.admin.discord_webhook) async def modify_user(user: UserNotificationResponse, by: str): @@ -93,9 +91,8 @@ async def modify_user(user: UserNotificationResponse, by: str): if settings.notify_discord: webhook = get_discord_webhook(settings, ENTITY) await send_discord_webhook(data, webhook) - if user.admin and user.admin.discord_webhook: - if should_send_admin_notification(user.admin, "modify"): - await send_discord_webhook(data, user.admin.discord_webhook) + if user.admin and user.admin.discord_webhook and should_send_admin_notification(user.admin, "modify"): + await send_discord_webhook(data, user.admin.discord_webhook) async def remove_user(user: UserNotificationResponse, by: str): @@ -112,9 +109,8 @@ async def remove_user(user: UserNotificationResponse, by: str): if settings.notify_discord: webhook = get_discord_webhook(settings, ENTITY) await send_discord_webhook(data, webhook) - if user.admin and user.admin.discord_webhook: - if should_send_admin_notification(user.admin, "delete"): - await send_discord_webhook(data, user.admin.discord_webhook) + if user.admin and user.admin.discord_webhook and should_send_admin_notification(user.admin, "delete"): + await send_discord_webhook(data, user.admin.discord_webhook) async def reset_user_data_usage(user: UserNotificationResponse, by: str): @@ -134,9 +130,8 @@ async def reset_user_data_usage(user: UserNotificationResponse, by: str): if settings.notify_discord: webhook = get_discord_webhook(settings, ENTITY) await send_discord_webhook(data, webhook) - if user.admin and user.admin.discord_webhook: - if should_send_admin_notification(user.admin, "reset_data_usage"): - await send_discord_webhook(data, user.admin.discord_webhook) + if user.admin and user.admin.discord_webhook and should_send_admin_notification(user.admin, "reset_data_usage"): + await send_discord_webhook(data, user.admin.discord_webhook) async def user_data_reset_by_next(user: UserNotificationResponse, by: str): @@ -157,9 +152,8 @@ async def user_data_reset_by_next(user: UserNotificationResponse, by: str): if settings.notify_discord: webhook = get_discord_webhook(settings, ENTITY) await send_discord_webhook(data, webhook) - if user.admin and user.admin.discord_webhook: - if should_send_admin_notification(user.admin, "data_reset_by_next"): - await send_discord_webhook(data, user.admin.discord_webhook) + if user.admin and user.admin.discord_webhook and should_send_admin_notification(user.admin, "data_reset_by_next"): + await send_discord_webhook(data, user.admin.discord_webhook) async def user_subscription_revoked(user: UserNotificationResponse, by: str): @@ -176,6 +170,5 @@ async def user_subscription_revoked(user: UserNotificationResponse, by: str): if settings.notify_discord: webhook = get_discord_webhook(settings, ENTITY) await send_discord_webhook(data, webhook) - if user.admin and user.admin.discord_webhook: - if should_send_admin_notification(user.admin, "subscription_revoked"): - await send_discord_webhook(data, user.admin.discord_webhook) + if user.admin and user.admin.discord_webhook and should_send_admin_notification(user.admin, "subscription_revoked"): + await send_discord_webhook(data, user.admin.discord_webhook) diff --git a/app/notification/discord/user_template.py b/app/notification/discord/user_template.py index 5e879a439..56d0fe9b6 100644 --- a/app/notification/discord/user_template.py +++ b/app/notification/discord/user_template.py @@ -1,7 +1,7 @@ +from app.models.settings import NotificationSettings +from app.models.user_template import UserTemplateResponse from app.notification.client import send_discord_webhook from app.notification.helpers import get_discord_webhook -from app.models.user_template import UserTemplateResponse -from app.models.settings import NotificationSettings from app.settings import notification_settings from app.utils.helpers import escape_ds_markdown_list diff --git a/app/notification/discord/utils.py b/app/notification/discord/utils.py index 86ee3b5a0..46f3f2e46 100644 --- a/app/notification/discord/utils.py +++ b/app/notification/discord/utils.py @@ -1,8 +1,8 @@ -from app.utils.helpers import escape_ds_markdown_list -from app.models.user import UserNotificationResponse +from app.models.core import CoreResponse from app.models.host import BaseHost +from app.models.user import UserNotificationResponse from app.models.user_template import UserTemplateResponse -from app.models.core import CoreResponse +from app.utils.helpers import escape_ds_markdown_list def escape_md_user(user: UserNotificationResponse, by: str) -> tuple[str, str, str]: diff --git a/app/notification/nats_queue.py b/app/notification/nats_queue.py index d8e17a967..56ab7b564 100644 --- a/app/notification/nats_queue.py +++ b/app/notification/nats_queue.py @@ -85,7 +85,7 @@ async def enqueue(self, item: dict): try: await self._js.publish(self.SUBJECT, data) return - except (asyncio.TimeoutError, nats_errors.TimeoutError) as err: + except (TimeoutError, nats_errors.TimeoutError) as err: if attempt == PUBLISH_TIMEOUT_MAX_ATTEMPTS - 1: raise @@ -113,7 +113,7 @@ async def dequeue(self, timeout: int | None = None): except Exception: await msg.nak() # Negative ack on parse error return None - except asyncio.TimeoutError: + except TimeoutError: return None except Exception: return None @@ -132,6 +132,6 @@ async def dequeue(self, timeout: int | None = None): if timeout: try: return await asyncio.wait_for(self.q.get(), timeout=timeout) - except asyncio.TimeoutError: + except TimeoutError: return None return await self.q.get() diff --git a/app/notification/queue_manager.py b/app/notification/queue_manager.py index 920d5eb96..ca7e5e91e 100644 --- a/app/notification/queue_manager.py +++ b/app/notification/queue_manager.py @@ -1,10 +1,10 @@ import asyncio -from typing import Literal, Optional +from typing import Literal from pydantic import BaseModel, Field from app.nats import is_nats_enabled -from app.notification.nats_queue import NatsNotificationQueue, InMemoryNotificationQueue, NotificationQueue +from app.notification.nats_queue import InMemoryNotificationQueue, NatsNotificationQueue, NotificationQueue from config import nats_settings, runtime_settings @@ -13,8 +13,8 @@ class TelegramNotification(BaseModel): type: Literal["telegram"] = Field(default="telegram") message: str - chat_id: Optional[int] = Field(default=None) - topic_id: Optional[int] = Field(default=None) + chat_id: int | None = Field(default=None) + topic_id: int | None = Field(default=None) tries: int = Field(default=0) @@ -93,7 +93,7 @@ async def shutdown_queue(queue: NotificationQueue): if queue._nc and not queue._nc.is_closed: try: await asyncio.wait_for(queue._nc.close(), timeout=3) - except asyncio.TimeoutError: + except TimeoutError: # Don't block shutdown if NATS is slow to close pass except Exception: @@ -116,7 +116,7 @@ def get_webhook_queue() -> NotificationQueue: return webhook_queue_instance -async def enqueue_telegram(message: str, chat_id: Optional[int] = None, topic_id: Optional[int] = None) -> None: +async def enqueue_telegram(message: str, chat_id: int | None = None, topic_id: int | None = None) -> None: """Add a Telegram notification to the queue""" notification = TelegramNotification(message=message, chat_id=chat_id, topic_id=topic_id) await get_queue().enqueue(notification.model_dump()) diff --git a/app/notification/telegram/__init__.py b/app/notification/telegram/__init__.py index 77ec75583..5c3973c79 100644 --- a/app/notification/telegram/__init__.py +++ b/app/notification/telegram/__init__.py @@ -30,44 +30,44 @@ from .user_template import create_user_template, modify_user_template, remove_user_template __all__ = [ + "admin_login", + "admin_reset_usage", + "admin_usage_limit_reached", + "connect_node", + "create_admin", "create_admin_role", - "modify_admin_role", - "remove_admin_role", "create_api_key_tg", - "modify_api_key_tg", - "remove_api_key_tg", + "create_core", + "create_group", "create_host", - "modify_host", - "remove_host", - "modify_hosts", - "create_user_template", - "modify_user_template", - "remove_user_template", "create_node", - "modify_node", - "remove_node", - "connect_node", - "recovered_node", + "create_user", + "create_user_template", "error_node", "limited_node", - "reset_node_usage", - "create_group", - "modify_group", - "remove_group", - "create_core", - "modify_core", - "remove_core", - "create_admin", "modify_admin", - "remove_admin", - "admin_reset_usage", - "admin_usage_limit_reached", - "admin_login", - "user_status_change", - "create_user", + "modify_admin_role", + "modify_api_key_tg", + "modify_core", + "modify_group", + "modify_host", + "modify_hosts", + "modify_node", "modify_user", + "modify_user_template", + "recovered_node", + "remove_admin", + "remove_admin_role", + "remove_api_key_tg", + "remove_core", + "remove_group", + "remove_host", + "remove_node", "remove_user", + "remove_user_template", + "reset_node_usage", "reset_user_data_usage", "user_data_reset_by_next", + "user_status_change", "user_subscription_revoked", ] diff --git a/app/notification/telegram/admin_role.py b/app/notification/telegram/admin_role.py index 9f1664c33..8d34ab6df 100644 --- a/app/notification/telegram/admin_role.py +++ b/app/notification/telegram/admin_role.py @@ -1,9 +1,10 @@ -from app.notification.client import send_telegram_message -from app.notification.helpers import get_telegram_channel from app.models.admin_role import AdminRoleResponse from app.models.settings import NotificationSettings +from app.notification.client import send_telegram_message +from app.notification.helpers import get_telegram_channel from app.settings import notification_settings from app.utils.helpers import escape_tg_html + from . import messages ENTITY = "admin_role" diff --git a/app/notification/telegram/core.py b/app/notification/telegram/core.py index e85e31f4e..1e630f1a8 100644 --- a/app/notification/telegram/core.py +++ b/app/notification/telegram/core.py @@ -1,13 +1,13 @@ from html import escape -from app.notification.client import send_telegram_message -from app.notification.helpers import get_telegram_channel from app.models.core import CoreResponse from app.models.settings import NotificationSettings +from app.notification.client import send_telegram_message +from app.notification.helpers import get_telegram_channel from app.settings import notification_settings -from .utils import escape_html_core from . import messages +from .utils import escape_html_core ENTITY = "core" diff --git a/app/notification/telegram/group.py b/app/notification/telegram/group.py index 798fe0c3d..be43a0441 100644 --- a/app/notification/telegram/group.py +++ b/app/notification/telegram/group.py @@ -1,11 +1,12 @@ from html import escape -from app.notification.client import send_telegram_message -from app.notification.helpers import get_telegram_channel from app.models.group import GroupResponse from app.models.settings import NotificationSettings +from app.notification.client import send_telegram_message +from app.notification.helpers import get_telegram_channel from app.settings import notification_settings from app.utils.helpers import escape_tg_html + from . import messages ENTITY = "group" diff --git a/app/notification/telegram/host.py b/app/notification/telegram/host.py index 4c9b6418f..ce58731b5 100644 --- a/app/notification/telegram/host.py +++ b/app/notification/telegram/host.py @@ -1,14 +1,14 @@ from html import escape -from app.notification.client import send_telegram_message -from app.notification.helpers import get_telegram_channel from app.models.host import BaseHost from app.models.settings import NotificationSettings +from app.notification.client import send_telegram_message +from app.notification.helpers import get_telegram_channel from app.settings import notification_settings from app.utils.helpers import escape_tg_html -from .utils import escape_html_host from . import messages +from .utils import escape_html_host ENTITY = "host" diff --git a/app/notification/telegram/node.py b/app/notification/telegram/node.py index c5041fe57..3f1bedd87 100644 --- a/app/notification/telegram/node.py +++ b/app/notification/telegram/node.py @@ -1,12 +1,13 @@ from html import escape -from app.notification.client import send_telegram_message -from app.notification.helpers import get_telegram_channel from app.models.node import NodeNotification, NodeResponse from app.models.settings import NotificationSettings +from app.notification.client import send_telegram_message +from app.notification.helpers import get_telegram_channel from app.settings import notification_settings from app.utils.helpers import escape_tg_html from app.utils.system import readable_size + from . import messages ENTITY = "node" diff --git a/app/notification/telegram/user.py b/app/notification/telegram/user.py index 628558310..c5ebed416 100644 --- a/app/notification/telegram/user.py +++ b/app/notification/telegram/user.py @@ -31,9 +31,8 @@ async def user_status_change(user: UserNotificationResponse, by: str): if settings.notify_telegram: chat_id, topic_id = get_telegram_channel(settings, ENTITY) await send_telegram_message(data, chat_id, topic_id) - if user.admin and user.admin.telegram_id: - if should_send_admin_notification(user.admin, "status_change"): - await send_telegram_message(data, chat_id=user.admin.telegram_id) + if user.admin and user.admin.telegram_id and should_send_admin_notification(user.admin, "status_change"): + await send_telegram_message(data, chat_id=user.admin.telegram_id) async def create_user(user: UserNotificationResponse, by: str): @@ -52,9 +51,8 @@ async def create_user(user: UserNotificationResponse, by: str): if settings.notify_telegram: chat_id, topic_id = get_telegram_channel(settings, ENTITY) await send_telegram_message(data, chat_id, topic_id) - if user.admin and user.admin.telegram_id: - if should_send_admin_notification(user.admin, "create"): - await send_telegram_message(data, chat_id=user.admin.telegram_id) + if user.admin and user.admin.telegram_id and should_send_admin_notification(user.admin, "create"): + await send_telegram_message(data, chat_id=user.admin.telegram_id) async def modify_user(user: UserNotificationResponse, by: str): @@ -73,9 +71,8 @@ async def modify_user(user: UserNotificationResponse, by: str): if settings.notify_telegram: chat_id, topic_id = get_telegram_channel(settings, ENTITY) await send_telegram_message(data, chat_id, topic_id) - if user.admin and user.admin.telegram_id: - if should_send_admin_notification(user.admin, "modify"): - await send_telegram_message(data, chat_id=user.admin.telegram_id) + if user.admin and user.admin.telegram_id and should_send_admin_notification(user.admin, "modify"): + await send_telegram_message(data, chat_id=user.admin.telegram_id) async def remove_user(user: UserNotificationResponse, by: str): @@ -89,9 +86,8 @@ async def remove_user(user: UserNotificationResponse, by: str): if settings.notify_telegram: chat_id, topic_id = get_telegram_channel(settings, ENTITY) await send_telegram_message(data, chat_id, topic_id) - if user.admin and user.admin.telegram_id: - if should_send_admin_notification(user.admin, "delete"): - await send_telegram_message(data, chat_id=user.admin.telegram_id) + if user.admin and user.admin.telegram_id and should_send_admin_notification(user.admin, "delete"): + await send_telegram_message(data, chat_id=user.admin.telegram_id) async def reset_user_data_usage(user: UserNotificationResponse, by: str): @@ -106,9 +102,8 @@ async def reset_user_data_usage(user: UserNotificationResponse, by: str): if settings.notify_telegram: chat_id, topic_id = get_telegram_channel(settings, ENTITY) await send_telegram_message(data, chat_id, topic_id) - if user.admin and user.admin.telegram_id: - if should_send_admin_notification(user.admin, "reset_data_usage"): - await send_telegram_message(data, chat_id=user.admin.telegram_id) + if user.admin and user.admin.telegram_id and should_send_admin_notification(user.admin, "reset_data_usage"): + await send_telegram_message(data, chat_id=user.admin.telegram_id) async def user_data_reset_by_next(user: UserNotificationResponse, by: str): @@ -124,9 +119,8 @@ async def user_data_reset_by_next(user: UserNotificationResponse, by: str): if settings.notify_telegram: chat_id, topic_id = get_telegram_channel(settings, ENTITY) await send_telegram_message(data, chat_id, topic_id) - if user.admin and user.admin.telegram_id: - if should_send_admin_notification(user.admin, "data_reset_by_next"): - await send_telegram_message(data, chat_id=user.admin.telegram_id) + if user.admin and user.admin.telegram_id and should_send_admin_notification(user.admin, "data_reset_by_next"): + await send_telegram_message(data, chat_id=user.admin.telegram_id) async def user_subscription_revoked(user: UserNotificationResponse, by: str): @@ -140,6 +134,5 @@ async def user_subscription_revoked(user: UserNotificationResponse, by: str): if settings.notify_telegram: chat_id, topic_id = get_telegram_channel(settings, ENTITY) await send_telegram_message(data, chat_id, topic_id) - if user.admin and user.admin.telegram_id: - if should_send_admin_notification(user.admin, "subscription_revoked"): - await send_telegram_message(data, chat_id=user.admin.telegram_id) + if user.admin and user.admin.telegram_id and should_send_admin_notification(user.admin, "subscription_revoked"): + await send_telegram_message(data, chat_id=user.admin.telegram_id) diff --git a/app/notification/telegram/user_template.py b/app/notification/telegram/user_template.py index e29df5325..a4e244204 100644 --- a/app/notification/telegram/user_template.py +++ b/app/notification/telegram/user_template.py @@ -1,12 +1,12 @@ +from app.models.settings import NotificationSettings +from app.models.user_template import UserTemplateResponse from app.notification.client import send_telegram_message from app.notification.helpers import get_telegram_channel -from app.models.user_template import UserTemplateResponse -from app.models.settings import NotificationSettings from app.settings import notification_settings from app.utils.helpers import escape_tg_html -from .utils import escape_html_template from . import messages +from .utils import escape_html_template ENTITY = "user_template" diff --git a/app/notification/telegram/utils.py b/app/notification/telegram/utils.py index 1d3cb6ed4..b3cb91c22 100644 --- a/app/notification/telegram/utils.py +++ b/app/notification/telegram/utils.py @@ -1,8 +1,8 @@ -from app.utils.helpers import escape_tg_html -from app.models.user import UserNotificationResponse +from app.models.core import CoreResponse from app.models.host import BaseHost +from app.models.user import UserNotificationResponse from app.models.user_template import UserTemplateResponse -from app.models.core import CoreResponse +from app.utils.helpers import escape_tg_html def escape_html_user(user: UserNotificationResponse, by: str) -> tuple[str, str, str]: diff --git a/app/notification/webhook/__init__.py b/app/notification/webhook/__init__.py index 7e0abe748..672169884 100644 --- a/app/notification/webhook/__init__.py +++ b/app/notification/webhook/__init__.py @@ -1,19 +1,18 @@ -from datetime import datetime as dt, timezone as tz +from datetime import UTC, datetime as dt from enum import Enum -from typing import Type from fastapi.encoders import jsonable_encoder from pydantic import BaseModel, Field -from app.settings import webhook_settings from app.models.admin import AdminDetails from app.models.user import UserNotificationResponse, UserStatus from app.notification.queue_manager import enqueue_webhook +from app.settings import webhook_settings def get_current_timestamp() -> float: """Factory function to get current timestamp""" - return dt.now(tz.utc).timestamp() + return dt.now(UTC).timestamp() class Notification(BaseModel): @@ -118,18 +117,16 @@ async def status_change(user: UserNotificationResponse): await notify(UserExpired(username=user.username, user=user)) elif user.status == UserStatus.disabled: await notify(UserDisabled(username=user.username, user=user)) - elif user.status == UserStatus.active: - await notify(UserEnabled(username=user.username, user=user)) - elif user.status == UserStatus.on_hold: + elif user.status == UserStatus.active or user.status == UserStatus.on_hold: await notify(UserEnabled(username=user.username, user=user)) -async def notify(message: Type[Notification]) -> None: +async def notify(message: type[Notification]) -> None: if (await webhook_settings()).enable: await enqueue_webhook(jsonable_encoder(message)) -async def bulk_notify(messages: list[Type[Notification]]) -> None: +async def bulk_notify(messages: list[type[Notification]]) -> None: if (await webhook_settings()).enable: for message in messages: await enqueue_webhook(jsonable_encoder(message)) diff --git a/app/operation/__init__.py b/app/operation/__init__.py index 4005c6041..53232d0c6 100644 --- a/app/operation/__init__.py +++ b/app/operation/__init__.py @@ -1,6 +1,6 @@ -from datetime import datetime as dt, timedelta as td, timezone as tz -from enum import IntEnum import re +from datetime import UTC, datetime as dt, timedelta as td +from enum import IntEnum from typing import Any from fastapi import HTTPException @@ -9,8 +9,8 @@ from app.db import AsyncSession from app.db.crud import ( get_admin, - get_core_config_by_id, get_client_template_by_id, + get_core_config_by_id, get_group_by_id, get_host_by_id, get_node_by_id, @@ -20,12 +20,12 @@ from app.db.crud.admin import get_admin_by_id from app.db.crud.group import get_groups_by_ids from app.db.crud.user import get_user_by_id -from app.db.models import Admin as DBAdmin, CoreConfig, ClientTemplate, Group, Node, ProxyHost, User, UserTemplate +from app.db.models import Admin as DBAdmin, ClientTemplate, CoreConfig, Group, Node, ProxyHost, User, UserTemplate from app.models.admin import AdminDetails from app.models.group import BulkGroup from app.models.user import UserCreate, UserModify -from app.utils.helpers import ensure_datetime_timezone from app.operation.permissions import get_scope_admin_id +from app.utils.helpers import ensure_datetime_timezone from app.utils.jwt import get_subscription_payload @@ -115,9 +115,9 @@ async def validate_dates(self, start: dt | None, end: dt | None, set_default_val if set_default_values: if not start_date: - start_date = dt.now(tz.utc) - td(days=30) + start_date = dt.now(UTC) - td(days=30) if not end_date: - end_date = dt.now(tz.utc) + end_date = dt.now(UTC) # Validate that start and end have the same timezone if start_date and end_date: @@ -130,7 +130,7 @@ async def validate_dates(self, start: dt | None, end: dt | None, set_default_val return start_date, end_date except ValueError as e: - await self.raise_error(message=f"Invalid date range or format: {str(e)}", code=400) + await self.raise_error(message=f"Invalid date range or format: {e!s}", code=400) async def get_validated_host(self, db: AsyncSession, host_id: int) -> ProxyHost: db_host = await get_host_by_id(db, host_id) @@ -150,8 +150,8 @@ async def get_validated_sub(self, db: AsyncSession, token: str, *, load_admin_ro if ( not db_user - or db_user.created_at.astimezone(tz.utc) > sub["created_at"] - or (db_user.sub_revoked_at and db_user.sub_revoked_at.astimezone(tz.utc) > sub["created_at"]) + or db_user.created_at.astimezone(UTC) > sub["created_at"] + or (db_user.sub_revoked_at and db_user.sub_revoked_at.astimezone(UTC) > sub["created_at"]) ): await self.raise_error(message="Not Found", code=404) diff --git a/app/operation/admin.py b/app/operation/admin.py index a9e819f7c..0d7766a89 100644 --- a/app/operation/admin.py +++ b/app/operation/admin.py @@ -120,9 +120,13 @@ async def _modify_admin( ): await self.raise_error(message="You're not allowed to change your own role.", code=403) - if not current_admin.is_owner and is_self: - if modified_admin.status is not None and modified_admin.status == AdminStatus.disabled: - await self.raise_error(message="You're not allowed to disable your own account.", code=403) + if ( + not current_admin.is_owner + and is_self + and modified_admin.status is not None + and modified_admin.status == AdminStatus.disabled + ): + await self.raise_error(message="You're not allowed to disable your own account.", code=403) if modified_admin.telegram_id: existing_admins = await find_admins_by_telegram_id( @@ -335,8 +339,8 @@ async def _get_admin_usage( db: AsyncSession, db_admin: Admin, admin: AdminDetails, - start: dt = None, - end: dt = None, + start: dt | None = None, + end: dt | None = None, period: Period = Period.hour, node_id: int | None = None, group_by_node: bool = False, diff --git a/app/operation/admin_role.py b/app/operation/admin_role.py index 853167349..67fc81b3e 100644 --- a/app/operation/admin_role.py +++ b/app/operation/admin_role.py @@ -19,8 +19,8 @@ AdminRoleListQuery, AdminRoleModify, AdminRoleResponse, - AdminRolesResponse, AdminRoleSimple, + AdminRolesResponse, AdminRolesSimpleResponse, ) from app.operation import BaseOperation diff --git a/app/operation/admin_sync.py b/app/operation/admin_sync.py index f7fee5a52..022cf8e44 100644 --- a/app/operation/admin_sync.py +++ b/app/operation/admin_sync.py @@ -3,8 +3,7 @@ from app.db.crud.user import get_users from app.db.models import Admin, AdminStatus, UserStatus from app.models.user import UserListQuery -from app.node.sync import remove_users as sync_remove_users -from app.node.sync import sync_users +from app.node.sync import remove_users as sync_remove_users, sync_users async def admin_users_sync_blocked(admin: Admin) -> bool: diff --git a/app/operation/api_key.py b/app/operation/api_key.py index 0ee2b9169..49765bb19 100644 --- a/app/operation/api_key.py +++ b/app/operation/api_key.py @@ -1,4 +1,4 @@ -from datetime import datetime as dt, timezone as tz +from datetime import UTC, datetime as dt from sqlalchemy.exc import IntegrityError @@ -13,23 +13,23 @@ revoke_api_key as revoke_api_key_crud, update_api_key, ) -from app.notification import ( - create_api_key as notify_create, - modify_api_key as notify_modify, - remove_api_key as notify_delete, -) from app.models.admin import AdminDetails from app.models.admin_role import RolePermissions from app.models.api_key import ( APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, - APIKeyUpdate, APIKeysQuery, APIKeysResponse, + APIKeyUpdate, BulkAPIKeySelection, RemoveAPIKeysResponse, ) +from app.notification import ( + create_api_key as notify_create, + modify_api_key as notify_modify, + remove_api_key as notify_delete, +) from app.operation import BaseOperation @@ -102,7 +102,7 @@ async def create_api_key( if duplicates: await self.raise_error(message="API key name already exists", code=409) - if model.expire_date is not None and model.expire_date <= dt.now(tz.utc): + if model.expire_date is not None and model.expire_date <= dt.now(UTC): await self.raise_error(message="expire_date must be in the future", code=422) try: diff --git a/app/operation/client_template.py b/app/operation/client_template.py index 062bef28c..6c218aefa 100644 --- a/app/operation/client_template.py +++ b/app/operation/client_template.py @@ -85,7 +85,7 @@ async def _validate_template_content(self, template_type: ClientTemplateType, co if not out: raise ValueError("Subscription template content must contain at least one outbound proxy") except Exception as exc: - await self.raise_error(message=f"Invalid template content: {str(exc)}", code=400) + await self.raise_error(message=f"Invalid template content: {exc!s}", code=400) async def create_client_template( self, diff --git a/app/operation/core.py b/app/operation/core.py index 6ee3edeef..dc6f6f767 100644 --- a/app/operation/core.py +++ b/app/operation/core.py @@ -27,14 +27,14 @@ CoreListQuery, CoreResponse, CoreResponseList, - CoreSimpleListQuery, CoreSimple, + CoreSimpleListQuery, CoresSimpleResponse, CoreType, RemoveCoresResponse, ) -from app.node.sync import sync_users from app.models.reality_scan import RealityScanRequest, RealityScanResult +from app.node.sync import sync_users from app.operation import BaseOperation from app.utils.logger import get_logger from app.utils.reality_scan import RealityScanError, scan_reality_target diff --git a/app/operation/group.py b/app/operation/group.py index 394e5537f..28bd4da85 100644 --- a/app/operation/group.py +++ b/app/operation/group.py @@ -14,24 +14,24 @@ remove_groups, ) from app.db.crud.user import get_users +from app.db.crud.wireguard import sync_users_allocations from app.db.models import Admin from app.models.group import ( - BulkGroupsActionResponse, BulkGroup, + BulkGroupsActionResponse, BulkGroupSelection, Group, GroupCreate, GroupListQuery, GroupModify, GroupResponse, + GroupSimple, GroupSimpleListQuery, GroupsResponse, - GroupSimple, GroupsSimpleResponse, RemoveGroupsResponse, ) from app.models.user import BulkOperationDryRunResponse, UserListQuery -from app.db.crud.wireguard import sync_users_allocations from app.node.sync import sync_users from app.operation import BaseOperation, OperatorType from app.operation.permissions import apply_group_access diff --git a/app/operation/host.py b/app/operation/host.py index 459b0e153..b9d4fb052 100644 --- a/app/operation/host.py +++ b/app/operation/host.py @@ -1,32 +1,30 @@ import asyncio +from app import notification +from app.core.hosts import host_manager from app.db import AsyncSession +from app.db.crud.host import ( + create_host, + get_host_by_id, + get_hosts, + modify_host, + remove_host, + remove_hosts, +) from app.db.models import ProxyHost from app.models.admin import AdminDetails from app.models.client_template import ClientTemplateType from app.models.host import ( BaseHost, - BulkHostSelection, BulkHostsActionResponse, + BulkHostSelection, CreateHost, HostListQuery, RemoveHostsResponse, ) from app.operation import BaseOperation -from app.db.crud.host import ( - create_host, - get_host_by_id, - get_hosts, - modify_host, - remove_host, - remove_hosts, -) -from app.core.hosts import host_manager from app.utils.logger import get_logger -from app import notification - - logger = get_logger("host-operation") diff --git a/app/operation/node.py b/app/operation/node.py index cfc24f5ea..bb89b1df7 100644 --- a/app/operation/node.py +++ b/app/operation/node.py @@ -1,5 +1,5 @@ import asyncio -from typing import AsyncIterator, Callable +from collections.abc import AsyncIterator, Callable from fastapi import HTTPException from PasarGuardNodeBridge import NodeAPIError, PasarGuardNode @@ -537,7 +537,7 @@ async def clear_usage_data(self, db: AsyncSession, table: UsageTable, query: Nod await clear_usage_data(db, table, query.start, query.end) return {"detail": f"All data from '{table}' has been deleted successfully."} except Exception as e: - await self.raise_error(code=400, message=f"Deletion failed due to server error: {str(e)}") + await self.raise_error(code=400, message=f"Deletion failed due to server error: {e!s}") async def update_node(self, db: AsyncSession, node_id: int) -> dict: await self.get_validated_node(db, node_id) diff --git a/app/operation/settings.py b/app/operation/settings.py index 19c3e7d45..5c0b7d15e 100644 --- a/app/operation/settings.py +++ b/app/operation/settings.py @@ -2,14 +2,15 @@ from sqlalchemy.ext.asyncio import AsyncSession -from app.db.models import Settings from app.db.crud.settings import get_settings, modify_settings +from app.db.models import Settings from app.models.settings import General, SettingsSchema, Subscription from app.nats.message import MessageTopic from app.nats.router import router -from app.settings import refresh_caches from app.notification.client import define_client +from app.settings import refresh_caches from app.telegram import startup_telegram_bot + from . import BaseOperation diff --git a/app/operation/subscription.py b/app/operation/subscription.py index d8a4cc35a..1df08bee4 100644 --- a/app/operation/subscription.py +++ b/app/operation/subscription.py @@ -1,6 +1,6 @@ import re from json import dumps as json_dumps -from typing import Any +from typing import Any, ClassVar from fastapi import Response from fastapi.responses import HTMLResponse @@ -86,7 +86,7 @@ class SubscriptionOperation(BaseOperation): - _ENCODED_RULE_RESPONSE_HEADERS = {"announce", "profile-title"} + _ENCODED_RULE_RESPONSE_HEADERS: ClassVar[set[str]] = {"announce", "profile-title"} @staticmethod async def validated_user(db_user: User) -> UsersResponseWithInbounds: diff --git a/app/operation/user.py b/app/operation/user.py index 443d9b430..68e4a6459 100644 --- a/app/operation/user.py +++ b/app/operation/user.py @@ -3,7 +3,7 @@ import secrets import warnings from collections import Counter -from datetime import datetime, datetime as dt, timedelta as td, timezone, timezone as tz +from datetime import UTC, datetime, datetime as dt, timedelta as td from fastapi import HTTPException from pydantic import ValidationError @@ -83,11 +83,11 @@ UsernameGenerationStrategy, UserNotificationResponse, UserResponse, - UserStatusToggle, UserSimple, UserSimpleListQuery, UsersResponse, UsersSimpleResponse, + UserStatusToggle, UserSubscriptionUpdateChart, UserSubscriptionUpdateChartSegment, UserSubscriptionUpdateList, @@ -170,9 +170,9 @@ def _duplicate_wireguard_public_key_usernames(users: list[UserCreate]) -> tuple[ def _resolve_enabled_user_status(user: User) -> UserStatus: - now = dt.now(tz.utc) + now = dt.now(UTC) expire = user.expire - if expire is not None and expire.replace(tzinfo=tz.utc) <= now: + if expire is not None and expire.replace(tzinfo=UTC) <= now: return UserStatus.expired if user.data_limit is not None and user.data_limit > 0 and user.used_traffic >= user.data_limit: return UserStatus.limited @@ -532,7 +532,7 @@ async def _enforce_user_limits( if expire is not None and expire != 0: expire_dt = fix_datetime_timezone(expire) - seconds = (expire_dt - datetime.now(timezone.utc)).total_seconds() + seconds = (expire_dt - datetime.now(UTC)).total_seconds() if limits.expire_min is not None and seconds < limits.expire_min: await self.raise_error( message=f"Expire must be at least {readable_duration(limits.expire_min)} from now", @@ -573,7 +573,7 @@ async def _enforce_user_limits( if on_hold_timeout is not None and on_hold_timeout != 0: if isinstance(on_hold_timeout, dt): - timeout_seconds = (fix_datetime_timezone(on_hold_timeout) - datetime.now(timezone.utc)).total_seconds() + timeout_seconds = (fix_datetime_timezone(on_hold_timeout) - datetime.now(UTC)).total_seconds() else: timeout_seconds = on_hold_timeout @@ -771,20 +771,24 @@ async def _prepare_modified_user( require_finite_hwid_limit=hwid_limit_was_changed, ) - if hwid_limit_was_changed and effective_hwid_limit is not None and not admin.is_owner: - if effective_hwid_conf is not None: - if effective_hwid_conf.min_limit is not None and effective_hwid_limit < effective_hwid_conf.min_limit: - await self.raise_error( - message=f"HWID limit cannot be less than {effective_hwid_conf.min_limit}", code=400, db=db - ) - if ( - effective_hwid_conf.max_limit is not None - and effective_hwid_conf.max_limit > 0 - and (effective_hwid_limit > effective_hwid_conf.max_limit or effective_hwid_limit == 0) - ): - await self.raise_error( - message=f"HWID limit cannot exceed {effective_hwid_conf.max_limit}", code=400, db=db - ) + if ( + hwid_limit_was_changed + and effective_hwid_limit is not None + and not admin.is_owner + and effective_hwid_conf is not None + ): + if effective_hwid_conf.min_limit is not None and effective_hwid_limit < effective_hwid_conf.min_limit: + await self.raise_error( + message=f"HWID limit cannot be less than {effective_hwid_conf.min_limit}", code=400, db=db + ) + if ( + effective_hwid_conf.max_limit is not None + and effective_hwid_conf.max_limit > 0 + and (effective_hwid_limit > effective_hwid_conf.max_limit or effective_hwid_limit == 0) + ): + await self.raise_error( + message=f"HWID limit cannot exceed {effective_hwid_conf.max_limit}", code=400, db=db + ) validated_groups = None if modified_user.group_ids is not None: @@ -859,7 +863,7 @@ async def _set_user_disabled( if db_user.status != new_status: db_user.status = new_status - db_user.last_status_change = dt.now(tz.utc) + db_user.last_status_change = dt.now(UTC) await db.commit() await load_user_attrs(db_user, load_admin_role=True) @@ -1180,7 +1184,7 @@ async def _bulk_set_users_disabled( changed_user_ids: list[int] = [] original_statuses: dict[int, UserStatus] = {} - changed_at = dt.now(tz.utc) + changed_at = dt.now(UTC) try: for db_user in db_users: @@ -1333,8 +1337,8 @@ async def _get_user_usage( db: AsyncSession, db_user: User, admin: AdminDetails, - start: dt = None, - end: dt = None, + start: dt | None = None, + end: dt | None = None, period: Period = Period.hour, node_id: int | None = None, group_by_node: bool = False, @@ -1597,14 +1601,14 @@ def load_base_user_args(template: UserTemplate) -> dict: if template.status == UserStatus.active: if template.expire_duration: - user_args["expire"] = dt.now(tz.utc) + td(seconds=template.expire_duration) + user_args["expire"] = dt.now(UTC) + td(seconds=template.expire_duration) else: user_args["expire"] = 0 else: user_args["expire"] = 0 user_args["on_hold_expire_duration"] = template.expire_duration if template.on_hold_timeout: - user_args["on_hold_timeout"] = dt.now(tz.utc) + td(seconds=template.on_hold_timeout) + user_args["on_hold_timeout"] = dt.now(UTC) + td(seconds=template.on_hold_timeout) else: user_args["on_hold_timeout"] = 0 @@ -1657,10 +1661,7 @@ async def create_user_from_template( if user_template.is_disabled: await self.raise_error("this template is disabled", 403) - try: - new_user = self._build_user_create_from_template(user_template, new_template_user) - except HTTPException as exc: - raise exc + new_user = self._build_user_create_from_template(user_template, new_template_user) # Template defines data_limit/expire/etc — only check max_users await self._enforce_user_limits( diff --git a/app/operation/user_template.py b/app/operation/user_template.py index f06b92476..c67e56693 100644 --- a/app/operation/user_template.py +++ b/app/operation/user_template.py @@ -1,9 +1,9 @@ +import asyncio + from sqlalchemy.exc import IntegrityError +from app import notification from app.db import AsyncSession -import asyncio - -from app.db.models import Admin, UserTemplate from app.db.crud.user_template import ( create_user_template, get_user_templates, @@ -13,8 +13,7 @@ remove_user_template, remove_user_templates, ) -from app.operation import BaseOperation -from app.operation.permissions import apply_template_access +from app.db.models import Admin, UserTemplate from app.models.user_template import ( BulkUserTemplatesActionResponse, BulkUserTemplateSelection, @@ -23,12 +22,13 @@ UserTemplateListQuery, UserTemplateModify, UserTemplateResponse, - UserTemplateSimpleListQuery, UserTemplateSimple, + UserTemplateSimpleListQuery, UserTemplatesSimpleResponse, ) +from app.operation import BaseOperation +from app.operation.permissions import apply_template_access from app.utils.logger import get_logger -from app import notification logger = get_logger("user-template-operation") diff --git a/app/routers/admin.py b/app/routers/admin.py index f5d49fadb..7ae023427 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -12,9 +12,9 @@ AdminListQuery, AdminModify, AdminSimpleListQuery, - AdminStatus, AdminsResponse, AdminsSimpleResponse, + AdminStatus, AdminUsageQuery, BulkAdminsActionResponse, BulkAdminSelection, diff --git a/app/routers/api_key.py b/app/routers/api_key.py index 6e81a6ce5..01c2d7adf 100644 --- a/app/routers/api_key.py +++ b/app/routers/api_key.py @@ -7,9 +7,9 @@ APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, - APIKeyUpdate, APIKeysQuery, APIKeysResponse, + APIKeyUpdate, BulkAPIKeySelection, RemoveAPIKeysResponse, ) diff --git a/app/routers/authentication.py b/app/routers/authentication.py index eaaa5ef7d..02890a0da 100644 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -1,4 +1,4 @@ -from datetime import timezone as tz +from datetime import UTC from uuid import UUID from aiogram.utils.web_app import WebAppInitData, safe_parse_webapp_init_data @@ -42,7 +42,7 @@ def _is_token_valid_for_admin(db_admin: Admin, payload: dict) -> bool: return True if not payload.get("created_at"): return False - return db_admin.password_reset_at.astimezone(tz.utc) <= payload.get("created_at") + return db_admin.password_reset_at.astimezone(UTC) <= payload.get("created_at") def _extract_api_key(request: Request) -> str | None: diff --git a/app/routers/dependencies/__init__.py b/app/routers/dependencies/__init__.py index 809769f43..719555a95 100644 --- a/app/routers/dependencies/__init__.py +++ b/app/routers/dependencies/__init__.py @@ -25,10 +25,10 @@ __all__ = [ # admin "get_admin_list_query", - "get_admin_simple_list_query", - "get_admin_usage_query", # admin_role "get_admin_role_list_query", + "get_admin_simple_list_query", + "get_admin_usage_query", # api_key "get_api_key_list_query", # client_template @@ -37,6 +37,8 @@ # core "get_core_list_query", "get_core_simple_list_query", + # user + "get_expired_users_query", # group "get_group_list_query", "get_group_simple_list_query", @@ -51,13 +53,11 @@ # subscription "get_subscription_headers", "get_subscription_usage_query", - # user - "get_expired_users_query", "get_user_list_query", "get_user_simple_list_query", - "get_user_usage_query", - "get_users_usage_query", # user_template "get_user_template_list_query", "get_user_template_simple_list_query", + "get_user_usage_query", + "get_users_usage_query", ] diff --git a/app/routers/dependencies/_common.py b/app/routers/dependencies/_common.py index b36ab0c62..e61ff430b 100644 --- a/app/routers/dependencies/_common.py +++ b/app/routers/dependencies/_common.py @@ -1,10 +1,10 @@ +from collections.abc import Callable from dataclasses import dataclass from inspect import Parameter, Signature -from typing import Any, Callable, cast +from typing import Any, cast from fastapi import Header, HTTPException, Query, status -from fastapi.params import Header as HeaderParam -from fastapi.params import Query as QueryParam +from fastapi.params import Header as HeaderParam, Query as QueryParam from pydantic import BaseModel, ValidationError from pydantic_core import PydanticUndefined diff --git a/app/routers/group.py b/app/routers/group.py index a6ced4efb..056533fd6 100644 --- a/app/routers/group.py +++ b/app/routers/group.py @@ -20,9 +20,9 @@ from app.operation import OperatorType from app.operation.group import GroupOperation from app.utils import responses -from .dependencies import get_group_list_query, get_group_simple_list_query from .authentication import require_permission +from .dependencies import get_group_list_query, get_group_simple_list_query router = APIRouter(prefix="/api/group", tags=["Groups"], responses={401: responses._401, 403: responses._403}) group_operator = GroupOperation(OperatorType.API) diff --git a/app/routers/host.py b/app/routers/host.py index 98e7d994d..dab342e01 100644 --- a/app/routers/host.py +++ b/app/routers/host.py @@ -2,7 +2,7 @@ from app.db import AsyncSession, get_db from app.models.admin import AdminDetails -from app.models.host import BaseHost, BulkHostSelection, BulkHostsActionResponse, CreateHost, RemoveHostsResponse +from app.models.host import BaseHost, BulkHostsActionResponse, BulkHostSelection, CreateHost, RemoveHostsResponse from app.operation import OperatorType from app.operation.host import HostOperation from app.utils import responses diff --git a/app/routers/hwid.py b/app/routers/hwid.py index 31a5c8847..547026fb3 100644 --- a/app/routers/hwid.py +++ b/app/routers/hwid.py @@ -6,6 +6,7 @@ from app.operation import OperatorType from app.operation.hwid import HWIDOperation from app.utils import responses + from .authentication import require_permission hwid_operator = HWIDOperation(operator_type=OperatorType.API) diff --git a/app/routers/node.py b/app/routers/node.py index 014dfd34a..505a84fa4 100644 --- a/app/routers/node.py +++ b/app/routers/node.py @@ -1,5 +1,6 @@ import asyncio -from typing import Annotated, AsyncGenerator +from collections.abc import AsyncGenerator +from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Request, status from PasarGuardNodeBridge import NodeAPIError @@ -62,7 +63,7 @@ async def _node_logs_local(node_id: int, request: Request) -> EventSourceResponse: context_manager = await node_operator.get_logs(node_id=node_id) - async def event_generator() -> AsyncGenerator[str, None]: + async def event_generator() -> AsyncGenerator[str]: try: async with context_manager() as log_queue: while True: @@ -84,7 +85,7 @@ async def event_generator() -> AsyncGenerator[str, None]: async def _node_logs_remote(node_id: int, request: Request) -> EventSourceResponse: - async def event_generator() -> AsyncGenerator[str, None]: + async def event_generator() -> AsyncGenerator[str]: sub = None stop_subject = None nc = await node_nats_client.get_client() @@ -107,7 +108,7 @@ async def event_generator() -> AsyncGenerator[str, None]: break try: msg = await sub.next_msg(timeout=1) - except asyncio.TimeoutError: + except TimeoutError: continue yield msg.data.decode() except asyncio.CancelledError: diff --git a/app/routers/system.py b/app/routers/system.py index d987417e7..0052bf42d 100644 --- a/app/routers/system.py +++ b/app/routers/system.py @@ -6,9 +6,9 @@ from fastapi.responses import JSONResponse from app.db import AsyncSession, get_db +from app.db.crud.wireguard import get_subnet_usage from app.models.admin import AdminDetails from app.models.settings import Telegram -from app.db.crud.wireguard import get_subnet_usage from app.models.system import ( InboundSummary, SystemResourceStats, diff --git a/app/routers/user.py b/app/routers/user.py index 269aafc80..ab715fd8b 100644 --- a/app/routers/user.py +++ b/app/routers/user.py @@ -13,10 +13,10 @@ from app.models.user import ( BulkUser, BulkUsersActionResponse, + BulkUsersApplyTemplate, BulkUsersCreateResponse, BulkUsersFromTemplate, BulkUsersProxy, - BulkUsersApplyTemplate, BulkUsersSelection, BulkUsersSetOwner, CreateUserFromTemplate, @@ -28,19 +28,21 @@ UserModify, UserResponse, UserSimpleListQuery, - UserStatusToggle, - UserUsageQuery, UsersResponse, UsersSimpleResponse, - UsersUsageQuery, + UserStatusToggle, UserSubscriptionUpdateChart, UserSubscriptionUpdateList, + UsersUsageQuery, + UserUsageQuery, ) from app.operation import OperatorType from app.operation.node import NodeOperation from app.operation.subscription import SubscriptionOperation from app.operation.user import UserOperation from app.utils import responses + +from .authentication import require_permission, require_scope_all from .dependencies import ( get_expired_users_query, get_user_list_query, @@ -49,8 +51,6 @@ get_users_usage_query, ) -from .authentication import require_permission, require_scope_all - user_operator = UserOperation(operator_type=OperatorType.API) node_operator = NodeOperation(operator_type=OperatorType.API) subscription_operator = SubscriptionOperation(operator_type=OperatorType.API) diff --git a/app/routers/user_template.py b/app/routers/user_template.py index 1e3db54f0..c357af8fc 100644 --- a/app/routers/user_template.py +++ b/app/routers/user_template.py @@ -1,8 +1,7 @@ -from fastapi import Depends, APIRouter, status +from fastapi import APIRouter, Depends, status from app.db import AsyncSession, get_db from app.models.admin import AdminDetails -from .authentication import require_permission from app.models.user_template import ( BulkUserTemplatesActionResponse, BulkUserTemplateSelection, @@ -15,8 +14,9 @@ from app.operation import OperatorType from app.operation.user_template import UserTemplateOperation from app.utils import responses -from .dependencies import get_user_template_list_query, get_user_template_simple_list_query +from .authentication import require_permission +from .dependencies import get_user_template_list_query, get_user_template_simple_list_query router = APIRouter(tags=["User Template"], prefix="/api/user_template") template_operator = UserTemplateOperation(OperatorType.API) diff --git a/app/scheduler.py b/app/scheduler.py index 0196767a4..539b09b4c 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -1,4 +1,3 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler - scheduler = AsyncIOScheduler(job_defaults={"max_instances": 30}, timezone="UTC") diff --git a/app/subscription/__init__.py b/app/subscription/__init__.py index 9e7a4de81..ed96ec793 100644 --- a/app/subscription/__init__.py +++ b/app/subscription/__init__.py @@ -1,18 +1,18 @@ from .base import BaseSubscription +from .clash import ClashConfiguration, ClashMetaConfiguration from .links import StandardLinks -from .xray import XrayConfiguration -from .singbox import SingBoxConfiguration from .outline import OutlineConfiguration -from .clash import ClashConfiguration, ClashMetaConfiguration +from .singbox import SingBoxConfiguration from .wireguard import WireGuardConfiguration +from .xray import XrayConfiguration __all__ = [ "BaseSubscription", - "XrayConfiguration", - "StandardLinks", - "SingBoxConfiguration", - "OutlineConfiguration", "ClashConfiguration", "ClashMetaConfiguration", + "OutlineConfiguration", + "SingBoxConfiguration", + "StandardLinks", "WireGuardConfiguration", + "XrayConfiguration", ] diff --git a/app/subscription/clash.py b/app/subscription/clash.py index ea8adf4ec..961f3a4ad 100644 --- a/app/subscription/clash.py +++ b/app/subscription/clash.py @@ -427,9 +427,7 @@ def _apply_transport( # Build transport config if network == "ws": net_opts = handler(inbound.transport_config, path, is_httpupgrade, random_user_agent) - elif network == "http": - net_opts = handler(inbound.transport_config, path, random_user_agent) - elif network == "xhttp": + elif network == "http" or network == "xhttp": net_opts = handler(inbound.transport_config, path, random_user_agent) else: net_opts = handler(inbound.transport_config, path) diff --git a/app/subscription/links.py b/app/subscription/links.py index 187577cc8..89e36a586 100644 --- a/app/subscription/links.py +++ b/app/subscription/links.py @@ -63,7 +63,7 @@ def add_link(self, link): def render(self): if subscription_env_settings.external_config: self.links.append(subscription_env_settings.external_config) - return "\n".join((self.links)) + return "\n".join(self.links) def add(self, remark: str, address: str, inbound: SubscriptionInboundData, settings: dict): """ @@ -196,11 +196,8 @@ def _apply_tls_settings(self, payload: dict, tls_config: TLSConfig, fragment_set payload["alpn"] = tls_config.alpn_links # Fragment settings (from inbound, not TLS) - if fragment_settings: - if xray_fragment := fragment_settings.get("xray"): - payload["fragment"] = ( - f"{xray_fragment['length']},{xray_fragment['interval']},{xray_fragment['packets']}" - ) + if fragment_settings and (xray_fragment := fragment_settings.get("xray")): + payload["fragment"] = f"{xray_fragment['length']},{xray_fragment['interval']},{xray_fragment['packets']}" if tls_config.ech_config_list: payload["ech"] = tls_config.ech_config_list diff --git a/app/subscription/share.py b/app/subscription/share.py index 7f8313f8f..9790713c5 100644 --- a/app/subscription/share.py +++ b/app/subscription/share.py @@ -2,7 +2,7 @@ import random import secrets from collections import defaultdict -from datetime import datetime as dt, timedelta, timezone +from datetime import UTC, datetime as dt, timedelta from jdatetime import date as jd @@ -15,6 +15,7 @@ from app.settings import subscription_settings from app.subscription.client_templates import subscription_client_templates, subscription_xray_templates from app.utils.system import readable_size + from . import ( ClashConfiguration, ClashMetaConfiguration, @@ -188,7 +189,7 @@ def setup_format_variables(user: UsersResponseWithInbounds, custom_variables: li user_status = user.status expire = user.expire on_hold_expire_duration = user.on_hold_expire_duration - now = dt.now(timezone.utc) + now = dt.now(UTC) admin_username = "" if admin_data := user.admin: @@ -231,8 +232,7 @@ def setup_format_variables(user: UsersResponseWithInbounds, custom_variables: li data_left = user.data_limit - user.used_traffic usage_Percentage = round((user.used_traffic / user.data_limit) * 100.0, 2) - if data_left < 0: - data_left = 0 + data_left = max(data_left, 0) data_left = readable_size(data_left) else: data_limit = "∞" diff --git a/app/subscription/xray.py b/app/subscription/xray.py index f21740614..63c4aac36 100644 --- a/app/subscription/xray.py +++ b/app/subscription/xray.py @@ -239,16 +239,13 @@ def _transport_tcp(self, config: TCPTransportConfig, path: str) -> dict: else: tcp_settings = {"header": {"type": headers}} - if any((path, host, config.random_user_agent)): - if "request" not in tcp_settings["header"]: - tcp_settings["header"]["request"] = {} - - if any((config.random_user_agent, host)): - if ( - "headers" not in tcp_settings["header"]["request"] - or tcp_settings["header"]["request"]["headers"] is None - ): - tcp_settings["header"]["request"]["headers"] = {} + if any((path, host, config.random_user_agent)) and "request" not in tcp_settings["header"]: + tcp_settings["header"]["request"] = {} + + if any((config.random_user_agent, host)) and ( + "headers" not in tcp_settings["header"]["request"] or tcp_settings["header"]["request"]["headers"] is None + ): + tcp_settings["header"]["request"]["headers"] = {} if path: tcp_settings["header"]["request"]["path"] = [path] diff --git a/app/telegram/__init__.py b/app/telegram/__init__.py index 21a4a23ad..e0b70fe42 100644 --- a/app/telegram/__init__.py +++ b/app/telegram/__init__.py @@ -80,7 +80,7 @@ async def _try_claim_webhook_initiator(self, settings: Telegram) -> bool: try: # Set up KV connection if not already done if not self._kv: - self._nats_conn, js, self._kv = await setup_nats_kv(nats_settings.telegram_kv_bucket) + self._nats_conn, _js, self._kv = await setup_nats_kv(nats_settings.telegram_kv_bucket) if not self._kv: logger.warning("NATS KV unavailable, allowing this worker to set webhook") return True diff --git a/app/telegram/fsm_storage.py b/app/telegram/fsm_storage.py index 659238643..c9a42760c 100644 --- a/app/telegram/fsm_storage.py +++ b/app/telegram/fsm_storage.py @@ -79,7 +79,7 @@ def create_isolation( self, lock_ttl: float = DEFAULT_LOCK_TTL_SECONDS, retry_delay: float = DEFAULT_LOCK_RETRY_DELAY_SECONDS, - ) -> "NatsEventIsolation": + ) -> NatsEventIsolation: return NatsEventIsolation( storage=self, key_builder=self.key_builder, @@ -351,7 +351,7 @@ async def _release_distributed_lock(self, kv: KeyValue, lock_key: str, token: st logger.warning(f"Failed to release Telegram FSM lock in NATS KV: {exc}") @asynccontextmanager - async def lock(self, key: StorageKey) -> AsyncGenerator[None, None]: + async def lock(self, key: StorageKey) -> AsyncGenerator[None]: lock_key = self.storage.build_kv_key(key, "lock", key_builder=self.key_builder) kv = await self.storage.ensure_kv() diff --git a/app/telegram/handlers/__init__.py b/app/telegram/handlers/__init__.py index 1d74283b8..cd1838b7b 100644 --- a/app/telegram/handlers/__init__.py +++ b/app/telegram/handlers/__init__.py @@ -1,6 +1,6 @@ from aiogram import Dispatcher -from . import admin, base, error_handler, client +from . import admin, base, client, error_handler def include_routers(dp: Dispatcher) -> None: diff --git a/app/telegram/handlers/admin/__init__.py b/app/telegram/handlers/admin/__init__.py index be527b5e7..f2b9af5d1 100644 --- a/app/telegram/handlers/admin/__init__.py +++ b/app/telegram/handlers/admin/__init__.py @@ -1,8 +1,8 @@ from aiogram import Router from app.telegram.utils.filters import IsAdminFilter -from . import main_menu, user, confirm_action, bulk_actions +from . import bulk_actions, confirm_action, main_menu, user router = Router(name="admin") diff --git a/app/telegram/handlers/admin/bulk_actions.py b/app/telegram/handlers/admin/bulk_actions.py index 1b2deb26c..aec73828e 100644 --- a/app/telegram/handlers/admin/bulk_actions.py +++ b/app/telegram/handlers/admin/bulk_actions.py @@ -1,6 +1,6 @@ -from datetime import datetime as dt, timezone as tz, timedelta as td +from datetime import UTC, datetime as dt, timedelta as td -from aiogram import Router, F +from aiogram import F, Router from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, Message @@ -16,14 +16,14 @@ from app.telegram.keyboards.admin import AdminPanel, AdminPanelAction from app.telegram.keyboards.base import CancelKeyboard from app.telegram.keyboards.bulk_actions import ( - BulkActionPanel, BulkAction, + BulkActionPanel, BulkTemplateSelector, UsernameStrategySelector, ) from app.telegram.keyboards.confim_action import ConfirmAction from app.telegram.utils import forms -from app.telegram.utils.filters import IsScopeAll, HasPermission +from app.telegram.utils.filters import HasPermission, IsScopeAll from app.telegram.utils.shared import add_to_messages_to_delete, delete_messages from app.telegram.utils.texts import Message as Texts @@ -289,13 +289,13 @@ async def process_expire_before(event: Message, state: FSMContext): async def delete_expired_done( event: CallbackQuery, db: AsyncSession, admin: AdminDetails, callback_data: BulkActionPanel.Callback ): - expire_before = dt.now(tz.utc) - td(days=int(callback_data.amount)) + expire_before = dt.now(UTC) - td(days=int(callback_data.amount)) result = await user_operations.delete_expired_users( db, admin, ExpiredUsersQuery( expired_before=expire_before, - expired_after=dt.fromtimestamp(0, tz.utc), + expired_after=dt.fromtimestamp(0, UTC), ), ) await event.answer(Texts.users_deleted(result.count)) diff --git a/app/telegram/handlers/admin/confirm_action.py b/app/telegram/handlers/admin/confirm_action.py index dad00a7c5..fdef18c5c 100644 --- a/app/telegram/handlers/admin/confirm_action.py +++ b/app/telegram/handlers/admin/confirm_action.py @@ -9,7 +9,6 @@ from app.telegram.keyboards.user import UserPanel, UserPanelAction from app.telegram.utils.texts import Message as Texts - router = Router(name="confirm_action") user_operations = UserOperation(OperatorType.TELEGRAM) diff --git a/app/telegram/handlers/admin/user.py b/app/telegram/handlers/admin/user.py index 189669246..34332494c 100644 --- a/app/telegram/handlers/admin/user.py +++ b/app/telegram/handlers/admin/user.py @@ -1,5 +1,5 @@ import random -from datetime import datetime as dt, timedelta as td +from datetime import UTC, datetime as dt, timedelta as td from io import BytesIO from aiogram import F, Router @@ -244,10 +244,10 @@ async def process_done(event: CallbackQuery, db: AsyncSession, admin: AdminDetai data["status"] = UserStatus.on_hold data["on_hold_expire_duration"] = td(days=duration).total_seconds() if duration else 0 timeout = data.get("on_hold_timeout") - data["on_hold_timeout"] = (dt.now() + td(days=timeout)) if timeout else None + data["on_hold_timeout"] = (dt.now(UTC) + td(days=timeout)) if timeout else None else: data["status"] = UserStatus.active - data["expire"] = (dt.now() + td(days=duration)) if duration else None + data["expire"] = (dt.now(UTC) + td(days=duration)) if duration else None data["data_limit"] *= 1024**3 @@ -355,7 +355,7 @@ async def modify_expiry_done(event: Message, state: FSMContext, db: AsyncSession else: modified_user = UserModify(status=UserStatusModify.active, expire=0) else: - modified_user = UserModify(expire=(dt.now() + td(days=duration)) if duration else 0) + modified_user = UserModify(expire=(dt.now(UTC) + td(days=duration)) if duration else 0) user = await user_operations.modify_user(db, user.username, modified_user, admin) groups = await user_operations.validate_all_groups(db, user) await event.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup()) diff --git a/app/telegram/handlers/base.py b/app/telegram/handlers/base.py index e856265ea..e480d5d44 100644 --- a/app/telegram/handlers/base.py +++ b/app/telegram/handlers/base.py @@ -1,17 +1,17 @@ -from aiogram import Router, types, F +from aiogram import F, Router, types from aiogram.exceptions import TelegramBadRequest from aiogram.filters import CommandStart +from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.models.admin import AdminDetails -from app.telegram.keyboards.admin import AdminPanel -from app.telegram.keyboards.base import CancelAction, CancelKeyboard -from aiogram.fsm.context import FSMContext from app.operation import OperatorType from app.operation.system import SystemOperation -from app.telegram.utils.texts import Message as Texts -from app.telegram.utils.shared import delete_messages from app.settings import telegram_settings +from app.telegram.keyboards.admin import AdminPanel +from app.telegram.keyboards.base import CancelAction, CancelKeyboard +from app.telegram.utils.shared import delete_messages +from app.telegram.utils.texts import Message as Texts system_operator = SystemOperation(OperatorType.TELEGRAM) diff --git a/app/telegram/handlers/client/__init__.py b/app/telegram/handlers/client/__init__.py index 51db2c729..06eb17138 100644 --- a/app/telegram/handlers/client/__init__.py +++ b/app/telegram/handlers/client/__init__.py @@ -1,6 +1,6 @@ from aiogram import Router -from . import show_info +from . import show_info router = Router(name="client") diff --git a/app/telegram/handlers/client/show_info.py b/app/telegram/handlers/client/show_info.py index ff87f88d7..f4f2b6943 100644 --- a/app/telegram/handlers/client/show_info.py +++ b/app/telegram/handlers/client/show_info.py @@ -2,8 +2,7 @@ from urllib.parse import urlparse from aiogram import F, Router -from aiogram.types import BufferedInputFile -from aiogram.types import Message +from aiogram.types import BufferedInputFile, Message from sqlalchemy.ext.asyncio import AsyncSession from app.models.settings import ConfigFormat diff --git a/app/telegram/handlers/error_handler.py b/app/telegram/handlers/error_handler.py index b4dce5e01..6329208fa 100644 --- a/app/telegram/handlers/error_handler.py +++ b/app/telegram/handlers/error_handler.py @@ -1,8 +1,8 @@ -from aiogram import Router, F +from aiogram import F, Router +from aiogram.exceptions import TelegramAPIError from aiogram.filters import ExceptionTypeFilter from aiogram.fsm.context import FSMContext from aiogram.types import ErrorEvent -from aiogram.exceptions import TelegramAPIError from pydantic import ValidationError from app.utils.helpers import format_validation_error diff --git a/app/telegram/keyboards/admin.py b/app/telegram/keyboards/admin.py index ed3417a2e..b892afbb1 100644 --- a/app/telegram/keyboards/admin.py +++ b/app/telegram/keyboards/admin.py @@ -1,10 +1,10 @@ from enum import Enum -from aiogram.utils.keyboard import InlineKeyboardBuilder, WebAppInfo from aiogram.filters.callback_data import CallbackData +from aiogram.utils.keyboard import InlineKeyboardBuilder, WebAppInfo from app.models.admin import AdminDetails -from app.operation.permissions import enforce_permission, is_scope_all, PermissionDenied +from app.operation.permissions import PermissionDenied, enforce_permission, is_scope_all from app.telegram.utils.texts import Button as Texts @@ -32,7 +32,7 @@ class AdminPanel(InlineKeyboardBuilder): class Callback(CallbackData, prefix="panel"): action: AdminPanelAction - def __init__(self, admin: AdminDetails | None = None, panel_url: str = None, *args, **kwargs): + def __init__(self, admin: AdminDetails | None = None, panel_url: str | None = None, *args, **kwargs): super().__init__(*args, **kwargs) adjust = [] diff --git a/app/telegram/keyboards/base.py b/app/telegram/keyboards/base.py index ccf811e5f..3d5b20fa0 100644 --- a/app/telegram/keyboards/base.py +++ b/app/telegram/keyboards/base.py @@ -1,6 +1,7 @@ from enum import StrEnum -from aiogram.utils.keyboard import InlineKeyboardBuilder + from aiogram.filters.callback_data import CallbackData +from aiogram.utils.keyboard import InlineKeyboardBuilder from app.telegram.utils.texts import Button as Texts diff --git a/app/telegram/keyboards/bulk_actions.py b/app/telegram/keyboards/bulk_actions.py index 0a047639e..19fe55060 100644 --- a/app/telegram/keyboards/bulk_actions.py +++ b/app/telegram/keyboards/bulk_actions.py @@ -1,11 +1,11 @@ from enum import Enum -from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.filters.callback_data import CallbackData +from aiogram.utils.keyboard import InlineKeyboardBuilder from app.models.user import UsernameGenerationStrategy from app.telegram.keyboards.admin import AdminPanel, AdminPanelAction -from app.telegram.keyboards.base import CancelKeyboard, CancelAction +from app.telegram.keyboards.base import CancelAction, CancelKeyboard from app.telegram.utils.texts import Button as Texts diff --git a/app/telegram/keyboards/confim_action.py b/app/telegram/keyboards/confim_action.py index fe6201c57..86798705d 100644 --- a/app/telegram/keyboards/confim_action.py +++ b/app/telegram/keyboards/confim_action.py @@ -1,5 +1,5 @@ -from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.filters.callback_data import CallbackData +from aiogram.utils.keyboard import InlineKeyboardBuilder from app.telegram.utils.texts import Button as Texts diff --git a/app/telegram/keyboards/group.py b/app/telegram/keyboards/group.py index d71361bdd..d3da81745 100644 --- a/app/telegram/keyboards/group.py +++ b/app/telegram/keyboards/group.py @@ -1,9 +1,10 @@ from enum import StrEnum -from aiogram.utils.keyboard import InlineKeyboardBuilder -from app.models.group import GroupsResponse + from aiogram.filters.callback_data import CallbackData +from aiogram.utils.keyboard import InlineKeyboardBuilder -from app.telegram.keyboards.base import CancelKeyboard, CancelAction +from app.models.group import GroupsResponse +from app.telegram.keyboards.base import CancelAction, CancelKeyboard from app.telegram.keyboards.user import UserPanel from app.telegram.utils.texts import Button as Texts @@ -15,7 +16,9 @@ class SelectGroupAction(StrEnum): class GroupsSelector(InlineKeyboardBuilder): - def __init__(self, groups: GroupsResponse, selected_groups: list[int] = None, user_id: int = 0, *args, **kwargs): + def __init__( + self, groups: GroupsResponse, selected_groups: list[int] | None = None, user_id: int = 0, *args, **kwargs + ): selected_groups = selected_groups or [] super().__init__(*args, **kwargs) for group in groups.groups: diff --git a/app/telegram/keyboards/user.py b/app/telegram/keyboards/user.py index 9e0a062df..470768568 100644 --- a/app/telegram/keyboards/user.py +++ b/app/telegram/keyboards/user.py @@ -1,17 +1,16 @@ from enum import Enum -from typing import List +from aiogram.filters.callback_data import CallbackData from aiogram.types import CopyTextButton from aiogram.utils.keyboard import InlineKeyboardBuilder -from aiogram.filters.callback_data import CallbackData from app.models.admin import AdminDetails from app.models.user import UserResponse, UserStatus from app.models.user_template import UserTemplate -from app.operation.permissions import enforce_permission, PermissionDenied +from app.operation.permissions import PermissionDenied, enforce_permission from app.telegram.utils.texts import Button as Texts -from .base import CancelAction, CancelKeyboard +from .base import CancelAction, CancelKeyboard from .confim_action import ConfirmAction @@ -162,7 +161,7 @@ class Callback(CallbackData, prefix="choose_template"): template_id: int user_id: int = 0 # in case choose template for modify - def __init__(self, templates: List[UserTemplate], user_id: int = 0, *args, **kwargs): + def __init__(self, templates: list[UserTemplate], user_id: int = 0, *args, **kwargs): super().__init__(*args, **kwargs) for template in templates: diff --git a/app/telegram/middlewares/acl.py b/app/telegram/middlewares/acl.py index f44286bb4..af634dbfd 100644 --- a/app/telegram/middlewares/acl.py +++ b/app/telegram/middlewares/acl.py @@ -1,4 +1,5 @@ -from typing import Any, Awaitable, Callable, Dict +from collections.abc import Awaitable, Callable +from typing import Any from aiogram import BaseMiddleware from aiogram.types import Update @@ -6,13 +7,13 @@ from app.db import GetDB from app.db.crud.admin import build_admin_details, get_admin_by_telegram_id from app.db.models import AdminStatus -from app.settings import telegram_settings from app.models.settings import Telegram +from app.settings import telegram_settings class ACLMiddleware(BaseMiddleware): async def __call__( - self, handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]], event: Update, data: Dict[str, Any] + self, handler: Callable[[Update, dict[str, Any]], Awaitable[Any]], event: Update, data: dict[str, Any] ) -> Any: message_obj = event.message or event.callback_query or event.inline_query user_id = message_obj.from_user.id diff --git a/app/telegram/utils/filters.py b/app/telegram/utils/filters.py index b1a4ada11..b23d6e481 100644 --- a/app/telegram/utils/filters.py +++ b/app/telegram/utils/filters.py @@ -1,7 +1,7 @@ from aiogram.filters import Filter from app.models.admin import AdminDetails -from app.operation.permissions import enforce_permission, is_scope_all, PermissionDenied +from app.operation.permissions import PermissionDenied, enforce_permission, is_scope_all class IsAdminFilter(Filter): diff --git a/app/telegram/utils/qr.py b/app/telegram/utils/qr.py index 563bd427a..088bbb42e 100644 --- a/app/telegram/utils/qr.py +++ b/app/telegram/utils/qr.py @@ -5,8 +5,7 @@ import qrcode from aiogram.exceptions import TelegramAPIError -from aiogram.types import BufferedInputFile -from aiogram.types import Message +from aiogram.types import BufferedInputFile, Message def _png_chunk(chunk_type: bytes, data: bytes) -> bytes: diff --git a/app/telegram/utils/shared.py b/app/telegram/utils/shared.py index aaaa22d8a..97b9c5041 100644 --- a/app/telegram/utils/shared.py +++ b/app/telegram/utils/shared.py @@ -1,5 +1,5 @@ import math -from typing import List + from aiogram.exceptions import TelegramAPIError from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, Message @@ -15,7 +15,7 @@ def readable_size(size_bytes: int): is_negative = True size_name = ("Bytes", "KB", "MB", "GB", "TB", "PT") - i = int(math.floor(math.log(size_bytes, 1024))) + i = math.floor(math.log(size_bytes, 1024)) s = round(size_bytes / (1024**i), 1) return f"{'-' if is_negative else ''}{int(s) if s.is_integer() else s} {size_name[i]}" @@ -27,7 +27,9 @@ async def add_to_messages_to_delete(state: FSMContext, *messages: Message): await state.update_data(messages_to_delete=messages_to_delete) -async def delete_messages(event: Message | CallbackQuery, state: FSMContext = None, message_ids: List[int] = None): +async def delete_messages( + event: Message | CallbackQuery, state: FSMContext = None, message_ids: list[int] | None = None +): message_ids = message_ids or [] if state: message_ids += await state.get_value("messages_to_delete", []) diff --git a/app/telegram/utils/texts.py b/app/telegram/utils/texts.py index 3a522215e..1c6e65bf9 100644 --- a/app/telegram/utils/texts.py +++ b/app/telegram/utils/texts.py @@ -1,4 +1,4 @@ -from datetime import datetime as dt, timedelta as td, timezone as tz +from datetime import UTC, datetime as dt, timedelta as td from html import escape from aiogram.utils.formatting import html_decoration @@ -126,7 +126,7 @@ def user_details(user: UserResponse, groups: list[Group]) -> str: data_limit = c(readable_size(user.data_limit)) if user.data_limit else "∞" used_traffic = c(readable_size(user.used_traffic)) expire = user.expire.strftime("%Y-%m-%d %H:%M") if user.expire else "∞" - days_left = (user.expire - dt.now(tz.utc)).days if user.expire else "∞" + days_left = (user.expire - dt.now(UTC)).days if user.expire else "∞" on_hold_timeout = user.on_hold_timeout.strftime("%Y-%m-%d %H:%M") if user.on_hold_timeout else "-" on_hold_expire_duration = td(seconds=user.on_hold_expire_duration).days if user.on_hold_expire_duration else "0" online_at = bl(user.online_at.strftime("%Y-%m-%d %H:%M:%S")) if user.online_at else "-" @@ -164,7 +164,7 @@ def user_short_detail(user: UserResponse) -> str: if user.status == UserStatus.on_hold: expiry = int(user.on_hold_expire_duration / 24 / 60 / 60) else: - expiry = (user.expire - dt.now(tz.utc)).days if user.expire else "∞" + expiry = (user.expire - dt.now(UTC)).days if user.expire else "∞" return f"{used_traffic} / {data_limit} | {expiry} days\n{user.note or ''}" @classmethod @@ -172,7 +172,7 @@ def client_user_details(cls, user: UserResponse) -> str: data_limit = c(readable_size(user.data_limit)) if user.data_limit else "∞" used_traffic = c(readable_size(user.used_traffic)) expire = user.expire.strftime("%Y-%m-%d %H:%M") if user.expire else "∞" - days_left = (user.expire - dt.now(tz.utc)).days if user.expire else "∞" + days_left = (user.expire - dt.now(UTC)).days if user.expire else "∞" online_at = bl(user.online_at.strftime("%Y-%m-%d %H:%M:%S")) if user.online_at else "-" emojy_status = cls.status_emoji(user.status) diff --git a/app/templates/__init__.py b/app/templates/__init__.py index 629d0fefe..e1a17a632 100644 --- a/app/templates/__init__.py +++ b/app/templates/__init__.py @@ -1,5 +1,4 @@ -from datetime import datetime as dt, timezone as tz -from typing import Union +from datetime import UTC, datetime as dt from jinja2 import Environment, FileSystemLoader from jinja2.sandbox import SandboxedEnvironment @@ -15,16 +14,16 @@ env = Environment(loader=FileSystemLoader(template_directories)) env.filters.update(CUSTOM_FILTERS) -env.globals["now"] = lambda: dt.now(tz.utc) +env.globals["now"] = lambda: dt.now(UTC) sandbox_env = SandboxedEnvironment() sandbox_env.filters.update(CUSTOM_FILTERS) -sandbox_env.globals["now"] = lambda: dt.now(tz.utc) +sandbox_env.globals["now"] = lambda: dt.now(UTC) -def render_template(template: str, context: Union[dict, None] = None) -> str: +def render_template(template: str, context: dict | None = None) -> str: return env.get_template(template).render(context or {}) -def render_template_string(template_content: str, context: Union[dict, None] = None) -> str: +def render_template_string(template_content: str, context: dict | None = None) -> str: return sandbox_env.from_string(template_content).render(context or {}) diff --git a/app/templates/filters.py b/app/templates/filters.py index b9b01da2c..70713657d 100644 --- a/app/templates/filters.py +++ b/app/templates/filters.py @@ -1,5 +1,5 @@ import os -from datetime import datetime +from datetime import UTC, datetime import yaml @@ -23,7 +23,7 @@ def only_keys(obj, *target_keys): def datetimeformat(dt): if isinstance(dt, int): - dt = datetime.fromtimestamp(dt) + dt = datetime.fromtimestamp(dt, tz=UTC) formatted_datetime = dt.strftime("%Y-%m-%d %H:%M:%S") return formatted_datetime diff --git a/app/utils/helpers.py b/app/utils/helpers.py index 3d7fb10b0..69d7b8d78 100644 --- a/app/utils/helpers.py +++ b/app/utils/helpers.py @@ -1,8 +1,7 @@ import html import json import re -from datetime import datetime as dt, timezone as tz -from typing import Union +from datetime import UTC, datetime as dt, timezone as tz from uuid import UUID from pydantic import ValidationError @@ -12,7 +11,7 @@ def yml_uuid_representer(dumper, data): return dumper.represent_scalar("tag:yaml.org,2002:str", str(data)) -def readable_datetime(date_time: Union[dt, int, None], include_date: bool = True, include_time: bool = True): +def readable_datetime(date_time: dt | int | None, include_date: bool = True, include_time: bool = True): def get_datetime_format(): dt_format = "" if include_date: @@ -25,7 +24,7 @@ def get_datetime_format(): return dt_format if isinstance(date_time, int): - date_time = dt.fromtimestamp(date_time) + date_time = dt.fromtimestamp(date_time, tz=UTC) return date_time.strftime(get_datetime_format()) if date_time else "-" @@ -34,22 +33,22 @@ def fix_datetime_timezone(value: dt | int | str): if isinstance(value, dt): # If datetime is naive (no timezone), assume it's UTC if value.tzinfo is None: - return value.replace(tzinfo=tz.utc) + return value.replace(tzinfo=UTC) return value # Already has timezone info elif isinstance(value, int): # Timestamp will be assume it's UTC - return dt.fromtimestamp(value, tz=tz.utc) + return dt.fromtimestamp(value, tz=UTC) elif isinstance(value, str): # SQLite strftime returns naive ISO strings; treat them as UTC. parsed = dt.fromisoformat(value) if parsed.tzinfo is None: - return parsed.replace(tzinfo=tz.utc) + return parsed.replace(tzinfo=UTC) return parsed raise ValueError("input can be datetime or timestamp") -def ensure_datetime_timezone(value: dt | int | str, default_tz: tz = tz.utc) -> dt: +def ensure_datetime_timezone(value: dt | int | str, default_tz: tz = UTC) -> dt: """ Ensures datetime has timezone info WITHOUT converting to UTC. @@ -145,7 +144,7 @@ def convert_to_utc_for_filtering(dt_value: dt | None) -> dt | None: if dt_value is None: return None if dt_value.tzinfo is not None: - return dt_value.astimezone(tz.utc) + return dt_value.astimezone(UTC) return dt_value diff --git a/app/utils/jwt.py b/app/utils/jwt.py index b2c5598b4..8452d25d9 100644 --- a/app/utils/jwt.py +++ b/app/utils/jwt.py @@ -1,12 +1,13 @@ import hmac import time -import jwt from base64 import b64decode, b64encode -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from hashlib import sha256 from math import ceil +import jwt from aiocache import cached + from app.db import GetDB from app.db.crud.general import get_jwt_secret_key from config import jwt_settings @@ -20,11 +21,11 @@ async def get_secret_key(): async def create_admin_token(admin_id: int | None, username: str) -> str: - data = {"sub": username, "access": "admin", "iat": datetime.now(timezone.utc)} + data = {"sub": username, "access": "admin", "iat": datetime.now(UTC)} if admin_id is not None: data["aid"] = int(admin_id) if jwt_settings.access_token_expire_minutes > 0: - expire = datetime.now(timezone.utc) + timedelta(minutes=jwt_settings.access_token_expire_minutes) + expire = datetime.now(UTC) + timedelta(minutes=jwt_settings.access_token_expire_minutes) data["exp"] = expire encoded_jwt = jwt.encode(data, await get_secret_key(), algorithm="HS256") return encoded_jwt @@ -44,7 +45,7 @@ async def get_admin_payload(token: str) -> dict | None: if not username or access not in ("admin", "sudo"): return try: - created_at = datetime.fromtimestamp(payload["iat"], tz=timezone.utc) + created_at = datetime.fromtimestamp(payload["iat"], tz=UTC) except KeyError: created_at = None @@ -87,7 +88,7 @@ def _parse_subscription_data(data_str: str) -> dict | None: return return { "user_id": u_user_id, - "created_at": datetime.fromtimestamp(u_created_at, tz=timezone.utc), + "created_at": datetime.fromtimestamp(u_created_at, tz=UTC), } if len(parts) == 2: @@ -98,7 +99,7 @@ def _parse_subscription_data(data_str: str) -> dict | None: return return { "username": u_username, - "created_at": datetime.fromtimestamp(u_created_at, tz=timezone.utc), + "created_at": datetime.fromtimestamp(u_created_at, tz=UTC), } return @@ -128,7 +129,7 @@ async def get_subscription_payload(token: str) -> dict | None: return return { "username": username, - "created_at": datetime.fromtimestamp(payload["iat"], tz=timezone.utc), + "created_at": datetime.fromtimestamp(payload["iat"], tz=UTC), } else: return diff --git a/app/utils/reality_scan.py b/app/utils/reality_scan.py index 8eefd7c2e..842b5be66 100644 --- a/app/utils/reality_scan.py +++ b/app/utils/reality_scan.py @@ -24,14 +24,12 @@ DNS_TIMEOUT = 5.0 MAX_CONCURRENT_SCANS = 4 -_scan_semaphores: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Semaphore]" = ( - weakref.WeakKeyDictionary() -) +_scan_semaphores: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Semaphore] = weakref.WeakKeyDictionary() -_scan_executor: "ThreadPoolExecutor | None" = None +_scan_executor: ThreadPoolExecutor | None = None -def _get_scan_semaphore() -> "asyncio.Semaphore": +def _get_scan_semaphore() -> asyncio.Semaphore: loop = asyncio.get_running_loop() sem = _scan_semaphores.get(loop) if sem is None: @@ -40,7 +38,7 @@ def _get_scan_semaphore() -> "asyncio.Semaphore": return sem -def _get_scan_executor() -> "ThreadPoolExecutor": +def _get_scan_executor() -> ThreadPoolExecutor: global _scan_executor if _scan_executor is None: _scan_executor = ThreadPoolExecutor(max_workers=MAX_CONCURRENT_SCANS + 2, thread_name_prefix="reality-scan") @@ -181,7 +179,7 @@ async def _resolve_public_ip_async(host: str, timeout: float) -> str: loop = asyncio.get_running_loop() try: infos = await asyncio.wait_for(loop.getaddrinfo(host, None, type=socket.SOCK_STREAM), timeout=timeout) - except asyncio.TimeoutError, TimeoutError: + except TimeoutError: raise RealityScanError(f"DNS lookup for {host} timed out.") except socket.gaierror: raise RealityScanError(f"Could not resolve host: {host}") @@ -391,7 +389,7 @@ def _handshake( try: version, alpn, der, latency = _handshake(_make_permissive_ctx(), sni) result["latency_ms"] = round(latency) - except (ssl.SSLError, socket.timeout, TimeoutError, OSError, UnicodeError) as exc2: + except (ssl.SSLError, TimeoutError, OSError, UnicodeError) as exc2: logger.debug("reality-scan: permissive fallback handshake failed for %s: %s", sni, exc2) return result else: @@ -409,9 +407,9 @@ def _handshake( result["latency_ms"] = round(latency) except ssl.SSLCertVerificationError as exc: result["reason"] = f"Certificate did not validate: {getattr(exc, 'verify_message', None) or exc}" - except (ssl.SSLError, socket.timeout, TimeoutError, OSError, UnicodeError) as exc: + except (ssl.SSLError, TimeoutError, OSError, UnicodeError) as exc: result["reason"] = f"Certificate re-validation failed: {exc}" - except socket.timeout, TimeoutError: + except TimeoutError: result["reason"] = "Connection timed out." return result except ssl.SSLError as exc: @@ -513,7 +511,7 @@ def _recv_exact(sock: socket.socket, count: int, deadline: float) -> bytes | Non sock.settimeout(remaining) try: chunk = sock.recv(count - len(buf)) - except socket.timeout, TimeoutError: + except TimeoutError: return None if not chunk: return None diff --git a/app/utils/system.py b/app/utils/system.py index bf3227742..6b4f33fe3 100644 --- a/app/utils/system.py +++ b/app/utils/system.py @@ -71,13 +71,13 @@ def readable_size(size_bytes): if not size_bytes or size_bytes <= 0: return "0 B" size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") - i = int(math.floor(math.log(size_bytes, 1024))) + i = math.floor(math.log(size_bytes, 1024)) p = math.pow(1024, i) s = round(size_bytes / p, 2) return f"{s} {size_name[i]}" -def readable_duration(seconds: int | float) -> str: +def readable_duration(seconds: float) -> str: """Format a duration (in seconds) as a human-readable string. Mirrors :func:`readable_size`: caller always passes seconds, this picks the diff --git a/app/utils/wireguard.py b/app/utils/wireguard.py index c6d9ed1e6..a79726172 100644 --- a/app/utils/wireguard.py +++ b/app/utils/wireguard.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Iterable +from collections.abc import Iterable from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession diff --git a/cli/admin.py b/cli/admin.py index 8680e74e5..a19e93e86 100644 --- a/cli/admin.py +++ b/cli/admin.py @@ -3,6 +3,7 @@ """ import asyncio + from app.db.base import GetDB from app.db.crud.temp_key import create_temp_key from cli import console diff --git a/cli/main.py b/cli/main.py old mode 100644 new mode 100755 index 22e51c98e..9c25902d4 --- a/cli/main.py +++ b/cli/main.py @@ -2,6 +2,7 @@ """PasarGuard CLI""" import typer + from cli import console from cli.admin import generate_temp_key diff --git a/config.py b/config.py index 95164b813..cfb8ab2e1 100644 --- a/config.py +++ b/config.py @@ -164,7 +164,7 @@ class AuthSettings(EnvSettings): sudoers: dict[str, str] = Field(default_factory=dict) @model_validator(mode="after") - def build_sudoers(self) -> "AuthSettings": + def build_sudoers(self) -> AuthSettings: if self.sudo_username and self.sudo_password and not self.sudoers: self.sudoers[self.sudo_username] = self.sudo_password return self diff --git a/node_worker.py b/node_worker.py index ac82004a6..2d38a38a4 100644 --- a/node_worker.py +++ b/node_worker.py @@ -1,14 +1,15 @@ import asyncio import signal -from config import runtime_settings # noqa: E402 -from role import Role # noqa: E402 + +from config import runtime_settings +from role import Role runtime_settings.role = Role.NODE -from app import create_app # noqa: E402 -from app.lifecycle import lifespan # noqa: E402 -from app.nats import is_nats_enabled # noqa: E402 -from app.utils.logger import get_logger # noqa: E402 +from app import create_app +from app.lifecycle import lifespan +from app.nats import is_nats_enabled +from app.utils.logger import get_logger logger = get_logger("node-worker") app = create_app() diff --git a/pasarguard-cli.py b/pasarguard-cli.py old mode 100644 new mode 100755 diff --git a/pyproject.toml b/pyproject.toml index 559bea8e6..ad7b4d9de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,17 @@ fix = true [tool.ruff.lint] fixable = ["ALL"] +# Intentional resilience / no-op catch patterns across the codebase. +ignore = ["BLE001", "S110"] + +[tool.ruff.lint.flake8-bugbear] +# FastAPI dependency injection is the standard Depends()/factory pattern. +extend-immutable-calls = [ + "fastapi.Depends", + "fastapi.params.Depends", + "app.routers.authentication.require_permission", + "app.routers.authentication.require_scope_all", +] [tool.ruff.lint.isort] combine-as-imports = true diff --git a/scheduler_worker.py b/scheduler_worker.py index 82cb423d2..e4e3edd5e 100644 --- a/scheduler_worker.py +++ b/scheduler_worker.py @@ -1,14 +1,15 @@ import asyncio import signal + from config import runtime_settings -from role import Role # noqa: E402 +from role import Role runtime_settings.role = Role.SCHEDULER -from app import create_app # noqa: E402 -from app.lifecycle import lifespan # noqa: E402 -from app.nats import is_nats_enabled # noqa: E402 -from app.utils.logger import get_logger # noqa: E402 +from app import create_app +from app.lifecycle import lifespan +from app.nats import is_nats_enabled +from app.utils.logger import get_logger logger = get_logger("scheduler-worker") app = create_app() diff --git a/scripts/export_openapi.py b/scripts/export_openapi.py index c02785ba0..7cddc5122 100644 --- a/scripts/export_openapi.py +++ b/scripts/export_openapi.py @@ -62,7 +62,7 @@ def run_orval(schema_path: Path) -> None: cmd = ["bun", "run", "gen:api"] print(f"Running orval: {' '.join(cmd)} (cwd={DASHBOARD_DIR})") try: - proc = subprocess.run(cmd, cwd=DASHBOARD_DIR, env=env) + proc = subprocess.run(cmd, cwd=DASHBOARD_DIR, env=env, check=False) except FileNotFoundError: raise SystemExit("`bun` was not found on PATH. Install bun or run `make install-front` first.") if proc.returncode != 0: diff --git a/tests/api/helpers.py b/tests/api/helpers.py index e3039937c..ea95ade48 100644 --- a/tests/api/helpers.py +++ b/tests/api/helpers.py @@ -1,7 +1,8 @@ from __future__ import annotations import time -from typing import Any, Iterable +from collections.abc import Iterable +from typing import Any from uuid import uuid4 from fastapi import status diff --git a/tests/api/test_admin.py b/tests/api/test_admin.py index fa4f970d3..042152bc0 100644 --- a/tests/api/test_admin.py +++ b/tests/api/test_admin.py @@ -1,7 +1,7 @@ import asyncio import os import re -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from types import SimpleNamespace import pytest @@ -815,7 +815,7 @@ async def test_admin_usage_returns_stats_for_admin(access_token): admin_token = login_response.json()["access_token"] user = create_user(admin_token, payload={"username": unique_name("admin_usage_user")}) - now = datetime.now(timezone.utc) + now = datetime.now(UTC) usages = [ NodeUserUsage(user_id=user["id"], node_id=None, created_at=now - timedelta(hours=2), used_traffic=123), NodeUserUsage(user_id=user["id"], node_id=None, created_at=now - timedelta(hours=1), used_traffic=456), diff --git a/tests/api/test_bulk.py b/tests/api/test_bulk.py index c91fa2496..523d05439 100644 --- a/tests/api/test_bulk.py +++ b/tests/api/test_bulk.py @@ -1,13 +1,12 @@ import asyncio -from datetime import datetime as dt, timedelta as td, timezone as tz +from datetime import UTC, datetime as dt, timedelta as td from fastapi import status from sqlalchemy import select from app.db.models import User from app.utils.crypto import generate_wireguard_keypair, get_wireguard_public_key -from tests.api import TestSession -from tests.api import client +from tests.api import TestSession, client from tests.api.helpers import ( create_admin, create_core, @@ -277,7 +276,7 @@ def test_bulk_expire_with_range(access_token): # User 1: expired 2 days ago # User 2: expired 10 days ago - now = dt.now(tz.utc).replace(microsecond=0) + now = dt.now(UTC).replace(microsecond=0) expire1 = now - td(days=2) expire2 = now - td(days=10) @@ -315,13 +314,13 @@ def test_bulk_expire_with_range(access_token): # Verify user1 was updated resp1 = client.get(f"/api/user/{user1['username']}", headers={"Authorization": f"Bearer {access_token}"}) - new_expire1 = dt.fromisoformat(resp1.json()["expire"].replace("Z", "+00:00")) + new_expire1 = dt.fromisoformat(resp1.json()["expire"]) # Should be approximately expire1 + 1 hour assert (new_expire1 - expire1).total_seconds() == 3600 # Verify user2 was NOT updated resp2 = client.get(f"/api/user/{user2['username']}", headers={"Authorization": f"Bearer {access_token}"}) - new_expire2 = dt.fromisoformat(resp2.json()["expire"].replace("Z", "+00:00")) + new_expire2 = dt.fromisoformat(resp2.json()["expire"]) # Should be exactly expire2 (or very close) assert abs((new_expire2 - expire2).total_seconds()) < 1 @@ -336,7 +335,7 @@ def test_bulk_data_limit_with_expire_range_without_expired_status(access_token): core = create_core(access_token) group = create_group(access_token, name=unique_name("bulk_data_range_group")) - now = dt.now(tz.utc).replace(microsecond=0) + now = dt.now(UTC).replace(microsecond=0) expire1 = now - td(days=2) expire2 = now - td(days=10) diff --git a/tests/api/test_bulk_delete_entities.py b/tests/api/test_bulk_delete_entities.py index 9d422cb6b..fbe57be72 100644 --- a/tests/api/test_bulk_delete_entities.py +++ b/tests/api/test_bulk_delete_entities.py @@ -1,17 +1,16 @@ import asyncio -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock from uuid import uuid4 from fastapi import status from sqlalchemy import func, select, update -from app.db.crud.node import create_node as db_create_node -from app.db.crud.node import remove_node as db_remove_node +from app.db.crud.node import create_node as db_create_node, remove_node as db_remove_node from app.db.models import ( - APIKey, Admin, AdminUsageLogs, + APIKey, NextPlan, Node, NodeStat, @@ -215,7 +214,7 @@ async def _get(): def seed_node_usage_rows(node_id: int, user_id: int) -> None: async def _seed(): async with TestSession() as session: - now = datetime.now(timezone.utc) + now = datetime.now(UTC) session.add_all( [ NodeUserUsage(user_id=user_id, node_id=node_id, created_at=now, used_traffic=1), diff --git a/tests/api/test_bulk_entity_actions.py b/tests/api/test_bulk_entity_actions.py index a9c422c5c..d97b90889 100644 --- a/tests/api/test_bulk_entity_actions.py +++ b/tests/api/test_bulk_entity_actions.py @@ -5,8 +5,7 @@ from fastapi import status from sqlalchemy import func, select, update -from app.db.crud.node import create_node as db_create_node -from app.db.crud.node import remove_node as db_remove_node +from app.db.crud.node import create_node as db_create_node, remove_node as db_remove_node from app.db.models import Admin, AdminUsageLogs, Node from app.models.node import NodeCreate from tests.api import TestSession, client diff --git a/tests/api/test_group.py b/tests/api/test_group.py index 033521b23..38cafbb0b 100644 --- a/tests/api/test_group.py +++ b/tests/api/test_group.py @@ -3,7 +3,7 @@ from fastapi import status from tests.api import client -from tests.api.helpers import create_core, delete_core, create_group, delete_group, get_inbounds, unique_name +from tests.api.helpers import create_core, create_group, delete_core, delete_group, get_inbounds, unique_name def test_group_create(access_token): diff --git a/tests/api/test_node.py b/tests/api/test_node.py index 80999a2df..546e4823e 100644 --- a/tests/api/test_node.py +++ b/tests/api/test_node.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, MagicMock from uuid import UUID, uuid4 @@ -31,26 +31,26 @@ UserStatus, users_groups_association, ) -from app.models.core import CoreCreate from app.models.admin import AdminDetails, AdminRoleData +from app.models.core import CoreCreate from app.models.node import NodeCreate, NodeModify, NodeResponse, NodeSettings, NodesResponse +from app.models.proxy import ProxyTable from app.models.stats import ( NodeRealtimeStats, NodeStats, NodeStatsList, - UserCountMetric, - UserCountMetricStat, - UserCountMetricStatsList, NodeUsageStat, NodeUsageStatsList, Period, + UserCountMetric, + UserCountMetricStat, + UserCountMetricStatsList, ) +from app.node import user as node_user_module +from app.node.sync import _blocked_admin_ids_for_users from app.operation import OperatorType from app.operation.node import NodeOperation from app.routers import node as node_router -from app.node import user as node_user_module -from app.node.sync import _blocked_admin_ids_for_users -from app.models.proxy import ProxyTable from tests.api import TestSession, client, engine from tests.api.helpers import auth_headers, unique_name from tests.api.sample_data import XRAY_CONFIG @@ -511,7 +511,7 @@ async def cleanup_nodes_simple(core_id: int, node_ids: list[int]) -> None: def usage_stats_payload() -> NodeUsageStatsList: - start = datetime(2024, 1, 1, tzinfo=timezone.utc) + start = datetime(2024, 1, 1, tzinfo=UTC) end = start + timedelta(days=1) return NodeUsageStatsList( start=start, @@ -527,7 +527,7 @@ def usage_stats_payload() -> NodeUsageStatsList: def user_count_metric_stats_payload() -> UserCountMetricStatsList: - start = datetime(2024, 1, 1, tzinfo=timezone.utc) + start = datetime(2024, 1, 1, tzinfo=UTC) end = start + timedelta(days=1) return UserCountMetricStatsList( metric=UserCountMetric.online, @@ -539,7 +539,7 @@ def user_count_metric_stats_payload() -> UserCountMetricStatsList: def node_stats_payload() -> NodeStatsList: - start = datetime(2024, 1, 1, tzinfo=timezone.utc) + start = datetime(2024, 1, 1, tzinfo=UTC) end = start + timedelta(hours=2) return NodeStatsList( start=start, @@ -623,7 +623,7 @@ def test_get_node_settings_returns_defaults(access_token): def test_get_usage_passes_filters(access_token, node_operator_mock): usage = usage_stats_payload() node_operator_mock.get_usage.return_value = usage - start = datetime(2024, 2, 1, tzinfo=timezone.utc) + start = datetime(2024, 2, 1, tzinfo=UTC) end = start + timedelta(days=7) response = client.get( "/api/node/usage", @@ -651,7 +651,7 @@ def test_get_usage_passes_filters(access_token, node_operator_mock): def test_get_user_count_metric_passes_filters(access_token, node_operator_mock): counts = user_count_metric_stats_payload() node_operator_mock.get_user_count_metric.return_value = counts - start = datetime(2024, 2, 1, tzinfo=timezone.utc) + start = datetime(2024, 2, 1, tzinfo=UTC) end = start + timedelta(days=7) response = client.get( "/api/node/user_counts/online", @@ -913,7 +913,7 @@ def test_bulk_update_nodes(access_token, node_operator_mock): def test_get_node_stats(access_token, node_operator_mock): stats = node_stats_payload() node_operator_mock.get_node_stats_periodic.return_value = stats - start = datetime(2024, 3, 1, tzinfo=timezone.utc) + start = datetime(2024, 3, 1, tzinfo=UTC) end = start + timedelta(days=1) response = client.get( "/api/node/8/stats", @@ -1209,7 +1209,7 @@ async def test_remove_node_deletes_associated_usage_tables(): await session.commit() await session.refresh(user) - now = datetime.now(timezone.utc) + now = datetime.now(UTC) user_usages = [ NodeUserUsage( user_id=user.id, diff --git a/tests/api/test_permissions.py b/tests/api/test_permissions.py index c84e4be3f..180ef5681 100644 --- a/tests/api/test_permissions.py +++ b/tests/api/test_permissions.py @@ -1,6 +1,7 @@ """Unit tests for app/operation/permissions.py""" import pytest + from app.models.admin import AdminDetails from app.operation.permissions import ( PermissionDenied, diff --git a/tests/api/test_setup.py b/tests/api/test_setup.py index d4d1b3c58..3e376caf7 100644 --- a/tests/api/test_setup.py +++ b/tests/api/test_setup.py @@ -3,7 +3,7 @@ """ import asyncio -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from fastapi import status from sqlalchemy import select @@ -22,15 +22,15 @@ def _make_temp_key(*, used: bool = False, expired: bool = False) -> str: async def _insert(): async with TestSession() as session: if expired: - expires_at = datetime.now(timezone.utc) - timedelta(minutes=10) + expires_at = datetime.now(UTC) - timedelta(minutes=10) else: - expires_at = datetime.now(timezone.utc) + timedelta(minutes=5) + expires_at = datetime.now(UTC) + timedelta(minutes=5) key = TempKey( key=__import__("uuid").uuid4().__str__(), action="setup", expires_at=expires_at, - used_at=datetime.now(timezone.utc) if used else None, + used_at=datetime.now(UTC) if used else None, used_by_ip="127.0.0.1" if used else None, ) session.add(key) diff --git a/tests/api/test_usage_functions_timezone.py b/tests/api/test_usage_functions_timezone.py index e82fd9589..db4be3456 100644 --- a/tests/api/test_usage_functions_timezone.py +++ b/tests/api/test_usage_functions_timezone.py @@ -5,30 +5,31 @@ This includes strict testing with multiple data rows, edge cases for each period, and expected responses. """ -from datetime import datetime, timedelta, timezone -import pytest +from datetime import UTC, datetime, timedelta, timezone from uuid import uuid4 + +import pytest from sqlalchemy import select +from app.db.crud.admin import get_admin_usages +from app.db.crud.node import get_nodes_usage +from app.db.crud.user import get_all_users_usages, get_user_count_metric_stats, get_user_usages from app.db.models import ( + Admin, + Node, NodeUsage, NodeUserUsage, User, UserStatus, - Admin, - Node, ) +from app.models.proxy import ProxyTable from app.models.stats import ( - Period, NodeUsageStatsList, + Period, UserCountMetric, UserCountMetricStatsList, UserUsageStatsList, ) -from app.models.proxy import ProxyTable -from app.db.crud.node import get_nodes_usage -from app.db.crud.user import get_user_count_metric_stats, get_user_usages, get_all_users_usages -from app.db.crud.admin import get_admin_usages from tests.api import TestSession @@ -86,23 +87,23 @@ async def test_timezone_filtering_tehran_hour_strict(self): - Period grouping works correctly in Tehran timezone """ async with TestSession() as session: - admin_id, user_id, node_id = await setup_test_data(session) + _admin_id, _user_id, node_id = await setup_test_data(session) # Inject 10 data points: 3 before, 6 in range, 1 after # Tehran timezone is UTC+03:30 # Request range: 2026-02-10 00:00:00+03:30 to 03:00:00+03:30 # Which equals: 2026-02-09 20:30:00 UTC to 2026-02-09 23:30:00 UTC timestamps_utc = [ - datetime(2026, 2, 9, 19, 45, 0, tzinfo=timezone.utc), # 23:15 Tehran - BEFORE - datetime(2026, 2, 9, 20, 0, 0, tzinfo=timezone.utc), # 23:30 Tehran - BEFORE - datetime(2026, 2, 9, 20, 15, 0, tzinfo=timezone.utc), # 23:45 Tehran - BEFORE - datetime(2026, 2, 9, 20, 30, 0, tzinfo=timezone.utc), # 00:00 Tehran - IN RANGE ✓ - datetime(2026, 2, 9, 20, 45, 0, tzinfo=timezone.utc), # 00:15 Tehran - IN RANGE ✓ - datetime(2026, 2, 9, 21, 0, 0, tzinfo=timezone.utc), # 00:30 Tehran - IN RANGE ✓ - datetime(2026, 2, 9, 21, 30, 0, tzinfo=timezone.utc), # 01:00 Tehran - IN RANGE ✓ - datetime(2026, 2, 9, 22, 30, 0, tzinfo=timezone.utc), # 02:00 Tehran - IN RANGE ✓ - datetime(2026, 2, 9, 23, 15, 0, tzinfo=timezone.utc), # 02:45 Tehran - IN RANGE ✓ - datetime(2026, 2, 10, 0, 0, 0, tzinfo=timezone.utc), # 03:30 Tehran - AFTER + datetime(2026, 2, 9, 19, 45, 0, tzinfo=UTC), # 23:15 Tehran - BEFORE + datetime(2026, 2, 9, 20, 0, 0, tzinfo=UTC), # 23:30 Tehran - BEFORE + datetime(2026, 2, 9, 20, 15, 0, tzinfo=UTC), # 23:45 Tehran - BEFORE + datetime(2026, 2, 9, 20, 30, 0, tzinfo=UTC), # 00:00 Tehran - IN RANGE ✓ + datetime(2026, 2, 9, 20, 45, 0, tzinfo=UTC), # 00:15 Tehran - IN RANGE ✓ + datetime(2026, 2, 9, 21, 0, 0, tzinfo=UTC), # 00:30 Tehran - IN RANGE ✓ + datetime(2026, 2, 9, 21, 30, 0, tzinfo=UTC), # 01:00 Tehran - IN RANGE ✓ + datetime(2026, 2, 9, 22, 30, 0, tzinfo=UTC), # 02:00 Tehran - IN RANGE ✓ + datetime(2026, 2, 9, 23, 15, 0, tzinfo=UTC), # 02:45 Tehran - IN RANGE ✓ + datetime(2026, 2, 10, 0, 0, 0, tzinfo=UTC), # 03:30 Tehran - AFTER ] for idx, ts in enumerate(timestamps_utc): @@ -183,19 +184,19 @@ async def test_timezone_filtering_negative_offset_new_york_strict(self): Verifies correct filtering with negative timezone offset. """ async with TestSession() as session: - admin_id, user_id, node_id = await setup_test_data(session) + _admin_id, _user_id, node_id = await setup_test_data(session) # New York timezone is UTC-05:00 # Request: 2026-03-10 00:00:00-05:00 = 2026-03-10 05:00:00 UTC timestamps_utc = [ - datetime(2026, 3, 10, 4, 0, 0, tzinfo=timezone.utc), # BEFORE - datetime(2026, 3, 10, 4, 30, 0, tzinfo=timezone.utc), # BEFORE - datetime(2026, 3, 10, 5, 15, 0, tzinfo=timezone.utc), # IN RANGE (00:15 NY) - datetime(2026, 3, 10, 6, 15, 0, tzinfo=timezone.utc), # IN RANGE (01:15 NY) - datetime(2026, 3, 10, 7, 15, 0, tzinfo=timezone.utc), # IN RANGE (02:15 NY) - datetime(2026, 3, 10, 8, 0, 0, tzinfo=timezone.utc), # IN RANGE (03:00 NY boundary) - datetime(2026, 3, 10, 8, 30, 0, tzinfo=timezone.utc), # AFTER - datetime(2026, 3, 10, 9, 0, 0, tzinfo=timezone.utc), # AFTER + datetime(2026, 3, 10, 4, 0, 0, tzinfo=UTC), # BEFORE + datetime(2026, 3, 10, 4, 30, 0, tzinfo=UTC), # BEFORE + datetime(2026, 3, 10, 5, 15, 0, tzinfo=UTC), # IN RANGE (00:15 NY) + datetime(2026, 3, 10, 6, 15, 0, tzinfo=UTC), # IN RANGE (01:15 NY) + datetime(2026, 3, 10, 7, 15, 0, tzinfo=UTC), # IN RANGE (02:15 NY) + datetime(2026, 3, 10, 8, 0, 0, tzinfo=UTC), # IN RANGE (03:00 NY boundary) + datetime(2026, 3, 10, 8, 30, 0, tzinfo=UTC), # AFTER + datetime(2026, 3, 10, 9, 0, 0, tzinfo=UTC), # AFTER ] for ts in timestamps_utc: @@ -255,7 +256,7 @@ async def test_day_period_does_not_include_previous_day_tehran(self): the response must start from 2026-02-04, not 2026-02-03. """ async with TestSession() as session: - admin_id, user_id, node_id = await setup_test_data(session) + _admin_id, _user_id, node_id = await setup_test_data(session) tehran_tz = timezone(timedelta(hours=3, minutes=30)) start = datetime(2026, 4, 4, 0, 0, 0, tzinfo=tehran_tz) @@ -275,7 +276,7 @@ async def test_day_period_does_not_include_previous_day_tehran(self): for idx, ts_local in enumerate(local_timestamps): session.add( NodeUsage( - created_at=ts_local.astimezone(timezone.utc), + created_at=ts_local.astimezone(UTC), node_id=node_id, uplink=1000 + idx, downlink=2000 + idx, @@ -304,7 +305,7 @@ async def test_hour_period_excludes_partial_first_bucket(self): Regression test for extra first hour bucket when start is not hour-aligned. """ async with TestSession() as session: - admin_id, user_id, node_id = await setup_test_data(session) + _admin_id, _user_id, node_id = await setup_test_data(session) tehran_tz = timezone(timedelta(hours=3, minutes=30)) start = datetime(2026, 5, 9, 14, 2, 37, tzinfo=tehran_tz) @@ -319,7 +320,7 @@ async def test_hour_period_excludes_partial_first_bucket(self): for idx, ts_local in enumerate(local_timestamps): session.add( NodeUsage( - created_at=ts_local.astimezone(timezone.utc), + created_at=ts_local.astimezone(UTC), node_id=node_id, uplink=10000 + idx, downlink=20000 + idx, @@ -353,23 +354,23 @@ async def test_timezone_filtering_no_early_data(self, period): requested start time is included in the response. """ async with TestSession() as session: - admin_id, user_id, node_id = await setup_test_data(session) + _admin_id, _user_id, node_id = await setup_test_data(session) # UTC timestamps spanning a range - start_utc = datetime(2026, 6, 10, 0, 0, 0, tzinfo=timezone.utc) - end_utc = datetime(2026, 6, 11, 0, 0, 0, tzinfo=timezone.utc) + start_utc = datetime(2026, 6, 10, 0, 0, 0, tzinfo=UTC) + end_utc = datetime(2026, 6, 11, 0, 0, 0, tzinfo=UTC) # Inject data BEFORE the range (this is what was being returned before the fix) before_timestamps = [ - datetime(2026, 6, 9, 20, 0, 0, tzinfo=timezone.utc), - datetime(2026, 6, 9, 22, 0, 0, tzinfo=timezone.utc), + datetime(2026, 6, 9, 20, 0, 0, tzinfo=UTC), + datetime(2026, 6, 9, 22, 0, 0, tzinfo=UTC), ] # Inject data IN the range in_range_timestamps = [ - datetime(2026, 6, 10, 6, 0, 0, tzinfo=timezone.utc), - datetime(2026, 6, 10, 12, 0, 0, tzinfo=timezone.utc), - datetime(2026, 6, 10, 18, 0, 0, tzinfo=timezone.utc), + datetime(2026, 6, 10, 6, 0, 0, tzinfo=UTC), + datetime(2026, 6, 10, 12, 0, 0, tzinfo=UTC), + datetime(2026, 6, 10, 18, 0, 0, tzinfo=UTC), ] all_timestamps = before_timestamps + in_range_timestamps @@ -437,18 +438,18 @@ async def test_user_usages_timezone_filtering_strict(self): Verifies correct filtering for user usage statistics. """ async with TestSession() as session: - admin_id, user_id, node_id = await setup_test_data(session) + _admin_id, user_id, node_id = await setup_test_data(session) # Inject 8 data points: 2 before, 5 in range, 1 after timestamps_utc = [ - datetime(2026, 7, 9, 20, 0, 0, tzinfo=timezone.utc), # BEFORE - datetime(2026, 7, 9, 20, 15, 0, tzinfo=timezone.utc), # BEFORE - datetime(2026, 7, 9, 20, 30, 0, tzinfo=timezone.utc), # IN RANGE ✓ - datetime(2026, 7, 9, 20, 45, 0, tzinfo=timezone.utc), # IN RANGE ✓ - datetime(2026, 7, 9, 21, 30, 0, tzinfo=timezone.utc), # IN RANGE ✓ - datetime(2026, 7, 9, 22, 15, 0, tzinfo=timezone.utc), # IN RANGE ✓ - datetime(2026, 7, 9, 23, 15, 0, tzinfo=timezone.utc), # IN RANGE ✓ - datetime(2026, 7, 10, 0, 0, 0, tzinfo=timezone.utc), # AFTER + datetime(2026, 7, 9, 20, 0, 0, tzinfo=UTC), # BEFORE + datetime(2026, 7, 9, 20, 15, 0, tzinfo=UTC), # BEFORE + datetime(2026, 7, 9, 20, 30, 0, tzinfo=UTC), # IN RANGE ✓ + datetime(2026, 7, 9, 20, 45, 0, tzinfo=UTC), # IN RANGE ✓ + datetime(2026, 7, 9, 21, 30, 0, tzinfo=UTC), # IN RANGE ✓ + datetime(2026, 7, 9, 22, 15, 0, tzinfo=UTC), # IN RANGE ✓ + datetime(2026, 7, 9, 23, 15, 0, tzinfo=UTC), # IN RANGE ✓ + datetime(2026, 7, 10, 0, 0, 0, tzinfo=UTC), # AFTER ] for ts in timestamps_utc: @@ -511,11 +512,11 @@ async def test_user_usages_multiple_periods_strict(self, period): Strict test: Multiple periods with proper data distribution. """ async with TestSession() as session: - admin_id, user_id, node_id = await setup_test_data(session) + _admin_id, user_id, node_id = await setup_test_data(session) # Create data spanning 3 months - start_utc = datetime(2026, 8, 1, 0, 0, 0, tzinfo=timezone.utc) - end_utc = datetime(2026, 11, 1, 0, 0, 0, tzinfo=timezone.utc) + start_utc = datetime(2026, 8, 1, 0, 0, 0, tzinfo=UTC) + end_utc = datetime(2026, 11, 1, 0, 0, 0, tzinfo=UTC) # Add records at various points current = start_utc @@ -577,14 +578,14 @@ async def test_all_users_usages_timezone_filtering_strict(self): # Inject data with mixture of before and in-range records before_timestamps = [ - datetime(2026, 9, 9, 20, 0, 0, tzinfo=timezone.utc), - datetime(2026, 9, 9, 20, 30, 0, tzinfo=timezone.utc), + datetime(2026, 9, 9, 20, 0, 0, tzinfo=UTC), + datetime(2026, 9, 9, 20, 30, 0, tzinfo=UTC), ] in_range_timestamps = [ - datetime(2026, 9, 9, 20, 45, 0, tzinfo=timezone.utc), - datetime(2026, 9, 9, 21, 30, 0, tzinfo=timezone.utc), - datetime(2026, 9, 9, 22, 15, 0, tzinfo=timezone.utc), + datetime(2026, 9, 9, 20, 45, 0, tzinfo=UTC), + datetime(2026, 9, 9, 21, 30, 0, tzinfo=UTC), + datetime(2026, 9, 9, 22, 15, 0, tzinfo=UTC), ] all_timestamps = before_timestamps + in_range_timestamps @@ -668,14 +669,14 @@ async def test_admin_usages_timezone_filtering_strict(self): # Inject 8 data points for each user timestamps_utc = [ - datetime(2026, 10, 9, 20, 0, 0, tzinfo=timezone.utc), # BEFORE - datetime(2026, 10, 9, 20, 15, 0, tzinfo=timezone.utc), # BEFORE - datetime(2026, 10, 9, 20, 30, 0, tzinfo=timezone.utc), # IN RANGE - datetime(2026, 10, 9, 20, 45, 0, tzinfo=timezone.utc), # IN RANGE - datetime(2026, 10, 9, 21, 30, 0, tzinfo=timezone.utc), # IN RANGE - datetime(2026, 10, 9, 22, 15, 0, tzinfo=timezone.utc), # IN RANGE - datetime(2026, 10, 9, 23, 15, 0, tzinfo=timezone.utc), # IN RANGE - datetime(2026, 10, 10, 0, 0, 0, tzinfo=timezone.utc), # AFTER + datetime(2026, 10, 9, 20, 0, 0, tzinfo=UTC), # BEFORE + datetime(2026, 10, 9, 20, 15, 0, tzinfo=UTC), # BEFORE + datetime(2026, 10, 9, 20, 30, 0, tzinfo=UTC), # IN RANGE + datetime(2026, 10, 9, 20, 45, 0, tzinfo=UTC), # IN RANGE + datetime(2026, 10, 9, 21, 30, 0, tzinfo=UTC), # IN RANGE + datetime(2026, 10, 9, 22, 15, 0, tzinfo=UTC), # IN RANGE + datetime(2026, 10, 9, 23, 15, 0, tzinfo=UTC), # IN RANGE + datetime(2026, 10, 10, 0, 0, 0, tzinfo=UTC), # AFTER ] for ts in timestamps_utc: @@ -750,8 +751,8 @@ async def test_admin_usages_multiple_periods_strict(self, period): async with TestSession() as session: admin_id, user_id, node_id = await setup_test_data(session) - start_utc = datetime(2026, 11, 1, 0, 0, 0, tzinfo=timezone.utc) - end_utc = datetime(2026, 11, 15, 0, 0, 0, tzinfo=timezone.utc) + start_utc = datetime(2026, 11, 1, 0, 0, 0, tzinfo=UTC) + end_utc = datetime(2026, 11, 15, 0, 0, 0, tzinfo=UTC) # Create records spanning the range current = start_utc @@ -838,7 +839,7 @@ async def test_timezone_filtering_distinct_counts_and_current_statuses(self): for idx, (user_id, local_ts) in enumerate(rows): session.add( NodeUserUsage( - created_at=local_ts.astimezone(timezone.utc), + created_at=local_ts.astimezone(UTC), user_id=user_id, node_id=node_id, used_traffic=idx + 1, @@ -901,8 +902,8 @@ async def test_single_metric_responses_share_count_logic(self): session.add_all([expired_user, limited_user]) await session.flush() - start = datetime(2026, 12, 12, 0, 0, 0, tzinfo=timezone.utc) - end = datetime(2026, 12, 12, 1, 0, 0, tzinfo=timezone.utc) + start = datetime(2026, 12, 12, 0, 0, 0, tzinfo=UTC) + end = datetime(2026, 12, 12, 1, 0, 0, tzinfo=UTC) rows = [ (active_user_id, start + timedelta(minutes=5)), (active_user_id, start + timedelta(minutes=15)), @@ -969,7 +970,7 @@ async def test_partial_first_bucket_is_excluded(self): for idx, local_ts in enumerate(local_timestamps): session.add( NodeUserUsage( - created_at=local_ts.astimezone(timezone.utc), + created_at=local_ts.astimezone(UTC), user_id=user_id, node_id=node_id, used_traffic=idx + 1, @@ -1021,8 +1022,8 @@ async def test_node_grouping_node_filter_and_admin_filter(self): session.add(other_user) await session.flush() - start = datetime(2026, 12, 11, 0, 0, 0, tzinfo=timezone.utc) - end = datetime(2026, 12, 11, 1, 0, 0, tzinfo=timezone.utc) + start = datetime(2026, 12, 11, 0, 0, 0, tzinfo=UTC) + end = datetime(2026, 12, 11, 1, 0, 0, tzinfo=UTC) rows = [ (user_id, node_id, start + timedelta(minutes=10)), (user_id, node_two.id, start + timedelta(minutes=20)), @@ -1081,8 +1082,8 @@ async def test_status_metrics_reject_node_scope(self): async with TestSession() as session: _admin_id, _user_id, node_id = await setup_test_data(session) - start = datetime(2026, 12, 11, 0, 0, 0, tzinfo=timezone.utc) - end = datetime(2026, 12, 11, 1, 0, 0, tzinfo=timezone.utc) + start = datetime(2026, 12, 11, 0, 0, 0, tzinfo=UTC) + end = datetime(2026, 12, 11, 1, 0, 0, tzinfo=UTC) with pytest.raises(ValueError, match="Only online user counts"): await get_user_count_metric_stats( diff --git a/tests/api/test_user.py b/tests/api/test_user.py index b316040e3..a892d3db8 100644 --- a/tests/api/test_user.py +++ b/tests/api/test_user.py @@ -5,7 +5,7 @@ import zipfile from base64 import b64encode from copy import deepcopy -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta, timezone from hashlib import sha256 from math import ceil from unittest.mock import AsyncMock, MagicMock @@ -188,7 +188,7 @@ def test_user_create_active(access_token): """Test that the user create active route is accessible.""" core, groups = setup_groups(access_token, 2) group_ids = [group["id"] for group in groups] - expire = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(days=30) + expire = datetime.now(UTC).replace(microsecond=0) + timedelta(days=30) user = create_user( access_token, group_ids=group_ids, @@ -220,7 +220,7 @@ def test_user_create_active(access_token): def test_user_hwid_limit_stays_null_on_create_and_null_modify_clears(access_token): core, groups = setup_groups(access_token, 1) group_ids = [group["id"] for group in groups] - expire = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(days=30) + expire = datetime.now(UTC).replace(microsecond=0) + timedelta(days=30) usernames: list[str] = [] try: @@ -281,7 +281,7 @@ def test_limited_admin_cannot_create_or_modify_user_to_unlimited_data_or_expire( admin = create_admin(access_token, role_id=role["id"]) admin_token = _login(admin["username"], admin["password"]) username = unique_name("bounded_limit_user") - finite_expire = (datetime.now(timezone.utc).replace(microsecond=0) + timedelta(hours=1)).isoformat() + finite_expire = (datetime.now(UTC).replace(microsecond=0) + timedelta(hours=1)).isoformat() try: response = client.post( @@ -369,7 +369,7 @@ def test_user_create_expire_timezone_offset_normalized_to_utc(access_token): """Expire with non-UTC offset should be persisted as the same UTC instant.""" core, groups = setup_groups(access_token, 1) tehran_tz = timezone(timedelta(hours=3, minutes=30)) - expire_utc = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(days=30) + expire_utc = datetime.now(UTC).replace(microsecond=0) + timedelta(days=30) expire_tehran = expire_utc.astimezone(tehran_tz) user = create_user( access_token, @@ -383,7 +383,7 @@ def test_user_create_expire_timezone_offset_normalized_to_utc(access_token): ) try: response_expire = datetime.fromisoformat(user["expire"]) - assert response_expire.astimezone(timezone.utc).replace(microsecond=0) == expire_utc + assert response_expire.astimezone(UTC).replace(microsecond=0) == expire_utc finally: delete_user(access_token, user["username"]) cleanup_groups(access_token, core, groups) @@ -393,7 +393,7 @@ def test_user_create_on_hold(access_token): """Test that the user create on hold route is accessible.""" core, groups = setup_groups(access_token, 2) group_ids = [group["id"] for group in groups] - expire = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(days=30) + expire = datetime.now(UTC).replace(microsecond=0) + timedelta(days=30) user = create_user( access_token, group_ids=group_ids, @@ -570,7 +570,7 @@ def test_users_get_filters_by_no_data_limit(access_token): def test_users_get_filters_by_expire_date_range(access_token): core, groups = setup_groups(access_token, 1) - now = datetime.now(timezone.utc).replace(microsecond=0) + now = datetime.now(UTC).replace(microsecond=0) early_expire = now + timedelta(days=5) late_expire = now + timedelta(days=45) early_user = create_user( @@ -612,7 +612,7 @@ def test_users_get_filters_by_expire_date_range(access_token): def test_users_get_filters_by_online_date_range(access_token): core, groups = setup_groups(access_token, 1) - now = datetime.now(timezone.utc).replace(microsecond=0) + now = datetime.now(UTC).replace(microsecond=0) recent_online_at = now - timedelta(days=2) old_online_at = now - timedelta(days=20) recent_user = create_user( @@ -658,7 +658,7 @@ def test_users_get_filters_by_online_date_range(access_token): def test_users_get_filters_by_online_users(access_token): core, groups = setup_groups(access_token, 1) - now = datetime.now(timezone.utc).replace(microsecond=0) + now = datetime.now(UTC).replace(microsecond=0) online_user = create_user( access_token, group_ids=[groups[0]["id"]], @@ -711,7 +711,7 @@ def test_users_get_filters_by_no_expire(access_token): group_ids=[groups[0]["id"]], payload={ "username": unique_name("test_user_with_expire"), - "expire": (datetime.now(timezone.utc).replace(microsecond=0) + timedelta(days=30)).isoformat(), + "expire": (datetime.now(UTC).replace(microsecond=0) + timedelta(days=30)).isoformat(), }, ) @@ -914,7 +914,7 @@ def test_user_routes_by_id_and_by_username(access_token): def test_get_users_count_metric_passes_filters(access_token, monkeypatch): - start = datetime(2024, 2, 1, tzinfo=timezone.utc) + start = datetime(2024, 2, 1, tzinfo=UTC) end = start + timedelta(days=7) counts = UserCountMetricStatsList( metric=UserCountMetric.online, @@ -1827,7 +1827,7 @@ async def _add_chart_row(): NodeUserUsage( user_id=user["id"], node_id=None, - created_at=datetime.now(timezone.utc) - timedelta(minutes=10), + created_at=datetime.now(UTC) - timedelta(minutes=10), used_traffic=123, ) ) @@ -2170,7 +2170,7 @@ def test_enable_disabled_user_resolves_expired_limited_on_hold_and_active_status group_ids=[groups[0]["id"]], payload={ "username": unique_name("toggle_expired"), - "expire": (datetime.now(timezone.utc) - timedelta(days=1)).isoformat(), + "expire": (datetime.now(UTC) - timedelta(days=1)).isoformat(), }, ) limited_user = create_user( @@ -2740,7 +2740,7 @@ def test_get_users_simple_invalid_sort(access_token): def test_get_users_simple_search_and_sort(access_token): """Test combining search and sort parameters.""" - core, groups = setup_groups(access_token, 1) + _core, _groups = setup_groups(access_token, 1) created_usernames = [] try: # Create 4 users diff --git a/tests/conftest.py b/tests/conftest.py index a2ba1aed9..a47039d6a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ sys.path.insert(0, project_root) # Override settings for tests -from config import auth_settings, runtime_settings, server_settings # noqa: E402 +from config import auth_settings, runtime_settings, server_settings runtime_settings.testing = True runtime_settings.debug = True diff --git a/tests/test_reality_scan_unit.py b/tests/test_reality_scan_unit.py index b6b90efa0..c3b0b7340 100644 --- a/tests/test_reality_scan_unit.py +++ b/tests/test_reality_scan_unit.py @@ -120,7 +120,7 @@ def test_parse_server_hello_x25519(): def test_parse_server_hello_post_quantum_hrr(): - is_hrr, group, tls13 = rs._parse_server_hello(_server_hello(rs.GROUP_X25519MLKEM768, hrr=True)) + is_hrr, group, _tls13 = rs._parse_server_hello(_server_hello(rs.GROUP_X25519MLKEM768, hrr=True)) assert is_hrr is True assert group == rs.GROUP_X25519MLKEM768 @@ -172,7 +172,7 @@ def _self_signed_der(cn: str, sans: list[str]) -> bytes: name = x509.Name( [x509.NameAttribute(NameOID.COMMON_NAME, cn), x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Test Org")] ) - now = datetime.datetime.now(datetime.timezone.utc) + now = datetime.datetime.now(datetime.UTC) builder = ( x509.CertificateBuilder() .subject_name(name) diff --git a/tests/test_subscription_clash_hysteria.py b/tests/test_subscription_clash_hysteria.py index e99be2e91..81e9254e4 100644 --- a/tests/test_subscription_clash_hysteria.py +++ b/tests/test_subscription_clash_hysteria.py @@ -1,7 +1,6 @@ from app.models.subscription import SubscriptionInboundData, TCPTransportConfig, TLSConfig from app.subscription.clash import ClashMetaConfiguration - USER_ID = "11111111-1111-1111-1111-111111111111" diff --git a/tests/test_subscription_clash_xhttp.py b/tests/test_subscription_clash_xhttp.py index 58bc83902..187e021ce 100644 --- a/tests/test_subscription_clash_xhttp.py +++ b/tests/test_subscription_clash_xhttp.py @@ -3,7 +3,6 @@ from app.models.subscription import SubscriptionInboundData, TLSConfig, XHTTPTransportConfig from app.subscription.clash import ClashConfiguration, ClashMetaConfiguration - USER_ID = "11111111-1111-1111-1111-111111111111"