hockey-blast-common-lib 0.1.65__py3-none-any.whl → 0.1.67__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,313 @@
1
+ """
2
+ Aggregate skater statistics by team.
3
+
4
+ This module aggregates player statistics for each team, counting only games
5
+ where the player was on that specific team (using GameRoster.team_id).
6
+
7
+ Key difference from regular aggregation:
8
+ - Aggregates by (aggregation_id, team_id, human_id) instead of just (aggregation_id, human_id)
9
+ - Filters to only games where GameRoster.team_id matches the target team
10
+ - Stores results in OrgStatsSkaterTeam / DivisionStatsSkaterTeam
11
+
12
+ """
13
+ import os
14
+ import sys
15
+
16
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
17
+
18
+ from datetime import datetime
19
+
20
+ import sqlalchemy
21
+ from sqlalchemy import and_, case, func
22
+
23
+ from hockey_blast_common_lib.db_connection import create_session
24
+ from hockey_blast_common_lib.models import (
25
+ Division,
26
+ Game,
27
+ GameRoster,
28
+ Goal,
29
+ Human,
30
+ Organization,
31
+ Penalty,
32
+ Team,
33
+ )
34
+ from hockey_blast_common_lib.options import (
35
+ MIN_GAMES_FOR_DIVISION_STATS,
36
+ MIN_GAMES_FOR_ORG_STATS,
37
+ )
38
+ from hockey_blast_common_lib.progress_utils import create_progress_tracker
39
+ from hockey_blast_common_lib.stats_models import (
40
+ DivisionStatsSkaterTeam,
41
+ OrgStatsSkaterTeam,
42
+ )
43
+ from hockey_blast_common_lib.utils import (
44
+ calculate_percentile_value,
45
+ get_non_human_ids,
46
+ get_percentile_human,
47
+ )
48
+
49
+ # Import status constants for game filtering
50
+ FINAL_STATUS = "Final"
51
+ FINAL_SO_STATUS = "Final(SO)"
52
+ FORFEIT_STATUS = "FORFEIT"
53
+ NOEVENTS_STATUS = "NOEVENTS"
54
+
55
+
56
+ def aggregate_team_skater_stats(session, aggregation_type, aggregation_id):
57
+ """
58
+ Aggregate skater stats by team for an organization or division.
59
+
60
+ For each team in the aggregation scope, calculates stats for all players
61
+ who played for that team, counting only games where they were on that team.
62
+
63
+ Args:
64
+ session: Database session
65
+ aggregation_type: "org" or "division"
66
+ aggregation_id: ID of the organization or division
67
+ """
68
+ # Capture start time for aggregation tracking
69
+ aggregation_start_time = datetime.utcnow()
70
+
71
+ human_ids_to_filter = get_non_human_ids(session)
72
+
73
+ # Determine aggregation details
74
+ if aggregation_type == "org":
75
+ StatsModel = OrgStatsSkaterTeam
76
+ min_games = MIN_GAMES_FOR_ORG_STATS
77
+ aggregation_name = (
78
+ session.query(Organization)
79
+ .filter(Organization.id == aggregation_id)
80
+ .first()
81
+ .organization_name
82
+ )
83
+ filter_condition = Game.org_id == aggregation_id
84
+ elif aggregation_type == "division":
85
+ StatsModel = DivisionStatsSkaterTeam
86
+ min_games = MIN_GAMES_FOR_DIVISION_STATS
87
+ aggregation_name = (
88
+ session.query(Division).filter(Division.id == aggregation_id).first().level
89
+ )
90
+ filter_condition = Game.division_id == aggregation_id
91
+ else:
92
+ raise ValueError(f"Invalid aggregation type: {aggregation_type}")
93
+
94
+ print(f"Aggregating team skater stats for {aggregation_name}...")
95
+
96
+ # Delete existing stats for this aggregation
97
+ session.query(StatsModel).filter(StatsModel.aggregation_id == aggregation_id).delete()
98
+ session.commit()
99
+
100
+ # Get all teams in this aggregation scope
101
+ if aggregation_type == "org":
102
+ teams_query = (
103
+ session.query(Team.id, Team.name)
104
+ .join(Game, (Game.home_team_id == Team.id) | (Game.visitor_team_id == Team.id))
105
+ .filter(Game.org_id == aggregation_id)
106
+ .distinct()
107
+ )
108
+ else: # division
109
+ teams_query = (
110
+ session.query(Team.id, Team.name)
111
+ .join(Game, (Game.home_team_id == Team.id) | (Game.visitor_team_id == Team.id))
112
+ .filter(Game.division_id == aggregation_id)
113
+ .distinct()
114
+ )
115
+
116
+ teams = teams_query.all()
117
+ print(f"Found {len(teams)} teams in {aggregation_name}")
118
+
119
+ # Process each team
120
+ progress = create_progress_tracker(len(teams), description="Processing teams")
121
+ for team_id, team_name in teams:
122
+ progress.update(1)
123
+
124
+ # Aggregate stats for this team
125
+ # Filter to only games where players were on THIS team
126
+ games_played_query = (
127
+ session.query(
128
+ GameRoster.human_id,
129
+ func.count(Game.id).label("games_played"),
130
+ func.count(Game.id).label("games_participated"),
131
+ func.count(Game.id).label("games_with_stats"),
132
+ func.array_agg(Game.id).label("game_ids"),
133
+ )
134
+ .join(Game, Game.id == GameRoster.game_id)
135
+ .filter(
136
+ GameRoster.team_id == team_id, # KEY: Filter by team
137
+ ~GameRoster.role.ilike("g"), # Exclude goalies
138
+ GameRoster.human_id.notin_(human_ids_to_filter),
139
+ Game.status.in_([FINAL_STATUS, FINAL_SO_STATUS, FORFEIT_STATUS, NOEVENTS_STATUS]),
140
+ filter_condition, # org_id or division_id filter
141
+ )
142
+ .group_by(GameRoster.human_id)
143
+ .having(func.count(Game.id) >= min_games)
144
+ )
145
+
146
+ games_played_data = games_played_query.all()
147
+ if not games_played_data:
148
+ continue # No players met minimum games for this team
149
+
150
+ # Create stats dictionary
151
+ stats_dict = {}
152
+ for row in games_played_data:
153
+ stats_dict[row.human_id] = {
154
+ "games_played": row.games_played,
155
+ "games_participated": row.games_participated,
156
+ "games_with_stats": row.games_with_stats,
157
+ "game_ids": row.game_ids,
158
+ "first_game_id": row.game_ids[0] if row.game_ids else None,
159
+ "last_game_id": row.game_ids[-1] if row.game_ids else None,
160
+ }
161
+
162
+ # Aggregate goals, assists, points
163
+ goals_assists_query = (
164
+ session.query(
165
+ GameRoster.human_id,
166
+ func.count(func.distinct(case((Goal.goal_scorer_id == GameRoster.human_id, Goal.id)))).label("goals"),
167
+ func.count(
168
+ func.distinct(
169
+ case(
170
+ (
171
+ (Goal.assist_1_id == GameRoster.human_id) | (Goal.assist_2_id == GameRoster.human_id),
172
+ Goal.id,
173
+ )
174
+ )
175
+ )
176
+ ).label("assists"),
177
+ )
178
+ .join(Game, Game.id == GameRoster.game_id)
179
+ .outerjoin(Goal, Game.id == Goal.game_id)
180
+ .filter(
181
+ GameRoster.team_id == team_id, # KEY: Filter by team
182
+ ~GameRoster.role.ilike("g"),
183
+ GameRoster.human_id.in_(stats_dict.keys()),
184
+ Game.status.in_([FINAL_STATUS, FINAL_SO_STATUS]),
185
+ filter_condition,
186
+ )
187
+ .group_by(GameRoster.human_id)
188
+ )
189
+
190
+ for row in goals_assists_query.all():
191
+ if row.human_id in stats_dict:
192
+ stats_dict[row.human_id]["goals"] = row.goals
193
+ stats_dict[row.human_id]["assists"] = row.assists
194
+ stats_dict[row.human_id]["points"] = row.goals + row.assists
195
+
196
+ # Aggregate penalties
197
+ penalties_query = (
198
+ session.query(
199
+ GameRoster.human_id,
200
+ func.count(Penalty.id).label("penalties"),
201
+ func.sum(case((Penalty.penalty_minutes == "GM", 1), else_=0)).label("gm_penalties"),
202
+ )
203
+ .join(Game, Game.id == GameRoster.game_id)
204
+ .outerjoin(Penalty, and_(Game.id == Penalty.game_id, Penalty.penalized_player_id == GameRoster.human_id))
205
+ .filter(
206
+ GameRoster.team_id == team_id, # KEY: Filter by team
207
+ ~GameRoster.role.ilike("g"),
208
+ GameRoster.human_id.in_(stats_dict.keys()),
209
+ Game.status.in_([FINAL_STATUS, FINAL_SO_STATUS, FORFEIT_STATUS, NOEVENTS_STATUS]),
210
+ filter_condition,
211
+ )
212
+ .group_by(GameRoster.human_id)
213
+ )
214
+
215
+ for row in penalties_query.all():
216
+ if row.human_id in stats_dict:
217
+ stats_dict[row.human_id]["penalties"] = row.penalties
218
+ stats_dict[row.human_id]["gm_penalties"] = row.gm_penalties
219
+
220
+ # Calculate per-game averages
221
+ for human_id, stats in stats_dict.items():
222
+ games_with_stats = stats.get("games_with_stats", 0)
223
+ if games_with_stats > 0:
224
+ stats["goals_per_game"] = stats.get("goals", 0) / games_with_stats
225
+ stats["assists_per_game"] = stats.get("assists", 0) / games_with_stats
226
+ stats["points_per_game"] = stats.get("points", 0) / games_with_stats
227
+ stats["penalties_per_game"] = stats.get("penalties", 0) / games_with_stats
228
+ stats["gm_penalties_per_game"] = stats.get("gm_penalties", 0) / games_with_stats
229
+ else:
230
+ stats["goals_per_game"] = 0.0
231
+ stats["assists_per_game"] = 0.0
232
+ stats["points_per_game"] = 0.0
233
+ stats["penalties_per_game"] = 0.0
234
+ stats["gm_penalties_per_game"] = 0.0
235
+
236
+ # Insert stats for each player on this team
237
+ for human_id, stats in stats_dict.items():
238
+ skater_stat = StatsModel(
239
+ aggregation_id=aggregation_id,
240
+ team_id=team_id,
241
+ human_id=human_id,
242
+ games_played=stats.get("games_played", 0),
243
+ games_participated=stats.get("games_participated", 0),
244
+ games_with_stats=stats.get("games_with_stats", 0),
245
+ goals=stats.get("goals", 0),
246
+ assists=stats.get("assists", 0),
247
+ points=stats.get("points", 0),
248
+ penalties=stats.get("penalties", 0),
249
+ gm_penalties=stats.get("gm_penalties", 0),
250
+ goals_per_game=stats.get("goals_per_game", 0.0),
251
+ assists_per_game=stats.get("assists_per_game", 0.0),
252
+ points_per_game=stats.get("points_per_game", 0.0),
253
+ penalties_per_game=stats.get("penalties_per_game", 0.0),
254
+ gm_penalties_per_game=stats.get("gm_penalties_per_game", 0.0),
255
+ total_in_rank=len(stats_dict),
256
+ first_game_id=stats.get("first_game_id"),
257
+ last_game_id=stats.get("last_game_id"),
258
+ # Initialize streak fields to 0 (not calculated for team stats)
259
+ current_point_streak=0,
260
+ current_point_streak_avg_points=0.0,
261
+ # Ranks will be assigned later if needed
262
+ games_played_rank=0,
263
+ games_participated_rank=0,
264
+ games_with_stats_rank=0,
265
+ goals_rank=0,
266
+ assists_rank=0,
267
+ points_rank=0,
268
+ penalties_rank=0,
269
+ gm_penalties_rank=0,
270
+ goals_per_game_rank=0,
271
+ assists_per_game_rank=0,
272
+ points_per_game_rank=0,
273
+ penalties_per_game_rank=0,
274
+ gm_penalties_per_game_rank=0,
275
+ current_point_streak_rank=0,
276
+ current_point_streak_avg_points_rank=0,
277
+ aggregation_started_at=aggregation_start_time,
278
+ )
279
+ session.add(skater_stat)
280
+
281
+ session.commit()
282
+
283
+ # Update all records with completion timestamp
284
+ aggregation_end_time = datetime.utcnow()
285
+ session.query(StatsModel).filter(
286
+ StatsModel.aggregation_id == aggregation_id
287
+ ).update({StatsModel.aggregation_completed_at: aggregation_end_time})
288
+ session.commit()
289
+
290
+ progress.finish()
291
+ print(f"✓ Team skater stats aggregation complete for {aggregation_name}")
292
+
293
+
294
+ def run_aggregate_team_skater_stats():
295
+ """
296
+ Run team skater stats aggregation for all organizations and divisions.
297
+ """
298
+ from hockey_blast_common_lib.utils import get_all_division_ids_for_org
299
+
300
+ session = create_session("boss")
301
+
302
+ # Get all org_id present in the Organization table
303
+ org_ids = session.query(Organization.id).all()
304
+ org_ids = [org_id[0] for org_id in org_ids]
305
+
306
+ for org_id in org_ids:
307
+ # Aggregate for organization level
308
+ aggregate_team_skater_stats(session, "org", org_id)
309
+
310
+ # Aggregate for all divisions in this organization
311
+ division_ids = get_all_division_ids_for_org(session, org_id)
312
+ for division_id in division_ids:
313
+ aggregate_team_skater_stats(session, "division", division_id)
@@ -53,7 +53,8 @@ class Game(db.Model):
53
53
  time = db.Column(db.Time)
