Coverage for integrations / social / friend_service.py: 0.0%

184 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2HevolveSocial — Friendship + Block service. 

3 

4Phase 7c.1. Plan reference: sunny-gliding-eich.md, Part E.8 + Part R.5. 

5 

6Coexists with the existing one-direction `FollowService` (services.py). 

7This service adds a SYMMETRIC, STATEFUL relationship layer: 

8 

9 pending ─accept─→ active ─block─→ blocked 

10 │ │ ↑ 

11 └─reject──→ rejected │ 

12 └─cancel──→ deleted │ 

13 active ─block (anytime)┘ 

14 any ─unblock──→ active or deleted 

15 

16Existing Follow rows are untouched. On accept(), reciprocal Follow rows 

17are auto-created so legacy code reading the follow graph (feed 

18ranking, recommendations) continues to work without change. 

19 

20Transport: P2P-first via NotificationService.create which already 

21publishes on the existing social.{user_id} WAMP topic and fans out 

22through MessageBus (plan Part R). When two users become friends the 

23PeerLink trust ratchet upgrades automatically (link_manager.upgrade_peer) 

24the next time both come online. 

25""" 

26 

27from __future__ import annotations 

28 

29import logging 

30import uuid 

31from typing import List, Optional, Tuple 

32 

33logger = logging.getLogger('hevolve_social') 

34 

35 

36def _sorted_pair(a: str, b: str) -> Tuple[str, str]: 

37 """Friendship rows are stored with (min, max) of the pair so each 

38 relationship has exactly one canonical row regardless of who 

39 initiated.""" 

40 return (a, b) if a < b else (b, a) 

41 

42 

43def _is_blocked_either_way(db, x: str, y: str) -> bool: 

44 """True if x blocks y OR y blocks x. Used to gate friend requests.""" 

45 from sqlalchemy import text 

46 try: 

47 result = db.execute(text( 

48 "SELECT 1 FROM blocks " 

49 "WHERE (blocker_id = :x AND blocked_id = :y) " 

50 "OR (blocker_id = :y AND blocked_id = :x) LIMIT 1"), 

51 {'x': x, 'y': y} 

52 ).fetchone() 

53 return result is not None 

54 except Exception: 

55 return False 

56 

57 

58class FriendError(ValueError): 

59 pass 

60 

61 

62class FriendService: 

63 """All public methods are static. Each takes an explicit `db` so 

64 they can be called from any context without coupling to Flask `g`. 

65 """ 

66 

67 # ── Sending / responding ───────────────────────────────────── 

68 

69 @staticmethod 

70 def send_request(db, from_user_id: str, to_user_id: str, 

71 tenant_id: Optional[str] = None) -> dict: 

72 """Create a pending Friendship row and notify the recipient. 

73 

74 Idempotent: if a row already exists, returns its current 

75 state without creating a duplicate. Refuses if either party 

76 has blocked the other (silently — no leak whether the block 

77 is on which side). 

