Skip to content

Commit ccc1b57

Browse files
committed
feat(users): add bulk user actions APIs and improve table selection UX
- add bulk delete, reset usage, revoke subscription, and set owner APIs - wire frontend bulk actions, dialogs, and translations - add advanced search apply loading and selection checkbox preference - improve selected row styling and selection performance
1 parent d6f2d15 commit ccc1b57

File tree

21 files changed

+1360
-252
lines changed

21 files changed

+1360
-252
lines changed

app/db/crud/user.py

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,16 @@ async def bulk_reset_user_data_usage(db: AsyncSession, users: list[User]) -> lis
960960
return users
961961

962962

963+
def _build_revoked_proxy_settings(db_user: User) -> dict:
964+
proxy_settings = ProxyTable()
965+
proxy_settings.vless.flow = db_user.proxy_settings.get("vless", {}).get("flow", "")
966+
proxy_settings.shadowsocks.method = db_user.proxy_settings.get("shadowsocks", {}).get(
967+
"method", "chacha20-ietf-poly1305"
968+
)
969+
proxy_settings.wireguard.peer_ips = db_user.proxy_settings.get("wireguard", {}).get("peer_ips", []) or []
970+
return proxy_settings.dict()
971+
972+
963973
async def reset_user_by_next(db: AsyncSession, db_user: User) -> User:
964974
"""
965975
Resets the data usage of a user based on next user.
@@ -1034,18 +1044,34 @@ async def revoke_user_sub(db: AsyncSession, db_user: User) -> User:
10341044
User: The updated user object.
10351045
"""
10361046
db_user.sub_revoked_at = datetime.now(timezone.utc)
1037-
proxy_settings = ProxyTable()
1038-
proxy_settings.vless.flow = db_user.proxy_settings.get("vless", {}).get("flow", "")
1039-
proxy_settings.shadowsocks.method = db_user.proxy_settings.get("shadowsocks", {}).get(
1040-
"method", "chacha20-ietf-poly1305"
1041-
)
1042-
proxy_settings.wireguard.peer_ips = db_user.proxy_settings.get("wireguard", {}).get("peer_ips", []) or []
1043-
db_user.proxy_settings = proxy_settings.dict()
1047+
db_user.proxy_settings = _build_revoked_proxy_settings(db_user)
10441048
await db.commit()
10451049
await refresh_and_load_user(db, db_user)
10461050
return db_user
10471051

10481052

1053+
async def bulk_revoke_user_sub(db: AsyncSession, users: list[User]) -> list[User]:
1054+
"""
1055+
Revoke subscriptions for multiple users in a single transaction.
1056+
1057+
Args:
1058+
db (AsyncSession): Database session.
1059+
users (list[User]): Users whose subscriptions should be revoked.
1060+
1061+
Returns:
1062+
list[User]: The refreshed users.
1063+
"""
1064+
revoked_at = datetime.now(timezone.utc)
1065+
for user in users:
1066+
user.sub_revoked_at = revoked_at
1067+
user.proxy_settings = _build_revoked_proxy_settings(user)
1068+
1069+
await db.commit()
1070+
for user in users:
1071+
await refresh_and_load_user(db, user)
1072+
return users
1073+
1074+
10491075
async def user_sub_update(db: AsyncSession, user_id: User, user_agent: str) -> User:
10501076
"""
10511077
Updates the user's subscription details.
@@ -1282,6 +1308,27 @@ async def set_owner(db: AsyncSession, db_user: User, admin: Admin) -> User:
12821308
return db_user
12831309

12841310

1311+
async def bulk_set_owner(db: AsyncSession, users: list[User], admin: Admin) -> list[User]:
1312+
"""
1313+
Set the same owner for multiple users in a single transaction.
1314+
1315+
Args:
1316+
db (AsyncSession): Database session.
1317+
users (list[User]): Users to update.
1318+
admin (Admin): Admin that should become the owner.
1319+
1320+
Returns:
1321+
list[User]: The refreshed users.
1322+
"""
1323+
for user in users:
1324+
user.admin = admin
1325+
1326+
await db.commit()
1327+
for user in users:
1328+
await refresh_and_load_user(db, user)
1329+
return users
1330+
1331+
12851332
async def start_users_expire(db: AsyncSession, users: list[User]) -> list[User]:
12861333
"""
12871334
Starts the expiration timer for a user.