54
54
  day_of_week = db.Column(db.Integer) # 1 to 7 for Monday to Sunday
55
55
  period_length = db.Column(db.Integer) # In minutes
56
- location = db.Column(db.String(100))
56
+ location = db.Column(db.String(100)) # DEPRECATED: Use location_id instead
57
+ location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), nullable=True)
57
58
  scorekeeper_id = db.Column(db.Integer, db.ForeignKey("humans.id"))
58
59
  referee_1_id = db.Column(db.Integer, db.ForeignKey("humans.id"))
59
60
  referee_2_id = db.Column(db.Integer, db.ForeignKey("humans.id"))
@@ -72,6 +73,7 @@ class Game(db.Model):
72
73
  home_ot_score = db.Column(db.Integer, default=0)
73
74
  visitor_ot_score = db.Column(db.Integer, default=0)
74
75
  game_type = db.Column(db.String(50))
76
+ live_time = db.Column(db.String(50), nullable=True) # e.g., "Period 1, 1:10 left" for live games
75
77
  went_to_ot = db.Column(db.Boolean, default=False)
76
78
  home_period_1_shots = db.Column(db.Integer)
77
79
  home_period_2_shots = db.Column(db.Integer)
@@ -245,6 +247,7 @@ class Level(db.Model):
245
247
  org_id = db.Column(db.Integer, db.ForeignKey("organizations.id"), nullable=False)
