Coverage for app/backend/src/couchers/db.py: 83%

97 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-04 22:32 +0000

1import functools 

2import inspect 

3import logging 

4import os 

5from collections.abc import Generator, Sequence 

6from contextlib import contextmanager 

7from os import getpid 

8from threading import get_ident 

9from typing import TYPE_CHECKING 

10 

11from alembic import command 

12from alembic.config import Config 

13from geoalchemy2 import WKBElement 

14from opentelemetry import trace 

15from sqlalchemy import Engine, Row, Subquery, create_engine, select, text, true 

16from sqlalchemy.dialects import registry 

17from sqlalchemy.orm.session import Session 

18from sqlalchemy.pool import QueuePool 

19from sqlalchemy.sql import and_, func, literal, or_ 

20 

21from couchers.config import config 

22from couchers.constants import DB_POOL_SIZE 

23from couchers.models import ( 

24 Cluster, 

25 ClusterRole, 

26 ClusterSubscription, 

27 FriendRelationship, 

28 FriendStatus, 

29 Geom, 

30 Node, 

31 TimezoneArea, 

32 User, 

33) 

34from couchers.perf import register_perf_listeners 

35from couchers.sql import where_users_column_visible 

36 

37if TYPE_CHECKING: 

38 from couchers.context import CouchersContext 

39 

40# Register psycopg (psycopg3) as the default driver for postgresql:// URLs 

41# This must happen before any engine is created 

42registry.register("postgresql", "sqlalchemy.dialects.postgresql.psycopg", "PGDialect_psycopg") 

43 

44logger = logging.getLogger(__name__) 

45 

46tracer = trace.get_tracer(__name__) 

47 

48 

49def apply_migrations() -> None: 

50 alembic_dir = os.path.dirname(__file__) + "/../.." 

51 cwd = os.getcwd() 

52 try: 

53 os.chdir(alembic_dir) 

54 alembic_cfg = Config("alembic.ini") 

55 # alembic screws up logging config by default, this tells it not to screw it up if being run at startup like this 

56 alembic_cfg.set_main_option("dont_mess_up_logging", "False") 

57 command.upgrade(alembic_cfg, "head") 

58 finally: 

59 os.chdir(cwd) 

60 

61 

62@functools.cache 

63def _get_base_engine() -> Engine: 

64 engine = create_engine( 

65 config.DATABASE_CONNECTION_STRING, 

66 # checks that the connections in the pool are alive before using them, which avoids the "server closed the 

67 # connection unexpectedly" errors 

68 pool_pre_ping=True, 

69 # one connection per thread 

70 poolclass=QueuePool, 

71 # each process keeps its own pool, so total connections ~= process count * pool_size, kept under postgres 

72 # max_connections. ~2 per thread since a thread can hold two connections at once (handler + _store_log, 

73 # or the jobs worker's own session_scope + the handler's). 

74 pool_size=DB_POOL_SIZE, 

75 max_overflow=0, 

76 ) 

77 register_perf_listeners(engine) 

78 return engine 

79 

80 

81@contextmanager 

82def session_scope() -> Generator[Session]: 

83 with tracer.start_as_current_span("session_scope") as rollspan: 

84 with Session(_get_base_engine()) as session: 

85 session.begin() 

86 try: 

87 if logger.isEnabledFor(logging.DEBUG): 87 ↛ 88line 87 didn't jump to line 88 because the condition on line 87 was never true

88 try: 

89 frame = inspect.stack()[2] 

90 filename_line = f"{frame.filename}:{frame.lineno}" 

91 except Exception as e: 

92 filename_line = "{unknown file}" 

93 backend_pid = session.execute(text("SELECT pg_backend_pid();")).scalar_one() 

94 logger.debug(f"SScope: got {backend_pid=} at {filename_line}") 

95 rollspan.set_attribute("db.backend_pid", backend_pid) 

96 rollspan.set_attribute("db.filename_line", filename_line) 

97 rollspan.set_attribute("rpc.thread", get_ident()) 

98 rollspan.set_attribute("rpc.pid", getpid()) 

99 

100 yield session 

101 session.commit() 

102 except: 

103 session.rollback() 

104 raise 

105 finally: 

106 if logger.isEnabledFor(logging.DEBUG): 106 ↛ 107line 106 didn't jump to line 107 because the condition on line 106 was never true

107 logger.debug(f"SScope: closed {backend_pid=}") 

108 

109 

110def db_post_fork() -> None: 

111 """ 

112 Fix post-fork issues with sqlalchemy 

113 """ 

114 # see https://docs.sqlalchemy.org/en/20/core/pooling.html#using-connection-pools-with-multiprocessing-or-os-fork 

115 _get_base_engine().dispose(close=False) 

116 

117 

118def are_friends(session: Session, context: CouchersContext, other_user: int) -> bool: 

119 query = select(FriendRelationship) 

120 query = where_users_column_visible(query, context, FriendRelationship.from_user_id) 

121 query = where_users_column_visible(query, context, FriendRelationship.to_user_id) 

