Coverage for app/backend/src/couchers/moderation/utils.py: 100%
31 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 11:56 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 11:56 +0000
1"""
2Utility functions for the Unified Moderation System (UMS)
3"""
5from collections.abc import Callable
6from datetime import datetime, timedelta
7from typing import Any
9from sqlalchemy import Subquery, func, select, union_all
10from sqlalchemy.orm import Session
12from couchers.config import config
13from couchers.metrics import observe_moderation_action, observe_moderation_queue_item_created
14from couchers.models import (
15 ModerationAction,
16 ModerationLog,
17 ModerationObjectType,
18 ModerationQueueItem,
19 ModerationState,
20 ModerationTrigger,
21 ModerationVisibility,
22 User,
23 get_moderated_models,
24)
27def create_moderation(
28 session: Session,
29 object_type: ModerationObjectType,
30 object_id: int | Callable[[int], int],
31 # None for objects that are their own creator, whose id isn't known until the callback has run
32 creator_user_id: int | None = None,
33 data: Any | None = None,
34) -> ModerationState:
35 has_own_visibility_mechanism = get_moderated_models()[object_type].has_own_visibility_mechanism
36 visibility = None if has_own_visibility_mechanism else ModerationVisibility.shadowed
38 # Handle callback pattern for circular dependencies
39 if callable(object_id):
40 moderation_state = ModerationState(
41 object_type=object_type,
42 object_id=0, # Placeholder
43 visibility=visibility,
44 )
45 session.add(moderation_state)
46 session.flush()
48 # Call the callback to create the object and get its ID
49 actual_object_id = object_id(moderation_state.id)
50 moderation_state.object_id = actual_object_id
51 else:
52 actual_object_id = object_id
53 moderation_state = ModerationState(
54 object_type=object_type,
55 object_id=object_id,
56 visibility=visibility,
57 )
58 session.add(moderation_state)
59 session.flush()
61 session.add(
62 ModerationLog(
63 moderation_state_id=moderation_state.id,
64 action=ModerationAction.create,
65 moderator_user_id=creator_user_id if creator_user_id is not None else actual_object_id,
66 new_visibility=visibility,
67 reason="Object created.",
68 )
69 )
71 if not has_own_visibility_mechanism:
72 session.add(
73 ModerationQueueItem(
74 moderation_state_id=moderation_state.id,
75 trigger=ModerationTrigger.initial_review,
76 reason="Object created.",
77 data=data,
78 )
79 )
80 observe_moderation_queue_item_created(ModerationTrigger.initial_review, object_type)
82 observe_moderation_action(ModerationAction.create, object_type)
84 session.flush()
86 return moderation_state
89def auto_approve_due_queue_items(cutoff: datetime) -> Subquery:
90 """
91 Open INITIAL_REVIEW items on still-shadowed content whose auto-approve deadline passed before the cutoff, as
92 (id, moderation_state_id, time_created, deadline). The deadline comes from the author's
93 moderation_auto_approve_deadline_seconds, falling back to MODERATION_AUTO_APPROVE_DEADLINE_SECONDS. Content by
94 shadowed authors, or authors whose moderation_auto_approve_deadline_seconds is zero, is never due.
95 """
96 deadline = func.coalesce(
97 User.moderation_auto_approve_deadline_seconds, config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS
98 )
99 return union_all(
100 *(
101 select(
102 ModerationQueueItem.id,
103 ModerationQueueItem.moderation_state_id,
104 ModerationQueueItem.time_created,
105 deadline.label("deadline"),
106 )
107 .join(ModerationState, ModerationState.id == ModerationQueueItem.moderation_state_id)
108 .join(entry.model, entry.moderation_state_id_column == ModerationState.id)
109 .join(User, User.id == entry.author_column)
110 .where(ModerationQueueItem.trigger == ModerationTrigger.initial_review)
111 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
112 .where(ModerationState.visibility == ModerationVisibility.shadowed)
113 .where(~User.is_shadowed)
114 .where(User.moderation_auto_approve_deadline_seconds.is_distinct_from(0))
115 .where(ModerationQueueItem.time_created + deadline * timedelta(seconds=1) < cutoff)
116 for entry in get_moderated_models().values()
117 if not entry.has_own_visibility_mechanism
118 )
119 ).subquery()