From 591643d2b8813ae595d5ff72f6749331207c8164 Mon Sep 17 00:00:00 2001 From: Rerowros <85008083+Rerowros@users.noreply.github.com> Date: Sun, 10 May 2026 22:41:43 +0700 Subject: [PATCH 1/4] test: keep API JWT lookup on test database --- tests/api/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 43d3f8f23..f7ea74f23 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -13,6 +13,7 @@ def mock_db_session(monkeypatch: pytest.MonkeyPatch): db_session = MagicMock(spec=TestSession) monkeypatch.setattr("app.settings.GetDB", db_session) monkeypatch.setattr("app.subscription.client_templates.GetDB", GetTestDB) + monkeypatch.setattr("app.utils.jwt.GetDB", GetTestDB) return db_session From 2d038027a1b554aff0572cf555defa7d9e3edc4b Mon Sep 17 00:00:00 2001 From: Rerowros <85008083+Rerowros@users.noreply.github.com> Date: Sun, 10 May 2026 22:41:54 +0700 Subject: [PATCH 2/4] fix(user-usage): keep manual resets out of reset cycles --- app/db/crud/bulk.py | 24 +++++- app/db/crud/user.py | 34 +++++++-- ...8e4e47b9f2c_add_user_usage_reset_source.py | 28 +++++++ app/db/models.py | 48 +++++++++++- app/jobs/reset_user_data_usage.py | 2 + tests/api/test_user_usage_reset_cycle.py | 73 +++++++++++++++++++ 6 files changed, 197 insertions(+), 12 deletions(-) create mode 100644 app/db/migrations/versions/b8e4e47b9f2c_add_user_usage_reset_source.py create mode 100644 tests/api/test_user_usage_reset_cycle.py diff --git a/app/db/crud/bulk.py b/app/db/crud/bulk.py index 6c8922cfe..4ee334492 100644 --- a/app/db/crud/bulk.py +++ b/app/db/crud/bulk.py @@ -13,6 +13,7 @@ NodeUserUsage, User, UserStatus, + UserUsageResetSource, UserUsageResetLogs, users_groups_association, ) @@ -38,7 +39,8 @@ async def reset_all_users_data_usage( Operations performed: - Sets `used_traffic` to 0 for all target users. - Sets `status` to `active` for all users, unless filtered by admin. - - Deletes all related `UserUsageResetLogs` and `NextPlan` entries. + - Adds a manual `UserUsageResetLogs` row for lifetime traffic accounting. + - Deletes all related `NextPlan` entries. - Deletes `NodeUserUsage` chart rows when `clean_chart_data` is enabled. Args: @@ -51,15 +53,29 @@ async def reset_all_users_data_usage( - This function assumes proper foreign key constraints and cascading rules are in place. - The function commits changes at the end of the operation. """ - user_ids_query = select(User.id).where(User.admin_id == admin.id) if admin else select(User.id) - user_ids = (await db.execute(user_ids_query)).scalars().all() + users_query = ( + select(User.id, User.used_traffic).where(User.admin_id == admin.id) + if admin + else select(User.id, User.used_traffic) + ) + user_rows = (await db.execute(users_query)).all() + user_ids = [row.id for row in user_rows] if not user_ids: return + db.add_all( + [ + UserUsageResetLogs( + user_id=row.id, + used_traffic_at_reset=row.used_traffic, + reset_source=UserUsageResetSource.manual.value, + ) + for row in user_rows + ] + ) await db.execute(update(User).where(User.id.in_(user_ids)).values(used_traffic=0, status=UserStatus.active)) - await db.execute(delete(UserUsageResetLogs).where(UserUsageResetLogs.user_id.in_(user_ids))) if clean_chart_data: await db.execute(delete(NodeUserUsage).where(NodeUserUsage.user_id.in_(user_ids))) await db.execute(delete(NextPlan).where(NextPlan.user_id.in_(user_ids))) diff --git a/app/db/crud/user.py b/app/db/crud/user.py index 0d3ad5804..3ad6a3bb7 100644 --- a/app/db/crud/user.py +++ b/app/db/crud/user.py @@ -20,6 +20,7 @@ ReminderType, User, UserStatus, + UserUsageResetSource, UserSubscriptionUpdate, UserUsageResetLogs, users_groups_association, @@ -571,6 +572,11 @@ async def get_users_to_reset_data_usage(db: AsyncSession) -> list[User]: UserUsageResetLogs.user_id, func.max(UserUsageResetLogs.reset_at).label("last_reset_at"), ) + .where( + UserUsageResetLogs.reset_source.in_( + [UserUsageResetSource.scheduled.value, UserUsageResetSource.next_plan.value] + ) + ) .group_by(UserUsageResetLogs.user_id) .subquery() ) @@ -1009,12 +1015,18 @@ async def modify_user( return db_user -async def _reset_user_traffic_and_log(db: AsyncSession, db_user: User): +async def _reset_user_traffic_and_log( + db: AsyncSession, + db_user: User, + *, + reset_source: UserUsageResetSource = UserUsageResetSource.manual, +): """Helper to reset user traffic and log the action.""" await db_user.awaitable_attrs.next_plan usage_log = UserUsageResetLogs( user_id=db_user.id, used_traffic_at_reset=db_user.used_traffic, + reset_source=reset_source.value, ) db.add(usage_log) @@ -1032,7 +1044,13 @@ async def clear_user_node_usages(db: AsyncSession, user_id: int, *, before: date await db.execute(stmt) -async def reset_user_data_usage(db: AsyncSession, db_user: User, *, clean_chart_data: bool = False) -> User: +async def reset_user_data_usage( + db: AsyncSession, + db_user: User, + *, + clean_chart_data: bool = False, + reset_source: UserUsageResetSource = UserUsageResetSource.manual, +) -> User: """ Resets the data usage of a user and logs the reset. @@ -1043,7 +1061,7 @@ async def reset_user_data_usage(db: AsyncSession, db_user: User, *, clean_chart_ Returns: User: The updated user object. """ - await _reset_user_traffic_and_log(db, db_user) + await _reset_user_traffic_and_log(db, db_user, reset_source=reset_source) if clean_chart_data: await clear_user_node_usages(db, db_user.id) @@ -1056,7 +1074,11 @@ async def reset_user_data_usage(db: AsyncSession, db_user: User, *, clean_chart_ async def bulk_reset_user_data_usage( - db: AsyncSession, users: list[User], *, clean_chart_data: bool = False + db: AsyncSession, + users: list[User], + *, + clean_chart_data: bool = False, + reset_source: UserUsageResetSource = UserUsageResetSource.manual, ) -> list[User]: """ Resets the data usage for a list of users and logs the reset. @@ -1069,7 +1091,7 @@ async def bulk_reset_user_data_usage( list[User]: The updated list of user objects. """ for db_user in users: - await _reset_user_traffic_and_log(db, db_user) + await _reset_user_traffic_and_log(db, db_user, reset_source=reset_source) if clean_chart_data: await clear_user_node_usages(db, db_user.id) if db_user.status not in [UserStatus.expired, UserStatus.disabled]: @@ -1143,7 +1165,7 @@ async def reset_user_by_next(db: AsyncSession, db_user: User, *, clean_chart_dat db_user.proxy_settings = proxy_settings db_user.data_limit_reset_strategy = db_user.next_plan.user_template.data_limit_reset_strategy - await _reset_user_traffic_and_log(db, db_user) + await _reset_user_traffic_and_log(db, db_user, reset_source=UserUsageResetSource.next_plan) if clean_chart_data: await clear_user_node_usages(db, db_user.id) db_user.status = UserStatus.active diff --git a/app/db/migrations/versions/b8e4e47b9f2c_add_user_usage_reset_source.py b/app/db/migrations/versions/b8e4e47b9f2c_add_user_usage_reset_source.py new file mode 100644 index 000000000..212e66bfc --- /dev/null +++ b/app/db/migrations/versions/b8e4e47b9f2c_add_user_usage_reset_source.py @@ -0,0 +1,28 @@ +"""add user usage reset source + +Revision ID: b8e4e47b9f2c +Revises: 73c78c6a9b24 +Create Date: 2026-05-10 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "b8e4e47b9f2c" +down_revision = "73c78c6a9b24" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "user_usage_logs", + sa.Column("reset_source", sa.String(length=32), nullable=False, server_default="manual"), + ) + op.execute("UPDATE user_usage_logs SET reset_source = 'scheduled'") + + +def downgrade() -> None: + op.drop_column("user_usage_logs", "reset_source") diff --git a/app/db/models.py b/app/db/models.py index 69c892889..de9b72550 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -1,5 +1,5 @@ import os -from datetime import datetime as dt, timezone as tz +from datetime import datetime as dt, timedelta as td, timezone as tz from enum import Enum from typing import Any, Dict, List, Optional @@ -140,6 +140,12 @@ class DataLimitResetStrategy(str, Enum): year = "year" +class UserUsageResetSource(str, Enum): + manual = "manual" + scheduled = "scheduled" + next_plan = "next_plan" + + class User(Base): __tablename__ = "users" @@ -157,7 +163,11 @@ class User(Base): 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) + usage_logs: Mapped[List["UserUsageResetLogs"]] = relationship( + back_populates="user", + init=False, + order_by="UserUsageResetLogs.reset_at", + ) admin: Mapped["Admin"] = relationship(back_populates="users", init=False) next_plan: Mapped[Optional["NextPlan"]] = relationship( uselist=False, back_populates="user", cascade="all, delete-orphan", init=False @@ -222,6 +232,35 @@ def lifetime_used_traffic(self) -> int: def last_traffic_reset_time(self): return self.usage_logs[-1].reset_at if self.usage_logs else self.created_at + @property + def last_traffic_reset_at(self): + return self.usage_logs[-1].reset_at if self.usage_logs else None + + @property + def last_scheduled_traffic_reset_at(self): + scheduled_sources = {UserUsageResetSource.scheduled.value, UserUsageResetSource.next_plan.value} + for log in reversed(self.usage_logs): + if log.reset_source in scheduled_sources: + return log.reset_at + return None + + @property + def next_traffic_reset_at(self): + reset_days = { + DataLimitResetStrategy.day: 1, + DataLimitResetStrategy.week: 7, + DataLimitResetStrategy.month: 30, + DataLimitResetStrategy.year: 365, + } + days = reset_days.get(self.data_limit_reset_strategy) + if days is None: + return None + + cycle_start = self.last_scheduled_traffic_reset_at or self.created_at + if cycle_start.tzinfo is None: + cycle_start = cycle_start.replace(tzinfo=tz.utc) + return cycle_start + td(days=days) + async def inbounds(self) -> list[str]: """Returns a flat list of all included inbound tags for enabled groups.""" session = async_object_session(self) @@ -405,6 +444,11 @@ class UserUsageResetLogs(Base): user_id: Mapped[Optional[int]] = 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_source: Mapped[str] = mapped_column( + String(32), + default=UserUsageResetSource.manual.value, + server_default=UserUsageResetSource.manual.value, + ) reset_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default=lambda: dt.now(tz.utc), init=False) diff --git a/app/jobs/reset_user_data_usage.py b/app/jobs/reset_user_data_usage.py index 2d496d1b6..9d8e3bf96 100644 --- a/app/jobs/reset_user_data_usage.py +++ b/app/jobs/reset_user_data_usage.py @@ -4,6 +4,7 @@ from app import 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.models import UserUsageResetSource from app.operation import OperatorType from app.operation.user import UserOperation from app import notification @@ -24,6 +25,7 @@ async def reset_data_usage(): db, users, clean_chart_data=usage_settings.reset_user_usage_clean_chart_data, + reset_source=UserUsageResetSource.scheduled, ) for db_user in updated_users: diff --git a/tests/api/test_user_usage_reset_cycle.py b/tests/api/test_user_usage_reset_cycle.py new file mode 100644 index 000000000..1dd8e1e52 --- /dev/null +++ b/tests/api/test_user_usage_reset_cycle.py @@ -0,0 +1,73 @@ +from datetime import datetime, timedelta, timezone +from uuid import uuid4 + +import pytest +from sqlalchemy import delete + +from app.db.crud.user import get_users_to_reset_data_usage +from app.db.models import ( + Admin, + DataLimitResetStrategy, + User, + UserUsageResetLogs, + UserUsageResetSource, +) +from app.models.proxy import ProxyTable +from tests.api import TestSession + + +@pytest.mark.asyncio +async def test_manual_reset_does_not_delay_periodic_data_usage_reset(): + now = datetime.now(timezone.utc) + + async with TestSession() as session: + admin = Admin(username=f"admin_reset_cycle_{uuid4().hex[:8]}", hashed_password="secret") + session.add(admin) + await session.flush() + + manual_reset_user = User( + username=f"manual_reset_cycle_{uuid4().hex[:8]}", + admin_id=admin.id, + data_limit_reset_strategy=DataLimitResetStrategy.month, + proxy_settings=ProxyTable().dict(no_obj=True), + ) + manual_reset_user.created_at = now - timedelta(days=31) + + scheduled_reset_user = User( + username=f"scheduled_reset_cycle_{uuid4().hex[:8]}", + admin_id=admin.id, + data_limit_reset_strategy=DataLimitResetStrategy.month, + proxy_settings=ProxyTable().dict(no_obj=True), + ) + scheduled_reset_user.created_at = now - timedelta(days=31) + + session.add_all([manual_reset_user, scheduled_reset_user]) + await session.flush() + await session.execute( + delete(UserUsageResetLogs).where( + UserUsageResetLogs.user_id.in_([manual_reset_user.id, scheduled_reset_user.id]) + ) + ) + + manual_log = UserUsageResetLogs( + user_id=manual_reset_user.id, + used_traffic_at_reset=1024, + reset_source=UserUsageResetSource.manual.value, + ) + manual_log.reset_at = now - timedelta(days=1) + + scheduled_log = UserUsageResetLogs( + user_id=scheduled_reset_user.id, + used_traffic_at_reset=2048, + reset_source=UserUsageResetSource.scheduled.value, + ) + scheduled_log.reset_at = now - timedelta(days=1) + + session.add_all([manual_log, scheduled_log]) + await session.commit() + + users_to_reset = await get_users_to_reset_data_usage(session) + user_ids_to_reset = {user.id for user in users_to_reset} + + assert manual_reset_user.id in user_ids_to_reset + assert scheduled_reset_user.id not in user_ids_to_reset From 518272d05c60acd431dc59a9b17d0fe6ef2c9d70 Mon Sep 17 00:00:00 2001 From: Rerowros <85008083+Rerowros@users.noreply.github.com> Date: Sun, 10 May 2026 22:42:04 +0700 Subject: [PATCH 3/4] feat(subscription): expose traffic reset dates --- app/models/user.py | 11 ++++++- app/operation/subscription.py | 2 ++ app/templates/subscription/index.html | 6 ++++ .../src/components/dialogs/user-modal.tsx | 30 +++++++++++++++++++ dashboard/src/service/api/index.ts | 12 ++++++++ tests/api/test_user.py | 29 ++++++++++++++++-- 6 files changed, 86 insertions(+), 4 deletions(-) diff --git a/app/models/user.py b/app/models/user.py index 18fd2bb00..d981e17e2 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -104,6 +104,8 @@ class UserNotificationResponse(User): created_at: dt edit_at: dt | None = Field(default=None) online_at: dt | None = Field(default=None) + last_traffic_reset_at: dt | None = Field(default=None) + next_traffic_reset_at: dt | None = Field(default=None) subscription_url: str = Field(default="") admin: AdminContactInfo | None = Field(default=None) group_names: list[str] | None = Field(default_factory=list) @@ -114,7 +116,14 @@ class UserNotificationResponse(User): def cast_to_int(cls, v): return NumericValidatorMixin.cast_to_int(v) - @field_validator("created_at", "edit_at", "online_at", mode="before") + @field_validator( + "created_at", + "edit_at", + "online_at", + "last_traffic_reset_at", + "next_traffic_reset_at", + mode="before", + ) @classmethod def validator_date(cls, v): if not v: diff --git a/app/operation/subscription.py b/app/operation/subscription.py index b8c3b21d0..c0f202312 100644 --- a/app/operation/subscription.py +++ b/app/operation/subscription.py @@ -82,6 +82,8 @@ async def validated_user(db_user: User) -> UsersResponseWithInbounds: user.inbounds = await db_user.inbounds() user.expire = db_user.expire user.lifetime_used_traffic = db_user.lifetime_used_traffic + user.last_traffic_reset_at = db_user.last_traffic_reset_at + user.next_traffic_reset_at = db_user.next_traffic_reset_at return user diff --git a/app/templates/subscription/index.html b/app/templates/subscription/index.html index b595019c8..6f32a02f5 100644 --- a/app/templates/subscription/index.html +++ b/app/templates/subscription/index.html @@ -577,6 +577,12 @@