78 """ 

79 from sqlalchemy import text 

80 from .services import NotificationService 

81 

82 if from_user_id == to_user_id: 

83 raise FriendError("cannot friend yourself") 

84 if _is_blocked_either_way(db, from_user_id, to_user_id): 

85 raise FriendError("cannot send request") 

86 

87 a, b = _sorted_pair(from_user_id, to_user_id) 

88 # Look up existing row. 

89 existing = db.execute(text( 

90 "SELECT id, status, initiator_id FROM friendships " 

91 "WHERE user_a_id = :a AND user_b_id = :b"), 

92 {'a': a, 'b': b} 

93 ).fetchone() 

94 if existing: 

95 fid, status, initiator = existing[0], existing[1], existing[2] 

96 if status == 'active': 

97 return {'id': fid, 'status': 'active'} 

98 if status == 'pending': 

99 # Idempotent — same initiator re-sending = no change; 

100 # OTHER user re-sending = auto-accept. 

101 if initiator == from_user_id: 

102 return {'id': fid, 'status': 'pending'} 

103 # Auto-accept: the other side already requested. 

104 return FriendService._accept_internal( 

105 db, fid, accepting_user_id=from_user_id) 

106 if status == 'rejected': 

107 # Reset to pending — give them another shot. 

108 db.execute(text( 

109 "UPDATE friendships SET status='pending', " 

110 "initiator_id=:i, created_at=CURRENT_TIMESTAMP, " 

111 "accepted_at=NULL " 

112 "WHERE id=:fid"), 

113 {'i': from_user_id, 'fid': fid}) 

114 db.commit() 

115 FriendService._notify_request(db, fid, from_user_id, to_user_id) 

116 return {'id': fid, 'status': 'pending'} 

117 if status == 'blocked': 

118 raise FriendError("cannot send request") 

119 

120 # No prior row → insert pending. 

121 fid = str(uuid.uuid4()) 

122 db.execute(text( 

123 "INSERT INTO friendships " 

124 "(id, tenant_id, user_a_id, user_b_id, status, initiator_id, " 

125 " created_at) " 

126 "VALUES " 

127 "(:id, :tid, :a, :b, 'pending', :i, CURRENT_TIMESTAMP)"), 

128 {'id': fid, 'tid': tenant_id, 'a': a, 'b': b, 

129 'i': from_user_id}) 

130 db.commit() 

131 FriendService._notify_request(db, fid, from_user_id, to_user_id) 

132 return {'id': fid, 'status': 'pending'} 

133 

134 @staticmethod 

135 def accept(db, friendship_id: str, accepting_user_id: str) -> dict: 

136 return FriendService._accept_internal(db, friendship_id, accepting_user_id) 

137 

138 @staticmethod 

139 def _accept_internal(db, friendship_id: str, 

140 accepting_user_id: str) -> dict: 

141 from sqlalchemy import text 

142 from .services import NotificationService 

143 

144 row = db.execute(text( 

145 "SELECT user_a_id, user_b_id, status, initiator_id " 

146 "FROM friendships WHERE id = :id"), 

147 {'id': friendship_id} 

148 ).fetchone() 

149 if row is None: 

150 raise FriendError("not found") 

151 a, b, status, initiator = row[0], row[1], row[2], row[3] 

152 if accepting_user_id not in (a, b): 

153 raise FriendError("not a participant") 

154 if accepting_user_id == initiator and status == 'pending': 

155 raise FriendError("initiator cannot self-accept") 

156 if status == 'active': 

157 return {'id': friendship_id, 'status': 'active'} 

158 if status not in ('pending', 'rejected'): 

159 raise FriendError(f"cannot accept from status={status}") 

160 

161 db.execute(text( 

162 "UPDATE friendships SET status='active', " 

163 "accepted_at=CURRENT_TIMESTAMP WHERE id = :id"), 

164 {'id': friendship_id}) 

165 

166 # Auto-create reciprocal Follow rows so downstream code 

167 # reading the follow graph (feed ranking, recommendations) 

168 # treats friends as mutual followers without needing a 

169 # parallel code path. 

170 # 

171 # Idempotent. We dispatch on the live dialect rather than 

172 # try/except — on Postgres a failed INSERT leaves the tx in 

173 # pending-rollback, so the second try would fail with 

174 # "current transaction is aborted" instead of silently 

175 # ignoring the duplicate. 

176 dialect = db.bind.dialect.name if db.bind is not None else 'sqlite' 

177 if dialect == 'sqlite': 

178 stmt = ("INSERT OR IGNORE INTO follows " 

179 "(id, follower_id, following_id, created_at) " 

180 "VALUES (:id, :f, :t, CURRENT_TIMESTAMP)") 

181 else: 

182 # Postgres + MySQL 8+ both accept ON CONFLICT DO NOTHING. 

183 stmt = ("INSERT INTO follows " 

184 "(id, follower_id, following_id, created_at) " 

185 "VALUES (:id, :f, :t, CURRENT_TIMESTAMP) " 

186 "ON CONFLICT DO NOTHING") 

187 for follower, followed in ((a, b), (b, a)): 

188 db.execute(text(stmt), {'id': str(uuid.uuid4()), 

189 'f': follower, 't': followed}) 

190 

191 db.commit() 

192 

193 # Notify the initiator that their request was accepted. 

194 try: 

195 NotificationService.create( 

196 db, user_id=initiator, type='friend_accepted', 

197 source_user_id=accepting_user_id, 

198 target_type='user', target_id=accepting_user_id, 

199 message="Your friend request was accepted") 

200 except Exception as e: 

201 logger.warning("FriendService.accept: notify failed: %s", e) 

202 

203 return {'id': friendship_id, 'status': 'active'} 

204 

205 @staticmethod 

206 def reject(db, friendship_id: str, rejecting_user_id: str) -> dict: 

207 from sqlalchemy import text 

208 row = db.execute(text( 

209 "SELECT user_a_id, user_b_id, status, initiator_id " 

210 "FROM friendships WHERE id = :id"), 

211 {'id': friendship_id} 

212 ).fetchone() 

213 if row is None: 

214 raise FriendError("not found") 

215 a, b, status, initiator = row[0], row[1], row[2], row[3] 

216 if rejecting_user_id not in (a, b): 

217 raise FriendError("not a participant") 

218 if rejecting_user_id == initiator: 

219 raise FriendError("initiator cannot reject — use cancel") 

220 if status not in ('pending',): 

221 raise FriendError(f"cannot reject from status={status}") 

222 db.execute(text( 

223 "UPDATE friendships SET status='rejected' WHERE id = :id"), 

224 {'id': friendship_id}) 

225 db.commit() 

226 return {'id': friendship_id, 'status': 'rejected'} 

227 

228 @staticmethod 

229 def cancel(db, friendship_id: str, canceller_id: str) -> dict: 

230 """Initiator withdraws their own pending request — row deleted.""" 

231 from sqlalchemy import text 

232 row = db.execute(text( 

233 "SELECT initiator_id, status FROM friendships WHERE id = :id"), 

234 {'id': friendship_id} 

235 ).fetchone() 

236 if row is None: 

237 raise FriendError("not found") 

238 initiator, status = row[0], row[1] 

239 if canceller_id != initiator: 

240 raise FriendError("only the initiator can cancel") 

241 if status != 'pending': 

242 raise FriendError("only pending requests can be cancelled") 

243 db.execute(text("DELETE FROM friendships WHERE id = :id"), 

244 {'id': friendship_id}) 

245 db.commit() 

246 return {'id': friendship_id, 'status': 'cancelled'} 

247 

248 @staticmethod 

249 def unfriend(db, requester_id: str, other_id: str) -> dict: 

250 """Remove an active friendship without blocking. 

