rairo commited on
Commit
9286bd1
·
verified ·
1 Parent(s): 59c2b4c

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +67 -166
main.py CHANGED
@@ -15,9 +15,8 @@ from firebase_admin import credentials, db, storage, auth
15
  import firebase_admin
16
  import logging
17
  import traceback
18
- from bs4 import BeautifulSoup, Comment # BeautifulSoup is still imported but not used for team stats now.
19
 
20
- # Import BRScraper
21
  try:
22
  from BRScraper import nba
23
  BRSCRAPER_AVAILABLE = True
@@ -25,18 +24,14 @@ except ImportError:
25
  BRSCRAPER_AVAILABLE = False
26
  logging.error("BRScraper not found. Please install with: `pip install BRScraper`")
27
 
28
- # Initialize Flask app and CORS
29
  app = Flask(__name__)
30
  CORS(app)
31
 
32
- # Configure logging
33
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
34
 
35
- # Firebase initialization
36
  Firebase_DB = os.getenv("Firebase_DB")
37
  Firebase_Storage = os.getenv("Firebase_Storage")
38
 
39
- # Global flag for Firebase initialization status
40
  FIREBASE_INITIALIZED = False
41
 
42
  try:
@@ -56,12 +51,9 @@ except Exception as e:
56
  logging.error(f"Error initializing Firebase: {e}")
57
  traceback.print_exc()
58
 
59
- # Only initialize bucket if Firebase is initialized
60
  bucket = storage.bucket() if FIREBASE_INITIALIZED else None
61
 
62
- # Helper functions
63
  def verify_token(token):
64
- """Verifies Firebase ID token and returns UID."""
65
  try:
66
  decoded_token = auth.verify_id_token(token)
67
  return decoded_token['uid']
@@ -70,7 +62,6 @@ def verify_token(token):
70
  return None
71
 
72
  def verify_admin(auth_header):
73
- """Verifies admin privileges based on Firebase Realtime DB 'is_admin' flag."""
74
  if not auth_header or not auth_header.startswith('Bearer '):
75
  raise ValueError('Invalid token format')
76
  token = auth_header.split(' ')[1]
@@ -83,12 +74,7 @@ def verify_admin(auth_header):
83
  raise PermissionError('Admin access required')
84
  return uid
85
 
86
- # Decorator for credit deduction
87
  def credit_required(cost=1):
88
- """
89
- Decorator to check user authentication, suspension status, and deduct credits.
90
- If cost is 0, it only checks authentication and suspension.
91
- """
92
  def decorator(f):
93
  def wrapper(*args, **kwargs):
94
  auth_header = request.headers.get('Authorization', '')
@@ -112,7 +98,6 @@ def credit_required(cost=1):
112
  return jsonify({'error': f'Insufficient credits. You need {cost} credits, but have {current_credits}.'}), 403
113
 
114
  try:
115
- # Deduct credits only if cost > 0
116
  if cost > 0:
117
  user_ref.update({'credits': current_credits - cost})
118
  logging.info(f"Deducted {cost} credits from user {uid}. New balance: {current_credits - cost}")
@@ -120,16 +105,13 @@ def credit_required(cost=1):
120
  except Exception as e:
121
  logging.error(f"Failed to process credits for user {uid}: {e}")
122
  return jsonify({'error': 'Failed to process credits. Please try again.'}), 500
123
- wrapper.__name__ = f.__name__ # Preserve original function name for Flask routing
124
  return wrapper
125
  return decorator
126
 
127
 
128
- # ---------- Authentication Endpoints ----------
129
-
130
  @app.route('/api/auth/signup', methods=['POST'])
131
  def signup():
132
- """Handles user signup and creates a new user entry in Firebase Auth and DB."""
133
  try:
134
  data = request.get_json()
135
  email = data.get('email')
@@ -141,7 +123,7 @@ def signup():
141
  user_ref = db.reference(f'users/{user.uid}')
142
  user_data = {
143
  'email': email,
144
- 'credits': 10, # Initial credits for new users
145
  'is_admin': False,
146
  'created_at': datetime.utcnow().isoformat()
147
  }
@@ -159,7 +141,6 @@ def signup():
159
 
160
  @app.route('/api/user/profile', methods=['GET'])
161
  def get_user_profile():
162
- """Retrieves the authenticated user's profile information."""
163
  try:
164
  auth_header = request.headers.get('Authorization', '')
165
  if not auth_header.startswith('Bearer '):
@@ -186,7 +167,6 @@ def get_user_profile():
186
 
187
  @app.route('/api/auth/google-signin', methods=['POST'])
188
  def google_signin():
189
- """Handles Google Sign-In, creating user DB entry if it doesn't exist."""
190
  try:
191
  auth_header = request.headers.get('Authorization', '')
192
  if not auth_header.startswith('Bearer '):
@@ -203,7 +183,7 @@ def google_signin():
203
  if not user_data:
204
  user_data = {
205
  'email': email,
206
- 'credits': 10, # Initial credits for new users
207
  'is_admin': False,
208
  'created_at': datetime.utcnow().isoformat(),
209
  }
@@ -222,13 +202,12 @@ def google_signin():
222
  return jsonify({'error': str(e)}), 400
223
 
224
  @app.route('/api/user/request-credits', methods=['POST'])
225
- @credit_required(cost=0) # This endpoint doesn't cost credits, but requires auth
226
  def request_credits():
227
- """Allows a user to request more credits from an admin."""
228
  try:
229
  auth_header = request.headers.get('Authorization', '')
230
  token = auth_header.split(' ')[1]
231
- uid = verify_token(token) # uid is already verified by decorator, but get it here
232
 
233
  data = request.get_json()
234
  requested_credits = data.get('requested_credits')
@@ -248,44 +227,40 @@ def request_credits():
248
  return jsonify({'error': str(e)}), 500
249
 
250
  @app.route('/api/user/submit_feedback', methods=['POST'])
251
- @credit_required(cost=0) # Requires authentication but no credit cost
252
  @cross_origin()
253
  def submit_feedback():
254
- """Allows a user to submit feedback to administrators."""
255
  try:
256
  auth_header = request.headers.get('Authorization', '')
257
  token = auth_header.split(' ')[1]
258
- uid = verify_token(token) # UID already verified by decorator
259
 
260
  data = request.get_json()
261
- feedback_type = data.get('type') # e.g., 'bug', 'feature_request', 'general'
262
  message = data.get('message')
263
 
264
  if not feedback_type or not message:
265
  return jsonify({'error': 'Feedback type and message are required'}), 400
266
 
267
  user_data = db.reference(f'users/{uid}').get()
268
- user_email = user_data.get('email', 'unknown_email') # Get email for admin view
269
 
270
  feedback_ref = db.reference('feedback').push()
271
  feedback_ref.set({
272
  'user_id': uid,
273
- 'user_email': user_email, # Store user email for easier admin lookup
274
  'type': feedback_type,
275
  'message': message,
276
  'created_at': datetime.utcnow().isoformat(),
277
- 'status': 'open' # Initial status for new feedback
278
  })
279
  return jsonify({'success': True, 'feedback_id': feedback_ref.key}), 201
280
  except Exception as e:
281
  logging.error(f"Submit feedback error: {e}")
282
  return jsonify({'error': str(e)}), 500
283
 
284
- # ---------- Admin Endpoints ----------
285
-
286
  @app.route('/api/admin/profile', methods=['GET'])
287
  def get_admin_profile():
288
- """Retrieves admin profile and aggregated user/credit statistics."""
289
  try:
290
  admin_uid = verify_admin(request.headers.get('Authorization', ''))
291
  admin_data = db.reference(f'users/{admin_uid}').get()
@@ -301,7 +276,6 @@ def get_admin_profile():
301
  total_current_credits = sum(user.get('credits', 0) for user in all_users_data.values())
302
  total_normal_current_credits = sum(user.get('credits', 0) for user in normal_users_data)
303
 
304
- # Updated initial credits calculation to 10
305
  total_initial_credits = total_normal_users * 10
306
  credit_usage = total_initial_credits - total_normal_current_credits
307
 
@@ -325,7 +299,6 @@ def get_admin_profile():
325
 
326
  @app.route('/api/admin/credit_requests', methods=['GET'])
