Coverage for dao/match_event_dao.py: 7.82%

131 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2026-04-13 14:11 +0000

1""" 

2Match Event Data Access Object. 

3 

4Handles all database operations related to live match events including: 

5- Goals 

6- Chat messages 

7- Status changes 

8- Cleanup of expired messages 

9""" 

10 

11from datetime import UTC, datetime 

12 

13import structlog 

14 

15from dao.base_dao import BaseDAO 

16 

17logger = structlog.get_logger() 

18 

19 

20class MatchEventDAO(BaseDAO): 

21 """Data access object for match event operations (live match activity stream).""" 

22 

23 def create_event( 

24 self, 

25 match_id: int, 

26 event_type: str, 

27 message: str, 

28 created_by: str | None = None, 

29 created_by_username: str | None = None, 

30 team_id: int | None = None, 

31 player_name: str | None = None, 

32 player_id: int | None = None, 

33 match_minute: int | None = None, 

34 extra_time: int | None = None, 

35 player_out_id: int | None = None, 

36 ) -> dict | None: 

37 """Create a new match event. 

38 

39 Args: 

40 match_id: The match this event belongs to 

41 event_type: Type of event ('goal', 'message', 'status_change', 'substitution') 

42 message: Event message/description 

43 created_by: UUID of user who created the event 

44 created_by_username: Username of creator (denormalized for display) 

45 team_id: Team ID (for goal/substitution events) 

46 player_name: Player name (for goal events) 

47 player_id: Player ID from roster (for goal events, player coming on for subs) 

48 match_minute: Minute when event occurred (e.g., 22 for 22nd minute) 

49 extra_time: Stoppage/injury time minutes (e.g., 5 for 90+5) 

50 player_out_id: Player ID being substituted off (for substitution events) 

51 

52 Returns: 

53 Created event record or None on error 

54 """ 

55 try: 

56 data = { 

57 "match_id": match_id, 

58 "event_type": event_type, 

59 "message": message, 

60 "created_by": created_by, 

61 "created_by_username": created_by_username, 

62 } 

63 

64 # Add optional fields for goal/substitution events 

65 if team_id is not None: 

66 data["team_id"] = team_id 

67 if player_name is not None: 

68 data["player_name"] = player_name 

69 if player_id is not None: 

70 data["player_id"] = player_id 

71 if match_minute is not None: 

72 data["match_minute"] = match_minute 

73 if extra_time is not None: 

74 data["extra_time"] = extra_time 

75 if player_out_id is not None: 

76 data["player_out_id"] = player_out_id 

77 

78 response = self.client.table("match_events").insert(data).execute() 

79 

80 if response.data: 

81 logger.info( 

82 "match_event_created", 

83 match_id=match_id, 

84 event_type=event_type, 

85 event_id=response.data[0].get("id"), 

86 ) 

87 return response.data[0] 

88 return None 

89 

90 except Exception: 

91 logger.exception( 

92 "Error creating match event", 

93 match_id=match_id, 

94 event_type=event_type, 

95 ) 

96 return None 

97 

98 def get_events( 

99 self, 

100 match_id: int, 

101 limit: int = 50, 

102 before_id: int | None = None, 

103 ) -> list[dict]: 

104 """Get paginated events for a match. 

105 

106 Args: 

107 match_id: Match to get events for 

108 limit: Maximum number of events to return 

109 before_id: Return events with ID less than this (for pagination) 

110 

111 Returns: 

112 List of event records, newest first 

113 """ 

114 try: 

115 query = ( 

116 self.client.table("match_events") 

117 .select("*") 

118 .eq("match_id", match_id) 

119 .eq("is_deleted", False) 

120 .order("created_at", desc=True) 

121 .limit(limit) 

122 ) 

123 

124 if before_id is not None: 

125 query = query.lt("id", before_id) 

126 

127 response = query.execute() 

128 return response.data or [] 

129 

130 except Exception: 

131 logger.exception("Error getting match events", match_id=match_id) 

132 return [] 

133 

