From 40adb6c17abe9e6edc25454940f01bd65861f493 Mon Sep 17 00:00:00 2001 From: dr-hoseyn <95236112+dr-hoseyn@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:47:42 +0330 Subject: [PATCH 1/2] feat(cleanup): delete inactive users by last activity --- app/db/crud/user.py | 13 ++- app/models/user.py | 8 +- app/operation/user.py | 6 +- app/routers/user.py | 6 +- dashboard/public/statics/locales/en.json | 10 ++- dashboard/public/statics/locales/fa.json | 10 ++- dashboard/public/statics/locales/ru.json | 10 ++- dashboard/public/statics/locales/zh.json | 10 ++- .../src/pages/_dashboard.settings.cleanup.tsx | 83 ++++++++++++------- dashboard/src/service/api/index.ts | 8 +- 10 files changed, 121 insertions(+), 43 deletions(-) diff --git a/app/db/crud/user.py b/app/db/crud/user.py index d1e16b10d..f1c67224f 100644 --- a/app/db/crud/user.py +++ b/app/db/crud/user.py @@ -554,7 +554,7 @@ def _cleanup_target_user_conditions( expired_after: datetime | None = None, expired_before: datetime | None = None, admin_id: int | None = None, - target: Literal["expired", "limited", "on_hold", "disabled"] = "expired", + target: Literal["expired", "limited", "on_hold", "disabled", "inactive"] = "expired", ): if target == "expired": # Time-expired users: date range filters on the expiration date. @@ -563,6 +563,15 @@ def _cleanup_target_user_conditions( conditions.append(User.expire >= expired_after) if expired_before: conditions.append(User.expire <= expired_before) + elif target == "inactive": + # Fall back to account creation for users that have never connected, + # so newly created accounts are not treated as long-term inactive. + last_activity = func.coalesce(User.online_at, User.created_at) + conditions = [] + if expired_after: + conditions.append(last_activity >= expired_after) + if expired_before: + conditions.append(last_activity <= expired_before) else: # For limited / on_hold / disabled: date range filters apply to # last_status_change (i.e. when the user entered that status). @@ -588,7 +597,7 @@ async def remove_expired_users( expired_after: datetime | None = None, expired_before: datetime | None = None, admin_id: int | None = None, - target: Literal["expired", "limited", "on_hold", "disabled"] = "expired", + target: Literal["expired", "limited", "on_hold", "disabled", "inactive"] = "expired", dry_run: bool = False, ) -> list[str]: conditions = _cleanup_target_user_conditions(expired_after, expired_before, admin_id, target) diff --git a/app/models/user.py b/app/models/user.py index 7af5d0756..61cdf6604 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -300,7 +300,7 @@ class UsersUsageQuery(UserUsageQuery): class ExpiredUsersQuery(BaseModel): admin_username: str | None = Field(default=None) - target: Literal["expired", "limited", "on_hold", "disabled"] = Field(default="expired") + target: Literal["expired", "limited", "on_hold", "disabled", "inactive"] = Field(default="expired") expired_after: OptionalAwareDatetime = Field(default=None, examples=["2024-01-01T00:00:00+03:30"]) expired_before: OptionalAwareDatetime = Field(default=None, examples=["2024-01-31T23:59:59+03:30"]) dry_run: bool = Field( @@ -308,6 +308,12 @@ class ExpiredUsersQuery(BaseModel): description="If true, returns users that would be deleted without actually deleting them.", ) + @model_validator(mode="after") + def validate_inactive_cutoff(self): + if self.target == "inactive" and self.expired_before is None: + raise ValueError("expired_before is required when target is inactive") + return self + class UserSubscriptionUpdateSchema(BaseModel): created_at: AwareDatetime diff --git a/app/operation/user.py b/app/operation/user.py index 663a5d512..bd8e2b008 100644 --- a/app/operation/user.py +++ b/app/operation/user.py @@ -1567,10 +1567,11 @@ async def get_expired_users( """ Get users who match the cleanup target within the specified date range. - - **target**: `expired` | `limited` | `on_hold` | `disabled` + - **target**: `expired` | `limited` | `on_hold` | `disabled` | `inactive` - **expired_after** / **expired_before** UTC datetime (optional) - For `expired`: filters by expiration date. - For `limited` / `on_hold` / `disabled`: filters by last_status_change. + - For `inactive`: filters by last activity (online_at, or created_at if never online). - If both dates are omitted, returns all users matching target. """ @@ -1595,10 +1596,11 @@ async def delete_expired_users( """ Delete users who match the cleanup target within the specified date range. - - **target**: `expired` | `limited` | `on_hold` | `disabled` + - **target**: `expired` | `limited` | `on_hold` | `disabled` | `inactive` - **expired_after** / **expired_before** UTC datetime (optional) - For `expired`: filters by expiration date. - For `limited` / `on_hold` / `disabled`: filters by last_status_change. + - For `inactive`: filters by last activity (online_at, or created_at if never online). - **dry_run**: if true, returns the list of users that would be deleted without deleting them. """ diff --git a/app/routers/user.py b/app/routers/user.py index 305e13d0c..be5a168eb 100644 --- a/app/routers/user.py +++ b/app/routers/user.py @@ -605,10 +605,11 @@ async def get_expired_users( """ Get cleanup-target users in the specified scope. - - **target**: `expired` | `limited` | `on_hold` | `disabled` + - **target**: `expired` | `limited` | `on_hold` | `disabled` | `inactive` - **expired_after** / **expired_before** UTC datetime (optional) - For `expired`: filters by expiration date. - For `limited` / `on_hold` / `disabled`: filters by last_status_change (when they entered that status). + - For `inactive`: filters by last activity (online_at, or created_at if never online); expired_before is required. """ return await user_operator.get_expired_users(db, query=query) @@ -623,10 +624,11 @@ async def delete_expired_users( """ Delete cleanup-target users in the specified scope. - - **target**: `expired` | `limited` | `on_hold` | `disabled` + - **target**: `expired` | `limited` | `on_hold` | `disabled` | `inactive` - **expired_after** / **expired_before** UTC datetime (optional) - For `expired`: filters by expiration date. - For `limited` / `on_hold` / `disabled`: filters by last_status_change (when they entered that status). + - For `inactive`: filters by last activity (online_at, or created_at if never online); expired_before is required. - **dry_run**: if true, returns users that would be deleted without deleting them. """ return await user_operator.delete_expired_users(db, admin, query=query) diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index 089a36e0a..d6bdb078c 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -545,10 +545,12 @@ "expired": "Time Expired", "limited": "Usage Limited", "on_hold": "On Hold", - "disabled": "Disabled" + "disabled": "Disabled", + "inactive": "Inactive Users" }, "statusChangedAfter": "Status Changed After", "statusChangedBefore": "Status Changed Before", + "lastActivityBefore": "Last Activity Before", "expiredAfterPlaceholder": "Select start date", "expiredBeforePlaceholder": "Select end date", "dateRange": "Date Range", @@ -559,27 +561,33 @@ "deleteLimited": "Delete Limited", "deleteOnHold": "Delete On Hold", "deleteDisabled": "Delete Disabled", + "deleteInactive": "Delete Inactive", "deleting": "Deleting...", "deleteSuccess": "{{count}} expired accounts deleted successfully", "deleteLimitedSuccess": "{{count}} limited accounts deleted successfully", "deleteOnHoldSuccess": "{{count}} on-hold accounts deleted successfully", "deleteDisabledSuccess": "{{count}} disabled accounts deleted successfully", + "deleteInactiveSuccess": "{{count}} inactive accounts deleted successfully", "dryRunSuccess": "Dry run: {{count}} accounts would be deleted", "deleteFailed": "Failed to delete expired accounts", "deleteLimitedFailed": "Failed to delete limited accounts", "deleteOnHoldFailed": "Failed to delete on-hold accounts", "deleteDisabledFailed": "Failed to delete disabled accounts", + "deleteInactiveFailed": "Failed to delete inactive accounts", "confirmDelete": "Delete Expired Accounts", "confirmDeleteLimited": "Delete Limited Accounts", "confirmDeleteOnHold": "Delete On Hold Accounts", "confirmDeleteDisabled": "Delete Disabled Accounts", + "confirmDeleteInactive": "Delete Inactive Accounts", "confirmDeleteMessage": "Are you sure you want to delete all expired accounts in the selected date range? This action cannot be undone.", "confirmDeleteLimitedMessage": "Are you sure you want to delete all usage-limited accounts in the selected date range? This action cannot be undone.", "confirmDeleteOnHoldMessage": "Are you sure you want to delete all on-hold accounts in the selected date range? This action cannot be undone.", "confirmDeleteDisabledMessage": "Are you sure you want to delete all disabled accounts in the selected date range? This action cannot be undone.", + "confirmDeleteInactiveMessage": "Are you sure you want to delete accounts whose last activity was before the selected date? This action cannot be undone.", "noDateSelected": "Please select at least one date", "selectDateRange": "Filter by expiration date. Leave both empty to delete all matching accounts.", "selectStatusChangedDateRange": "Filter by the date the user entered this status. Leave both empty to delete all matching accounts.", + "selectInactiveDate": "Select a cutoff date. Accounts last online before it will match; for accounts never online, their creation date is used.", "selectLimitedInfo": "Delete all usage-limited accounts for the selected admin scope." }, "resetUsage": { diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index 0aa7573c5..896d180ff 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -406,10 +406,12 @@ "expired": "انقضای زمانی", "limited": "محدودیت حجم", "on_hold": "در انتظار", - "disabled": "غیرفعال" + "disabled": "غیرفعال", + "inactive": "کاربران بدون فعالیت" }, "statusChangedAfter": "تغییر وضعیت بعد از", "statusChangedBefore": "تغییر وضعیت قبل از", + "lastActivityBefore": "آخرین فعالیت قبل از", "expiredAfterPlaceholder": "تاریخ شروع را انتخاب کنید", "expiredBeforePlaceholder": "تاریخ پایان را انتخاب کنید", "dateRange": "بازه زمانی", @@ -420,27 +422,33 @@ "deleteLimited": "حذف محدودها", "deleteOnHold": "حذف در انتظارها", "deleteDisabled": "حذف غیرفعال‌ها", + "deleteInactive": "حذف کاربران بدون فعالیت", "deleting": "در حال حذف...", "deleteSuccess": "{{count}} حساب منقضی شده با موفقیت حذف شد", "deleteLimitedSuccess": "{{count}} حساب محدود با موفقیت حذف شد", "deleteOnHoldSuccess": "{{count}} حساب در انتظار با موفقیت حذف شد", "deleteDisabledSuccess": "{{count}} حساب غیرفعال با موفقیت حذف شد", + "deleteInactiveSuccess": "{{count}} حساب بدون فعالیت با موفقیت حذف شد", "dryRunSuccess": "آزمایشی: {{count}} حساب حذف می‌شد", "deleteFailed": "حذف حساب‌های منقضی شده ناموفق بود", "deleteLimitedFailed": "حذف حساب‌های محدود ناموفق بود", "deleteOnHoldFailed": "حذف حساب‌های در انتظار ناموفق بود", "deleteDisabledFailed": "حذف حساب‌های غیرفعال ناموفق بود", + "deleteInactiveFailed": "حذف حساب‌های بدون فعالیت ناموفق بود", "confirmDelete": "حذف حساب‌های منقضی شده", "confirmDeleteLimited": "حذف حساب‌های محدود", "confirmDeleteOnHold": "حذف حساب‌های در انتظار", "confirmDeleteDisabled": "حذف حساب‌های غیرفعال", + "confirmDeleteInactive": "حذف حساب‌های بدون فعالیت", "confirmDeleteMessage": "آیا مطمئنید که می‌خواهید تمام حساب‌های منقضی شده در بازه زمانی انتخابی را حذف کنید؟ این عمل قابل بازگشت نیست.", "confirmDeleteLimitedMessage": "آیا مطمئنید که می‌خواهید تمام حساب‌های محدود در بازه زمانی انتخابی را حذف کنید؟ این عمل قابل بازگشت نیست.", "confirmDeleteOnHoldMessage": "آیا مطمئنید که می‌خواهید تمام حساب‌های در انتظار در بازه زمانی انتخابی را حذف کنید؟ این عمل قابل بازگشت نیست.", "confirmDeleteDisabledMessage": "آیا مطمئنید که می‌خواهید تمام حساب‌های غیرفعال در بازه زمانی انتخابی را حذف کنید؟ این عمل قابل بازگشت نیست.", + "confirmDeleteInactiveMessage": "آیا مطمئنید که می‌خواهید حساب‌هایی را که آخرین فعالیتشان قبل از تاریخ انتخابی بوده حذف کنید؟ این عمل قابل بازگشت نیست.", "noDateSelected": "لطفاً حداقل یک تاریخ را انتخاب کنید", "selectDateRange": "بر اساس تاریخ انقضا فیلتر کنید. اگر هر دو تاریخ خالی باشند، تمام حساب‌های مطابق حذف می‌شوند.", "selectStatusChangedDateRange": "بر اساس تاریخ ورود کاربر به این وضعیت فیلتر کنید. اگر هر دو تاریخ خالی باشند، تمام حساب‌های مطابق حذف می‌شوند.", + "selectInactiveDate": "یک تاریخ مرزی انتخاب کنید. حساب‌هایی که آخرین اتصالشان قبل از آن بوده انتخاب می‌شوند؛ برای حساب‌هایی که هرگز آنلاین نشده‌اند، تاریخ ساخت حساب ملاک است.", "selectLimitedInfo": "حذف تمام حساب‌های محدود در محدوده مدیر انتخابی." }, "resetUsage": { diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index 59ff7c337..367640fae 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -562,10 +562,12 @@ "expired": "По сроку", "limited": "По трафику", "on_hold": "На удержании", - "disabled": "Отключённые" + "disabled": "Отключённые", + "inactive": "Неактивные пользователи" }, "statusChangedAfter": "Статус изменён после", "statusChangedBefore": "Статус изменён до", + "lastActivityBefore": "Последняя активность до", "expiredAfterPlaceholder": "Выберите дату начала", "expiredBeforePlaceholder": "Выберите дату окончания", "dateRange": "Диапазон дат", @@ -576,27 +578,33 @@ "deleteLimited": "Удалить ограниченные", "deleteOnHold": "Удалить на удержании", "deleteDisabled": "Удалить отключённые", + "deleteInactive": "Удалить неактивные", "deleting": "Удаление...", "deleteSuccess": "{{count}} просроченных аккаунтов успешно удалено", "deleteLimitedSuccess": "Успешно удалено {{count}} ограниченных аккаунтов", "deleteOnHoldSuccess": "Успешно удалено {{count}} аккаунтов на удержании", "deleteDisabledSuccess": "Успешно удалено {{count}} отключённых аккаунтов", + "deleteInactiveSuccess": "Успешно удалено {{count}} неактивных аккаунтов", "dryRunSuccess": "Тестовый запуск: {{count}} аккаунтов будет удалено", "deleteFailed": "Не удалось удалить просроченные аккаунты", "deleteLimitedFailed": "Не удалось удалить ограниченные аккаунты", "deleteOnHoldFailed": "Не удалось удалить аккаунты на удержании", "deleteDisabledFailed": "Не удалось удалить отключённые аккаунты", + "deleteInactiveFailed": "Не удалось удалить неактивные аккаунты", "confirmDelete": "Удалить просроченные аккаунты", "confirmDeleteLimited": "Удалить ограниченные аккаунты", "confirmDeleteOnHold": "Удалить аккаунты на удержании", "confirmDeleteDisabled": "Удалить отключённые аккаунты", + "confirmDeleteInactive": "Удалить неактивные аккаунты", "confirmDeleteMessage": "Вы уверены, что хотите удалить все просроченные аккаунты в выбранном диапазоне дат? Это действие нельзя отменить.", "confirmDeleteLimitedMessage": "Вы уверены, что хотите удалить все ограниченные аккаунты в выбранном диапазоне дат? Это действие нельзя отменить.", "confirmDeleteOnHoldMessage": "Вы уверены, что хотите удалить все аккаунты на удержании в выбранном диапазоне дат? Это действие нельзя отменить.", "confirmDeleteDisabledMessage": "Вы уверены, что хотите удалить все отключённые аккаунты в выбранном диапазоне дат? Это действие нельзя отменить.", + "confirmDeleteInactiveMessage": "Удалить аккаунты, последняя активность которых была до выбранной даты? Это действие нельзя отменить.", "noDateSelected": "Пожалуйста, выберите хотя бы одну дату", "selectDateRange": "Фильтровать по дате истечения срока. Если обе даты пусты, будут удалены все подходящие аккаунты.", "selectStatusChangedDateRange": "Фильтровать по дате, когда пользователь получил этот статус. Если обе даты пусты, будут удалены все подходящие аккаунты.", + "selectInactiveDate": "Выберите дату отсечения. Будут выбраны аккаунты, которые последний раз были онлайн до неё; для аккаунтов, которые ещё не были онлайн, используется дата создания.", "selectLimitedInfo": "Удаляются все аккаунты с ограничением по трафику в выбранной области администратора." }, "resetUsage": { diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index 7f7c1b72c..70ffd61ab 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -523,10 +523,12 @@ "expired": "按时间过期", "limited": "按流量受限", "on_hold": "暂停中", - "disabled": "已禁用" + "disabled": "已禁用", + "inactive": "不活跃用户" }, "statusChangedAfter": "状态变更时间晚于", "statusChangedBefore": "状态变更时间早于", + "lastActivityBefore": "最后活动时间早于", "expiredAfterPlaceholder": "选择开始日期", "expiredBeforePlaceholder": "选择结束日期", "dateRange": "日期范围", @@ -537,27 +539,33 @@ "deleteLimited": "删除受限账户", "deleteOnHold": "删除暂停账户", "deleteDisabled": "删除禁用账户", + "deleteInactive": "删除不活跃账户", "deleting": "删除中...", "deleteSuccess": "成功删除 {{count}} 个过期账户", "deleteLimitedSuccess": "成功删除 {{count}} 个受限账户", "deleteOnHoldSuccess": "成功删除 {{count}} 个暂停账户", "deleteDisabledSuccess": "成功删除 {{count}} 个禁用账户", + "deleteInactiveSuccess": "成功删除 {{count}} 个不活跃账户", "dryRunSuccess": "模拟运行:将删除 {{count}} 个账户", "deleteFailed": "删除过期账户失败", "deleteLimitedFailed": "删除受限账户失败", "deleteOnHoldFailed": "删除暂停账户失败", "deleteDisabledFailed": "删除禁用账户失败", + "deleteInactiveFailed": "删除不活跃账户失败", "confirmDelete": "删除过期账户", "confirmDeleteLimited": "删除受限账户", "confirmDeleteOnHold": "删除暂停账户", "confirmDeleteDisabled": "删除禁用账户", + "confirmDeleteInactive": "删除不活跃账户", "confirmDeleteMessage": "您确定要删除所选日期范围内的所有过期账户吗?此操作无法撤销。", "confirmDeleteLimitedMessage": "您确定要删除所选日期范围内的所有受限账户吗?此操作无法撤销。", "confirmDeleteOnHoldMessage": "您确定要删除所选日期范围内的所有暂停账户吗?此操作无法撤销。", "confirmDeleteDisabledMessage": "您确定要删除所选日期范围内的所有禁用账户吗?此操作无法撤销。", + "confirmDeleteInactiveMessage": "您确定要删除最后活动时间早于所选日期的账户吗?此操作无法撤销。", "noDateSelected": "请至少选择一个日期", "selectDateRange": "按到期日期过滤。如果开始和结束日期都为空,将删除所有匹配账户。", "selectStatusChangedDateRange": "按用户进入此状态的日期过滤。如果开始和结束日期都为空,将删除所有匹配账户。", + "selectInactiveDate": "请选择截止日期。最后在线时间早于该日期的账户将被选中;从未上线的账户使用创建日期。", "selectLimitedInfo": "将删除所选管理员范围内的所有按流量受限账户。" }, "resetUsage": { diff --git a/dashboard/src/pages/_dashboard.settings.cleanup.tsx b/dashboard/src/pages/_dashboard.settings.cleanup.tsx index aebcfd999..26fef1d41 100644 --- a/dashboard/src/pages/_dashboard.settings.cleanup.tsx +++ b/dashboard/src/pages/_dashboard.settings.cleanup.tsx @@ -30,7 +30,7 @@ import { toast } from 'sonner' import { hasScopeAll, isOwner, roleLabel } from '@/utils/rbac' const PAGE_SIZE = 20 -type CleanupDeleteTarget = 'expired' | 'limited' | 'on_hold' | 'disabled' +type CleanupDeleteTarget = 'expired' | 'limited' | 'on_hold' | 'disabled' | 'inactive' export default function CleanupSettings() { const { t, i18n } = useTranslation() @@ -110,7 +110,7 @@ export default function CleanupSettings() { const deleteExpiredUsersMutation = useDeleteExpiredUsers() const resetUsersDataUsageMutation = useResetUsersDataUsage() const clearUsageDataMutation = useClearUsageData() - + // Track which operation is running const [isPreviewRunning, setIsPreviewRunning] = useState(false) @@ -123,7 +123,7 @@ export default function CleanupSettings() { const target = deleteTarget const params: any = { target, dry_run: isDryRun } - if (statusChangedAfter) params.expired_after = startOfDay(statusChangedAfter).toISOString() + if (statusChangedAfter && target !== 'inactive') params.expired_after = startOfDay(statusChangedAfter).toISOString() if (statusChangedBefore) params.expired_before = endOfDay(statusChangedBefore).toISOString() if (selectedAdmin) params.admin_username = selectedAdmin.username @@ -132,12 +132,14 @@ export default function CleanupSettings() { limited: 'settings.cleanup.expiredUsers.deleteLimitedSuccess', on_hold: 'settings.cleanup.expiredUsers.deleteOnHoldSuccess', disabled: 'settings.cleanup.expiredUsers.deleteDisabledSuccess', + inactive: 'settings.cleanup.expiredUsers.deleteInactiveSuccess', } const failKeyMap: Record = { expired: 'settings.cleanup.expiredUsers.deleteFailed', limited: 'settings.cleanup.expiredUsers.deleteLimitedFailed', on_hold: 'settings.cleanup.expiredUsers.deleteOnHoldFailed', disabled: 'settings.cleanup.expiredUsers.deleteDisabledFailed', + inactive: 'settings.cleanup.expiredUsers.deleteInactiveFailed', } // Set preview state if this is a dry run @@ -184,7 +186,7 @@ export default function CleanupSettings() { } toast.error(t(failureMessageKey), { description: errorMessage }) - + // Reset preview state on error if (isDryRun) { setIsPreviewRunning(false) @@ -405,33 +407,44 @@ export default function CleanupSettings() { {t('settings.cleanup.expiredUsers.targets.disabled')} + + {t('settings.cleanup.expiredUsers.targets.inactive')} +
-
- -
+ {deleteTarget !== 'inactive' && ( +
+ +
+ )}
- {deleteTarget === 'expired' ? t('settings.cleanup.expiredUsers.selectDateRange') : t('settings.cleanup.expiredUsers.selectStatusChangedDateRange')} + {deleteTarget === 'expired' + ? t('settings.cleanup.expiredUsers.selectDateRange') + : deleteTarget === 'inactive' + ? t('settings.cleanup.expiredUsers.selectInactiveDate') + : t('settings.cleanup.expiredUsers.selectStatusChangedDateRange')}
{/* Preview Button */} - {/* Delete Button with confirmation */} -