246
248
  skill_value = db.Column(db.Float) # A number from 0 (NHL) to 100 (pedestrian)
247
249
  level_name = db.Column(db.String(100))
250
+ short_name = db.Column(db.String(50)) # Shortened display name (e.g., "D-7B-W" for "Adult Division 7B West")
248
251
  level_alternative_name = db.Column(db.String(100))
249
252
  is_seed = db.Column(db.Boolean, nullable=True, default=False) # New field
250
253
  skill_propagation_sequence = db.Column(db.Integer, nullable=True, default=-1)
@@ -253,6 +256,17 @@ class Level(db.Model):
253
256
  )
254
257
 
255
258
 
259
+ class Location(db.Model):
260
+ __tablename__ = "locations"
261
+ id = db.Column(db.Integer, primary_key=True)
262
+ location_in_game_source = db.Column(db.String(200), nullable=False, unique=True) # Raw string from Game.location, e.g., "San Jose Orange (N)"
263
+ location_name = db.Column(db.String(200), nullable=True) # Optional: Facility name, e.g., "Sharks Ice At San Jose"
264
+ rink_name = db.Column(db.String(200), nullable=True) # Optional: Specific rink, e.g., "Orange (N)"
265
+ address = db.Column(db.String(500), nullable=True)
266
+ google_maps_link = db.Column(db.String(500), nullable=True)
267
+ master_location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), nullable=True) # Points to the canonical location for this rink
268
+
269
+
256
270
  class LevelsMonthly(db.Model):