134 def get_event_by_id(self, event_id: int) -> dict | None: 

135 """Get a single event by ID. 

136 

137 Args: 

138 event_id: The event ID 

139 

140 Returns: 

141 Event record or None if not found 

142 """ 

143 try: 

144 response = self.client.table("match_events").select("*").eq("id", event_id).limit(1).execute() 

145 if response.data: 

146 return response.data[0] 

147 return None 

148 except Exception: 

149 logger.exception("Error getting match event", event_id=event_id) 

150 return None 

151 

152 def soft_delete_event(self, event_id: int, deleted_by: str) -> bool: 

153 """Soft delete an event (for moderation). 

154 

155 Args: 

156 event_id: Event to delete 

157 deleted_by: UUID of user performing deletion 

158 

159 Returns: 

160 True if successful, False otherwise 

161 """ 

162 try: 

163 response = ( 

164 self.client.table("match_events") 

165 .update( 

166 { 

167 "is_deleted": True, 

168 "deleted_by": deleted_by, 

169 "deleted_at": datetime.now(UTC).isoformat(), 

170 } 

171 ) 

172 .eq("id", event_id) 

173 .execute() 

174 ) 

175 

176 if response.data: 

177 logger.info( 

178 "match_event_deleted", 

179 event_id=event_id, 

180 deleted_by=deleted_by, 

181 ) 

182 return True 

183 return False 

184 

185 except Exception: 

186 logger.exception("Error deleting match event", event_id=event_id) 

187 return False 

188 

189 def cleanup_expired_messages(self) -> int: 

190 """Delete message events that have expired (older than 10 days). 

191 

192 This method is called by a Celery scheduled task. 

193 Only deletes events where: 

194 - event_type = 'message' 

195 - expires_at is in the past 

196 - is_deleted = false (don't process already deleted) 

197 

198 Returns: 

199 Number of events deleted 

200 """ 

201 try: 

202 now = datetime.now(UTC).isoformat() 

203 

204 # First, get count of expired messages 

205 count_response = ( 

206 self.client.table("match_events") 

207 .select("id", count="exact") 

208 .eq("event_type", "message") 

209 .eq("is_deleted", False) 

210 .lt("expires_at", now) 

211 .execute() 

212 ) 

213 

214 count = count_response.count or 0 

215 if count == 0: 

216 logger.info("match_events_cleanup_none_expired") 

217 return 0 

218 

219 # Delete expired messages 

220 response = ( 

221 self.client.table("match_events") 

222 .delete() 

223 .eq("event_type", "message") 

224 .eq("is_deleted", False) 

225 .lt("expires_at", now) 

226 .execute() 

227 ) 

228 

229 deleted = len(response.data) if response.data else 0 

230 logger.info( 

231 "match_events_cleanup_completed", 

232 deleted_count=deleted, 

233 ) 

234 return deleted 

235 

236 except Exception: 

237 logger.exception("Error cleaning up expired match events") 

238 return 0 

239 

240 def update_event( 

241 self, 

242 event_id: int, 

243 match_minute: int | None = None, 

244 extra_time: int | None = None, 

245 player_name: str | None = None, 

246 player_id: int | None = None, 

247 ) -> dict | None: 

248 """Update editable fields on a match event. 

249 

250 Only updates fields that are provided (non-None). 

251 

252 Args: 

253 event_id: Event to update 

254 match_minute: New match minute 

255 extra_time: New extra/stoppage time 

256 player_name: New player name 

257 player_id: New player ID 

258 

259 Returns: 

260 Updated event record or None on error 

261 """ 

262 try: 

263 data = {} 

264 if match_minute is not None: 

265 data["match_minute"] = match_minute 

266 if extra_time is not None: 

267 data["extra_time"] = extra_time 

268 if player_name is not None: 

269 data["player_name"] = player_name 

270 if player_id is not None: 

271 data["player_id"] = player_id 

272 

273 if not data: 

274 return self.get_event_by_id(event_id) 

275 