122 query = query.where( 

123 or_( 

124 and_(FriendRelationship.from_user_id == context.user_id, FriendRelationship.to_user_id == other_user), 

125 and_(FriendRelationship.from_user_id == other_user, FriendRelationship.to_user_id == context.user_id), 

126 ) 

127 ).where(FriendRelationship.status == FriendStatus.accepted) 

128 return session.execute(query).scalar_one_or_none() is not None 

129 

130 

131def get_parent_node_at_location(session: Session, shape: WKBElement) -> Node | None: 

132 """ 

133 Finds the smallest node containing the shape. 

134 

135 Shape can be any PostGIS geo object, e.g., output from create_coordinate 

136 """ 

137 

138 # Find the lowest Node (in the Node tree) that contains the shape. By construction of nodes, the area of a sub-node 

139 # must always be less than its parent Node, so no need to actually traverse the tree! 

140 return ( 

141 session.execute( 

142 select(Node).where(func.ST_Contains(Node.geom, shape)).order_by(func.ST_Area(Node.geom)).limit(1) 

143 ) 

144 .scalars() 

145 .one_or_none() 

146 ) 

147 

148 

149def _get_node_parents_recursive_cte_subquery(node_id: int) -> Subquery: 

150 parents = ( 

151 select(Node.id, Node.parent_node_id, literal(0).label("level")) 

152 .where(Node.id == node_id) 

153 .cte("parents", recursive=True) 

154 ) 

155 

156 return select( 

157 parents.union( 

158 select(Node.id, Node.parent_node_id, (parents.c.level + 1).label("level")).join( 

159 parents, Node.id == parents.c.parent_node_id 

160 ) 

161 ) 

162 ).subquery() 

163 

164 

165def get_node_parents_recursively(session: Session, node_id: int) -> Sequence[Row[tuple[int, int, int, Cluster]]]: 

166 subquery = _get_node_parents_recursive_cte_subquery(node_id) 

167 return session.execute( 

168 select(subquery, Cluster) 

169 .join(Cluster, Cluster.parent_node_id == subquery.c.id) 

170 .where(Cluster.is_official_cluster) 

171 .order_by(subquery.c.level.desc()) 

172 ).all() 

173 

174 

175def _can_moderate_any_cluster(session: Session, user_id: int, cluster_ids: list[int]) -> bool: 

176 query = select( 

177 ( 

178 select(true()) 

179 .select_from(ClusterSubscription) 

180 .where(ClusterSubscription.role == ClusterRole.admin) 

181 .where(ClusterSubscription.user_id == user_id) 

182 .where(ClusterSubscription.cluster_id.in_(cluster_ids)) 

183 ).exists() 

184 ) 

185 return session.execute(query).scalar_one() 

186 

187 

188def can_moderate_node(session: Session, user_id: int, node_id: int) -> bool: 

189 """ 

190 Returns True if the user_id can moderate the given node (i.e., if they are admin of any community that is a parent of the node) 

191 """ 

192 subquery = _get_node_parents_recursive_cte_subquery(node_id) 

193 query = select( 

194 ( 

195 select(true()) 

196 .select_from(ClusterSubscription) 

197 .where(ClusterSubscription.role == ClusterRole.admin) 

198 .where(ClusterSubscription.user_id == user_id) 

199 .join(Cluster, Cluster.id == ClusterSubscription.cluster_id) 

200 .where(Cluster.is_official_cluster) 

201 .where(Cluster.parent_node_id == subquery.c.id) 

202 ).exists() 

203 ) 

204 return session.execute(query).scalar_one() 

205 

206 

207def can_moderate_at(session: Session, user_id: int, shape: Geom) -> bool: 

208 """ 

209 Returns True if the user_id can moderate a given geo-shape (i.e., if the shape is contained in any Node that the user is an admin of) 

210 """ 

211 query = select( 

212 ( 

213 select(true()) 

214 .select_from(ClusterSubscription) 

215 .where(ClusterSubscription.role == ClusterRole.admin) 

216 .where(ClusterSubscription.user_id == user_id) 

217 .join(Cluster, Cluster.id == ClusterSubscription.cluster_id) 

218 .join(Node, and_(Cluster.is_official_cluster, Node.id == Cluster.parent_node_id)) 

219 .where(func.ST_Contains(Node.geom, shape)) 

220 ).exists() 

221 ) 

222 return session.execute(query).scalar_one() 

223 

224 

225def is_user_in_node_geography(session: Session, user_id: int, node_id: int) -> bool: 

226 """ 

227 Returns True if the user's location is geographically contained within the node's boundary. 

228 This is used to check if a user can leave a community - users cannot leave communities 

229 that contain their geographic location. 

230 """ 

231 query = select( 

232 ( 

233 select(true()) 

234 .select_from(User) 

235 .join(Node, func.ST_Contains(Node.geom, User.geom)) 

236 .where(User.id == user_id) 

237 .where(Node.id == node_id) 

238 ).exists() 

239 ) 

240 return session.execute(query).scalar_one() 

241 

242 

243def timezone_at_coordinate(session: Session, geom: WKBElement) -> str | None: 

244 tzid = session.execute( 

245 select(TimezoneArea.tzid).where(func.ST_Contains(TimezoneArea.geom, geom)) 

246 ).scalar_one_or_none() 

247 return tzid