257
271
  __tablename__ = "levels_monthly"
258
272
  id = db.Column(db.Integer, primary_key=True)
@@ -427,6 +441,7 @@ class Season(db.Model):
427
441
  id = db.Column(db.Integer, primary_key=True)
428
442
  season_number = db.Column(db.Integer)
429
443
  season_name = db.Column(db.String(100))
444
+ base_season_name = db.Column(db.String(100)) # Static prefix for season name (e.g., "Silver Stick", "Over", etc.)
430
445
  start_date = db.Column(db.Date)
431
446
  end_date = db.Column(db.Date)
432
447
  league_number = db.Column(
@@ -4,7 +4,13 @@ from sqlalchemy.orm import synonym
4
4
  from hockey_blast_common_lib.models import db
5
5
 
6
6
 
7
- class BaseStatsHuman(db.Model):
7
+ class AggregationTimestampMixin:
8
+ """Mixin to add aggregation timestamp tracking to all stats models."""
9
+ aggregation_started_at = db.Column(db.DateTime, nullable=True)
10
+ aggregation_completed_at = db.Column(db.DateTime, nullable=True)
11
+
12
+
13
+ class BaseStatsHuman(AggregationTimestampMixin, db.Model):
8
14
  __abstract__ = True
9
15
  id = db.Column(db.Integer, primary_key=True)
10
16
  human_id = db.Column(db.Integer, db.ForeignKey("humans.id"), nullable=False)
@@ -92,7 +98,7 @@ class BaseStatsHuman(db.Model):
92
98
  )