276 response = ( 

277 self.client.table("match_events") 

278 .update(data) 

279 .eq("id", event_id) 

280 .execute() 

281 ) 

282 

283 if response.data: 

284 logger.info( 

285 "match_event_updated", 

286 event_id=event_id, 

287 updated_fields=list(data.keys()), 

288 ) 

289 return response.data[0] 

290 return None 

291 

292 except Exception: 

293 logger.exception("Error updating match event", event_id=event_id) 

294 return None 

295 

296 def get_goal_events( 

297 self, 

298 season_id: int | None = None, 

299 age_group_id: int | None = None, 

300 match_type_id: int | None = None, 

301 team_id: int | None = None, 

302 limit: int = 100, 

303 offset: int = 0, 

304 ) -> list[dict]: 

305 """Get goal events across matches with joined match context. 

306 

307 Args: 

308 season_id: Optional filter by season 

309 age_group_id: Optional filter by age group 

310 match_type_id: Optional filter by match type 

311 team_id: Optional filter by team (scoring team) 

312 limit: Maximum results 

313 offset: Pagination offset 

314 

315 Returns: 

316 List of goal events with match context 

317 """ 

318 try: 

319 query = ( 

320 self.client.table("match_events") 

321 .select( 

322 "*, match:matches!inner(" 

323 "id, match_date, home_score, away_score, " 

324 "season_id, age_group_id, match_type_id, " 

325 "home_team:teams!matches_home_team_id_fkey(id, name), " 

326 "away_team:teams!matches_away_team_id_fkey(id, name), " 

327 "season:seasons(id, name), " 

328 "age_group:age_groups(id, name), " 

329 "match_type:match_types(id, name)" 

330 ")" 

331 ) 

332 .eq("event_type", "goal") 

333 .eq("is_deleted", False) 

334 .order("created_at", desc=True) 

335 .range(offset, offset + limit - 1) 

336 ) 

337 

338 if season_id is not None: 

339 query = query.eq("match.season_id", season_id) 

340 if age_group_id is not None: 

341 query = query.eq("match.age_group_id", age_group_id) 

342 if match_type_id is not None: 

343 query = query.eq("match.match_type_id", match_type_id) 

344 if team_id is not None: 

345 query = query.eq("team_id", team_id) 

346 

347 response = query.execute() 

348 return response.data or [] 

349 

350 except Exception: 

351 logger.exception("Error getting goal events") 

352 return [] 

353 

354 def get_card_events_for_matches(self, match_ids: list[int]) -> dict[int, list[dict]]: 

355 """Get card events (red/yellow) for multiple matches in one query. 

356 

357 Args: 

358 match_ids: List of match IDs 

359 

360 Returns: 

361 Dict mapping match_id to list of card event dicts 

362 """ 

363 if not match_ids: 

364 return {} 

365 

366 try: 

367 response = ( 

368 self.client.table("match_events") 

369 .select("match_id, event_type, team_id, player_name") 

370 .in_("match_id", match_ids) 

371 .in_("event_type", ["red_card", "yellow_card"]) 

372 .eq("is_deleted", False) 

373 .execute() 

374 ) 

375 

376 result: dict[int, list[dict]] = {} 

377 for event in response.data or []: 

378 mid = event["match_id"] 

379 if mid not in result: 

380 result[mid] = [] 

381 result[mid].append(event) 

382 return result 

383 

384 except Exception: 

385 logger.exception("Error fetching card events for matches") 

386 return {} 

387 

388 def get_events_count(self, match_id: int) -> int: 

389 """Get total count of active events for a match. 

390 

391 Args: 

392 match_id: Match to count events for 

393 

394 Returns: 

395 Number of active events 

396 """ 

397 try: 

398 response = ( 

399 self.client.table("match_events") 

400 .select("id", count="exact") 

401 .eq("match_id", match_id) 

402 .eq("is_deleted", False) 

403 .execute() 

404 ) 

405 return response.count or 0 

406 except Exception: 

407 logger.exception("Error counting match events", match_id=match_id) 

408 return 0