251 

252 Either participant can unfriend. The reciprocal Follow rows 

253 auto-created on accept() are NOT removed — unfriend is the 

254 symmetric inverse of accept(), follows are independent and 

255 the user can unfollow separately if they want. This matches 

256 Twitter/Instagram semantics: "we're no longer friends" does 

257 not silently remove follows the user may have set up 

258 themselves. 

259 """ 

260 from sqlalchemy import text 

261 if requester_id == other_id: 

262 raise FriendError("cannot unfriend yourself") 

263 a, b = _sorted_pair(requester_id, other_id) 

264 row = db.execute(text( 

265 "SELECT id, status FROM friendships " 

266 "WHERE user_a_id = :a AND user_b_id = :b"), 

267 {'a': a, 'b': b} 

268 ).fetchone() 

269 if row is None: 

270 raise FriendError("not friends") 

271 fid, status = row[0], row[1] 

272 if status != 'active': 

273 raise FriendError(f"cannot unfriend from status={status}") 

274 db.execute(text("DELETE FROM friendships WHERE id = :id"), 

275 {'id': fid}) 

276 db.commit() 

277 return {'id': fid, 'status': 'unfriended'} 

278 

279 # ── Block ──────────────────────────────────────────────────── 

280 

281 @staticmethod 

282 def block(db, blocker_id: str, blocked_id: str, 

283 reason: Optional[str] = None, 

284 tenant_id: Optional[str] = None) -> dict: 

285 """Block a user. Tears down any active friendship and 

286 triggers the PeerLink trust-ratchet teardown via the 

287 link_manager (plan Part R.5). Idempotent.""" 

288 from sqlalchemy import text 

289 if blocker_id == blocked_id: 

290 raise FriendError("cannot block yourself") 

291 

292 # Insert/upsert block row. 

293 bid = str(uuid.uuid4()) 

294 existing = db.execute(text( 

295 "SELECT id FROM blocks " 

296 "WHERE blocker_id = :a AND blocked_id = :b"), 

297 {'a': blocker_id, 'b': blocked_id} 

298 ).fetchone() 

299 if existing is None: 

300 db.execute(text( 

301 "INSERT INTO blocks " 

302 "(id, tenant_id, blocker_id, blocked_id, reason, " 

303 " created_at) " 

304 "VALUES (:id, :tid, :a, :b, :r, CURRENT_TIMESTAMP)"), 

305 {'id': bid, 'tid': tenant_id, 'a': blocker_id, 

306 'b': blocked_id, 'r': reason}) 

307 

308 # Mark any existing friendship as blocked. 

309 a, b = _sorted_pair(blocker_id, blocked_id) 

310 db.execute(text( 

311 "UPDATE friendships SET status='blocked', " 

312 "blocked_at=CURRENT_TIMESTAMP " 

313 "WHERE user_a_id = :a AND user_b_id = :b"), 

314 {'a': a, 'b': b}) 

315 db.commit() 

316 

317 # Tear down PeerLink trust (best-effort — module may be 

318 # unavailable in a test environment). 

319 try: 

320 from core.peer_link.link_manager import get_link_manager 

321 mgr = get_link_manager() 

322 if mgr.has_link(blocked_id): 

323 mgr.close_link(blocked_id) 

324 except Exception: 

325 pass 

326 

327 return {'id': existing[0] if existing else bid, 'status': 'blocked'} 

328 

329 @staticmethod 

330 def unblock(db, blocker_id: str, blocked_id: str) -> dict: 

331 """Remove a block row. Friendship row stays in 'blocked' 