93
99
 
94
100
 
95
- class BaseStatsSkater(db.Model):
101
+ class BaseStatsSkater(AggregationTimestampMixin, db.Model):
96
102
  __abstract__ = True
97
103
  id = db.Column(db.Integer, primary_key=True)
98
104
  human_id = db.Column(db.Integer, db.ForeignKey("humans.id"), nullable=False)
@@ -193,7 +199,7 @@ class BaseStatsSkater(db.Model):
193
199
  )
194
200
 
195
201
 
196
- class BaseStatsGoalie(db.Model):
202
+ class BaseStatsGoalie(AggregationTimestampMixin, db.Model):
197
203
  __abstract__ = True
198
204
  id = db.Column(db.Integer, primary_key=True)
199
205
  human_id = db.Column(db.Integer, db.ForeignKey("humans.id"), nullable=False)
@@ -261,7 +267,7 @@ class BaseStatsGoalie(db.Model):
261
267
  )
262
268
 
263
269
 
264
- class BaseStatsReferee(db.Model):
270
+ class BaseStatsReferee(AggregationTimestampMixin, db.Model):
265
271
  __abstract__ = True
266
272
  id = db.Column(db.Integer, primary_key=True)
267
273
  human_id = db.Column(db.Integer, db.ForeignKey("humans.id"), nullable=False)
@@ -331,7 +337,7 @@ class BaseStatsReferee(db.Model):
331
337
  )
332
338
 
333
339
 
334
- class BaseStatsScorekeeper(db.Model):
340
+ class BaseStatsScorekeeper(AggregationTimestampMixin, db.Model):
335
341
  __abstract__ = True
336
342
  id = db.Column(db.Integer, primary_key=True)
337
343
  human_id = db.Column(db.Integer, db.ForeignKey("humans.id"), nullable=False)
@@ -922,3 +928,203 @@ class SkillValuePPGRatio(db.Model):
922
928
  "from_skill_value", "to_skill_value", name="_from_to_skill_value_uc"
923
929
  ),
924
930
  )