app/models/user.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,29 @@ class RemoveUsersResponse(BaseModel):
190190
count: int
191191

192192

193+
class BulkUsersActionResponse(BaseModel):
194+
users: list[str]
195+
count: int
196+
197+
198+
class BulkUsersSelection(BaseModel):
199+
ids: set[int] = Field(default_factory=set)
200+
201+
@field_validator("ids", mode="after")
202+
@classmethod
203+
def ids_validator(cls, v):
204+
return ListValidator.not_null_list(v, "user")
205+
206+
207+
class BulkUsersSetOwner(BulkUsersSelection):
208+
admin_username: str
209+
210+
@field_validator("admin_username", check_fields=False)
211+
@classmethod
212+
def validate_admin_username(cls, v):
213+
return UserValidator.validate_username(v)
214+
215+
193216
class ModifyUserByTemplate(BaseModel):
194217
user_template_id: int
195218
note: str | None = Field(max_length=500, default=None)

app/operation/user.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
from app.db.crud.user import (
2525
UsersSortingOptions,
2626
UsersSortingOptionsSimple,
27+
bulk_reset_user_data_usage,
28+
bulk_revoke_user_sub,
29+
bulk_set_owner,
2730
create_user,
2831
create_users_bulk,
2932
get_all_users_usages,
@@ -49,9 +52,12 @@
4952
from app.models.user import (
5053
BulkOperationDryRunResponse,
5154
BulkUser,
55+
BulkUsersActionResponse,
5256
BulkUsersCreateResponse,
5357
BulkUsersFromTemplate,
5458
BulkUsersProxy,
59+
BulkUsersSelection,
60+
BulkUsersSetOwner,
5561
BulkWireGuardPeerIPs,
5662
CreateUserFromTemplate,
5763
ModifyUserByTemplate,
@@ -350,6 +356,52 @@ async def remove_user(self, db: AsyncSession, username: str, admin: AdminDetails
350356
logger.info(f'User "{db_user.username}" with id "{db_user.id}" deleted by admin "{admin.username}"')
351357
return {}
352358

359+
async def _get_validated_users_by_ids(
360+
self,
361+
db: AsyncSession,
362+
user_ids: list[int] | set[int],
363+
admin: AdminDetails,
364+
*,
365+
load_admin: bool = True,
366+
load_next_plan: bool = True,
367+
load_usage_logs: bool = True,
368+
load_groups: bool = True,
369+
) -> list[User]:
370+
users: list[User] = []
371+
for user_id in user_ids:
372+
users.append(
373+
await self.get_validated_user_by_id(
374+
db,
375+
user_id,
376+
admin,
377+
load_admin=load_admin,
378+
load_next_plan=load_next_plan,
379+
load_usage_logs=load_usage_logs,
380+
load_groups=load_groups,
381+
)
382+
)
383+
return users
384+
385+
@staticmethod
386+
def _build_bulk_action_response(users: list[User | UserNotificationResponse]) -> BulkUsersActionResponse:
387+
usernames = [user.username for user in users]
388+
return BulkUsersActionResponse(users=usernames, count=len(usernames))
389+
390+
async def bulk_remove_users(
391+
self, db: AsyncSession, bulk_users: BulkUsersSelection, admin: AdminDetails
392+
) -> RemoveUsersResponse:
393+
db_users = await self._get_validated_users_by_ids(db, bulk_users.ids, admin)
394+
users = [await self.validate_user(db_user, include_subscription_url=False) for db_user in db_users]
395+
396+
await remove_users(db, db_users)
397+
398+
for user in users:
399+
await sync_remove_user(user)
400+
asyncio.create_task(notification.remove_user(user, admin))
401+
logger.info(f'User "{user.username}" with id "{user.id}" deleted by admin "{admin.username}"')
402+
403+
return RemoveUsersResponse(users=[user.username for user in users], count=len(users))
404+
353405
async def _reset_user_data_usage(
354406
self,
355407
db: AsyncSession,
@@ -377,6 +429,24 @@ async def reset_user_data_usage(self, db: AsyncSession, username: str, admin: Ad
377429

378430
return await self._reset_user_data_usage(db, db_user, admin)
379431

432+
async def bulk_reset_user_data_usage(
433+
self, db: AsyncSession, bulk_users: BulkUsersSelection, admin: AdminDetails
434+
) -> BulkUsersActionResponse:
435+
db_users = await self._get_validated_users_by_ids(db, bulk_users.ids, admin, load_usage_logs=False)
436+
old_statuses = {user.id: user.status for user in db_users}
437+
438+
db_users = await bulk_reset_user_data_usage(db, db_users)
439+
await sync_users(db_users)
440+
441+
users = [await self.validate_user(db_user) for db_user in db_users]
442+
for user in users:
443+
if user.status != old_statuses[user.id]:
444+
asyncio.create_task(notification.user_status_change(user, admin))
445+
asyncio.create_task(notification.reset_user_data_usage(user, admin))
446+
logger.info(f'User "{user.username}" usage was reset by admin "{admin.username}"')
447+
448+
return self._build_bulk_action_response(users)
449+
380450
async def revoke_user_sub(self, db: AsyncSession, username: str, admin: AdminDetails) -> UserResponse:
381451
db_user = await self.get_validated_user(db, username, admin)
382452

@@ -389,6 +459,21 @@ async def revoke_user_sub(self, db: AsyncSession, username: str, admin: AdminDet
389459

390460
return user
391461

462+
async def bulk_revoke_user_sub(
463+
self, db: AsyncSession, bulk_users: BulkUsersSelection, admin: AdminDetails
464+
) -> BulkUsersActionResponse:
465+
db_users = await self._get_validated_users_by_ids(db, bulk_users.ids, admin, load_usage_logs=False)
466+
467+
db_users = await bulk_revoke_user_sub(db, db_users)
468+
await sync_users(db_users)
469+
470+
users = [await self.validate_user(db_user) for db_user in db_users]
471+
for user in users:
472+
asyncio.create_task(notification.user_subscription_revoked(user, admin))
473+
logger.info(f'User "{user.username}" subscription was revoked by admin "{admin.username}"')
474+
475+
return self._build_bulk_action_response(users)
476+
392477
async def reset_users_data_usage(self, db: AsyncSession, admin: AdminDetails):
393478
"""Reset all users data usage"""
394479
db_admin = await self.get_validated_admin(db, admin.username)
@@ -429,6 +514,19 @@ async def set_owner(
429514

430515
return user
431516

517+
async def bulk_set_owner(
518+
self, db: AsyncSession, bulk_users: BulkUsersSetOwner, admin: AdminDetails
519+
) -> BulkUsersActionResponse:
520+
new_admin = await self.get_validated_admin(db, username=bulk_users.admin_username)
521+
db_users = await self._get_validated_users_by_ids(db, bulk_users.ids, admin, load_usage_logs=False)
522+
523+
db_users = await bulk_set_owner(db, db_users, new_admin)
524+
users = [await self.validate_user(db_user) for db_user in db_users]
525+
for user in users:
526+
logger.info(f'User "{user.username}" owner successfully set to "{new_admin.username}" by admin "{admin.username}"')
527+
528+
return self._build_bulk_action_response(users)
529+
432530
async def get_user_usage(
433531
self,
434532
db: AsyncSession,

app/routers/user.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@
99
from app.models.stats import Period, UserUsageStatsList
1010
from app.models.user import (
1111
BulkUser,
12+
BulkUsersActionResponse,
1213
BulkUsersCreateResponse,
1314
BulkUsersFromTemplate,
1415
BulkUsersProxy,
16+
BulkUsersSelection,
17+
BulkUsersSetOwner,
1518
BulkWireGuardPeerIPs,
1619
CreateUserFromTemplate,
1720
ModifyUserByTemplate,
@@ -349,6 +352,62 @@ async def delete_expired_users(
349352
)
350353

351354

355+
@router.post(
356+
"s/bulk/delete",
357+
response_model=RemoveUsersResponse,
358+
responses={400: responses._400, 403: responses._403, 404: responses._404},
359+
)
360+
async def bulk_delete_users(
361+
bulk_users: BulkUsersSelection,
362+
db: AsyncSession = Depends(get_db),
363+
admin: AdminDetails = Depends(get_current),
364+
):
365+
"""Delete selected users by ID."""
366+
return await user_operator.bulk_remove_users(db, bulk_users, admin)
367+
368+
369+
@router.post(
370+
"s/bulk/reset",
371+
response_model=BulkUsersActionResponse,
372+
responses={400: responses._400, 403: responses._403, 404: responses._404},
373+
)
374+
async def bulk_reset_users_data_usage(
375+
bulk_users: BulkUsersSelection,
376+
db: AsyncSession = Depends(get_db),
377+
admin: AdminDetails = Depends(get_current),
378+
):
379+
"""Reset usage for selected users by ID."""
380+
return await user_operator.bulk_reset_user_data_usage(db, bulk_users, admin)
381+
382+
383+
@router.post(
384+
"s/bulk/revoke_sub",
385+
response_model=BulkUsersActionResponse,
386+
responses={400: responses._400, 403: responses._403, 404: responses._404},
387+
)
388+
async def bulk_revoke_users_subscription(
389+
bulk_users: BulkUsersSelection,
390+
db: AsyncSession = Depends(get_db),
391+
admin: AdminDetails = Depends(get_current),
392+
):
393+
"""Revoke subscriptions for selected users by ID."""
394+
return await user_operator.bulk_revoke_user_sub(db, bulk_users, admin)
395+
396+
397+
@router.put(
398+
"s/bulk/set_owner",
399+
response_model=BulkUsersActionResponse,
400+
responses={400: responses._400, 403: responses._403, 404: responses._404},
401+
)
402+
async def bulk_set_owner(
403+
bulk_users: BulkUsersSetOwner,
404+
db: AsyncSession = Depends(get_db),
405+
admin: AdminDetails = Depends(check_sudo_admin),
406+
):
407+
"""Set a new owner for selected users by ID."""
408+
return await user_operator.bulk_set_owner(db, bulk_users, admin)
409+
410+
352411
@router.post("/from_template", status_code=status.HTTP_201_CREATED, response_model=UserResponse)
353412
async def create_user_from_template(
354413
new_template_user: CreateUserFromTemplate,

dashboard/public/statics/locales/en.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1932,9 +1932,25 @@
19321932
"selectAdmin": "Select new owner",
19331933
"confirm": "Set Owner",
19341934
"success": "User {{username}}'s owner changed to {{admin}} successfully.",
1935+
"bulkDescription": "Select a new owner for {{count}} selected users.",
1936+
"bulkSuccess": "Owner for {{count}} users changed to {{admin}} successfully.",
19351937
"error": "Failed to change owner of user {{username}} to {{admin}}.",
19361938
"loadError": "Failed to load admins."
19371939
},
1940+
"bulkUserActions": {
1941+
"deleteTitle": "Delete Selected Users",
1942+
"deletePrompt": "Are you sure you want to delete {{count}} selected users? This action cannot be undone.",
1943+
"deleteSuccess": "{{count}} users deleted successfully.",
1944+
"deleteError": "Failed to delete selected users.",
1945+
"resetTitle": "Reset Selected Users Usage",
1946+
"resetPrompt": "Are you sure you want to reset usage for {{count}} selected users?",
1947+
"resetSuccess": "Usage reset for {{count}} users.",
1948+
"resetError": "Failed to reset usage for selected users.",
1949+
"revokeTitle": "Revoke Selected Users Subscription",
1950+
"revokePrompt": "Are you sure you want to revoke subscriptions for {{count}} selected users?",
1951+
"revokeSuccess": "Subscriptions revoked for {{count}} users.",
1952+
"revokeError": "Failed to revoke subscriptions for selected users."
1953+
},
19381954
"bulk": {
19391955
"title": "Bulk",
19401956
"createUsers": "Create Users",
@@ -2096,6 +2112,7 @@
20962112
"byUsername": "Username and Notes",
20972113
"byProtocol": "Protocol Data",
20982114
"showCreatedBy": "Show created by",
2115+
"showSelectionCheckbox": "Show selection checkbox",
20992116
"byStatus": "Status",
21002117
"byAdmin": "Admin",
21012118
"byGroup": "Group",

0 commit comments

Comments
 (0)