Coverage for dao/match_event_dao.py: 7.82%
131 statements
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-15 13:02 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-15 13:02 +0000
1"""
2Match Event Data Access Object.
4Handles all database operations related to live match events including:
5- Goals
6- Chat messages
7- Status changes
8- Cleanup of expired messages
9"""
11from datetime import UTC, datetime
13import structlog
15from dao.base_dao import BaseDAO
17logger = structlog.get_logger()
20class MatchEventDAO(BaseDAO):
21 """Data access object for match event operations (live match activity stream)."""
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.
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)
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 }
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
78 response = self.client.table("match_events").insert(data).execute()
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
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
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.
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)
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 )
124 if before_id is not None:
125 query = query.lt("id", before_id)
127 response = query.execute()
128 return response.data or []
130 except Exception:
131 logger.exception("Error getting match events", match_id=match_id)
132 return []
134 def get_event_by_id(self, event_id: int) -> dict | None:
135 """Get a single event by ID.
137 Args:
138 event_id: The event ID
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
152 def soft_delete_event(self, event_id: int, deleted_by: str) -> bool:
153 """Soft delete an event (for moderation).
155 Args:
156 event_id: Event to delete
157 deleted_by: UUID of user performing deletion
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 )
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
185 except Exception:
186 logger.exception("Error deleting match event", event_id=event_id)
187 return False
189 def cleanup_expired_messages(self) -> int:
190 """Delete message events that have expired (older than 10 days).
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)
198 Returns:
199 Number of events deleted
200 """
201 try:
202 now = datetime.now(UTC).isoformat()
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 )
214 count = count_response.count or 0
215 if count == 0:
216 logger.info("match_events_cleanup_none_expired")
217 return 0
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 )
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
236 except Exception:
237 logger.exception("Error cleaning up expired match events")
238 return 0
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.
250 Only updates fields that are provided (non-None).
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
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
273 if not data:
274 return self.get_event_by_id(event_id)
276 response = (
277 self.client.table("match_events")
278 .update(data)
279 .eq("id", event_id)
280 .execute()
281 )
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
292 except Exception:
293 logger.exception("Error updating match event", event_id=event_id)
294 return None
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.
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
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 )
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)
347 response = query.execute()
348 return response.data or []
350 except Exception:
351 logger.exception("Error getting goal events")
352 return []
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.
357 Args:
358 match_ids: List of match IDs
360 Returns:
361 Dict mapping match_id to list of card event dicts
362 """
363 if not match_ids:
364 return {}
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 )
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
384 except Exception:
385 logger.exception("Error fetching card events for matches")
386 return {}
388 def get_events_count(self, match_id: int) -> int:
389 """Get total count of active events for a match.
391 Args:
392 match_id: Match to count events for
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