931
+
932
+
933
+ # Team-based statistics models (inherit from existing bases, add team_id field)
934
+ class OrgStatsSkaterTeam(BaseStatsSkater):
935
+ __tablename__ = "org_stats_skater_team"
936
+ org_id = db.Column(db.Integer, db.ForeignKey("organizations.id"), nullable=False)
937
+ team_id = db.Column(db.Integer, db.ForeignKey("teams.id"), nullable=False)
938
+ aggregation_id = synonym("org_id")
939
+
940
+ @declared_attr
941
+ def aggregation_type(cls):
942
+ return "org_team"
943
+
944
+ @classmethod
945
+ def get_aggregation_column(cls):
946
+ return "org_id"
947
+
948
+ @declared_attr
949
+ def __table_args__(cls):
950
+ return (
951
+ db.UniqueConstraint(
952
+ "org_id",
953
+ "team_id",
954
+ "human_id",
955
+ name="_org_team_human_uc_skater_team1",
956
+ ),
957
+ db.Index("idx_org_team_team_id", "team_id"),
958
+ db.Index("idx_org_team_human_id", "human_id"),
959
+ db.Index("idx_org_team_goals_per_game", "org_id", "goals_per_game"),
960
+ db.Index("idx_org_team_points_per_game", "org_id", "points_per_game"),
961
+ db.Index("idx_org_team_assists_per_game", "org_id", "assists_per_game"),
962
+ )
963
+
964
+
965
+ class DivisionStatsSkaterTeam(BaseStatsSkater):
966
+ __tablename__ = "division_stats_skater_team"
967
+ division_id = db.Column(db.Integer, db.ForeignKey("divisions.id"), nullable=False)
968
+ team_id = db.Column(db.Integer, db.ForeignKey("teams.id"), nullable=False)
969
+ aggregation_id = synonym("division_id")
970
+
971
+ @declared_attr
972
+ def aggregation_type(cls):
973
+ return "division_team"
974
+
975
+ @classmethod
976
+ def get_aggregation_column(cls):
977
+ return "division_id"
978
+
979
+ @declared_attr
980
+ def __table_args__(cls):
981
+ return (
982
+ db.UniqueConstraint(
983
+ "division_id",
984
+ "team_id",
985
+ "human_id",
986
+ name="_division_team_human_uc_skater_team1",
987
+ ),
988
+ db.Index("idx_division_team_team_id", "team_id"),
989
+ db.Index("idx_division_team_human_id", "human_id"),
990
+ db.Index("idx_division_team_goals_per_game", "division_id", "goals_per_game"),
991
+ db.Index("idx_division_team_points_per_game", "division_id", "points_per_game"),
992
+ db.Index("idx_division_team_assists_per_game", "division_id", "assists_per_game"),
993
+ )
994
+
995
+
996
+ class OrgStatsGoalieTeam(BaseStatsGoalie):
997
+ __tablename__ = "org_stats_goalie_team"
998
+ org_id = db.Column(db.Integer, db.ForeignKey("organizations.id"), nullable=False)
999
+ team_id = db.Column(db.Integer, db.ForeignKey("teams.id"), nullable=False)
1000
+ aggregation_id = synonym("org_id")
1001
+
1002
+ @declared_attr
1003
+ def aggregation_type(cls):
1004
+ return "org_team"
1005
+
1006
+ @classmethod
1007
+ def get_aggregation_column(cls):
1008
+ return "org_id"
1009
+
1010
+ @declared_attr
1011
+ def __table_args__(cls):
1012
+ return (
1013
+ db.UniqueConstraint(
1014
+ "org_id",
1015
+ "team_id",
1016
+ "human_id",
1017
+ name="_org_team_human_uc_goalie_team1",
1018
+ ),
1019
+ db.Index("idx_org_team_goalie_team_id", "team_id"),
1020
+ db.Index("idx_org_team_goalie_human_id", "human_id"),
1021
+ db.Index("idx_org_team_goalie_save_pct", "org_id", "save_percentage"),
1022
+ db.Index("idx_org_team_goalie_gaa", "org_id", "goals_allowed_per_game"),
1023
+ )
1024
+
1025
+
1026
+ class DivisionStatsGoalieTeam(BaseStatsGoalie):
1027
+ __tablename__ = "division_stats_goalie_team"
1028
+ division_id = db.Column(db.Integer, db.ForeignKey("divisions.id"), nullable=False)
1029
+ team_id = db.Column(db.Integer, db.ForeignKey("teams.id"), nullable=False)
1030
+ aggregation_id = synonym("division_id")
1031
+
1032
+ @declared_attr
1033
+ def aggregation_type(cls):
1034
+ return "division_team"
1035
+
1036
+ @classmethod
1037
+ def get_aggregation_column(cls):
1038
+ return "division_id"
1039
+
1040
+ @declared_attr
1041
+ def __table_args__(cls):
1042
+ return (
1043
+ db.UniqueConstraint(
1044
+ "division_id",
1045
+ "team_id",
1046
+ "human_id",
1047
+ name="_division_team_human_uc_goalie_team1",
1048
+ ),
1049
+ db.Index("idx_division_team_goalie_team_id", "team_id"),
1050
+ db.Index("idx_division_team_goalie_human_id", "human_id"),
1051
+ db.Index("idx_division_team_goalie_save_pct", "division_id", "save_percentage"),
1052
+ db.Index("idx_division_team_goalie_gaa", "division_id", "goals_allowed_per_game"),
1053
+ )
1054
+
1055
+
1056
+ # Per-Game Statistics Models (for RAG system)
1057
+ # These models store individual game performance data for each player/goalie
1058
+ # CRITICAL: Only non-zero rows are saved (games where player recorded stats)
1059
+
1060
+
1061
+ class GameStatsSkater(db.Model):
1062
+ """Per-game skater statistics.
1063
+
1064
+ Stores individual game performance for skaters.
1065
+ Only records where player had non-zero stats are saved.
1066
+ Optimized for queries like "show me top N games by points for player X".
1067
+ """
1068
+ __tablename__ = "game_stats_skater"
1069
+
1070
+ id = db.Column(db.Integer, primary_key=True)
1071
+ game_id = db.Column(db.Integer, db.ForeignKey("games.id"), nullable=False, index=True)
1072
+ human_id = db.Column(db.Integer, db.ForeignKey("humans.id"), nullable=False, index=True)
1073
+ team_id = db.Column(db.Integer, db.ForeignKey("teams.id"), nullable=False, index=True)
1074
+ org_id = db.Column(db.Integer, db.ForeignKey("organizations.id"), nullable=False, index=True)
1075
+ level_id = db.Column(db.Integer, db.ForeignKey("levels.id"), nullable=False, index=True)
1076
+
1077
+ # Denormalized game metadata for sorting/filtering
1078
+ game_date = db.Column(db.Date, nullable=False, index=True)
1079
+ game_time = db.Column(db.Time, nullable=False)
1080
+
1081
+ # Performance stats
1082
+ goals = db.Column(db.Integer, default=0, nullable=False)
1083
+ assists = db.Column(db.Integer, default=0, nullable=False)
1084
+ points = db.Column(db.Integer, default=0, nullable=False)
1085
+ penalty_minutes = db.Column(db.Integer, default=0, nullable=False)
1086
+
1087
+ # Tracking
1088
+ created_at = db.Column(db.DateTime, nullable=False, default=db.func.current_timestamp())
1089
+
1090
+ __table_args__ = (
1091
+ db.UniqueConstraint("game_id", "human_id", name="_game_human_uc_skater"),
1092
+ db.Index("idx_game_stats_skater_human_date", "human_id", "game_date", postgresql_using="btree"),
1093
+ db.Index("idx_game_stats_skater_human_team_date", "human_id", "team_id", "game_date", postgresql_using="btree"),
1094
+ )
1095
+
1096
+
1097
+ class GameStatsGoalie(db.Model):
1098
+ """Per-game goalie statistics.
1099
+
1100
+ Stores individual game performance for goalies.
1101
+ Only records where goalie faced shots are saved.
1102
+ Optimized for queries like "show me top N games by save% for goalie X".
1103
+ """
1104
+ __tablename__ = "game_stats_goalie"
1105
+
1106
+ id = db.Column(db.Integer, primary_key=True)
1107
+ game_id = db.Column(db.Integer, db.ForeignKey("games.id"), nullable=False, index=True)
1108
+ human_id = db.Column(db.Integer, db.ForeignKey("humans.id"), nullable=False, index=True)
1109
+ team_id = db.Column(db.Integer, db.ForeignKey("teams.id"), nullable=False, index=True)
1110
+ org_id = db.Column(db.Integer, db.ForeignKey("organizations.id"), nullable=False, index=True)
1111
+ level_id = db.Column(db.Integer, db.ForeignKey("levels.id"), nullable=False, index=True)
1112
+
1113
+ # Denormalized game metadata for sorting/filtering
1114
+ game_date = db.Column(db.Date, nullable=False, index=True)
1115
+ game_time = db.Column(db.Time, nullable=False)
1116
+
1117
+ # Performance stats
1118
+ goals_allowed = db.Column(db.Integer, default=0, nullable=False)
1119
+ shots_faced = db.Column(db.Integer, default=0, nullable=False)
1120
+ saves = db.Column(db.Integer, default=0, nullable=False) # Computed: shots_faced - goals_allowed
1121
+ save_percentage = db.Column(db.Float, default=0.0, nullable=False) # Computed: saves / shots_faced
1122
+
1123
+ # Tracking
1124
+ created_at = db.Column(db.DateTime, nullable=False, default=db.func.current_timestamp())
1125
+
1126
+ __table_args__ = (
1127
+ db.UniqueConstraint("game_id", "human_id", "team_id", name="_game_human_uc_goalie"),
1128
+ db.Index("idx_game_stats_goalie_human_date", "human_id", "game_date", postgresql_using="btree"),
1129
+ db.Index("idx_game_stats_goalie_human_team_date", "human_id", "team_id", "game_date", postgresql_using="btree"),
1130
+ )