Skip to content

Commit 043d34f

Browse files
committed
Work on admin APIs
1 parent a326b77 commit 043d34f

12 files changed

Lines changed: 618 additions & 208 deletions

File tree

app/backend/src/couchers/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,5 @@
129129

130130
# How long a container must run uninterrupted before /status reports stable=true
131131
STABLE_THRESHOLD_SECONDS = 5 * 60
132+
133+
MODERATION_AUTO_APPROVE_FLAG_PRIORITY = 1000

app/backend/src/couchers/i18n/admin_locales/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"errors": {
3+
"action_must_be_specified": "Action must be specified.",
34
"blog_blurb_too_long": "The blog post blurb is too long.",
45
"blog_title_too_long": "The blog post title is too long.",
56
"cannot_edit_badge": "Admins cannot edit that badge.",
@@ -25,16 +26,21 @@
2526
"ota_package_not_found": "OTA package not found.",
2627
"parent_node_not_found": "Parent node not found.",
2728
"postcard_not_sent": "The postcard has not been sent yet.",
29+
"queue_item_id_must_be_specified": "Queue item must be specified.",
30+
"queue_item_not_found": "Queue item not found.",
2831
"reference_not_found": "Reference not found.",
2932
"tag_already_exists": "That admin tag already exists.",
3033
"tag_cant_be_empty": "The admin tag cannot be empty.",
3134
"tag_not_found": "Admin tag not found.",
35+
"trigger_must_be_specified": "Trigger must be specified.",
36+
"unsupported_action": "Unsupported action.",
3237
"user_already_has_admin_tag": "The user already has that admin tag.",
3338
"user_already_has_badge": "The user already has that badge.",
3439
"user_already_volunteer": "This user is already a volunteer.",
3540
"user_does_not_have_admin_tag": "The user does not have that admin tag.",
3641
"user_does_not_have_badge": "The user does not have that badge.",
3742
"user_not_in_the_moderation_user_list": "User is not in the moderation user list.",
43+
"visibility_must_be_specified": "Visibility must be specified.",
3844
"volunteer_not_found": "Volunteer not found."
3945
}
4046
}

app/backend/src/couchers/i18n/locales/en.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,6 @@
220220
"unrecognized_phone_number": "Your mobile phone number is not recognized. Please double-check it and contact support if this error persists.",
221221
"upload_not_found": "Upload not found.",
222222
"upload_not_found_or_not_owned": "Upload not found or you don't own it.",
223-
"visibility_must_be_specified": "Visibility must be specified.",
224223
"user_already_admin": "That user is already an admin.",
225224
"user_already_blocked": "Target user has already been blocked.",
226225
"user_not_admin": "That user is not an admin.",