327
  def list_credit_requests():
328
- """Lists all credit requests submitted by users."""
329
  try:
330
  verify_admin(request.headers.get('Authorization', ''))
331
  requests_ref = db.reference('credit_requests')
@@ -338,7 +311,6 @@ def list_credit_requests():
338
 
339
  @app.route('/api/admin/credit_requests/<string:request_id>', methods=['PUT'])
340
  def process_credit_request(request_id):
341
- """Processes a specific credit request (approves or declines)."""
342
  try:
343
  admin_uid = verify_admin(request.headers.get('Authorization', ''))
344
  req_ref = db.reference(f'credit_requests/{request_id}')
@@ -377,7 +349,6 @@ def process_credit_request(request_id):
377
 
378
  @app.route('/api/admin/users', methods=['GET'])
379
  def admin_list_users():
380
- """Lists all users in the system."""
381
  try:
382
  verify_admin(request.headers.get('Authorization', ''))
383
  users_ref = db.reference('users')
@@ -400,7 +371,6 @@ def admin_list_users():
400
 
401
  @app.route('/api/admin/users/search', methods=['GET'])
402
  def admin_search_users():
403
- """Searches for users by email."""
404
  try:
405
  verify_admin(request.headers.get('Authorization', ''))
406
  email_query = request.args.get('email', '').lower().strip()
@@ -429,7 +399,6 @@ def admin_search_users():
429
 
430
  @app.route('/api/admin/users/<string:uid>/suspend', methods=['PUT'])
431
  def admin_suspend_user(uid):
432
- """Suspends or unsuspends a user account."""
433
  try:
434
  verify_admin(request.headers.get('Authorization', ''))
435
  data = request.get_json()
@@ -454,7 +423,6 @@ def admin_suspend_user(uid):
454
 
455
  @app.route('/api/admin/notifications', methods=['POST'])
456
  def send_notifications():
457
- """Sends notifications to users (all, single, or list)."""
458
  try:
459
  admin_uid = verify_admin(request.headers.get('Authorization', ''))
460
  data = request.get_json()
@@ -502,7 +470,6 @@ def send_notifications():
502
 
503
  @app.route('/api/admin/feedback', methods=['GET'])
504
  def admin_view_feedback():
505
- """Retrieves user feedback, with optional filtering by type and status."""
506
  try:
507
  admin_uid = verify_admin(request.headers.get('Authorization', ''))
508
  feedback_type = request.args.get('type')
@@ -534,7 +501,6 @@ def admin_view_feedback():
534
 
535
  @app.route('/api/admin/users/<string:uid>/credits', methods=['PUT'])
536
  def admin_update_credits(uid):
537
- """Directly adds or subtracts credits from a user's account."""
538
  try:
539
  verify_admin(request.headers.get('Authorization', ''))
540
  data = request.get_json()
@@ -559,57 +525,40 @@ def admin_update_credits(uid):
559
  # NBA Analytics Hub Data Fetching Utilities
560
  # ——————————————————————————————————————————————
561
 
562
- # Helper to clean DataFrame column names for Firebase keys
563
  def clean_firebase_keys(key_name):
564
- """Cleans a string to be a valid Firebase Realtime Database key."""
565
  if not isinstance(key_name, str):
566
  key_name = str(key_name)
567
- # Firebase invalid characters: . $ # [ ] /
568
- # Replace them with underscores or remove them
569
  cleaned_key = key_name.replace('.', '_').replace('$', '').replace('#', '').replace('[', '').replace(']', '').replace('/', '_')
570
- # Also handle '%' if it's problematic, though not explicitly listed by Firebase for keys
571
- cleaned_key = cleaned_key.replace('%', 'Pct') # Example: W/L% -> W_LPct
572
- # Ensure no empty keys
573
  if not cleaned_key:
574
- return "empty_key_" + str(uuid.uuid4())[:8] # Fallback for completely empty keys
575
  return cleaned_key
576
 
577
  def clean_df_for_firebase(df):
578
- """Applies Firebase key cleaning to all column names of a DataFrame."""
579
  if df.empty:
580
  return df
581
  df.columns = [clean_firebase_keys(col) for col in df.columns]
582
  return df
583
 
584
  def clean_team_name(team_name):
585
- """
586
- Clean and standardize team names from Basketball Reference.
587
- """
588
  if pd.isna(team_name):
589
  return team_name
590
- team_name = str(team_name).strip().replace('*', '')
591
- team_mapping = {
592
- 'NOP': 'NO', 'PHX': 'PHO', 'BRK': 'BKN', 'CHA': 'CHO', 'UTA': 'UTH'
593
- }
594
- return team_mapping.get(team_name, team_name)
595
 
596
  def is_data_stale(timestamp_str, max_age_hours=24):
597
- """Checks if a timestamp string indicates data is older than max_age_hours."""
598
  if not timestamp_str:
599
- return True # No timestamp means data is stale or never fetched
600
  try:
601
  last_updated = datetime.fromisoformat(timestamp_str)
602
  return (datetime.utcnow() - last_updated) > timedelta(hours=max_age_hours)
603
  except ValueError:
604
  logging.error(f"Invalid timestamp format: {timestamp_str}")
605
- return True # Treat invalid format as stale
606
 
607
  def get_team_standings_brscraper(year):
608
- """
609
- Scrapes the league’s team standings table from Basketball-Reference
610
- using BRScraper, with Firebase caching.
611
- Returns cleaned DataFrame.
612
- """
613
  if not FIREBASE_INITIALIZED:
614
  logging.warning("Firebase not initialized. Cannot use caching for team standings. Scraping directly.")
615
  return _scrape_team_standings_brscraper(year)
@@ -617,7 +566,6 @@ def get_team_standings_brscraper(year):
617
  db_ref = db.reference(f'scraped_data/team_standings/{year}')
618
  cached_data = db_ref.get()
619
 
620
- # Cache for 12 hours for standings data
621
  if cached_data and not is_data_stale(cached_data.get('last_updated'), max_age_hours=12):
622
  logging.info(f"Loading team standings for {year} from Firebase cache.")
623
  return pd.DataFrame.from_records(cached_data['data'])
@@ -633,33 +581,29 @@ def get_team_standings_brscraper(year):
633
  return df
634
 
635
  def _scrape_team_standings_brscraper(year):
636
- """Internal function to perform the actual scraping of team standings."""
637
  if not BRSCRAPER_AVAILABLE:
638
  logging.error("BRScraper not available for team standings.")
639
  return pd.DataFrame()
640
  try:
641
- df = nba.get_standings(year, info='total', rename=False)
642
  if df.empty:
643
  logging.warning(f"Could not find team standings for {year} using BRScraper.")
644
  return pd.DataFrame()
645
 
646
- # Standardize column names
647
  column_mapping = {
648
  'Tm': 'Team', 'W': 'WINS', 'L': 'LOSSES', 'W/L%': 'WIN_LOSS_PCT',
649
- 'Rk': 'RANK' # Rank might be useful
650
  }
651
  df = df.rename(columns={old_col: new_col for old_col, new_col in column_mapping.items() if old_col in df.columns})
652
 
653
- # Clean team names
654
  if 'Team' in df.columns:
 
655
  df['Team'] = df['Team'].apply(clean_team_name)
656
 
657
- # Convert numeric columns, coercing errors to NaN
658
  numeric_cols = [col for col in df.columns if col not in ['Team']]
659
  for col in numeric_cols:
660
  df[col] = pd.to_numeric(df[col], errors="coerce")
661
 
662
- # Replace NaN values with None for JSON compliance
663
  df = df.replace({np.nan: None})
664
  return df
665
  except Exception as e:
@@ -670,7 +614,7 @@ def get_available_seasons_util(num_seasons=6):
670
  current_year = datetime.now().year
671
  current_month = datetime.now().month
672
  latest_season_end_year = current_year
673
- if current_month >= 7: # NBA season typically ends in June, so July onwards means next season's data
674
  latest_season_end_year += 1
675
  seasons_list = []
676
  for i in range(num_seasons):
@@ -705,7 +649,6 @@ def get_player_index_brscraper():
705
  return df
706
 
707
  def _scrape_player_index_brscraper():
708
- """Internal function to perform the actual scraping of player index."""
709
  try:
710
  latest_season_end_year = int(get_available_seasons_util(1)[0].split('–')[1])
711
  df = nba.get_stats(latest_season_end_year, info='per_game', rename=False)
@@ -725,9 +668,6 @@ def _scrape_player_index_brscraper():
725
  return pd.DataFrame({'name': common_players})
726
 
727
  def get_player_career_stats_brscraper(player_name, seasons_to_check=10, playoffs=False):
728
- """
729
- Fetches career per-game or playoff stats for a given player using BRScraper.
730
- """
731
  if not BRSCRAPER_AVAILABLE:
732
  return pd.DataFrame()
733
  all_rows = []
@@ -753,7 +693,7 @@ def get_player_career_stats_brscraper(player_name, seasons_to_check=10, playoffs
753
  'G':'GP','GS':'GS','MP':'MIN', 'FG%':'FG_PCT','3P%':'FG3_PCT','FT%':'FT_PCT',
754
  'TRB':'REB','AST':'AST','STL':'STL','BLK':'BLK','TOV':'TO',
755
  'PF':'PF','PTS':'PTS','ORB':'OREB','DRB':'DREB',
756
- 'FG':'FGM','FGA':'FGA','3P':'FG3M','3PA':'FG3A', # Corrected 3PA mapping
757
  '2P':'FGM2','2PA':'FGA2','2P%':'FG2_PCT','eFG%':'EFG_PCT',
758
  'FT':'FTM','FTA':'FTA'
759
  }
