Skip to content

Commit 0ecb01a

Browse files
authored
Merge pull request #8469 from Couchers-org/backend/feature/list-admin-actions-and-moderation-states
Backend: Add ListAdminActions and ListModerationStates RPCs
2 parents b6f1610 + e3b8542 commit 0ecb01a

6 files changed

Lines changed: 269 additions & 1 deletion

File tree

app/backend/src/couchers/servicers/admin.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import grpc
55
from google.protobuf import empty_pb2
66
from sqlalchemy import select
7-
from sqlalchemy.orm import Session, selectinload
7+
from sqlalchemy.orm import Session, aliased, selectinload
88
from sqlalchemy.sql import and_, func, or_
99
from user_agents import parse as user_agents_parse
1010

@@ -114,6 +114,8 @@ def _user_to_details(session: Session, user: User) -> admin_pb2.UserDetails:
114114
level=adminactionlevel2api[action.level],
115115
note=action.note or "",
116116
tag=action.tag or "",
117+
target_user_id=action.target_user_id,
118+
target_username=user.username,
117119
)
118120
)
119121

@@ -1085,3 +1087,49 @@ def SetModScore(
10851087
user.mod_score = request.mod_score
10861088
log_admin_action(session, context, user, "set_mod_score", note=f"mod_score={request.mod_score}")
10871089
return _user_to_details(session, user)
1090+
1091+
def ListAdminActions(
1092+
self, request: admin_pb2.ListAdminActionsReq, context: CouchersContext, session: Session
1093+
) -> admin_pb2.ListAdminActionsRes:
1094+
page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
1095+
1096+
admin_user = aliased(User)
1097+
target_user = aliased(User)
1098+
1099+
statement = (
1100+
select(AdminAction, admin_user.username, target_user.username)
1101+
.join(admin_user, AdminAction.admin_user_id == admin_user.id)
1102+
.join(target_user, AdminAction.target_user_id == target_user.id)
1103+
)
1104+
1105+
if request.admin_user_id:
1106+
statement = statement.where(AdminAction.admin_user_id == request.admin_user_id)
1107+
if request.target_user_id:
1108+
statement = statement.where(AdminAction.target_user_id == request.target_user_id)
1109+
if request.page_token:
1110+
statement = statement.where(AdminAction.id < int(request.page_token))
1111+
1112+
statement = statement.order_by(AdminAction.id.desc()).limit(page_size + 1)
1113+
1114+
rows = session.execute(statement).all()
1115+
1116+
action_pbs = [
1117+
admin_pb2.AdminActionLog(
1118+
admin_action_id=action.id,
1119+
created=Timestamp_from_datetime(action.created),
1120+
admin_user_id=action.admin_user_id,
1121+
admin_username=admin_username,
1122+
action_type=action.action_type,
1123+
level=adminactionlevel2api[action.level],
1124+
note=action.note or "",
1125+
tag=action.tag or "",
1126+
target_user_id=action.target_user_id,
1127+
target_username=target_username,
1128+
)
1129+
for action, admin_username, target_username in rows[:page_size]
1130+
]
1131+
1132+
return admin_pb2.ListAdminActionsRes(
1133+
admin_actions=action_pbs,
1134+
next_page_token=str(rows[page_size - 1][0].id) if len(rows) > page_size else None,
1135+
)

app/backend/src/couchers/servicers/moderation.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,3 +605,46 @@ def SetUserContentVisibility(
605605
)
606606

607607
return moderation_pb2.SetUserContentVisibilityRes(updated_count=updated_count)
608+
609+
def ListModerationStates(
610+
self, request: moderation_pb2.ListModerationStatesReq, context: CouchersContext, session: Session
611+
) -> moderation_pb2.ListModerationStatesRes:
612+
"""Chronological, paginated list of ModerationState rows. Optional author_user_id filter."""
613+
page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
614+
615+
statement = select(ModerationState)
616+
617+
if request.page_token:
618+
page_token_id = int(request.page_token)
619+
if request.newest_first:
620+
statement = statement.where(ModerationState.id < page_token_id)
621+
else:
622+
statement = statement.where(ModerationState.id > page_token_id)
623+
624+
if request.author_user_id:
625+
author_exists_clauses = []
626+
for model in moderationobjecttype2model.values():
627+
author_col = getattr(model, model.__moderation_author_column__)
628+
author_exists_clauses.append(
629+
exists().where(
630+
and_(
631+
model.moderation_state_id == ModerationState.id,
632+
author_col == request.author_user_id,
633+
)
634+
)
635+
)
636+
statement = statement.where(or_(*author_exists_clauses))
637+
638+
if request.newest_first:
639+
statement = statement.order_by(ModerationState.created.desc(), ModerationState.id.desc())
640+
else:
641+
statement = statement.order_by(ModerationState.created.asc(), ModerationState.id.asc())
642+
643+
states = session.execute(statement.limit(page_size + 1)).scalars().all()
644+
645+
state_pbs = [moderation_state_to_pb(state, session) for state in states[:page_size]]
646+
647+
return moderation_pb2.ListModerationStatesRes(
648+
moderation_states=state_pbs,
649+
next_page_token=str(states[page_size - 1].id) if len(states) > page_size else None,
650+
)

app/backend/src/tests/test_admin.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1303,6 +1303,84 @@ def test_search_users_by_admin_note(db):
13031303
assert user2.id not in user_ids
13041304

13051305

1306+
def test_ListAdminActions_empty(db):
1307+
super_user, super_token = generate_user(is_superuser=True)
1308+
1309+
with real_admin_session(super_token) as api:
1310+
res = api.ListAdminActions(admin_pb2.ListAdminActionsReq())
1311+
assert len(res.admin_actions) == 0
1312+
assert res.next_page_token == ""
1313+
1314+
1315+
def test_ListAdminActions_returns_newest_first_with_target_info(db):
1316+
super_user, super_token = generate_user(is_superuser=True)
1317+
user1, _ = generate_user()
1318+
user2, _ = generate_user()
1319+
1320+
with real_admin_session(super_token) as api:
1321+
api.AddAdminNote(admin_pb2.AddAdminNoteReq(user=user1.username, admin_note="first note"))
1322+
api.AddAdminNote(admin_pb2.AddAdminNoteReq(user=user2.username, admin_note="second note"))
1323+
api.BanUser(admin_pb2.BanUserReq(user=user1.username, admin_note="ban reason"))
1324+
1325+
res = api.ListAdminActions(admin_pb2.ListAdminActionsReq())
1326+
1327+
assert len(res.admin_actions) == 3
1328+
# Newest first
1329+
assert res.admin_actions[0].action_type == "ban"
1330+
assert res.admin_actions[0].target_user_id == user1.id
1331+
assert res.admin_actions[0].target_username == user1.username
1332+
assert res.admin_actions[0].admin_user_id == super_user.id
1333+
assert res.admin_actions[0].admin_username == super_user.username
1334+
assert res.admin_actions[1].action_type == "note"
1335+
assert res.admin_actions[1].target_user_id == user2.id
1336+
assert res.admin_actions[2].action_type == "note"
1337+
assert res.admin_actions[2].target_user_id == user1.id
1338+
1339+
1340+
def test_ListAdminActions_filter_by_admin_and_target(db):
1341+
super1, super1_token = generate_user(is_superuser=True)
1342+
super2, super2_token = generate_user(is_superuser=True)
1343+
user1, _ = generate_user()
1344+
user2, _ = generate_user()
1345+
1346+
with real_admin_session(super1_token) as api:
1347+
api.AddAdminNote(admin_pb2.AddAdminNoteReq(user=user1.username, admin_note="from super1 to user1"))
1348+
api.AddAdminNote(admin_pb2.AddAdminNoteReq(user=user2.username, admin_note="from super1 to user2"))
1349+
with real_admin_session(super2_token) as api:
1350+
api.AddAdminNote(admin_pb2.AddAdminNoteReq(user=user1.username, admin_note="from super2 to user1"))
1351+
1352+
with real_admin_session(super1_token) as api:
1353+
res = api.ListAdminActions(admin_pb2.ListAdminActionsReq(admin_user_id=super1.id))
1354+
assert {a.note for a in res.admin_actions} == {"from super1 to user1", "from super1 to user2"}
1355+
1356+
res = api.ListAdminActions(admin_pb2.ListAdminActionsReq(target_user_id=user1.id))
1357+
assert {a.note for a in res.admin_actions} == {"from super1 to user1", "from super2 to user1"}
1358+
1359+
res = api.ListAdminActions(admin_pb2.ListAdminActionsReq(admin_user_id=super1.id, target_user_id=user1.id))
1360+
assert [a.note for a in res.admin_actions] == ["from super1 to user1"]
1361+
1362+
1363+
def test_ListAdminActions_pagination(db):
1364+
super_user, super_token = generate_user(is_superuser=True)
1365+
user, _ = generate_user()
1366+
1367+
with real_admin_session(super_token) as api:
1368+
for i in range(3):
1369+
api.AddAdminNote(admin_pb2.AddAdminNoteReq(user=user.username, admin_note=f"note {i}"))
1370+
1371+
res = api.ListAdminActions(admin_pb2.ListAdminActionsReq(page_size=2))
1372+
assert len(res.admin_actions) == 2
1373+
assert res.next_page_token != ""
1374+
first_page_notes = [a.note for a in res.admin_actions]
1375+
1376+
res2 = api.ListAdminActions(admin_pb2.ListAdminActionsReq(page_size=2, page_token=res.next_page_token))
1377+
assert len(res2.admin_actions) == 1
1378+
assert res2.next_page_token == ""
1379+
1380+
all_notes = first_page_notes + [a.note for a in res2.admin_actions]
1381+
assert set(all_notes) == {"note 0", "note 1", "note 2"}
1382+
1383+
13061384
# community invite feature tested in test_events.py
13071385
# SendBlogPostNotification tested in test_notifications.py
13081386
# MarkUserNeedsLocationUpdate tested in test_jail.py

app/backend/src/tests/test_moderation.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2844,3 +2844,66 @@ def test_SetUserContentVisibility_user_not_found(db):
28442844
)
28452845
)
28462846
assert e.value.code() == grpc.StatusCode.NOT_FOUND
2847+
2848+
2849+
def test_ListModerationStates_empty(db):
2850+
super_user, super_token = generate_user(is_superuser=True)
2851+
2852+
with real_moderation_session(super_token) as api:
2853+
res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq())
2854+
assert len(res.moderation_states) == 0
2855+
assert res.next_page_token == ""
2856+
2857+
2858+
def test_ListModerationStates_returns_states_chronologically(db):
2859+
super_user, super_token = generate_user(is_superuser=True)
2860+
surfer, surfer_token = generate_user()
2861+
host, _ = generate_user()
2862+
2863+
state1_id = create_test_host_request_with_moderation(surfer_token, host.id)
2864+
state2_id = create_test_host_request_with_moderation(surfer_token, host.id)
2865+
state3_id = create_test_host_request_with_moderation(surfer_token, host.id)
2866+
2867+
with real_moderation_session(super_token) as api:
2868+
res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq())
2869+
assert [s.moderation_state_id for s in res.moderation_states] == [state1_id, state2_id, state3_id]
2870+
2871+
res_newest = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(newest_first=True))
2872+
assert [s.moderation_state_id for s in res_newest.moderation_states] == [state3_id, state2_id, state1_id]
2873+
2874+
2875+
def test_ListModerationStates_filter_by_author(db):
2876+
super_user, super_token = generate_user(is_superuser=True)
2877+
surfer1, surfer1_token = generate_user()
2878+
surfer2, surfer2_token = generate_user()
2879+
host, _ = generate_user()
2880+
2881+
state1_id = create_test_host_request_with_moderation(surfer1_token, host.id)
2882+
state2_id = create_test_host_request_with_moderation(surfer2_token, host.id)
2883+
state3_id = create_test_host_request_with_moderation(surfer1_token, host.id)
2884+
2885+
with real_moderation_session(super_token) as api:
2886+
res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(author_user_id=surfer1.id))
2887+
assert {s.moderation_state_id for s in res.moderation_states} == {state1_id, state3_id}
2888+
2889+
res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(author_user_id=surfer2.id))
2890+
assert [s.moderation_state_id for s in res.moderation_states] == [state2_id]
2891+
2892+
2893+
def test_ListModerationStates_pagination(db):
2894+
super_user, super_token = generate_user(is_superuser=True)
2895+
surfer, surfer_token = generate_user()
2896+
host, _ = generate_user()
2897+
2898+
state_ids = [create_test_host_request_with_moderation(surfer_token, host.id) for _ in range(3)]
2899+
2900+
with real_moderation_session(super_token) as api:
2901+
res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(page_size=2))
2902+
assert [s.moderation_state_id for s in res.moderation_states] == state_ids[:2]
2903+
assert res.next_page_token != ""
2904+
2905+
res2 = api.ListModerationStates(
2906+
moderation_pb2.ListModerationStatesReq(page_size=2, page_token=res.next_page_token)
2907+
)
2908+
assert [s.moderation_state_id for s in res2.moderation_states] == [state_ids[2]]
2909+
assert res2.next_page_token == ""

