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

1""" 

2Utility functions for the Unified Moderation System (UMS) 

3""" 

4 

5from collections.abc import Callable 

6from datetime import datetime, timedelta 

7from typing import Any 

8 

9from sqlalchemy import Subquery, func, select, union_all 

10from sqlalchemy.orm import Session 

11 

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) 

25 

26 

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 

37 

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() 

47 

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() 

60 

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 ) 

70 

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) 

81 

82 observe_moderation_action(ModerationAction.create, object_type) 

83 

84 session.flush() 

85 

86 return moderation_state 

87 

88 

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()