@@ -765,14 +705,10 @@ def get_player_career_stats_brscraper(player_name, seasons_to_check=10, playoffs
765
  df[col] = pd.to_numeric(df[col], errors='coerce')
766
 
767
  df['Player'] = player_name
768
- df = df.replace({np.nan: None}) # Ensure NaN are None for JSON compliance
769
  return df
770
 
771
  def get_dashboard_info_brscraper():
772
- """
773
- Fetches various dashboard-level information using BRScraper with caching.
774
- Includes MVP votings and playoff probabilities.
775
- """
776
  if not BRSCRAPER_AVAILABLE:
777
  logging.error("BRScraper not available for dashboard info.")
778
  return {}
@@ -784,7 +720,6 @@ def get_dashboard_info_brscraper():
784
  db_ref = db.reference('scraped_data/dashboard_info')
785
  cached_data = db_ref.get()
786
 
787
- # Cache for 24 hours for dashboard info
788
  if cached_data and not is_data_stale(cached_data.get('last_updated'), max_age_hours=24):
789
  logging.info("Loading dashboard info from Firebase cache.")
790
  return cached_data['data']
@@ -800,31 +735,27 @@ def get_dashboard_info_brscraper():
800
  return data
801
 
802
  def _scrape_dashboard_info_brscraper():
803
- """Internal function to perform the actual scraping for dashboard info."""
804
  dashboard_data = {}
805
  try:
806
- # MVP Votings for 2025
807
  mvp_2025_df = nba.get_award_votings('mvp', 2025)
808
  if not mvp_2025_df.empty:
809
- mvp_2025_df = clean_df_for_firebase(mvp_2025_df) # Clean column names
810
  dashboard_data['mvp_2025_votings'] = mvp_2025_df.replace({np.nan: None}).to_dict(orient='records')
811
  else:
812
  dashboard_data['mvp_2025_votings'] = []
813
  logging.warning("Could not retrieve 2025 MVP votings.")
814
 
815
- # Current Season Playoff Probabilities (East)
816
  east_probs_df = nba.get_playoffs_probs('east')
817
  if not east_probs_df.empty:
818
- east_probs_df = clean_df_for_firebase(east_probs_df) # Clean column names
819
  dashboard_data['playoff_probs_east'] = east_probs_df.replace({np.nan: None}).to_dict(orient='records')
820
  else:
821
  dashboard_data['playoff_probs_east'] = []
822
  logging.warning("Could not retrieve Eastern Conference playoff probabilities.")
823
 
824
- # Current Season Playoff Probabilities (West)
825
  west_probs_df = nba.get_playoffs_probs('west')
826
  if not west_probs_df.empty:
827
- west_probs_df = clean_df_for_firebase(west_probs_df) # Clean column names
828
  dashboard_data['playoff_probs_west'] = west_probs_df.replace({np.nan: None}).to_dict(orient='records')
829
  else:
830
  dashboard_data['playoff_probs_west'] = []
@@ -832,21 +763,18 @@ def _scrape_dashboard_info_brscraper():
832
 
833
  except Exception as e:
834
  logging.error(f"Error scraping dashboard info with BRScraper: {e}")
835
- # Return partial data if some parts fail
836
  return dashboard_data
837
 
838
- # Perplexity API
839
  PERP_KEY = os.getenv("PERPLEXITY_API_KEY")
840
  PERP_URL = "https://api.perplexity.ai/chat/completions"
841
 
842
  def ask_perp(prompt, system="You are a helpful NBA analyst AI.", max_tokens=500, temp=0.2):
843
- """Sends a prompt to the Perplexity AI API and returns the response."""
844
  if not PERP_KEY:
845
  logging.error("PERPLEXITY_API_KEY env var not set.")
846
  return "Perplexity API key is not configured."
847
  hdr = {'Authorization':f'Bearer {PERP_KEY}','Content-Type':'application/json'}
848
  payload = {
849
- "model":"sonar-pro", # do not change
850
  "messages":[{"role":"system","content":system},{"role":"user","content":prompt}],
851
  "max_tokens":max_tokens, "temperature":temp
852
  }
@@ -869,12 +797,9 @@ def ask_perp(prompt, system="You are a helpful NBA analyst AI.", max_tokens=500,
869
  return f"An unexpected error occurred with AI: {str(e)}"
870
 
871
 
872
- # ---------- NBA Analytics Hub Endpoints ----------
873
-
874
  @app.route('/api/nba/players', methods=['GET'])
875
  @cross_origin()
876
  def get_players():
877
- """Returns a list of available NBA player names."""
878
  try:
879
  players_df = get_player_index_brscraper()
880
  if players_df.empty:
@@ -887,7 +812,6 @@ def get_players():
887
  @app.route('/api/nba/seasons', methods=['GET'])
888
  @cross_origin()
889
  def get_seasons():
890
- """Returns a list of available NBA seasons."""
891
  try:
892
  seasons_list = get_available_seasons_util()
893
  return jsonify({'seasons': seasons_list})
@@ -898,7 +822,6 @@ def get_seasons():
898
  @app.route('/api/nba/player_stats', methods=['POST'])
899
  @cross_origin()
900
  def get_player_stats():
901
- """Retrieves per-game statistics for selected NBA players and seasons (regular season)."""
902
  try:
903
  data = request.get_json()
904
  selected_players = data.get('players')
@@ -911,7 +834,6 @@ def get_player_stats():
911
  players_with_no_data = []
912
 
913
  for player_name in selected_players:
914
- # Use the general helper for regular season stats (playoffs=False by default)
915
  df_player_career = get_player_career_stats_brscraper(player_name, playoffs=False)
916
  if not df_player_career.empty:
917
  filtered_df = df_player_career[df_player_career['Season'].isin(selected_seasons)].copy()
@@ -930,7 +852,6 @@ def get_player_stats():
930
 
931
  comparison_df_raw = pd.concat(all_player_season_data, ignore_index=True)
932
 
933
- # Calculate basic stats
934
  if len(selected_seasons) > 1:
935
  basic_display_df = comparison_df_raw.groupby('Player').mean(numeric_only=True).reset_index()
936
  else:
@@ -939,7 +860,6 @@ def get_player_stats():
939
  basic_cols = ['Player', 'Season', 'GP', 'MIN', 'PTS', 'REB', 'AST', 'STL', 'BLK', 'FG_PCT', 'FT_PCT', 'FG3_PCT']
940
  basic_display_df = basic_display_df[[c for c in basic_cols if c in basic_display_df.columns]].round(2)
941
 
942
- # Calculate advanced stats
943
  advanced_df = comparison_df_raw.copy()
944
  advanced_df['FGA'] = pd.to_numeric(advanced_df.get('FGA', 0), errors='coerce').fillna(0)
945
  advanced_df['FTA'] = pd.to_numeric(advanced_df.get('FTA', 0), errors='coerce').fillna(0)
@@ -968,7 +888,6 @@ def get_player_stats():
968
  @app.route('/api/nba/player_playoff_stats', methods=['POST'])
969
  @cross_origin()
970
  def get_player_playoff_stats():
971
- """Retrieves per-game playoff statistics for selected NBA players and seasons."""
972
  try:
973
  data = request.get_json()
974
  selected_players = data.get('players')
@@ -981,7 +900,6 @@ def get_player_playoff_stats():
981
  players_with_no_data = []
982
 
983
  for player_name in selected_players:
984
- # Use the general helper for playoff stats (playoffs=True)
985
  df_player_career = get_player_career_stats_brscraper(player_name, playoffs=True)
986
  if not df_player_career.empty:
987
  filtered_df = df_player_career[df_player_career['Season'].isin(selected_seasons)].copy()
@@ -1000,7 +918,6 @@ def get_player_playoff_stats():
1000
 
1001
  comparison_df_raw = pd.concat(all_player_season_data, ignore_index=True)
1002
 
1003
- # Calculate basic stats
1004
  if len(selected_seasons) > 1:
1005
  basic_display_df = comparison_df_raw.groupby('Player').mean(numeric_only=True).reset_index()
1006
  else:
@@ -1009,7 +926,6 @@ def get_player_playoff_stats():
1009
  basic_cols = ['Player', 'Season', 'GP', 'MIN', 'PTS', 'REB', 'AST', 'STL', 'BLK', 'FG_PCT', 'FT_PCT', 'FG3_PCT']
1010
  basic_display_df = basic_display_df[[c for c in basic_cols if c in basic_display_df.columns]].round(2)
1011
 
1012
- # Calculate advanced stats
1013
  advanced_df = comparison_df_raw.copy()
1014
  advanced_df['FGA'] = pd.to_numeric(advanced_df.get('FGA', 0), errors='coerce').fillna(0)
1015
  advanced_df['FTA'] = pd.to_numeric(advanced_df.get('FTA', 0), errors='coerce').fillna(0)
@@ -1035,44 +951,54 @@ def get_player_playoff_stats():
1035
  logging.error(f"Error in /api/nba/player_playoff_stats: {e}")
1036
  return jsonify({'error': str(e)}), 500
1037
 
1038
- # In your Flask script (app.py or main.py on Hugging Face)
1039
-
1040
  @app.route('/api/nba/team_stats', methods=['POST'])
1041
  @cross_origin()
1042
  def get_team_stats():
1043
- # --- ADD THIS LINE AT THE VERY TOP OF THE FUNCTION ---
1044
  logging.info("DEBUG: Request successfully entered get_team_stats function!")
1045
- # --- END ADDITION ---
1046
  try:
1047
  data = request.get_json()
1048
- selected_teams = data.get('teams')
1049
  selected_season_str = data.get('season')
1050
 
1051
- if not selected_teams or not selected_season_str:
1052
- # This would return a 400, not a 404
1053
  return jsonify({'error': 'Teams and season are required'}), 400
1054
 
1055
  year_for_team_stats = int(selected_season_str.split('–')[1])
1056
  tm_df = get_team_standings_brscraper(year_for_team_stats)
1057
 
1058
  if tm_df.empty:
1059
- # This would return a 404 with a specific message
1060
  return jsonify({'error': f'No team data available for {selected_season_str}'}), 404
1061
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1062
  stats = []
1063
  teams_with_no_data = []
1064
 
1065
- for t in selected_teams:
1066
- df = tm_df[tm_df.Team == t].copy()
1067
- if not df.empty:
1068
- df_dict = df.iloc[0].to_dict()
1069
  df_dict['Season'] = selected_season_str
1070
  stats.append(df_dict)
1071
  else:
1072
- teams_with_no_data.append(t)
 
 
1073
 
1074
  if not stats:
1075
- # This would return a 404 with a specific message
1076
  return jsonify({
1077
  'error': 'No data available for selected teams.',
1078
  'teams_with_no_data': teams_with_no_data
@@ -1093,13 +1019,9 @@ def get_team_stats():
1093
  return jsonify({'error': str(e)}), 500
1094
 
1095
  @app.route('/api/nba/dashboard_info', methods=['GET'])
1096
- @credit_required(cost=0) # No credit cost, but requires authentication
1097
  @cross_origin()
1098
  def dashboard_info():
1099
- """
1100
- Provides various dashboard-level information including MVP votings
1101
- and current playoff probabilities for East and West conferences.
1102
- """
1103
  try:
1104
  dashboard_data = get_dashboard_info_brscraper()
1105
  if not dashboard_data:
@@ -1110,12 +1032,9 @@ def dashboard_info():
1110
  return jsonify({'error': str(e)}), 500
1111
 
1112
  @app.route('/api/nba/perplexity_explain', methods=['POST'])
1113
- @credit_required(cost=1) # Costs 1 credit
1114
  @cross_origin()
1115
  def perplexity_explain():
1116
- """
1117
- Provides an AI explanation for a given prompt and stores it in the database.
1118
- """
1119
  try:
1120
  data = request.get_json()
1121
  prompt = data.get('prompt')
@@ -1127,15 +1046,13 @@ def perplexity_explain():
1127
  if "Error from AI" in explanation:
1128
  return jsonify({'error': explanation}), 500
1129
 
1130
- # Get UID from the authenticated request (credit_required decorator ensures it's available)
1131
  auth_header = request.headers.get('Authorization', '')
1132
  token = auth_header.split(' ')[1]
1133
  uid = verify_token(token)
1134
 
1135
- # Store the analysis in Firebase Realtime Database
1136
  if FIREBASE_INITIALIZED:
1137
  user_analyses_ref = db.reference(f'user_analyses/{uid}')
1138
- analysis_id = str(uuid.uuid4()) # Generate a unique ID for this analysis
1139
  analysis_data = {
1140
  'prompt': prompt,
1141
  'explanation': explanation,
@@ -1152,10 +1069,9 @@ def perplexity_explain():
1152
  return jsonify({'error': str(e)}), 500
1153
 
1154
  @app.route('/api/user/analyses', methods=['GET'])
1155
- @credit_required(cost=0) # No credit cost, but requires authentication
1156
  @cross_origin()
1157
  def get_user_analyses():
1158
- """Retrieves all stored AI analyses for the authenticated user."""
1159
  try:
1160
  auth_header = request.headers.get('Authorization', '')
1161
  token = auth_header.split(' ')[1]
@@ -1176,7 +1092,6 @@ def get_user_analyses():
1176
  'created_at': data.get('created_at')
1177
  })
1178
 
1179
- # Sort by creation date, newest first
1180
  analyses_list.sort(key=lambda x: x.get('created_at', ''), reverse=True)
1181
 
1182
  return jsonify({'analyses': analyses_list})
@@ -1185,10 +1100,9 @@ def get_user_analyses():
1185
  return jsonify({'error': str(e)}), 500
1186
 
1187
  @app.route('/api/user/analyses/<string:analysis_id>', methods=['DELETE'])
1188
- @credit_required(cost=0) # No credit cost, but requires authentication
1189
  @cross_origin()
1190
  def delete_user_analysis(analysis_id):
1191
- """Deletes a specific AI analysis for the authenticated user."""
1192
  try:
1193
  auth_header = request.headers.get('Authorization', '')
1194
  token = auth_header.split(' ')[1]
@@ -1212,10 +1126,9 @@ def delete_user_analysis(analysis_id):
1212
 
1213
 
1214
  @app.route('/api/nba/perplexity_chat', methods=['POST'])
1215
- @credit_required(cost=1) # Costs 1 credit
1216
  @cross_origin()
1217
  def perplexity_chat():
1218
- """Handles AI chat interactions and stores chat history."""
1219
  try:
1220
  data = request.get_json()
1221
  prompt = data.get('prompt')
@@ -1223,26 +1136,21 @@ def perplexity_chat():
1223
  if not prompt:
1224
  return jsonify({'error': 'Prompt is required'}), 400
1225
 
1226
- # Get UID from the authenticated request for chat history storage
1227
  auth_header = request.headers.get('Authorization', '')
1228
  token = auth_header.split(' ')[1]
1229
- uid = verify_token(token) # Already verified by decorator, but need uid here
1230
 
1231
- # Get AI response
1232
  response_content = ask_perp(prompt, system="You are an NBA expert analyst AI.")
1233
  if "Error from AI" in response_content:
1234
  return jsonify({'error': response_content}), 500
1235
 
1236
- # Store chat history in Firebase
1237
  if FIREBASE_INITIALIZED:
1238
  user_chat_ref = db.reference(f'users/{uid}/chat_history')
1239
- # Push user's message
1240
  user_chat_ref.push({
1241
  'role': 'user',
1242
  'content': prompt,
1243
  'timestamp': datetime.utcnow().isoformat()
1244
  })
1245
- # Push AI's response
1246
  user_chat_ref.push({
1247
  'role': 'assistant',
1248
  'content': response_content,
@@ -1258,10 +1166,9 @@ def perplexity_chat():
1258
  return jsonify({'error': str(e)}), 500
1259
 
1260
  @app.route('/api/nba/awards_predictor', methods=['POST'])
1261
- @credit_required(cost=1) # Costs 1 credit
1262
  @cross_origin()
1263
  def awards_predictor():
1264
- """Predicts NBA award candidates based on user criteria."""
1265
  try:
1266
  data = request.get_json()
1267
  award_type = data.get('award_type')
@@ -1281,10 +1188,9 @@ def awards_predictor():
1281
  return jsonify({'error': str(e)}), 500
1282
 
1283
  @app.route('/api/nba/young_player_projection', methods=['POST'])
1284
- @credit_required(cost=1) # Costs 1 credit
1285
  @cross_origin()
1286
  def young_player_projection():
1287
- """Projects the future potential of a young NBA player."""
1288
  try:
1289
  data = request.get_json()
1290
  player_name = data.get('player_name')
@@ -1315,10 +1221,9 @@ def young_player_projection():
1315
  return jsonify({'error': str(e)}), 500
1316
 
1317
  @app.route('/api/nba/similar_players', methods=['POST'])
1318
- @credit_required(cost=1) # Costs 1 credit
1319
  @cross_origin()
1320
  def similar_players():
1321
- """Finds similar players based on specified criteria."""
1322
  try:
1323
  data = request.get_json()
1324
  target_player = data.get('target_player')
@@ -1338,10 +1243,9 @@ def similar_players():
1338
  return jsonify({'error': str(e)}), 500
1339
 
1340
  @app.route('/api/nba/manual_player_compare', methods=['POST'])
1341
- @credit_required(cost=1) # Costs 1 credit
1342
  @cross_origin()
1343
  def manual_player_compare():
1344
- """Compares two NBA players in detail."""
1345
  try:
1346
  data = request.get_json()
1347
  player1 = data.get('player1')
@@ -1365,10 +1269,9 @@ def manual_player_compare():
1365
  return jsonify({'error': str(e)}), 500
1366
 
1367
  @app.route('/api/nba/roster_suggestions', methods=['POST'])
1368
- @credit_required(cost=1) # Costs 1 credit
1369
  @cross_origin()
1370
  def roster_suggestions():
1371
- """Generates NBA roster suggestions based on user-defined constraints."""
1372
  try:
1373
  data = request.get_json()
1374
  salary_cap = data.get('salary_cap')
@@ -1406,10 +1309,9 @@ def roster_suggestions():
1406
  return jsonify({'error': str(e)}), 500
1407
 
1408
  @app.route('/api/nba/trade_analysis', methods=['POST'])
1409
- @credit_required(cost=1) # Costs 1 credit
1410
  @cross_origin()
1411
  def trade_analysis():
1412
- """Analyzes a potential NBA trade scenario."""
1413
  try:
1414
  data = request.get_json()
1415
  team1_trades = data.get('team1_trades')
@@ -1435,6 +1337,5 @@ def trade_analysis():
1435
  return jsonify({'error': str(e)}), 500
1436
 
1437
 
1438
- # ---------- Main ----------
1439
  if __name__ == '__main__':
1440
  app.run(debug=True, host="0.0.0.0", port=7860)
 
15
  import firebase_admin
16
  import logging
17
  import traceback
18
+ from bs4 import BeautifulSoup, Comment
19
 
 
20
  try:
21
  from BRScraper import nba
22
  BRSCRAPER_AVAILABLE = True
 
24
  BRSCRAPER_AVAILABLE = False
25
  logging.error("BRScraper not found. Please install with: `pip install BRScraper`")
26
 
 
27
  app = Flask(__name__)
28
  CORS(app)
29
 
 
30
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
31
 
 
32
  Firebase_DB = os.getenv("Firebase_DB")
33
  Firebase_Storage = os.getenv("Firebase_Storage")
34
 
 
35
  FIREBASE_INITIALIZED = False
36
 
37
  try:
 
51
  logging.error(f"Error initializing Firebase: {e}")
52
  traceback.print_exc()
53
 
 
54
  bucket = storage.bucket() if FIREBASE_INITIALIZED else None
55
 
 
56
  def verify_token(token):
 
57
  try:
58
  decoded_token = auth.verify_id_token(token)
59
  return decoded_token['uid']
 
62
  return None
63
 
64
  def verify_admin(auth_header):
 
65
  if not auth_header or not auth_header.startswith('Bearer '):
66
  raise ValueError('Invalid token format')
67
  token = auth_header.split(' ')[1]
 
74
  raise PermissionError('Admin access required')
75
  return uid
76
 
 
77
  def credit_required(cost=1):
 
 
 
 
78
  def decorator(f):
79
  def wrapper(*args, **kwargs):
80
  auth_header = request.headers.get('Authorization', '')
 
98
  return jsonify({'error': f'Insufficient credits. You need {cost} credits, but have {current_credits}.'}), 403
99
 
100
  try:
 
101
  if cost > 0:
102
  user_ref.update({'credits': current_credits - cost})
103
  logging.info(f"Deducted {cost} credits from user {uid}. New balance: {current_credits - cost}")
 
105
  except Exception as e:
106
  logging.error(f"Failed to process credits for user {uid}: {e}")
107
  return jsonify({'error': 'Failed to process credits. Please try again.'}), 500
108
+ wrapper.__name__ = f.__name__
109
  return wrapper
110
  return decorator
111
 
112
 
 
 
113
  @app.route('/api/auth/signup', methods=['POST'])
114
  def signup():
 
115
  try:
116
  data = request.get_json()
117
  email = data.get('email')
 
123
  user_ref = db.reference(f'users/{user.uid}')
124
  user_data = {
125
  'email': email,
126
+ 'credits': 10,
127
  'is_admin': False,
128
  'created_at': datetime.utcnow().isoformat()
129
  }
 
141
 
142
  @app.route('/api/user/profile', methods=['GET'])
143
  def get_user_profile():
 
144
  try:
145
  auth_header = request.headers.get('Authorization', '')
146
  if not auth_header.startswith('Bearer '):
 
167
 
168
  @app.route('/api/auth/google-signin', methods=['POST'])
169
  def google_signin():
 
170
  try:
171
  auth_header = request.headers.get('Authorization', '')
172
  if not auth_header.startswith('Bearer '):
 
183
  if not user_data:
184
  user_data = {
185
  'email': email,
186
+ 'credits': 10,
187
  'is_admin': False,
188
  'created_at': datetime.utcnow().isoformat(),
189
  }
 
202
  return jsonify({'error': str(e)}), 400
203
 
204
  @app.route('/api/user/request-credits', methods=['POST'])
205
+ @credit_required(cost=0)
206
  def request_credits():
 
207
  try:
208
  auth_header = request.headers.get('Authorization', '')
209
  token = auth_header.split(' ')[1]
210
+ uid = verify_token(token)
211
 
212
  data = request.get_json()
213
  requested_credits = data.get('requested_credits')
 
227
  return jsonify({'error': str(e)}), 500
228
 
229
  @app.route('/api/user/submit_feedback', methods=['POST'])
230
+ @credit_required(cost=0)
231
  @cross_origin()
232
  def submit_feedback():
 
233
  try:
234
  auth_header = request.headers.get('Authorization', '')
235
  token = auth_header.split(' ')[1]
236
+ uid = verify_token(token)
237
 
238
  data = request.get_json()
239
+ feedback_type = data.get('type')
240
  message = data.get('message')
241
 
242
  if not feedback_type or not message:
243
  return jsonify({'error': 'Feedback type and message are required'}), 400
244
 
245
  user_data = db.reference(f'users/{uid}').get()
246
+ user_email = user_data.get('email', 'unknown_email')
247
 
248
  feedback_ref = db.reference('feedback').push()
249
  feedback_ref.set({
250
  'user_id': uid,
251
+ 'user_email': user_email,
252
  'type': feedback_type,
253
  'message': message,
254
  'created_at': datetime.utcnow().isoformat(),
255
+ 'status': 'open'
256
  })
257
  return jsonify({'success': True, 'feedback_id': feedback_ref.key}), 201
258
  except Exception as e:
259
  logging.error(f"Submit feedback error: {e}")
260
  return jsonify({'error': str(e)}), 500
261
 
 
 
262
  @app.route('/api/admin/profile', methods=['GET'])
263
  def get_admin_profile():
 
264
  try:
265
  admin_uid = verify_admin(request.headers.get('Authorization', ''))
266
  admin_data = db.reference(f'users/{admin_uid}').get()
 
276
  total_current_credits = sum(user.get('credits', 0) for user in all_users_data.values())
277
  total_normal_current_credits = sum(user.get('credits', 0) for user in normal_users_data)
278
 
 
279
  total_initial_credits = total_normal_users * 10
280
  credit_usage = total_initial_credits - total_normal_current_credits
281
 
 
299
 
300
  @app.route('/api/admin/credit_requests', methods=['GET'])
301
  def list_credit_requests():
 
302
  try:
303
  verify_admin(request.headers.get('Authorization', ''))
304
  requests_ref = db.reference('credit_requests')
 
311
 
312
  @app.route('/api/admin/credit_requests/<string:request_id>', methods=['PUT'])
313
  def process_credit_request(request_id):
 
314
  try:
315
  admin_uid = verify_admin(request.headers.get('Authorization', ''))
316
  req_ref = db.reference(f'credit_requests/{request_id}')
 
349
 
350
  @app.route('/api/admin/users', methods=['GET'])
351
  def admin_list_users():
 
352
  try:
353
  verify_admin(request.headers.get('Authorization', ''))
354
  users_ref = db.reference('users')
 
371
 
372
  @app.route('/api/admin/users/search', methods=['GET'])
373
  def admin_search_users():
 
374
  try:
375
  verify_admin(request.headers.get('Authorization', ''))
376
  email_query = request.args.get('email', '').lower().strip()
 
399
 
400
  @app.route('/api/admin/users/<string:uid>/suspend', methods=['PUT'])
401
  def admin_suspend_user(uid):
 
402
  try:
403
  verify_admin(request.headers.get('Authorization', ''))
404
  data = request.get_json()
 
423
 
424
  @app.route('/api/admin/notifications', methods=['POST'])
425
  def send_notifications():
 
426
  try:
427
  admin_uid = verify_admin(request.headers.get('Authorization', ''))
428
  data = request.get_json()
 
470
 
471
  @app.route('/api/admin/feedback', methods=['GET'])
472
  def admin_view_feedback():
 
473
  try:
474
  admin_uid = verify_admin(request.headers.get('Authorization', ''))
475
  feedback_type = request.args.get('type')
 
501
 
502
  @app.route('/api/admin/users/<string:uid>/credits', methods=['PUT'])
503
  def admin_update_credits(uid):
 
504
  try:
505
  verify_admin(request.headers.get('Authorization', ''))
506
  data = request.get_json()
 
525
  # NBA Analytics Hub Data Fetching Utilities
526
  # ——————————————————————————————————————————————
527
 
 
528
  def clean_firebase_keys(key_name):
 
529
  if not isinstance(key_name, str):
530
  key_name = str(key_name)
 
 
531
  cleaned_key = key_name.replace('.', '_').replace('$', '').replace('#', '').replace('[', '').replace(']', '').replace('/', '_')
532
+ cleaned_key = cleaned_key.replace('%', 'Pct')
 
 
533
  if not cleaned_key:
534
+ return "empty_key_" + str(uuid.uuid4())[:8]
535
  return cleaned_key
536
 
537
  def clean_df_for_firebase(df):
 
538
  if df.empty:
539
  return df
540
  df.columns = [clean_firebase_keys(col) for col in df.columns]
541
  return df
542
 
543
  def clean_team_name(team_name):
 
 
 
544
  if pd.isna(team_name):
545
  return team_name
546
+ team_name = str(team_name).strip()
547
+ team_name = re.sub(r'\s*\(\d+\)$', '', team_name) # Remove playoff seeding suffix
548
+ team_name = team_name.replace('*', '') # Remove asterisks
549
+ return team_name
 
550
 
551
  def is_data_stale(timestamp_str, max_age_hours=24):
 
552
  if not timestamp_str:
553
+ return True
554
  try:
555
  last_updated = datetime.fromisoformat(timestamp_str)
556
  return (datetime.utcnow() - last_updated) > timedelta(hours=max_age_hours)
557
  except ValueError:
558
  logging.error(f"Invalid timestamp format: {timestamp_str}")
559
+ return True
560
 
561
  def get_team_standings_brscraper(year):
 
 
 
 
 
562
  if not FIREBASE_INITIALIZED:
563
  logging.warning("Firebase not initialized. Cannot use caching for team standings. Scraping directly.")
564
  return _scrape_team_standings_brscraper(year)
 
566
  db_ref = db.reference(f'scraped_data/team_standings/{year}')
567
  cached_data = db_ref.get()
568
 
 
569
  if cached_data and not is_data_stale(cached_data.get('last_updated'), max_age_hours=12):
570
  logging.info(f"Loading team standings for {year} from Firebase cache.")
571
  return pd.DataFrame.from_records(cached_data['data'])
 
581
  return df
582
 
583
  def _scrape_team_standings_brscraper(year):
 
584
  if not BRSCRAPER_AVAILABLE:
585
  logging.error("BRScraper not available for team standings.")
586
  return pd.DataFrame()
587
  try:
588
+ df = nba.get_standings(year, info='total') # Removed rename=False
589
  if df.empty:
590
  logging.warning(f"Could not find team standings for {year} using BRScraper.")
591
  return pd.DataFrame()
592
 
 
593
  column_mapping = {
594
  'Tm': 'Team', 'W': 'WINS', 'L': 'LOSSES', 'W/L%': 'WIN_LOSS_PCT',
595
+ 'Rk': 'RANK'
596
  }
597
  df = df.rename(columns={old_col: new_col for old_col, new_col in column_mapping.items() if old_col in df.columns})
598
 
 
599
  if 'Team' in df.columns:
600
+ df['Team'] = df['Team'].astype(str) # Ensure string type
601
  df['Team'] = df['Team'].apply(clean_team_name)
602
 
 
603
  numeric_cols = [col for col in df.columns if col not in ['Team']]
604
  for col in numeric_cols:
605
  df[col] = pd.to_numeric(df[col], errors="coerce")
606
 
 
607
  df = df.replace({np.nan: None})
608
  return df
609
  except Exception as e:
 
614
  current_year = datetime.now().year
615
  current_month = datetime.now().month
616
  latest_season_end_year = current_year
617
+ if current_month >= 7:
618
  latest_season_end_year += 1
619
  seasons_list = []
620
  for i in range(num_seasons):
 
649
  return df
650
 
651
  def _scrape_player_index_brscraper():
 
652
  try:
653
  latest_season_end_year = int(get_available_seasons_util(1)[0].split('–')[1])
654
  df = nba.get_stats(latest_season_end_year, info='per_game', rename=False)
 
668
  return pd.DataFrame({'name': common_players})
669
 
670
  def get_player_career_stats_brscraper(player_name, seasons_to_check=10, playoffs=False):
 
 
 
671
  if not BRSCRAPER_AVAILABLE:
672
  return pd.DataFrame()
673
  all_rows = []
 
693
  'G':'GP','GS':'GS','MP':'MIN', 'FG%':'FG_PCT','3P%':'FG3_PCT','FT%':'FT_PCT',
694
  'TRB':'REB','AST':'AST','STL':'STL','BLK':'BLK','TOV':'TO',
695
  'PF':'PF','PTS':'PTS','ORB':'OREB','DRB':'DREB',
696
+ 'FG':'FGM','FGA':'FGA','3P':'FG3M','3PA':'FG3A',
697
  '2P':'FGM2','2PA':'FGA2','2P%':'FG2_PCT','eFG%':'EFG_PCT',
698
  'FT':'FTM','FTA':'FTA'
699
  }
 
705
  df[col] = pd.to_numeric(df[col], errors='coerce')
706
 
707
  df['Player'] = player_name
708
+ df = df.replace({np.nan: None})
709
  return df
710
 
711
  def get_dashboard_info_brscraper():
 
 
 
 
712
  if not BRSCRAPER_AVAILABLE:
713
  logging.error("BRScraper not available for dashboard info.")
714
  return {}
 
720
  db_ref = db.reference('scraped_data/dashboard_info')
721
  cached_data = db_ref.get()
722
 
 
723
  if cached_data and not is_data_stale(cached_data.get('last_updated'), max_age_hours=24):
724
  logging.info("Loading dashboard info from Firebase cache.")
725
  return cached_data['data']
 
735
  return data
736
 
737
  def _scrape_dashboard_info_brscraper():
 
738
  dashboard_data = {}
739
  try:
 
740
  mvp_2025_df = nba.get_award_votings('mvp', 2025)
741
  if not mvp_2025_df.empty:
742
+ mvp_2025_df = clean_df_for_firebase(mvp_2025_df)
743
  dashboard_data['mvp_2025_votings'] = mvp_2025_df.replace({np.nan: None}).to_dict(orient='records')
744
  else:
745
  dashboard_data['mvp_2025_votings'] = []
746
  logging.warning("Could not retrieve 2025 MVP votings.")
747
 
 
748
  east_probs_df = nba.get_playoffs_probs('east')
749
  if not east_probs_df.empty:
750
+ east_probs_df = clean_df_for_firebase(east_probs_df)
751
  dashboard_data['playoff_probs_east'] = east_probs_df.replace({np.nan: None}).to_dict(orient='records')
752
  else:
753
  dashboard_data['playoff_probs_east'] = []
754
  logging.warning("Could not retrieve Eastern Conference playoff probabilities.")
755
 
 
756
  west_probs_df = nba.get_playoffs_probs('west')
757
  if not west_probs_df.empty:
758
+ west_probs_df = clean_df_for_firebase(west_probs_df)
759
  dashboard_data['playoff_probs_west'] = west_probs_df.replace({np.nan: None}).to_dict(orient='records')
760
  else:
761
  dashboard_data['playoff_probs_west'] = []
 
763
 
764
  except Exception as e:
765
  logging.error(f"Error scraping dashboard info with BRScraper: {e}")
 
766
  return dashboard_data
767
 
 
768
  PERP_KEY = os.getenv("PERPLEXITY_API_KEY")
769
  PERP_URL = "https://api.perplexity.ai/chat/completions"
770
 
771
  def ask_perp(prompt, system="You are a helpful NBA analyst AI.", max_tokens=500, temp=0.2):
 
772
  if not PERP_KEY:
773
  logging.error("PERPLEXITY_API_KEY env var not set.")
774
  return "Perplexity API key is not configured."
775
  hdr = {'Authorization':f'Bearer {PERP_KEY}','Content-Type':'application/json'}
776
  payload = {
777
+ "model":"sonar-pro",
778
  "messages":[{"role":"system","content":system},{"role":"user","content":prompt}],
779
  "max_tokens":max_tokens, "temperature":temp
780
  }
 
797
  return f"An unexpected error occurred with AI: {str(e)}"
798
 
799
 
 
 
800
  @app.route('/api/nba/players', methods=['GET'])
801
  @cross_origin()
802
  def get_players():
 
803
  try:
804
  players_df = get_player_index_brscraper()
805
  if players_df.empty:
 
812
  @app.route('/api/nba/seasons', methods=['GET'])
813
  @cross_origin()
814
  def get_seasons():
 
815
  try:
816
  seasons_list = get_available_seasons_util()
817
  return jsonify({'seasons': seasons_list})
 
822
  @app.route('/api/nba/player_stats', methods=['POST'])
823
  @cross_origin()
824
  def get_player_stats():
 
825
  try:
826
  data = request.get_json()
827
  selected_players = data.get('players')
 
834
  players_with_no_data = []
835
 
836
  for player_name in selected_players:
 
837
  df_player_career = get_player_career_stats_brscraper(player_name, playoffs=False)
838
  if not df_player_career.empty:
839
  filtered_df = df_player_career[df_player_career['Season'].isin(selected_seasons)].copy()
 
852
 
853
  comparison_df_raw = pd.concat(all_player_season_data, ignore_index=True)
854
 
 
855
  if len(selected_seasons) > 1:
856
  basic_display_df = comparison_df_raw.groupby('Player').mean(numeric_only=True).reset_index()
857
  else:
 
860
  basic_cols = ['Player', 'Season', 'GP', 'MIN', 'PTS', 'REB', 'AST', 'STL', 'BLK', 'FG_PCT', 'FT_PCT', 'FG3_PCT']
861
  basic_display_df = basic_display_df[[c for c in basic_cols if c in basic_display_df.columns]].round(2)
862
 
 
863
  advanced_df = comparison_df_raw.copy()
864
  advanced_df['FGA'] = pd.to_numeric(advanced_df.get('FGA', 0), errors='coerce').fillna(0)
865
  advanced_df['FTA'] = pd.to_numeric(advanced_df.get('FTA', 0), errors='coerce').fillna(0)
 
888
  @app.route('/api/nba/player_playoff_stats', methods=['POST'])
889
  @cross_origin()
890
  def get_player_playoff_stats():
 
891
  try:
892
  data = request.get_json()
893
  selected_players = data.get('players')
 
900
  players_with_no_data = []
901
 
902
  for player_name in selected_players:
 
903
  df_player_career = get_player_career_stats_brscraper(player_name, playoffs=True)
904
  if not df_player_career.empty:
905
  filtered_df = df_player_career[df_player_career['Season'].isin(selected_seasons)].copy()
 
918
 
919
  comparison_df_raw = pd.concat(all_player_season_data, ignore_index=True)
920
 
 
921
  if len(selected_seasons) > 1:
922
  basic_display_df = comparison_df_raw.groupby('Player').mean(numeric_only=True).reset_index()
923
  else:
 
926
  basic_cols = ['Player', 'Season', 'GP', 'MIN', 'PTS', 'REB', 'AST', 'STL', 'BLK', 'FG_PCT', 'FT_PCT', 'FG3_PCT']
927
  basic_display_df = basic_display_df[[c for c in basic_cols if c in basic_display_df.columns]].round(2)
928
 
 
929
  advanced_df = comparison_df_raw.copy()
930
  advanced_df['FGA'] = pd.to_numeric(advanced_df.get('FGA', 0), errors='coerce').fillna(0)
931
  advanced_df['FTA'] = pd.to_numeric(advanced_df.get('FTA', 0), errors='coerce').fillna(0)
 
951
  logging.error(f"Error in /api/nba/player_playoff_stats: {e}")
952
  return jsonify({'error': str(e)}), 500
953
 
 
 
954
  @app.route('/api/nba/team_stats', methods=['POST'])
955
  @cross_origin()
956
  def get_team_stats():
 
957
  logging.info("DEBUG: Request successfully entered get_team_stats function!")
 
958
  try:
959
  data = request.get_json()
960
+ selected_teams_abbrs = data.get('teams')
961
  selected_season_str = data.get('season')
962
 
963
+ if not selected_teams_abbrs or not selected_season_str:
 
964
  return jsonify({'error': 'Teams and season are required'}), 400
965
 
966
  year_for_team_stats = int(selected_season_str.split('–')[1])
967
  tm_df = get_team_standings_brscraper(year_for_team_stats)
968
 
969
  if tm_df.empty:
 
970
  return jsonify({'error': f'No team data available for {selected_season_str}'}), 404
971
 
972
+ # Map input abbreviations to full names for lookup
973
+ full_team_names_map = {
974
+ "ATL": "Atlanta Hawks", "BOS": "Boston Celtics", "BRK": "Brooklyn Nets",
975
+ "CHO": "Charlotte Hornets", "CHI": "Chicago Bulls", "CLE": "Cleveland Cavaliers",
976
+ "DAL": "Dallas Mavericks", "DEN": "Denver Nuggets", "DET": "Detroit Pistons",
977
+ "GSW": "Golden State Warriors", "HOU": "Houston Rockets", "IND": "Indiana Pacers",
978
+ "LAC": "Los Angeles Clippers", "LAL": "Los Angeles Lakers", "MEM": "Memphis Grizzlies",
979
+ "MIA": "Miami Heat", "MIL": "Milwaukee Bucks", "MIN": "Minnesota Timberwolves",
980
+ "NOP": "New Orleans Pelicans", "NYK": "New York Knicks", "OKC": "Oklahoma City Thunder",
981
+ "ORL": "Orlando Magic", "PHI": "Philadelphia 76ers", "PHX": "Phoenix Suns",
982
+ "POR": "Portland Trail Blazers", "SAC": "Sacramento Kings", "SAS": "San Antonio Spurs",
983
+ "TOR": "Toronto Raptors", "UTA": "Utah Jazz", "WAS": "Washington Wizards"
984
+ }
985
+ selected_teams_full_names = [full_team_names_map.get(abbr, abbr) for abbr in selected_teams_abbrs]
986
+
987
  stats = []
988
  teams_with_no_data = []
989
 
990
+ for team_full_name_lookup in selected_teams_full_names:
991
+ df_row = tm_df[tm_df.Team == team_full_name_lookup].copy()
992
+ if not df_row.empty:
993
+ df_dict = df_row.iloc[0].to_dict()
994
  df_dict['Season'] = selected_season_str
995
  stats.append(df_dict)
996
  else:
997
+ # Add original abbreviation to not_found list
998
+ original_abbr = next((abbr for abbr, name in full_team_names_map.items() if name == team_full_name_lookup), team_full_name_lookup)
999
+ teams_with_no_data.append(original_abbr)
1000
 
1001
  if not stats:
 
1002
  return jsonify({
1003
  'error': 'No data available for selected teams.',
1004
  'teams_with_no_data': teams_with_no_data
 
1019
  return jsonify({'error': str(e)}), 500
1020
 
1021
  @app.route('/api/nba/dashboard_info', methods=['GET'])
1022
+ @credit_required(cost=0)
1023
  @cross_origin()
1024
  def dashboard_info():
 
 
 
 
1025
  try:
1026
  dashboard_data = get_dashboard_info_brscraper()
1027
  if not dashboard_data:
 
1032
  return jsonify({'error': str(e)}), 500
1033
 
1034
  @app.route('/api/nba/perplexity_explain', methods=['POST'])
1035
+ @credit_required(cost=1)
1036
  @cross_origin()
1037
  def perplexity_explain():
 
 
 
1038
  try:
1039
  data = request.get_json()
1040
  prompt = data.get('prompt')
 
1046
  if "Error from AI" in explanation:
1047
  return jsonify({'error': explanation}), 500
1048
 
 
1049
  auth_header = request.headers.get('Authorization', '')
1050
  token = auth_header.split(' ')[1]
1051
  uid = verify_token(token)
1052
 
 
1053
  if FIREBASE_INITIALIZED:
1054
  user_analyses_ref = db.reference(f'user_analyses/{uid}')
1055
+ analysis_id = str(uuid.uuid4())
1056
  analysis_data = {
1057
  'prompt': prompt,
1058
  'explanation': explanation,
 
1069
  return jsonify({'error': str(e)}), 500
1070
 
1071
  @app.route('/api/user/analyses', methods=['GET'])
1072
+ @credit_required(cost=0)
1073
  @cross_origin()
1074
  def get_user_analyses():
 
1075
  try:
1076
  auth_header = request.headers.get('Authorization', '')
1077
  token = auth_header.split(' ')[1]
 
1092
  'created_at': data.get('created_at')
1093
  })
1094
 
 
1095
  analyses_list.sort(key=lambda x: x.get('created_at', ''), reverse=True)
1096
 
1097
  return jsonify({'analyses': analyses_list})
 
1100
  return jsonify({'error': str(e)}), 500
1101
 
1102
  @app.route('/api/user/analyses/<string:analysis_id>', methods=['DELETE'])
1103
+ @credit_required(cost=0)
1104
  @cross_origin()
1105
  def delete_user_analysis(analysis_id):
 
1106
  try:
1107
  auth_header = request.headers.get('Authorization', '')
1108
  token = auth_header.split(' ')[1]
 
1126
 
1127
 
1128
  @app.route('/api/nba/perplexity_chat', methods=['POST'])
1129
+ @credit_required(cost=1)
1130
  @cross_origin()
1131
  def perplexity_chat():
 
1132
  try:
1133
  data = request.get_json()
1134
  prompt = data.get('prompt')
 
1136
  if not prompt:
1137
  return jsonify({'error': 'Prompt is required'}), 400
1138
 
 
1139
  auth_header = request.headers.get('Authorization', '')
1140
  token = auth_header.split(' ')[1]
1141
+ uid = verify_token(token)
1142
 
 
1143
  response_content = ask_perp(prompt, system="You are an NBA expert analyst AI.")
1144
  if "Error from AI" in response_content:
1145
  return jsonify({'error': response_content}), 500
1146
 
 
1147
  if FIREBASE_INITIALIZED:
1148
  user_chat_ref = db.reference(f'users/{uid}/chat_history')
 
1149
  user_chat_ref.push({
1150
  'role': 'user',
1151
  'content': prompt,
1152
  'timestamp': datetime.utcnow().isoformat()
1153
  })
 
1154
  user_chat_ref.push({
1155
  'role': 'assistant',
1156
  'content': response_content,
 
1166
  return jsonify({'error': str(e)}), 500
1167
 
1168
  @app.route('/api/nba/awards_predictor', methods=['POST'])
1169
+ @credit_required(cost=1)
1170
  @cross_origin()
1171
  def awards_predictor():
 
1172
  try:
1173
  data = request.get_json()
1174
  award_type = data.get('award_type')
 
1188
  return jsonify({'error': str(e)}), 500
1189
 
1190
  @app.route('/api/nba/young_player_projection', methods=['POST'])
1191
+ @credit_required(cost=1)
1192
  @cross_origin()
1193
  def young_player_projection():
 
1194
  try:
1195
  data = request.get_json()
1196
  player_name = data.get('player_name')
 
1221
  return jsonify({'error': str(e)}), 500
1222
 
1223
  @app.route('/api/nba/similar_players', methods=['POST'])
1224
+ @credit_required(cost=1)
1225
  @cross_origin()
1226
  def similar_players():
 
1227
  try:
1228
  data = request.get_json()
1229
  target_player = data.get('target_player')
 
1243
  return jsonify({'error': str(e)}), 500
1244
 
1245
  @app.route('/api/nba/manual_player_compare', methods=['POST'])
1246
+ @credit_required(cost=1)
1247
  @cross_origin()
1248
  def manual_player_compare():
 
1249
  try:
1250
  data = request.get_json()
1251
  player1 = data.get('player1')
 
1269
  return jsonify({'error': str(e)}), 500
1270
 
1271
  @app.route('/api/nba/roster_suggestions', methods=['POST'])
1272
+ @credit_required(cost=1)
1273
  @cross_origin()
1274
  def roster_suggestions():
 
1275
  try:
1276
  data = request.get_json()
1277
  salary_cap = data.get('salary_cap')
 
1309
  return jsonify({'error': str(e)}), 500
1310
 
1311
  @app.route('/api/nba/trade_analysis', methods=['POST'])
1312
+ @credit_required(cost=1)
1313
  @cross_origin()
1314
  def trade_analysis():
 
1315
  try:
1316
  data = request.get_json()
1317
  team1_trades = data.get('team1_trades')
 
1337
  return jsonify({'error': str(e)}), 500
1338
 
1339
 
 
1340
  if __name__ == '__main__':
1341
  app.run(debug=True, host="0.0.0.0", port=7860)