app/proto/admin.proto

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ service Admin {
9999
rpc RemoveAdminTagFromUser(RemoveAdminTagFromUserReq) returns (UserDetails) {}
100100

101101
rpc SetModScore(SetModScoreReq) returns (UserDetails) {}
102+
103+
rpc ListAdminActions(ListAdminActionsReq) returns (ListAdminActionsRes) {}
102104
}
103105

104106
message AdminActionLog {
@@ -110,6 +112,8 @@ message AdminActionLog {
110112
string note = 6; // optional note/details
111113
string tag = 7; // for tag-related actions
112114
AdminActionLevel level = 8;
115+
int64 target_user_id = 9;
116+
string target_username = 10;
113117
}
114118

115119
message AdminTagInfo {
@@ -534,3 +538,18 @@ message SetModScoreReq {
534538
string user = 1;
535539
double mod_score = 2;
536540
}
541+
542+
message ListAdminActionsReq {
543+
// Filter to actions performed by this admin (optional)
544+
int64 admin_user_id = 1;
545+
// Filter to actions targeting this user (optional)
546+
int64 target_user_id = 2;
547+
548+
uint32 page_size = 3;
549+
string page_token = 4;
550+
}
551+
552+
message ListAdminActionsRes {
553+
repeated AdminActionLog admin_actions = 1;
554+
string next_page_token = 2;
555+
}

app/proto/moderation.proto

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ service Moderation {
2222
rpc UnflagContent(UnflagContentReq) returns (UnflagContentRes) {}
2323

2424
rpc SetUserContentVisibility(SetUserContentVisibilityReq) returns (SetUserContentVisibilityRes) {}
25+
26+
rpc ListModerationStates(ListModerationStatesReq) returns (ListModerationStatesRes) {}
2527
}
2628

2729

@@ -180,3 +182,18 @@ message SetUserContentVisibilityReq {
180182
message SetUserContentVisibilityRes {
181183
int64 updated_count = 1; // number of ModerationState rows whose visibility changed
182184
}
185+
186+
message ListModerationStatesReq {
187+
// Filter to states authored by this user (optional)
188+
int64 author_user_id = 1;
189+
190+
uint32 page_size = 2;
191+
string page_token = 3;
192+
// Order by newest first (default: false = oldest first)
193+
bool newest_first = 4;
194+
}
195+
196+
message ListModerationStatesRes {
197+
repeated ModerationStateInfo moderation_states = 1;
198+
string next_page_token = 2;
199+
}

0 commit comments

Comments
 (0)