332 state until the blocker explicitly re-friends — unblocking 

333 does NOT auto-restore an old friendship.""" 

334 from sqlalchemy import text 

335 result = db.execute(text( 

336 "DELETE FROM blocks " 

337 "WHERE blocker_id = :a AND blocked_id = :b"), 

338 {'a': blocker_id, 'b': blocked_id}) 

339 db.commit() 

340 return {'unblocked': True} 

341 

342 # ── Read paths ─────────────────────────────────────────────── 

343 

344 @staticmethod 

345 def list_friends(db, user_id: str, 

346 status: str = 'active') -> List[dict]: 

347 """Return friends in the given state. 

348 

349 Default 'active' returns the canonical "my friends" list. 

350 Pass status='pending' to get incoming + outgoing pending 

351 requests. status='all' for everything. 

352 """ 

353 from sqlalchemy import text 

354 from .models import User 

355 

356 if status == 'all': 

357 where = "" 

358 params = {'uid': user_id} 

359 else: 

360 where = "AND status = :st" 

361 params = {'uid': user_id, 'st': status} 

362 

363 rows = db.execute(text( 

364 "SELECT id, user_a_id, user_b_id, status, initiator_id, " 

365 " created_at, accepted_at " 

366 "FROM friendships " 

367 "WHERE (user_a_id = :uid OR user_b_id = :uid) " + where), 

368 params 

369 ).fetchall() 

370 

371 out = [] 

372 for row in rows: 

373 other_id = row[2] if row[1] == user_id else row[1] 

374 other = db.query(User).filter(User.id == other_id).first() 

375 if not other: 

376 continue 

377 out.append({ 

378 'friendship_id': row[0], 

379 'status': row[3], 

380 'initiator_id': row[4], 

381 'is_initiator': row[4] == user_id, 

382 'created_at': str(row[5]) if row[5] else None, 

383 'accepted_at': str(row[6]) if row[6] else None, 

384 'other_user': { 

385 'id': other.id, 

386 'username': other.username, 

387 'display_name': getattr(other, 'display_name', None) or other.username, 

388 'avatar_url': getattr(other, 'avatar_url', None), 

389 'agent_kind': 'agent' if (getattr(other, 'user_type', '') == 'agent') else 'human', 

390 }, 

391 }) 

392 return out 

393 

394 @staticmethod 

395 def list_blocks(db, user_id: str) -> List[dict]: 

396 from sqlalchemy import text 

397 from .models import User 

398 rows = db.execute(text( 

399 "SELECT b.id, b.blocked_id, b.reason, b.created_at " 

400 "FROM blocks b WHERE b.blocker_id = :uid"), 

401 {'uid': user_id} 

402 ).fetchall() 

403 out = [] 

404 for row in rows: 

405 blocked = db.query(User).filter(User.id == row[1]).first() 

406 if not blocked: 

407 continue 

408 out.append({ 

409 'block_id': row[0], 

410 'reason': row[2], 

411 'created_at': str(row[3]) if row[3] else None, 

412 'blocked_user': { 

413 'id': blocked.id, 

414 'username': blocked.username, 

415 }, 

416 }) 

417 return out 

418 

419 @staticmethod 

420 def is_friend(db, a: str, b: str) -> bool: 

421 """Single-row lookup used by privacy.py + autocomplete ranking.""" 

422 from sqlalchemy import text 

423 ua, ub = _sorted_pair(a, b) 

424 result = db.execute(text( 

425 "SELECT 1 FROM friendships " 

426 "WHERE user_a_id = :a AND user_b_id = :b " 

427 "AND status = 'active' LIMIT 1"), 

428 {'a': ua, 'b': ub} 

429 ).fetchone() 

430 return result is not None 

431 

432 # ── Internal helpers ───────────────────────────────────────── 

433 

434 @staticmethod 

435 def _notify_request(db, friendship_id: str, from_user_id: str, 

436 to_user_id: str): 

437 try: 

438 from .services import NotificationService 

439 NotificationService.create( 

440 db, user_id=to_user_id, type='friend_request', 

441 source_user_id=from_user_id, 

442 target_type='friendship', target_id=friendship_id, 

443 message="You have a new friend request") 

444 except Exception as e: 

445 logger.warning("FriendService: notify_request failed: %s", e)