app/backend/src/couchers/jobs/handlers.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
EVENT_REMINDER_TIMEDELTA,
3939
HOST_REQUEST_MAX_REMINDERS,
4040
HOST_REQUEST_REMINDER_INTERVAL,
41+
MODERATION_AUTO_APPROVE_FLAG_PRIORITY,
4142
)
4243
from couchers.context import make_background_user_context, make_notification_user_context
4344
from couchers.crypto import (
@@ -1544,8 +1545,10 @@ def check_database_consistency(payload: empty_pb2.Empty) -> None:
15441545

15451546
def auto_approve_moderation_queue(payload: empty_pb2.Empty) -> None:
15461547
"""
1547-
Dead man's switch: auto-approves unresolved INITIAL_REVIEW items older than the deadline.
1548-
Items explicitly actioned by moderators are left alone.
1548+
Dead man's switch: approves unresolved INITIAL_REVIEW content older than the deadline to VISIBLE, then
1549+
re-flags it as a high-priority MACHINE_FLAG superseding only the INITIAL_REVIEW item. The switch only fires
1550+
when moderators are behind, so every auto-approved item stays in the queue for a human to check. Other open
1551+
flags are untouched, and items already actioned by moderators are left alone.
15491552
"""
15501553
deadline_seconds = config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS
15511554
if deadline_seconds <= 0:
@@ -1578,13 +1581,27 @@ def auto_approve_moderation_queue(payload: empty_pb2.Empty) -> None:
15781581
return
15791582

15801583
logger.info(f"Auto-approving {len(approvable)} moderation queue items")
1584+
reason = f"Auto-approved: moderation deadline of {deadline_seconds} seconds exceeded."
15811585
for item in approvable:
15821586
Moderation().ModerateContent(
15831587
request=moderation_pb2.ModerateContentReq(
15841588
moderation_state_id=item.moderation_state_id,
15851589
action=moderation_pb2.MODERATION_ACTION_APPROVE,
15861590
visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1587-
reason=f"Auto-approved: moderation deadline of {deadline_seconds} seconds exceeded.",
1591+
reason=reason,
1592+
clear_flags=False,
1593+
),
1594+
context=ctx,
1595+
session=session,
1596+
)
1597+
Moderation().ModerateContent(
1598+
request=moderation_pb2.ModerateContentReq(
1599+
moderation_state_id=item.moderation_state_id,
1600+
action=moderation_pb2.MODERATION_ACTION_FLAG,
1601+
trigger=moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG,
1602+
priority=MODERATION_AUTO_APPROVE_FLAG_PRIORITY,
1603+
reason=reason,
1604+
supersede_queue_item_id=item.queue_item_id,
15881605
),
15891606
context=ctx,
15901607
session=session,

app/backend/src/couchers/migrations/versions/0136_add_macroregion_to_nodetype.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616

1717

1818
def upgrade():
19-
# ALTER TYPE ... ADD VALUE cannot run inside a transaction in PostgreSQL
19+
# This COMMIT is redundant: ADD VALUE works fine inside a transaction; only *using* the new value before
20+
# commit is disallowed, which this migration doesn't do. Kept as-is since it has already run.
2021
op.execute("COMMIT")
2122
op.execute("ALTER TYPE nodetype ADD VALUE IF NOT EXISTS 'macroregion' AFTER 'world'")
2223

app/backend/src/couchers/migrations/versions/0144_add_bulk_set_visibility_to_moderationaction.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616

1717

1818
def upgrade() -> None:
19-
# ALTER TYPE ... ADD VALUE cannot run inside a transaction in PostgreSQL
19+
# This COMMIT is redundant: ADD VALUE works fine inside a transaction; only *using* the new value before
20+
# commit is disallowed, which this migration doesn't do. Kept as-is since it has already run.
2021
op.execute("COMMIT")
2122
op.execute("ALTER TYPE moderationaction ADD VALUE IF NOT EXISTS 'bulk_set_visibility'")
2223

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Add priority to moderation queue items and flag references to the moderation log
2+
3+
Revision ID: 0168
4+
Revises: 0167
5+
Create Date: 2026-06-14 00:00:00.000000
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "0168"
14+
down_revision = "0167"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
op.add_column(
21+
"moderation_queue",
22+
sa.Column("priority", sa.Integer(), server_default="0", nullable=False),
23+
)
24+
op.add_column("moderation_log", sa.Column("new_priority", sa.Integer(), nullable=True))
25+
op.add_column("moderation_log", sa.Column("queue_item_id", sa.BigInteger(), nullable=True))
26+
op.create_index(op.f("ix_moderation_log_queue_item_id"), "moderation_log", ["queue_item_id"], unique=False)
27+
op.create_foreign_key(
28+
op.f("fk_moderation_log_queue_item_id_moderation_queue"),
29+
"moderation_log",
30+
"moderation_queue",
31+
["queue_item_id"],
32+
["id"],
33+
)
34+
35+
op.execute("ALTER TYPE moderationaction ADD VALUE IF NOT EXISTS 'set_priority'")
36+
37+
38+
def downgrade() -> None:
39+
op.drop_constraint(op.f("fk_moderation_log_queue_item_id_moderation_queue"), "moderation_log", type_="foreignkey")
40+
op.drop_index(op.f("ix_moderation_log_queue_item_id"), table_name="moderation_log")
41+
op.drop_column("moderation_log", "queue_item_id")
42+
op.drop_column("moderation_log", "new_priority")
43+
op.drop_column("moderation_queue", "priority")

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from functools import cache
1212
from typing import TYPE_CHECKING, Protocol
1313

14-
from sqlalchemy import BigInteger, ColumnElement, DateTime, Enum, ForeignKey, Index, String, func
14+
from sqlalchemy import BigInteger, ColumnElement, DateTime, Enum, ForeignKey, Index, Integer, String, func
1515
from sqlalchemy.orm import Mapped, mapped_column, relationship
1616

1717
from couchers.models.base import Base, moderation_seq
@@ -57,6 +57,8 @@ class ModerationAction(enum.Enum):
5757
flag = enum.auto()
5858
# Remove flag
5959
unflag = enum.auto()
60+
# Change a flag's priority
61+
set_priority = enum.auto()
6062
# Bulk visibility change applied to every item authored by a user
6163
bulk_set_visibility = enum.auto()
6264

@@ -131,6 +133,8 @@ class ModerationQueueItem(Base, kw_only=True):
131133
trigger: Mapped[ModerationTrigger] = mapped_column(Enum(ModerationTrigger))
132134
reason: Mapped[str] = mapped_column(String)
133135

136+
priority: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0", default=0)
137+
134138
# When resolved, this links to the log entry that resolved it
135139
resolved_by_log_id: Mapped[int | None] = mapped_column(ForeignKey("moderation_log.id"), index=True, default=None)
136140

@@ -174,6 +178,10 @@ class ModerationLog(Base, kw_only=True):
174178

175179
# State changes (nullable - only include fields that changed)
176180
new_visibility: Mapped[ModerationVisibility | None] = mapped_column(Enum(ModerationVisibility), default=None)
181+
new_priority: Mapped[int | None] = mapped_column(Integer, default=None)
182+
183+
# The queue item (flag) this action concerned, for flag-level actions
184+
queue_item_id: Mapped[int | None] = mapped_column(ForeignKey("moderation_queue.id"), index=True, default=None)
177185

178186
# Explanation for the action
179187
reason: Mapped[str] = mapped_column(String)

0 commit comments

Comments
 (0)