diff --git "a/main.py" "b/main.py" --- "a/main.py" +++ "b/main.py" @@ -1,1595 +1,1222 @@ -# -*- coding: utf-8 -*- -from flask import Flask, request, jsonify, g as flask_g # flask_g for storing user context -from flask_cors import CORS -import json import os -import graphviz -from datetime import datetime +import io +import uuid import re -import uuid # For generating unique IDs -import random # For quiz options shuffling -import google.generativeai as genai # Gemini API -from collections import defaultdict # For timeline and sibling grouping +import time +import tempfile +import requests +import json +import pandas as pd +import numpy as np +from datetime import datetime, timedelta # Import timedelta for daily refresh logic +from flask import Flask, request, jsonify, send_file +from flask_cors import CORS, cross_origin +from firebase_admin import credentials, db, storage, auth import firebase_admin -from firebase_admin import credentials, db, auth as firebase_auth # Added firebase_auth -from copy import deepcopy # For comparing data states -from dictdiffer import diff # Using dictdiffer for easier comparison -from functools import wraps # For authentication decorator +import logging +import traceback + +# Import BRScraper +try: + from BRScraper import nba + BRSCRAPER_AVAILABLE = True +except ImportError: + BRSCRAPER_AVAILABLE = False + logging.error("BRScraper not found. Please install with: `pip install BRScraper`") -# --- Flask Setup --- +# Initialize Flask app and CORS app = Flask(__name__) -CORS(app) # Enable CORS for all routes - -# --- Configuration & Constants --- -DEFAULT_PROFILE = { - "profile": {"name": "", "dob": "", "gender": "Unknown", "phone": ""}, - "family_members": [], - "relationships": [], - "settings": {"theme": "Default", "privacy": "Private"}, - "metadata": {"owner_phone": "", "tree_name": "My Family Tree", "created_at": ""} -} -DEFAULT_MEMBER_STRUCTURE = { - "id": "", "name": "", "dob": "", "dod": "", "gender": "Unknown", - "phone": "", "stories": [], "totem": "", - "created_at": "", "created_by": "", - "last_edited_at": "", "last_edited_by": "" -} -DEFAULT_STORY_STRUCTURE = { - "timestamp": "", "text": "", "added_by": "" -} -GEMINI_MODEL_NAME = "gemini-2.0-flash" - -# --- Environment Variable Loading --- -Firebase_DB_URL = os.environ.get("Firebase_DB") -Firebase_Credentials_JSON_Str = os.environ.get("FIREBASE") -GOOGLE_API_KEY = os.environ.get("Gemini") - -# --- Firebase Initialization --- -firebase_app = None -firebase_db_ref = None -firebase_error = None +CORS(app) -try: - if Firebase_Credentials_JSON_Str and Firebase_DB_URL: - credentials_json_parsed = json.loads(Firebase_Credentials_JSON_Str) - if not firebase_admin._apps: - cred = credentials.Certificate(credentials_json_parsed) - firebase_app = firebase_admin.initialize_app(cred, {'databaseURL': Firebase_DB_URL}) - firebase_db_ref = db.reference('/') - app.logger.info("Firebase Admin SDK initialized successfully.") - else: - firebase_app = firebase_admin.get_app() - firebase_db_ref = db.reference('/') - app.logger.info("Firebase Admin SDK already initialized.") - else: - firebase_error = "Firebase secrets (Firebase_DB, FIREBASE) missing in environment variables." - app.logger.error(firebase_error) -except Exception as e: - firebase_error = f"Error initializing Firebase: {e}" - app.logger.error(firebase_error) +# Configure logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') + +# Firebase initialization +Firebase_DB = os.getenv("Firebase_DB") +Firebase_Storage = os.getenv("Firebase_Storage") + +# Global flag for Firebase initialization status +FIREBASE_INITIALIZED = False -# --- Gemini API Client Initialization --- -genai_client = None -api_key_error = False try: - if not GOOGLE_API_KEY: - api_key_error = True - app.logger.error("GOOGLE_API_KEY missing in environment variables.") + credentials_json_string = os.environ.get("FIREBASE") + if credentials_json_string: + credentials_json = json.loads(credentials_json_string) + cred = credentials.Certificate(credentials_json) + firebase_admin.initialize_app(cred, { + 'databaseURL': f'{Firebase_DB}', + 'storageBucket': f'{Firebase_Storage}' + }) + FIREBASE_INITIALIZED = True + logging.info("Firebase Admin SDK initialized successfully.") else: - genai.configure(api_key=GOOGLE_API_KEY) - genai_client = genai - app.logger.info("Gemini API Client initialized successfully.") + logging.warning("FIREBASE secret not set. Firebase Admin SDK not initialized.") except Exception as e: - api_key_error = True - app.logger.error(f"Error initializing Gemini API Client: {e}") - -# --- Authentication Decorator --- -def token_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if firebase_error: # Check if Firebase Admin SDK initialized properly - return jsonify({"error": "Authentication service not available", "detail": "Firebase Admin SDK not initialized."}), 503 - - auth_header = request.headers.get('Authorization') - if not auth_header or not auth_header.startswith('Bearer '): - return jsonify({"error": "Authorization token is missing or invalid"}), 401 - - id_token = auth_header.split('Bearer ')[1] - try: - decoded_token = firebase_auth.verify_id_token(id_token) - flask_g.user = { - "uid": decoded_token.get('uid'), - "phone_number": decoded_token.get('phone_number') # This is crucial - } - if not flask_g.user["phone_number"]: - app.logger.error(f"Token for UID {flask_g.user['uid']} does not contain a phone number.") - return jsonify({"error": "Authenticated user has no phone number associated."}), 403 - - # Normalize the phone number from the token immediately - flask_g.user["phone_number"] = normalize_phone(flask_g.user["phone_number"]) - if not flask_g.user["phone_number"]: - app.logger.error(f"Failed to normalize phone number from token for UID {flask_g.user['uid']}.") - return jsonify({"error": "Invalid phone number format in token."}), 403 - - except firebase_auth.ExpiredIdTokenError: - return jsonify({"error": "Token has expired"}), 401 - except firebase_auth.InvalidIdTokenError: - return jsonify({"error": "Token is invalid"}), 401 - except Exception as e: - app.logger.error(f"Error during token verification: {e}") - return jsonify({"error": "Could not verify token", "detail": str(e)}), 500 - - return f(*args, **kwargs) - return decorated_function - - -# --- Helper Functions (Adapted for API context) --- -def normalize_phone(phone): - if not phone: return None - phone_str = str(phone).strip() - if not re.match(r"^\+\d{5,}$", phone_str): # Basic E.164-like check - # Allow numbers that might come from Firebase Auth without '+' if they are otherwise valid - if re.match(r"^\d{10,}$", phone_str.replace("+", "")): # If it's digits only and long enough - digits_only = "".join(filter(str.isdigit, phone_str)) - return f"+{digits_only}" # Attempt to normalize by adding '+' - return None - digits = "".join(filter(str.isdigit, phone_str)) - return f"+{digits}" - -def get_user_db_path(owner_phone): - normalized = normalize_phone(owner_phone) - if not normalized: return None - return f'users/{normalized}' - -def get_phone_index_path(member_phone): - normalized = normalize_phone(member_phone) - if not normalized: return None - return f'phone_index/{normalized}' - -def get_pending_changes_path(owner_phone, proposer_phone=None): - norm_owner = normalize_phone(owner_phone) - if not norm_owner: return None - base_path = f'pending_changes/{norm_owner}' - if proposer_phone: - norm_proposer = normalize_phone(proposer_phone) - if not norm_proposer: return None - return f'{base_path}/{norm_proposer}' - return base_path - -# --- Data Loading (Adapted) --- -def load_tree_data(owner_phone_to_load): # Renamed param to avoid conflict with flask_g.user.phone_number - if firebase_error or not firebase_db_ref: - return None, firebase_error or "Firebase not initialized" - - norm_owner_phone_to_load = normalize_phone(owner_phone_to_load) - if not norm_owner_phone_to_load: - return None, "Invalid owner phone format for loading data." + logging.error(f"Error initializing Firebase: {e}") + traceback.print_exc() - user_path = get_user_db_path(norm_owner_phone_to_load) - if not user_path: - return None, "Invalid owner phone format for loading data (path generation failed)." +# Only initialize bucket if Firebase is initialized +bucket = storage.bucket() if FIREBASE_INITIALIZED else None + +# Helper functions +def verify_token(token): try: - data = firebase_db_ref.child(user_path).get() - if data and isinstance(data, dict): - merged_data = deepcopy(DEFAULT_PROFILE) - if "metadata" not in data or not isinstance(data["metadata"], dict): data["metadata"] = {} - merged_data.update(data) - merged_data["settings"] = {**DEFAULT_PROFILE["settings"], **data.get("settings", {})} - merged_data["metadata"] = {**DEFAULT_PROFILE["metadata"], **data.get("metadata", {})} - merged_data["metadata"]["owner_phone"] = norm_owner_phone_to_load - - updated_members = [] - for member_data in merged_data.get("family_members", []): - if isinstance(member_data, dict): - complete_member = deepcopy(DEFAULT_MEMBER_STRUCTURE) - member_data.pop('photo_path', None) - complete_member.update(member_data) - updated_members.append(complete_member) - merged_data["family_members"] = updated_members - - if not isinstance(merged_data.get("relationships"), list): merged_data["relationships"] = [] - - for member in merged_data["family_members"]: - if "stories" not in member or not isinstance(member["stories"], list): member["stories"] = [] - cleaned_stories = [] - for story in member.get("stories", []): - if isinstance(story, dict): - complete_story = deepcopy(DEFAULT_STORY_STRUCTURE) - complete_story.update(story) - cleaned_stories.append(complete_story) - member["stories"] = cleaned_stories - return merged_data, None - elif data is None: # New tree - app.logger.info(f"No existing tree data for {norm_owner_phone_to_load}. Initializing new tree.") - new_tree = deepcopy(DEFAULT_PROFILE) - new_tree["metadata"]["owner_phone"] = norm_owner_phone_to_load - new_tree["metadata"]["created_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - # The 'Me' node represents the owner of this tree. - # Its phone number is the owner's phone number. - owner_me_node = deepcopy(DEFAULT_MEMBER_STRUCTURE) - owner_me_node.update({ - "id": "Me", "name": "Me", # User can update name later via profile - "phone": norm_owner_phone_to_load, # This is key - "created_at": new_tree["metadata"]["created_at"], - "created_by": norm_owner_phone_to_load # Owner created their own 'Me' node - }) - new_tree["family_members"].append(owner_me_node) - new_tree["profile"]["phone"] = norm_owner_phone_to_load # Profile phone also set - - # Save this new tree structure immediately - initial_save_success, initial_save_error = save_tree_data(norm_owner_phone_to_load, new_tree, DEFAULT_PROFILE) # Pass owner's phone - if not initial_save_success: - app.logger.error(f"Failed to save initial tree for {norm_owner_phone_to_load}: {initial_save_error}") - return None, f"Failed to initialize and save new tree: {initial_save_error}" - app.logger.info(f"Successfully initialized and saved new tree for {norm_owner_phone_to_load}.") - return new_tree, None - else: - return None, f"Invalid data format found for tree owner {norm_owner_phone_to_load}." + decoded_token = auth.verify_id_token(token) + return decoded_token['uid'] except Exception as e: - app.logger.error(f"Error loading tree data for {norm_owner_phone_to_load}: {e}") - return None, f"Error loading tree data: {e}" + logging.error(f"Token verification failed: {e}") + return None + +def verify_admin(auth_header): + if not auth_header or not auth_header.startswith('Bearer '): + raise ValueError('Invalid token format') + token = auth_header.split(' ')[1] + uid = verify_token(token) + if not uid: + raise PermissionError('Invalid user token') + user_ref = db.reference(f'users/{uid}') + user_data = user_ref.get() + if not user_data or not user_data.get('is_admin', False): + raise PermissionError('Admin access required') + return uid + +# Decorator for credit deduction +def credit_required(cost=1): + def decorator(f): + def wrapper(*args, **kwargs): + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return jsonify({'error': 'Authorization header missing or malformed'}), 401 + token = auth_header.split(' ')[1] + uid = verify_token(token) + if not uid: + return jsonify({'error': 'Invalid or expired token'}), 401 + + user_ref = db.reference(f'users/{uid}') + user_data = user_ref.get() + if not user_data: + return jsonify({'error': 'User not found'}), 404 + + if user_data.get('suspended', False): + return jsonify({'error': 'Account suspended. Please contact support.'}), 403 -def find_linked_trees(member_phone): - if firebase_error or not firebase_db_ref: return {}, firebase_error or "Firebase not initialized" - index_path = get_phone_index_path(member_phone) - if not index_path: return {}, "Invalid member phone for index query." + current_credits = user_data.get('credits', 0) + if current_credits < cost: + return jsonify({'error': f'Insufficient credits. You need {cost} credits, but have {current_credits}.'}), 403 + + try: + # Deduct credits + user_ref.update({'credits': current_credits - cost}) + logging.info(f"Deducted {cost} credits from user {uid}. New balance: {current_credits - cost}") + return f(*args, **kwargs) + except Exception as e: + logging.error(f"Failed to deduct credits for user {uid}: {e}") + return jsonify({'error': 'Failed to process credits. Please try again.'}), 500 + wrapper.__name__ = f.__name__ # Preserve original function name for Flask routing + return wrapper + return decorator + + +# ---------- Authentication Endpoints ---------- + +@app.route('/api/auth/signup', methods=['POST']) +def signup(): try: - owner_phones_dict = firebase_db_ref.child(index_path).child("trees").get() - return owner_phones_dict if owner_phones_dict and isinstance(owner_phones_dict, dict) else {}, None + data = request.get_json() + email = data.get('email') + password = data.get('password') + if not email or not password: + return jsonify({'error': 'Email and password are required'}), 400 + + user = auth.create_user(email=email, password=password) + user_ref = db.reference(f'users/{user.uid}') + user_data = { + 'email': email, + 'credits': 10, # Changed initial credits to 10 + 'is_admin': False, + 'created_at': datetime.utcnow().isoformat() + } + user_ref.set(user_data) + return jsonify({ + 'success': True, + 'user': { + 'uid': user.uid, + **user_data + } + }), 201 except Exception as e: - app.logger.error(f"Error querying phone index for {member_phone}: {e}") - return {}, f"Error querying phone index: {e}" - -# --- Index Update Logic (Adapted) --- -def update_phone_index(owner_phone_of_tree, previous_members, current_members): # Renamed param - if firebase_error or not firebase_db_ref: - return False, firebase_error or "Firebase not initialized" - - norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) - if not norm_owner_phone_of_tree: return False, "Invalid owner phone for index update." - - prev_phones = {normalize_phone(p.get('phone')) for p in previous_members if isinstance(p, dict) and normalize_phone(p.get('phone'))} - curr_phones = {normalize_phone(p.get('phone')) for p in current_members if isinstance(p, dict) and normalize_phone(p.get('phone'))} - - phones_added = curr_phones - prev_phones - phones_removed = prev_phones - curr_phones - - updates = {} - for phone in phones_added: - if phone: - index_entry_path = f"{get_phone_index_path(phone)}/trees/{norm_owner_phone_of_tree}" - updates[index_entry_path] = True - for phone in phones_removed: - if phone: - index_entry_path = f"{get_phone_index_path(phone)}/trees/{norm_owner_phone_of_tree}" - updates[index_entry_path] = None - - if not updates: return True, None + logging.error(f"Signup error: {e}") + return jsonify({'error': str(e)}), 400 + +@app.route('/api/user/profile', methods=['GET']) +def get_user_profile(): try: - firebase_db_ref.update(updates) - return True, None + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return jsonify({'error': 'Missing or invalid token'}), 401 + + token = auth_header.split(' ')[1] + uid = verify_token(token) + if not uid: + return jsonify({'error': 'Invalid or expired token'}), 401 + + user_data = db.reference(f'users/{uid}').get() + if not user_data: + return jsonify({'error': 'User not found'}), 404 + + return jsonify({ + 'uid': uid, + 'email': user_data.get('email'), + 'credits': user_data.get('credits', 0), + 'is_admin': user_data.get('is_admin', False) + }) except Exception as e: - app.logger.error(f"Error updating phone index: {e}. Updates: {json.dumps(updates)}") - return False, f"Error updating phone index: {e}" + logging.error(f"Error fetching user profile: {e}") + return jsonify({'error': str(e)}), 500 -# --- Data Saving (Owner - Adapted) --- -def save_tree_data(owner_phone_of_tree, current_data, previous_data): # Renamed param - if firebase_error or not firebase_db_ref: - return False, firebase_error or "Firebase not initialized. Cannot save." - - norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) - if not norm_owner_phone_of_tree: - return False, "Invalid owner phone format for saving." +@app.route('/api/auth/google-signin', methods=['POST']) +def google_signin(): + try: + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return jsonify({'error': 'Missing or invalid token'}), 401 + + token = auth_header.split(' ')[1] + decoded_token = auth.verify_id_token(token) + uid = decoded_token['uid'] + email = decoded_token.get('email') + + user_ref = db.reference(f'users/{uid}') + user_data = user_ref.get() + + if not user_data: + user_data = { + 'email': email, + 'credits': 10, # Changed initial credits to 10 + 'is_admin': False, + 'created_at': datetime.utcnow().isoformat(), + } + user_ref.set(user_data) - user_path = get_user_db_path(norm_owner_phone_of_tree) - if not user_path: # Should not happen if norm_owner_phone_of_tree is valid - return False, "Failed to generate user DB path for saving." + return jsonify({ + 'success': True, + 'user': { + 'uid': uid, + **user_data + } + }), 200 + except Exception as e: + logging.error(f"Google sign-in error: {e}") + return jsonify({'error': str(e)}), 400 + +@app.route('/api/user/request-credits', methods=['POST']) +@credit_required(cost=0) # This endpoint doesn't cost credits, but requires auth +def request_credits(): + try: + auth_header = request.headers.get('Authorization', '') + token = auth_header.split(' ')[1] + uid = verify_token(token) # uid is already verified by decorator, but get it here + + data = request.get_json() + requested_credits = data.get('requested_credits') + if requested_credits is None: + return jsonify({'error': 'requested_credits is required'}), 400 + + credit_request_ref = db.reference('credit_requests').push() + credit_request_ref.set({ + 'user_id': uid, + 'requested_credits': requested_credits, + 'status': 'pending', + 'requested_at': datetime.utcnow().isoformat() + }) + return jsonify({'success': True, 'request_id': credit_request_ref.key}) + except Exception as e: + logging.error(f"Request credits error: {e}") + return jsonify({'error': str(e)}), 500 - update_totems(current_data) - current_data["metadata"]["owner_phone"] = norm_owner_phone_of_tree # Ensure this is set correctly - if "family_members" in current_data: - for member in current_data["family_members"]: - if isinstance(member, dict): member.pop('photo_path', None) +# ---------- Admin Endpoints for Credit Requests ---------- +@app.route('/api/admin/profile', methods=['GET']) +def get_admin_profile(): try: - firebase_db_ref.child(user_path).set(current_data) + admin_uid = verify_admin(request.headers.get('Authorization', '')) + admin_data = db.reference(f'users/{admin_uid}').get() + if not admin_data: + return jsonify({'error': 'Admin user not found'}), 404 + + all_users_data = db.reference('users').get() or {} + total_users = len(all_users_data) + + normal_users_data = [user for user in all_users_data.values() if not user.get('is_admin', False)] + total_normal_users = len(normal_users_data) + + total_current_credits = sum(user.get('credits', 0) for user in all_users_data.values()) + total_normal_current_credits = sum(user.get('credits', 0) for user in normal_users_data) + + # Updated initial credits calculation to 10 + total_initial_credits = total_normal_users * 10 + credit_usage = total_initial_credits - total_normal_current_credits + + return jsonify({ + 'uid': admin_uid, + 'email': admin_data.get('email'), + 'credits': admin_data.get('credits', 0), + 'is_admin': True, + 'aggregated_stats': { + 'total_users': total_users, + 'total_normal_users': total_normal_users, + 'total_current_credits': total_current_credits, + 'total_normal_current_credits': total_normal_current_credits, + 'total_initial_credits_normal_users': total_initial_credits, + 'credit_usage': credit_usage + } + }) except Exception as e: - app.logger.error(f"Error saving tree data to {user_path}: {e}") - return False, f"Error saving tree data: {e}" - - prev_members = previous_data.get("family_members", []) if previous_data and isinstance(previous_data, dict) else [] - curr_members = current_data.get("family_members", []) - index_success, index_error = update_phone_index(norm_owner_phone_of_tree, prev_members, curr_members) - if not index_success: - app.logger.warning(f"Tree data saved for {norm_owner_phone_of_tree}, but failed to update phone index: {index_error}") - return True, f"Tree data saved, but phone index update failed: {index_error}" # Data saved, but index failed - return True, None - -# --- Collaboration Functions (Adapted) --- -# Proposer phone is the authenticated user (flask_g.user.phone_number) -def propose_changes(owner_phone_of_tree, proposer_phone_from_token, proposed_data): - if firebase_error or not firebase_db_ref: - return False, firebase_error or "Firebase not initialized. Cannot propose." - - norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) - norm_proposer_phone = normalize_phone(proposer_phone_from_token) + logging.error(f"Error fetching admin profile: {e}") + return jsonify({'error': str(e)}), 500 - if not norm_owner_phone_of_tree or not norm_proposer_phone: - return False, "Invalid owner or proposer phone format for proposing changes." +@app.route('/api/admin/credit_requests', methods=['GET']) +def list_credit_requests(): + try: + verify_admin(request.headers.get('Authorization', '')) + requests_ref = db.reference('credit_requests') + credit_requests = requests_ref.get() or {} + requests_list = [{'id': req_id, **data} for req_id, data in credit_requests.items()] + return jsonify({'credit_requests': requests_list}) + except Exception as e: + logging.error(f"List credit requests error: {e}") + return jsonify({'error': str(e)}), 500 - pending_path = get_pending_changes_path(norm_owner_phone_of_tree, norm_proposer_phone) - if not pending_path: # Should not happen if phones are valid - return False, "Failed to generate pending changes path." +@app.route('/api/admin/credit_requests/', methods=['PUT']) +def process_credit_request(request_id): + try: + admin_uid = verify_admin(request.headers.get('Authorization', '')) + req_ref = db.reference(f'credit_requests/{request_id}') + req_data = req_ref.get() + if not req_data: + return jsonify({'error': 'Credit request not found'}), 404 + + data = request.get_json() + decision = data.get('decision') + if decision not in ['approved', 'declined']: + return jsonify({'error': 'decision must be "approved" or "declined"'}), 400 + + if decision == 'approved': + user_ref = db.reference(f'users/{req_data["user_id"]}') + user_data = user_ref.get() + if not user_data: + return jsonify({'error': 'User not found'}), 404 + new_total = user_data.get('credits', 0) + float(req_data.get('requested_credits', 0)) + user_ref.update({'credits': new_total}) + req_ref.update({ + 'status': 'approved', + 'processed_by': admin_uid, + 'processed_at': datetime.utcnow().isoformat() + }) + return jsonify({'success': True, 'new_user_credits': new_total}) + else: + req_ref.update({ + 'status': 'declined', + 'processed_by': admin_uid, + 'processed_at': datetime.utcnow().isoformat() + }) + return jsonify({'success': True, 'message': 'Credit request declined'}) + except Exception as e: + logging.error(f"Process credit request error: {e}") + return jsonify({'error': str(e)}), 500 - proposal_payload = { - "proposed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "proposer_phone": norm_proposer_phone, # Storing the proposer's phone - "tree_data": proposed_data - } +@app.route('/api/admin/users', methods=['GET']) +def admin_list_users(): try: - firebase_db_ref.child(pending_path).set(proposal_payload) - return True, None + verify_admin(request.headers.get('Authorization', '')) + users_ref = db.reference('users') + all_users = users_ref.get() or {} + + user_list = [] + for uid, user_data in all_users.items(): + user_list.append({ + 'uid': uid, + 'email': user_data.get('email'), + 'credits': user_data.get('credits', 0), + 'is_admin': user_data.get('is_admin', False), + 'created_at': user_data.get('created_at', ''), + 'suspended': user_data.get('suspended', False) + }) + return jsonify({'users': user_list}), 200 except Exception as e: - app.logger.error(f"Error proposing changes to {pending_path}: {e}") - return False, f"Error proposing changes: {e}" + logging.error(f"Admin list users error: {e}") + return jsonify({'error': str(e)}), 500 -# Owner phone is the authenticated user (flask_g.user.phone_number) -def load_pending_changes(owner_phone_from_token): - if firebase_error or not firebase_db_ref: return {}, firebase_error or "Firebase not initialized" - - norm_owner_phone = normalize_phone(owner_phone_from_token) - if not norm_owner_phone: return {}, "Invalid owner phone for loading pending changes." +@app.route('/api/admin/users/search', methods=['GET']) +def admin_search_users(): + try: + verify_admin(request.headers.get('Authorization', '')) + email_query = request.args.get('email', '').lower().strip() + if not email_query: + return jsonify({'error': 'email query param is required'}), 400 + + users_ref = db.reference('users') + all_users = users_ref.get() or {} + + matched_users = [] + for uid, user_data in all_users.items(): + user_email = user_data.get('email', '').lower() + if email_query in user_email: + matched_users.append({ + 'uid': uid, + 'email': user_data.get('email'), + 'credits': user_data.get('credits', 0), + 'is_admin': user_data.get('is_admin', False), + 'created_at': user_data.get('created_at', ''), + 'suspended': user_data.get('suspended', False) + }) + return jsonify({'matched_users': matched_users}), 200 + except Exception as e: + logging.error(f"Admin search users error: {e}") + return jsonify({'error': str(e)}), 500 - pending_base_path = get_pending_changes_path(norm_owner_phone) - if not pending_base_path: return {}, "Failed to generate pending base path." +@app.route('/api/admin/users//suspend', methods=['PUT']) +def admin_suspend_user(uid): try: - data = firebase_db_ref.child(pending_base_path).get() - return data if data and isinstance(data, dict) else {}, None + verify_admin(request.headers.get('Authorization', '')) + data = request.get_json() + action = data.get('action') + if action not in ["suspend", "unsuspend"]: + return jsonify({'error': 'action must be "suspend" or "unsuspend"'}), 400 + + user_ref = db.reference(f'users/{uid}') + user_data = user_ref.get() + if not user_data: + return jsonify({'error': 'User not found'}), 404 + + if action == "suspend": + user_ref.update({'suspended': True}) + else: + user_ref.update({'suspended': False}) + + return jsonify({'success': True, 'message': f'User {uid} is now {action}ed'}) except Exception as e: - app.logger.error(f"Error loading pending changes for {norm_owner_phone}: {e}") - return {}, f"Error loading pending changes: {e}" + logging.error(f"Admin suspend user error: {e}") + return jsonify({'error': str(e)}), 500 -# Owner phone is the authenticated user (flask_g.user.phone_number) -def accept_changes(owner_phone_from_token, proposer_phone_to_accept): - if firebase_error or not firebase_db_ref: return False, firebase_error or "Firebase not initialized" +@app.route('/api/admin/stories', methods=['GET']) +def admin_list_stories(): + try: + verify_admin(request.headers.get('Authorization', '')) + stories_ref = db.reference('stories') + all_stories = stories_ref.get() or {} + total_stories = len(all_stories) - norm_owner_phone = normalize_phone(owner_phone_from_token) - norm_proposer_phone = normalize_phone(proposer_phone_to_accept) + users_ref = db.reference('users') + users_data = users_ref.get() or {} - if not norm_owner_phone or not norm_proposer_phone: - return False, "Invalid owner or proposer phone for accepting changes." + stories_per_user = {} + for sid, sdata in all_stories.items(): + user_id = sdata.get('uid') + if user_id: + user_email = users_data.get(user_id, {}).get('email', 'Unknown') + stories_per_user[user_email] = stories_per_user.get(user_email, 0) + 1 - pending_path = get_pending_changes_path(norm_owner_phone, norm_proposer_phone) - if not pending_path: return False, "Failed to generate pending path for accept." + return jsonify({ + 'total_stories': total_stories, + 'stories_per_user': stories_per_user + }), 200 + except Exception as e: + logging.error(f"Admin list stories error: {e}") + return jsonify({'error': str(e)}), 500 +@app.route('/api/admin/notifications', methods=['POST']) +def send_notifications(): try: - proposal_payload = firebase_db_ref.child(pending_path).get() - if not proposal_payload or "tree_data" not in proposal_payload: - return False, f"Proposal from {norm_proposer_phone} not found or invalid." - accepted_data = proposal_payload["tree_data"] - - previous_data, load_err = load_tree_data(norm_owner_phone) # Load owner's current tree - if load_err: - return False, f"Failed to load current owner data before accepting: {load_err}" - - save_success, save_error = save_tree_data(norm_owner_phone, accepted_data, previous_data) - if save_success: - firebase_db_ref.child(pending_path).delete() - return True, None - else: # save_error contains the reason - return False, f"Failed to save accepted changes or update index: {save_error}. Proposal not deleted." - except Exception as e: - app.logger.error(f"Error accepting changes from {norm_proposer_phone}: {e}") - return False, f"Error accepting changes: {e}" + admin_uid = verify_admin(request.headers.get('Authorization', '')) + data = request.get_json() + message = data.get('message') + if not message: + return jsonify({'error': 'message is required'}), 400 + + recipients = data.get('recipients', "all") + all_users_ref = db.reference('users') + all_users_data = all_users_ref.get() or {} + + user_ids_to_notify = [] + if recipients == "all": + user_ids_to_notify = list(all_users_data.keys()) + elif isinstance(recipients, list): + user_ids_to_notify = [uid for uid in recipients if uid in all_users_data] + elif isinstance(recipients, str): + if recipients in all_users_data: + user_ids_to_notify = [recipients] + else: + return jsonify({'error': 'Invalid single user_id'}), 400 + else: + return jsonify({'error': 'recipients must be "all", a user_id, or a list of user_ids'}), 400 + + now_str = datetime.utcnow().isoformat() + for user_id in user_ids_to_notify: + notif_id = str(uuid.uuid4()) + notif_ref = db.reference(f'notifications/{user_id}/{notif_id}') + notif_data = { + "from_admin": admin_uid, + "message": message, + "created_at": now_str, + "read": False + } + notif_ref.set(notif_data) -# Owner phone is the authenticated user (flask_g.user.phone_number) -def reject_changes(owner_phone_from_token, proposer_phone_to_reject): - if firebase_error or not firebase_db_ref: return False, firebase_error or "Firebase not initialized" - - norm_owner_phone = normalize_phone(owner_phone_from_token) - norm_proposer_phone = normalize_phone(proposer_phone_to_reject) + return jsonify({ + 'success': True, + 'message': f"Notification sent to {len(user_ids_to_notify)} user(s)." + }), 200 - if not norm_owner_phone or not norm_proposer_phone: - return False, "Invalid owner or proposer phone for rejecting." + except Exception as e: + logging.error(f"Send notifications error: {e}") + return jsonify({'error': str(e)}), 500 - pending_path = get_pending_changes_path(norm_owner_phone, norm_proposer_phone) - if not pending_path: return False, "Failed to generate pending path for reject." +@app.route('/api/admin/feedback', methods=['GET']) +def admin_view_feedback(): try: - firebase_db_ref.child(pending_path).delete() - return True, None + admin_uid = verify_admin(request.headers.get('Authorization', '')) + feedback_type = request.args.get('type') + feedback_status = request.args.get('status') + + feedback_ref = db.reference('feedback') + all_feedback = feedback_ref.get() or {} + + feedback_list = [] + for fb_id, fb_data in all_feedback.items(): + if feedback_type and fb_data.get('type') != feedback_type: + continue + if feedback_status and fb_data.get('status') != feedback_status: + continue + + feedback_list.append({ + 'feedback_id': fb_id, + 'user_id': fb_data.get('user_id'), + 'user_email': fb_data.get('user_email'), + 'type': fb_data.get('type', 'general'), + 'message': fb_data.get('message', ''), + 'created_at': fb_data.get('created_at'), + 'status': fb_data.get('status', 'open') + }) + return jsonify({'feedback': feedback_list}), 200 except Exception as e: - app.logger.error(f"Error rejecting changes from {norm_proposer_phone}: {e}") - return False, f"Error rejecting changes: {e}" - -# (generate_diff_summary, find_person_by_id, find_person_by_name, generate_unique_id remain the same) -# (Graphviz and AI functions like format_node_label, get_father_id, update_totems, generate_graphviz_object, safe_json_loads, call_gemini, etc. remain largely the same logic) -# --- find_person_by_id, find_person_by_name, generate_unique_id --- -def find_person_by_id(data, person_id): - for person in data.get("family_members", []): - if isinstance(person, dict) and person.get("id") == person_id: return person - return None - -def find_person_by_name(data, name): - if not name: return None - normalized_name = name.strip().lower() - return [ - person for person in data.get("family_members", []) - if isinstance(person, dict) and person.get("name", "").strip().lower() == normalized_name - ] - -def generate_unique_id(data=None): return uuid.uuid4().hex - - -# --- Graphviz, AI Functions (Adapted for API context) --- -def format_node_label(person): - label = f"{person.get('name', 'Unknown')}" - details = [] - if person.get('dob'): details.append(f"b. {person.get('dob')}") - if person.get('dod'): details.append(f"d. {person.get('dod')}") - if details: label += f"\n({' / '.join(details)})" - if person.get('totem'): label += f"\nTotem: {person.get('totem')}" - return label - -def get_father_id(person_id, relationships, members): - id_to_person = {p['id']: p for p in members if isinstance(p, dict)} - for rel in relationships: - if isinstance(rel, dict) and rel.get('type') == 'parent' and rel.get('to_id') == person_id: - parent_id = rel.get('from_id') - parent = id_to_person.get(parent_id) - if parent and parent.get('gender', '').lower() == 'male': - return parent_id - return None - -def update_totems(user_data): # Modifies user_data in-place - members = user_data.get("family_members", []) - relationships = user_data.get("relationships", []) - if not members or not relationships: return - id_to_person = {p['id']: p for p in members if isinstance(p, dict)} - visited = set(); processed_in_queue = set() - all_child_ids = {r['to_id'] for r in relationships if isinstance(r, dict) and r.get('type') == 'parent'} - queue = [p['id'] for p in members if isinstance(p, dict) and p['id'] not in all_child_ids] - queue.extend([p['id'] for p in members if isinstance(p, dict) and p['id'] not in queue]) - processed_count = 0 - max_process = len(members) * 2 + 5 - while queue and processed_count < max_process: - person_id = queue.pop(0) - processed_count += 1 - if person_id in visited: continue - person = id_to_person.get(person_id) - if not person: continue - totem_updated = False - current_totem = person.get('totem') - if current_totem is None or current_totem == "": - father_id = get_father_id(person_id, relationships, members) - if father_id: - father = id_to_person.get(father_id) - if father and father.get('totem') and (father_id in visited or father_id not in all_child_ids): - person['totem'] = father.get('totem') - totem_updated = True - visited.add(person_id) - for rel in relationships: - if isinstance(rel, dict) and rel.get('type') == 'parent' and rel.get('from_id') == person_id: - child_id = rel.get('to_id') - if child_id and child_id not in visited and child_id not in queue: - queue.append(child_id) - if totem_updated: - for rel in relationships: - if isinstance(rel, dict) and rel.get('type') == 'parent' and rel.get('from_id') == person_id: - child_id = rel.get('to_id') - if child_id and child_id in visited: - visited.remove(child_id) - if child_id not in queue: queue.append(child_id) - elif child_id and child_id not in queue: - queue.append(child_id) - if processed_count >= max_process: - app.logger.warning("WARN: Totem update BFS reached max iterations, potential loop?") - - -def generate_graphviz_object(data): # Renamed to avoid conflict if original is kept - dot = graphviz.Digraph(comment='Family Tree', format='svg') - dot.attr(rankdir='TB', splines='ortho', nodesep='0.6', ranksep='0.8') - theme = data.get("settings", {}).get("theme", "Default") - node_style = {'shape': 'box', 'style': 'filled', 'margin': '0.1'} - edge_color, marriage_node_color = 'gray50', 'gray50' - if theme == "Dark": - dot.attr(bgcolor='black', fontcolor='white'); node_style.update(fillcolor='grey30', fontcolor='white', color='white'); edge_color, marriage_node_color = 'white', 'white' - else: dot.attr(bgcolor='transparent'); node_style.update(fillcolor='lightblue', fontcolor='black', color='black') - members = data.get("family_members", []); relationships = data.get("relationships", []) - if not members: return dot, "No members to graph" - member_ids = {m['id'] for m in members if isinstance(m, dict)}; id_to_person = {p['id']: p for p in members if isinstance(p, dict)} - marriage_nodes = {}; parents_of_child = defaultdict(list); children_by_parent_pair = defaultdict(list) - for person in members: - if isinstance(person, dict): dot.node(person['id'], label=format_node_label(person), **node_style) - processed_spouses = set() - for rel in relationships: - if not isinstance(rel, dict): continue - from_id, to_id, rel_type = rel.get("from_id"), rel.get("to_id"), rel.get("type") - if from_id not in member_ids or to_id not in member_ids: continue - if rel_type == 'parent': parents_of_child[to_id].append(from_id) - elif rel_type == 'spouse': - pair = frozenset([from_id, to_id]) - if pair not in processed_spouses: - m_id = f"m_{uuid.uuid4().hex[:8]}"; marriage_nodes[pair] = m_id - dot.node(m_id, shape='point', width='0.1', height='0.1', label='', color=marriage_node_color) - dot.edge(from_id, m_id, style='invis', dir='none', weight='10') - dot.edge(to_id, m_id, style='invis', dir='none', weight='10') - with dot.subgraph(name=f"cluster_m_{m_id}") as sub: - sub.attr(rank='same', style='invis') - sub.node(from_id); sub.node(to_id) - processed_spouses.add(pair) - for child_id, parent_ids_list in parents_of_child.items(): - valid_p_ids = sorted([p_id for p_id in parent_ids_list if p_id in member_ids]); - if not valid_p_ids: continue - parent_key = frozenset(valid_p_ids); children_by_parent_pair[parent_key].append(child_id) - processed_child_links = set() - for parent_key, children_list in children_by_parent_pair.items(): - p_ids = list(parent_key); source_id = None - if len(p_ids) == 1: source_id = p_ids[0] - elif len(p_ids) > 1: source_id = marriage_nodes.get(parent_key) - if source_id: - for child_id in children_list: - if child_id in member_ids and (source_id, child_id) not in processed_child_links: - dot.edge(source_id, child_id, arrowhead='none', color=edge_color); processed_child_links.add((source_id, child_id)) - elif len(p_ids) > 1 and not source_id: - app.logger.warning(f"WARN: Missing marriage node for parents {p_ids}, linking child {children_list} from first parent.") - source_id = p_ids[0] - for child_id in children_list: - if child_id in member_ids and (source_id, child_id) not in processed_child_links: - dot.edge(source_id, child_id, arrowhead='none', color=edge_color); processed_child_links.add((source_id, child_id)) - for parent_key, siblings in children_by_parent_pair.items(): - valid_sibs = [sid for sid in siblings if sid in member_ids] - if len(valid_sibs) > 1: - with dot.subgraph(name=f"cluster_s_{uuid.uuid4().hex[:8]}") as sub: - sub.attr(rank='same', style='invis') - for sid in sorted(valid_sibs): sub.node(sid) - return dot, None - - -def safe_json_loads(text): - match = re.search(r"```(?:json)?\s*(\{.*\}|\[.*\])\s*```", text, re.DOTALL | re.IGNORECASE) - if match: json_text = match.group(1) - else: - start_brace, start_bracket = text.find('{'), text.find('[') - end_brace, end_bracket = text.rfind('}'), text.rfind(']') - start = min(start_brace, start_bracket) if start_brace != -1 and start_bracket != -1 else (start_brace if start_brace != -1 else start_bracket) - end = -1 - if start == start_brace and end_brace != -1: end = end_brace - elif start == start_bracket and end_bracket != -1: end = end_bracket - elif end_brace != -1 and end_bracket != -1: end = max(end_brace, end_bracket) - else: end = end_brace if end_brace != -1 else end_bracket - if start != -1 and end != -1 and end > start: json_text = text[start:end+1] - else: - json_text = text.strip() - if not ((json_text.startswith('{') and json_text.endswith('}')) or \ - (json_text.startswith('[') and json_text.endswith(']'))): - return None, f"Could not find JSON structure. Response: {text[:500]}..." - json_text = re.sub(r",\s*(\]|\})", r"\1", json_text) + logging.error(f"Admin view feedback error: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/admin/users//credits', methods=['PUT']) +def admin_update_credits(uid): try: - return json.loads(json_text), None - except json.JSONDecodeError as e: - return None, f"Error parsing JSON: {e}. Attempted JSON: {json_text[:500]}..." + verify_admin(request.headers.get('Authorization', '')) + data = request.get_json() + add_credits = data.get('add_credits') + if add_credits is None: + return jsonify({'error': 'add_credits is required'}), 400 + + user_ref = db.reference(f'users/{uid}') + user_data = user_ref.get() + if not user_data: + return jsonify({'error': 'User not found'}), 404 + + new_total = user_data.get('credits', 0) + float(add_credits) + user_ref.update({'credits': new_total}) + return jsonify({'success': True, 'new_total_credits': new_total}) except Exception as e: - return None, f"Unexpected error during JSON parsing: {e}. Response: {text[:500]}..." + logging.error(f"Admin update credits error: {e}") + return jsonify({'error': str(e)}), 500 -def call_gemini(prompt_text, is_json_output_expected=True): - if not genai_client or api_key_error: - return None, "Gemini API not ready or API key error." - try: - model = genai.GenerativeModel(GEMINI_MODEL_NAME) - safety_settings = [ - {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"}, - {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_MEDIUM_AND_ABOVE"}, - {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"}, - {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"}, - ] - gen_config = genai.types.GenerationConfig(response_mime_type="application/json") if is_json_output_expected else None - response = model.generate_content(prompt_text, generation_config=gen_config, safety_settings=safety_settings) - if not response.parts: - reason = "Unknown" - try: - reason = response.prompt_feedback.block_reason.name if hasattr(response, 'prompt_feedback') and response.prompt_feedback.block_reason else None - if not reason and response.candidates: reason = response.candidates[0].finish_reason.name - except Exception: pass - return None, f"AI response empty or blocked. Reason: {reason}" - if not hasattr(response, 'text') or not response.text: - return None, "AI response text empty." - - if is_json_output_expected: - parsed_json, err = safe_json_loads(response.text) - if err: return None, f"AI response received, but failed to parse as JSON: {err}. Raw: {response.text[:500]}..." - return parsed_json, None - else: - return response.text, None - except Exception as e: - app.logger.error(f"Gemini API Call Error: {e} (Type: {type(e).__name__})") - return None, f"Gemini API Call Error: {e}" - - -def generate_tree_from_description_gemini(description): - prompt = f"""Please analyze the following family description. Extract individuals mentioned, including their name, and if available, their date of birth (dob), date of death (dod), gender (Male/Female/Other/Unknown), and totem. Also, identify direct relationships like parent_of, spouse_of, or sibling_of between these individuals. - -Format the output as a single JSON object containing two keys: -1. "people": A list of objects, where each object represents an individual and has keys for "name", "dob", "dod", "gender", and "totem" (use null or omit if information is not present). Use the name "Me" if the description uses first-person references without providing a name. -2. "relationships": A list of objects, where each object represents a relationship. Each relationship object should have "person1_name", "person2_name", and "type" (e.g., "parent_of", "spouse_of", "sibling_of"). Ensure the names match exactly those extracted in the "people" list. - -Strictly output only the JSON object, without any introductory text, explanations, or markdown formatting like ```json. - -Description: -"{description}" - -JSON Output: -""" - return call_gemini(prompt, is_json_output_expected=True) - -def generate_quiz_questions_gemini(members, relationships, num_questions=3): - if not members or len(members) < 2: return None, "Need at least 2 members for a quiz." - id_to_name = {p["id"]: p.get("name", "Unknown") for p in members if isinstance(p, dict)} - rel_strings, processed_pairs = [], set() - for rel in relationships: - if not isinstance(rel, dict): continue - p1_id, p2_id, rt = rel.get("from_id"), rel.get("to_id"), rel.get("type") - p1n, p2n = id_to_name.get(p1_id), id_to_name.get(p2_id) - if p1n and p2n and p1n != p2n: - pair = frozenset([p1_id, p2_id]) - if rt == "parent": rel_strings.append(f"'{p1n}' is a Parent of '{p2n}'") - elif rt == "spouse" and pair not in processed_pairs: rel_strings.append(f"'{p1n}' is a Spouse of '{p2n}'"); processed_pairs.add(pair) - elif rt == "sibling" and pair not in processed_pairs: rel_strings.append(f"'{p1n}' is a Sibling of '{p2n}'"); processed_pairs.add(pair) - if not rel_strings: return None, "No relationships found to base quiz questions on." - unique_names = set(id_to_name.values()) - min_required_names, actual_num_questions = 4, num_questions - if len(unique_names) < min_required_names: - if len(unique_names) >= 2: actual_num_questions = max(1, len(unique_names) - 1) - else: return None, "Need at least 2 unique names in the tree to generate a quiz." - prompt = f"""Based *only* on the provided list of family relationships and names, generate {actual_num_questions} unique multiple-choice quiz questions. - -Each question should test knowledge about one specific relationship. -The output format must be a single JSON list, where each element is an object representing a question: -`{{"text": "Question text?", "options": ["Correct Answer", "Wrong Option 1", "Wrong Option 2", "Wrong Option 3"], "correct": "Correct Answer"}}` - -- The "options" list must contain the correct answer and 3 incorrect answers chosen from the provided 'Names' list. -- All options within a single question must be unique names. -- If fewer than 4 unique names are available in the 'Names' list, use all available names as options, ensuring the correct answer is included. -- Do not invent relationships or information not present in the 'Relationships' list. -- Ensure the 'correct' value exactly matches one of the strings in the 'options' list. -- Strictly output only the JSON list, without any other text or markdown formatting. - -Names: -{chr(10).join(f"- {name}" for name in sorted(list(unique_names)))} - -Relationships: -{chr(10).join(f"- {rel}" for rel in rel_strings)} - -JSON Output: -""" - return call_gemini(prompt, is_json_output_expected=True) - -def generate_diff_summary(current_data, proposed_data): - summary = [] +# —————————————————————————————————————————————— +# NBA Analytics Hub Endpoints +# ——���——————————————————————————————————————————— + +# Custom BeautifulSoup Data Fetching Utilities (for teams) +# Re-added for custom BS scraping +def fetch_html(url): + """Fetch raw HTML for a URL (with error handling).""" try: - # Use dictdiffer to find changes - result = list(diff(current_data, proposed_data, ignore={'last_edited_at', 'last_edited_by', 'created_at', 'created_by'})) # Ignore timestamps/editors for diff summary - - # Process members - current_members = {m['id']: m for m in current_data.get('family_members', []) if isinstance(m, dict)} - proposed_members = {m['id']: m for m in proposed_data.get('family_members', []) if isinstance(m, dict)} - added_members = set(proposed_members.keys()) - set(current_members.keys()) - deleted_members = set(current_members.keys()) - set(proposed_members.keys()) - common_members = set(current_members.keys()) & set(proposed_members.keys()) - - if added_members: - summary.append("**Members Added:**") - for mid in added_members: - summary.append(f"- {proposed_members[mid].get('name', 'Unnamed')} (ID: ...{mid[-6:]})") - if deleted_members: - summary.append("**Members Deleted:**") - for mid in deleted_members: - summary.append(f"- {current_members[mid].get('name', 'Unnamed')} (ID: ...{mid[-6:]})") - - modified_member_summary = [] - for mid in common_members: - member_diff = list(diff(current_members[mid], proposed_members[mid], ignore={'last_edited_at', 'last_edited_by', 'created_at', 'created_by', 'stories'})) # Ignore stories here, handle separately - if member_diff: - changes = [] - for change_type, path, values in member_diff: - field = path if isinstance(path, str) else path # Get field name - if change_type == 'change': changes.append(f"{field}: '{values}' -> '{values}'") - elif change_type == 'add': changes.append(f"{field}: added '{values}'") # Should be rare at top level - elif change_type == 'remove': changes.append(f"{field}: removed '{values}'") # Should be rare at top level - if changes: - modified_member_summary.append(f"- **{current_members[mid].get('name', 'Unnamed')}**: {'; '.join(changes)}") - if modified_member_summary: - summary.append("**Members Modified:**") - summary.extend(modified_member_summary) - - # Process Relationships (Simplified comparison) - current_rels = {(r.get('from_id'), r.get('to_id'), r.get('type')) for r in current_data.get('relationships', []) if isinstance(r, dict)} - proposed_rels = {(r.get('from_id'), r.get('to_id'), r.get('type')) for r in proposed_data.get('relationships', []) if isinstance(r, dict)} - added_rels = proposed_rels - current_rels - deleted_rels = current_rels - proposed_rels - - id_to_name_current = {m['id']: m.get('name', 'Unknown') for m in current_data.get('family_members', [])} - id_to_name_proposed = {m['id']: m.get('name', 'Unknown') for m in proposed_data.get('family_members', [])} - def format_rel(rel_tuple, names): - f_id, t_id, r_type = rel_tuple - f_name = names.get(f_id, f"...{f_id[-6:]}") - t_name = names.get(t_id, f"...{t_id[-6:]}") - return f"{f_name} {r_type} {t_name}" - - if added_rels: - summary.append("**Relationships Added:**") - for rel in added_rels: summary.append(f"- {format_rel(rel, id_to_name_proposed)}") - if deleted_rels: - summary.append("**Relationships Deleted:**") - for rel in deleted_rels: summary.append(f"- {format_rel(rel, id_to_name_current)}") - - # Process Stories (Simplified: check if stories list changed for any common member) - story_changes = [] - for mid in common_members: - current_stories = current_members[mid].get('stories', []) - proposed_stories = proposed_members[mid].get('stories', []) - # Simple check: compare lengths or string representations (not perfect but gives an idea) - if json.dumps(current_stories, sort_keys=True) != json.dumps(proposed_stories, sort_keys=True): - story_changes.append(f"- Stories for **{current_members[mid].get('name', 'Unnamed')}**") - if story_changes: - summary.append("**Stories Modified:**") - summary.extend(story_changes) + resp = requests.get(url, timeout=20) + resp.raise_for_status() + return resp.text + except requests.exceptions.RequestException as e: + logging.error(f"Failed to fetch {url}: {e}") + return "" except Exception as e: - summary.append(f"Error generating diff: {e}") - return "\n".join(summary) if summary else "No significant changes detected." - -# --- API Endpoints --- - -# Helper to check for Firebase/API key errors (used by non-authed endpoints) -def check_system_health(): - if firebase_error: return jsonify({"error": "Firebase system error", "detail": firebase_error}), 503 - return None - -@app.route("/health", methods=["GET"]) -def health_check(): - system_error_response = check_system_health() - if system_error_response: return system_error_response - if api_key_error: - return jsonify({"status": "Firebase OK, Gemini API Key Error"}), 200 - return jsonify({"status": "Firebase OK, Gemini OK"}), 200 - -# --- User/Auth related endpoints --- -@app.route('/user/me', methods=['GET']) -@token_required -def get_my_user_info(): + logging.error(f"An unexpected error occurred while fetching {url}: {e}") + return "" + +def parse_table(html, table_id=None): """ - Returns basic info about the authenticated user and their primary tree. - This also serves as a way to "register" or ensure the user's tree exists. + Given raw HTML and optional table_id, locate that , + handling cases where it's commented out, then parse it with pandas.read_html. """ - current_user_phone = flask_g.user["phone_number"] - - # Load (or initialize) the user's primary tree data - tree_data, error = load_tree_data(current_user_phone) - if error: - # Specific check for initialization failure, which might be a server-side issue - if "Failed to initialize and save new tree" in error: - app.logger.error(f"Critical error initializing tree for {current_user_phone}: {error}") - return jsonify({"error": "Could not initialize user tree data", "detail": error}), 500 - # Other load errors might be less critical or indicate no data (which is fine if new) - app.logger.warning(f"Could not load tree data for {current_user_phone} during /user/me: {error}") - # If tree_data is None and it's not an init error, it implies a load issue post-init. - # However, load_tree_data is designed to create if None, so this path needs careful thought. - # For now, assume if tree_data is None after load_tree_data, it's a significant issue. - if tree_data is None: - return jsonify({"error": "Could not load user tree data", "detail": error}), 404 # Or 500 - - return jsonify({ - "uid": flask_g.user["uid"], - "phone_number": current_user_phone, - "primary_tree_metadata": tree_data.get("metadata") if tree_data else None - }), 200 - - -# --- Tree Data Endpoints --- -# Get a specific tree (can be own or another if part of it - auth handled by client if needed for "view") -# For simplicity, this endpoint is public for GET, but write operations on trees are protected. -@app.route('/tree/', methods=['GET']) -def get_tree_data_api(owner_phone_of_tree_to_view): - system_error_response = check_system_health() # Basic check - if system_error_response: return system_error_response - - norm_phone = normalize_phone(owner_phone_of_tree_to_view) - if not norm_phone: - return jsonify({"error": "Invalid owner_phone format"}), 400 - - tree_data, error = load_tree_data(norm_phone) - if error: - if tree_data is None and "Failed to initialize" not in error : # If it's not an init error but still no data - return jsonify({"error": "Tree data not found or could not be loaded.", "detail": error}), 404 - return jsonify({"error": "Could not load or initialize tree data", "detail": error}), 500 - return jsonify(tree_data), 200 - - -@app.route('/user/linked_trees', methods=['GET']) -@token_required -def get_linked_trees_api(): - """Gets trees the authenticated user is a member of (excluding their own primary tree).""" - current_user_phone = flask_g.user["phone_number"] - - linked_owner_phones_map, error = find_linked_trees(current_user_phone) - if error: - return jsonify({"error": "Could not fetch linked trees", "detail": error}), 500 - - result = [] - for owner_ph, status_val_in_index in linked_owner_phones_map.items(): - norm_owner_ph = normalize_phone(owner_ph) - if norm_owner_ph == current_user_phone: # Skip user's own tree - continue - if norm_owner_ph: - tree_d, err = load_tree_data(norm_owner_ph) - tree_name = tree_d.get("metadata", {}).get("tree_name", f"Tree by ...{norm_owner_ph[-4:]}") if tree_d and not err else f"Unknown Tree ({norm_owner_ph})" - result.append({"owner_phone": norm_owner_ph, "tree_name": tree_name}) - - return jsonify(result), 200 - - -@app.route('/tree//graph', methods=['GET']) -# This can remain public for viewing, or add @token_required if graphs should be private. -# For now, keeping it similar to get_tree_data_api (public GET). -def get_tree_graph_api(owner_phone_of_tree): - system_error_response = check_system_health() - if system_error_response: return system_error_response - norm_phone = normalize_phone(owner_phone_of_tree) - if not norm_phone: return jsonify({"error": "Invalid owner_phone format"}), 400 - - tree_data, error = load_tree_data(norm_phone) - if error or not tree_data: return jsonify({"error": "Could not load tree data for graph", "detail": error or "Tree data is empty"}), 404 - + if not html: + return pd.DataFrame() + + soup = BeautifulSoup(html, "lxml") + tbl_html = "" + + if table_id: + tbl = soup.find("table", {"id": table_id}) + if tbl: + tbl_html = str(tbl) + else: + comment_pattern = re.compile(r'' % table_id, re.DOTALL) + comment_match = comment_pattern.search(html) + if comment_match: + comment_content = comment_match.group(0) + comment_content = comment_content.replace('', '') + comment_soup = BeautifulSoup(comment_content, 'lxml') + tbl = comment_soup.find('table', {'id': table_id}) + if tbl: + tbl_html = str(tbl) + else: + first = soup.find("table") + if first: + tbl_html = str(first) + + if not tbl_html: + return pd.DataFrame() + try: - graph_dot, graph_err = generate_graphviz_object(tree_data) - if graph_err: return jsonify({"error": "Could not generate graph", "detail": graph_err}), 500 - - svg_data = graph_dot.pipe(format='svg').decode('utf-8') - return jsonify({"svg": svg_data, "dot_source": graph_dot.source}), 200 - except graphviz.backend.execute.ExecutableNotFound: - app.logger.error("Graphviz executable not found.") - return jsonify({"error": "Graphviz executable not found on server."}), 500 + return pd.read_html(tbl_html)[0] + except ValueError: + logging.warning("No tables found in the provided HTML string for parsing.") + return pd.DataFrame() except Exception as e: - app.logger.error(f"Error generating graph SVG: {e}") - return jsonify({"error": "Error generating graph SVG", "detail": str(e)}), 500 - - -# --- Member Management Endpoints (Protected) --- -@app.route('/tree//members', methods=['POST']) -@token_required -def add_member_api(owner_phone_of_tree): - current_user_phone = flask_g.user["phone_number"] # Authenticated user - norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) - if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format in URL"}), 400 - - # Authorization: Only tree owner can add members directly to their own tree. - # Collaborators use the proposal mechanism. - if current_user_phone != norm_owner_phone_of_tree: - return jsonify({"error": "Forbidden: You can only add members to your own tree directly. Use proposals for other trees."}), 403 - - req_data = request.json - if not req_data: return jsonify({"error": "Missing request body"}), 400 - new_member_info = req_data.get("member_data") - confirm_conflict = req_data.get("confirm_conflict", False) - if not new_member_info or not new_member_info.get("name"): - return jsonify({"error": "Missing member data or name"}), 400 - - tree_data, load_error = load_tree_data(norm_owner_phone_of_tree) - if load_error: return jsonify({"error": "Could not load tree data", "detail": load_error}), 500 - - original_tree_data_for_save = deepcopy(tree_data) + logging.error(f"Error parsing table with pandas: {e}") + return pd.DataFrame() - conflicting_members = find_person_by_name(tree_data, new_member_info["name"]) - if conflicting_members and not confirm_conflict: - return jsonify({ - "error": "Name conflict", - "message": f"A member named '{new_member_info['name']}' already exists.", - "conflicting_members": [{"id": m["id"], "name": m["name"]} for m in conflicting_members] - }), 409 - - new_member_id = generate_unique_id() - current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - norm_new_phone = normalize_phone(new_member_info.get("phone")) - if new_member_info.get("phone") and not norm_new_phone: - return jsonify({"error": "Invalid phone format for new member"}), 400 - - new_member_entry = deepcopy(DEFAULT_MEMBER_STRUCTURE) - new_member_entry.update({ - "id": new_member_id, - "name": new_member_info["name"].strip(), - "dob": new_member_info.get("dob", "").strip(), - "dod": new_member_info.get("dod", "").strip(), - "gender": new_member_info.get("gender", "Unknown"), - "phone": norm_new_phone or "", - "totem": new_member_info.get("totem", "").strip(), - "created_at": current_time, - "created_by": current_user_phone, # Action performed by authenticated user - "last_edited_at": current_time, - "last_edited_by": current_user_phone, - "stories": [] - }) - tree_data.setdefault("family_members", []).append(new_member_entry) - - save_success, save_error = save_tree_data(norm_owner_phone_of_tree, tree_data, original_tree_data_for_save) - if not save_success: - return jsonify({"error": "Failed to save new member", "detail": save_error}), 500 - - response_message = "Member added successfully." - if save_error: response_message += f" Warning: {save_error}" - return jsonify({"message": response_message, "member": new_member_entry}), 201 +def clean_team_name(team_name): + """ + Clean and standardize team names from Basketball Reference. + """ + if pd.isna(team_name): + return team_name + team_name = str(team_name).strip().replace('*', '') + team_mapping = { + 'NOP': 'NO', 'PHX': 'PHO', 'BRK': 'BKN', 'CHA': 'CHO', 'UTA': 'UTH' + } + return team_mapping.get(team_name, team_name) +def is_data_stale(timestamp_str, max_age_hours=24): + """Checks if a timestamp string indicates data is older than max_age_hours.""" + if not timestamp_str: + return True # No timestamp means data is stale or never fetched + try: + last_updated = datetime.fromisoformat(timestamp_str) + return (datetime.utcnow() - last_updated) > timedelta(hours=max_age_hours) + except ValueError: + logging.error(f"Invalid timestamp format: {timestamp_str}") + return True # Treat invalid format as stale -@app.route('/tree//members/', methods=['PUT']) -@token_required -def edit_member_api(owner_phone_of_tree, member_id): - current_user_phone = flask_g.user["phone_number"] - norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) - if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format"}), 400 +def get_team_stats_bs(year): + """ + Scrapes the league’s per‑game team stats table from Basketball-Reference + using BeautifulSoup, with Firebase caching. + Returns cleaned DataFrame. + """ + if not FIREBASE_INITIALIZED: + logging.warning("Firebase not initialized. Cannot use caching for team stats. Scraping directly.") + return _scrape_team_stats_bs(year) - if current_user_phone != norm_owner_phone_of_tree: - return jsonify({"error": "Forbidden: You can only edit members in your own tree directly. Use proposals."}), 403 + db_ref = db.reference(f'scraped_data/team_stats/{year}') + cached_data = db_ref.get() - req_data = request.json - if not req_data: return jsonify({"error": "Missing request body"}), 400 - updated_info = req_data.get("member_data") - confirm_conflict = req_data.get("confirm_conflict", False) - if not updated_info: return jsonify({"error": "Missing member_data in request"}), 400 + if cached_data and not is_data_stale(cached_data.get('last_updated')): + logging.info(f"Loading team stats for {year} from Firebase cache.") + return pd.DataFrame.from_records(cached_data['data']) + else: + logging.info(f"Scraping team stats for {year} (cache stale or not found).") + df = _scrape_team_stats_bs(year) + if not df.empty: + db_ref.set({ + 'last_updated': datetime.utcnow().isoformat(), + 'data': df.to_dict(orient='records') + }) + logging.info(f"Team stats for {year} saved to Firebase cache.") + return df + +def _scrape_team_stats_bs(year): + """Internal function to perform the actual scraping of team stats.""" + url = f"https://www.basketball-reference.com/leagues/NBA_{year}_per_game.html" + html = fetch_html(url) + if not html: + return pd.DataFrame() + + possible_table_ids = ["per_game-team", "per_game_team", "team-stats-per_game", "teams_per_game"] + df = pd.DataFrame() + for table_id in possible_table_ids: + df = parse_table(html, table_id=table_id) + if not df.empty: + break + + if df.empty: + soup = BeautifulSoup(html, "lxml") + tables = soup.find_all("table") + for table in tables: + if table.find("th", string=lambda text: text and "team" in text.lower()): + df = parse_table(str(table)) + if not df.empty: + break + + if df.empty: + logging.warning(f"Could not find team stats table for {year}") + return pd.DataFrame() + + if isinstance(df.columns, pd.MultiIndex): + df.columns = ['_'.join(str(col).strip() for col in cols if str(col).strip() and str(col).strip() != 'Unnamed: 0_level_0') + for cols in df.columns.values] + df.columns = [str(col).strip() for col in df.columns] + + team_col = None + for col in df.columns: + if 'team' in col.lower() or col in ['Team', 'Tm']: + team_col = col + break + if team_col is None: + logging.warning(f"Could not find team column in team stats. Available columns: {df.columns.tolist()}") + return pd.DataFrame() - tree_data, load_error = load_tree_data(norm_owner_phone_of_tree) - if load_error: return jsonify({"error": "Could not load tree data", "detail": load_error}), 500 + if team_col != 'Team': + df = df.rename(columns={team_col: 'Team'}) + + df = df[df["Team"].astype(str) != "Team"].copy() + df = df[df["Team"].notna()].copy() - original_tree_data_for_save = deepcopy(tree_data) - member_to_edit = find_person_by_id(tree_data, member_id) - if not member_to_edit: return jsonify({"error": "Member not found"}), 404 - - is_me_node_of_owner = (member_id == "Me" and member_to_edit.get("phone") == norm_owner_phone_of_tree) - if is_me_node_of_owner and "phone" in updated_info and normalize_phone(updated_info["phone"]) != norm_owner_phone_of_tree: - return jsonify({"error": "Cannot change the phone number of the owner's 'Me' node."}), 403 - if is_me_node_of_owner and "name" in updated_info and updated_info["name"] != member_to_edit.get("name"): - # Allow 'Me' node name change if it's done via profile update, but direct edit here might be restricted - # For now, let's assume profile endpoint handles 'Me' node name better. - # If name is part of profile sync, this direct edit might be redundant or conflicting. - # For simplicity, we allow it here but it should be consistent with profile logic. - pass - - - new_name = updated_info.get("name", member_to_edit.get("name")).strip() - if new_name != member_to_edit.get("name"): # Check if name actually changed - conflicting_members = [ - m for m in tree_data.get("family_members", []) - if m["id"] != member_id and m.get("name", "").strip().lower() == new_name.lower() - ] - if conflicting_members and not confirm_conflict: - return jsonify({ - "error": "Name conflict on edit", - "message": f"Changing name to '{new_name}' would conflict with an existing member.", - "conflicting_members": [{"id": m["id"], "name": m["name"]} for m in conflicting_members] - }), 409 - - something_changed = False - editable_fields = ["name", "dob", "dod", "gender", "totem", "phone"] - - for field in editable_fields: - if field in updated_info: - new_value_raw = updated_info[field] - new_value = new_value_raw.strip() if isinstance(new_value_raw, str) else new_value_raw - - if field == "phone": - if is_me_node_of_owner and new_value != norm_owner_phone_of_tree : # Already checked but good to be safe - continue # Skip phone change for owner's 'Me' node here - norm_new_phone = normalize_phone(new_value) - if new_value and not norm_new_phone: return jsonify({"error": f"Invalid phone format for field {field}"}), 400 - new_value = norm_new_phone or "" - - if member_to_edit.get(field) != new_value: - member_to_edit[field] = new_value - something_changed = True + column_mapping = { + 'G': 'GP', 'MP': 'MIN', 'FG%': 'FG_PCT', '3P%': 'FG3_PCT', 'FT%': 'FT_PCT', + 'TRB': 'REB', 'AST': 'AST', 'STL': 'STL', 'BLK': 'BLK', 'TOV': 'TO', + 'PF': 'PF', 'PTS': 'PTS', 'Rk': 'RANK', 'W': 'WINS', 'L': 'LOSSES', 'W/L%': 'WIN_LOSS_PCT', + 'FG': 'FGM', 'FGA': 'FGA', '3P': 'FG3M', '3PA': 'FG3A', + '2P': 'FGM2', '2PA': 'FGA2', '2P%': 'FG2_PCT', 'eFG%': 'EFG_PCT', + 'FT': 'FTM', 'FTA': 'FTA', 'ORB': 'OREB', 'DRB': 'DREB' + } + for old_col, new_col in column_mapping.items(): + if old_col in df.columns: + df = df.rename(columns={old_col: new_col}) + + if 'Team' in df.columns: + df['Team'] = df['Team'].apply(clean_team_name) + + non_numeric_cols = {"Team", "RANK"} + for col in df.columns: + if col not in non_numeric_cols: + df[col] = pd.to_numeric(df[col], errors="coerce") + + return df + +# BRScraper Data Fetching Utilities (for players) +def get_available_seasons_util(num_seasons=6): + current_year = datetime.now().year + current_month = datetime.now().month + latest_season_end_year = current_year + if current_month >= 7: + latest_season_end_year += 1 + seasons_list = [] + for i in range(num_seasons): + end_year = latest_season_end_year - i + start_year = end_year - 1 + seasons_list.append(f"{start_year}–{end_year}") + return sorted(seasons_list, reverse=True) + +def get_player_index_brscraper(): + if not BRSCRAPER_AVAILABLE: + return pd.DataFrame(columns=['name']) - if something_changed: - member_to_edit["last_edited_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - member_to_edit["last_edited_by"] = current_user_phone - save_success, save_error = save_tree_data(norm_owner_phone_of_tree, tree_data, original_tree_data_for_save) - if not save_success: - return jsonify({"error": "Failed to save updated member", "detail": save_error}), 500 - - response_message = "Member updated successfully." - if save_error: response_message += f" Warning: {save_error}" - return jsonify({"message": response_message, "member": member_to_edit}), 200 + if not FIREBASE_INITIALIZED: + logging.warning("Firebase not initialized. Cannot use caching for player index. Scraping directly.") + return _scrape_player_index_brscraper() + + db_ref = db.reference('scraped_data/player_index') + cached_data = db_ref.get() + + if cached_data and not is_data_stale(cached_data.get('last_updated')): + logging.info("Loading player index from Firebase cache.") + return pd.DataFrame.from_records(cached_data['data']) else: - return jsonify({"message": "No changes detected for member.", "member": member_to_edit}), 200 + logging.info("Scraping player index (cache stale or not found).") + df = _scrape_player_index_brscraper() + if not df.empty: + db_ref.set({ + 'last_updated': datetime.utcnow().isoformat(), + 'data': df.to_dict(orient='records') + }) + logging.info("Player index saved to Firebase cache.") + return df +def _scrape_player_index_brscraper(): + """Internal function to perform the actual scraping of player index.""" + try: + latest_season_end_year = int(get_available_seasons_util(1)[0].split('–')[1]) + df = nba.get_stats(latest_season_end_year, info='per_game', rename=False) + if df.empty or 'Player' not in df.columns: + raise ValueError("No player column or empty DataFrame from BRScraper.") + player_names = df['Player'].dropna().unique().tolist() + return pd.DataFrame({'name': player_names}) + except Exception as e: + logging.error(f"Error fetching player index with BRScraper: {e}. Falling back to common players.") + common_players = [ + 'LeBron James', 'Stephen Curry', 'Kevin Durant', 'Giannis Antetokounmpo', + 'Nikola Jokic', 'Joel Embiid', 'Jayson Tatum', 'Luka Doncic', + 'Damian Lillard', 'Jimmy Butler', 'Kawhi Leonard', 'Paul George', + 'Anthony Davis', 'Rudy Gobert', 'Donovan Mitchell', 'Trae Young', + 'Devin Booker', 'Karl-Anthony Towns', 'Zion Williamson', 'Ja Morant' + ] + return pd.DataFrame({'name': common_players}) + +def get_player_career_stats_brscraper(player_name, seasons_to_check=10): + if not BRSCRAPER_AVAILABLE: + return pd.DataFrame() + all_rows = [] + seasons = get_available_seasons_util(seasons_to_check) + for season_str in seasons: + end_year = int(season_str.split('–')[1]) + try: + df_season = nba.get_stats(end_year, info='per_game', playoffs=False, rename=False) + if 'Player' in df_season.columns: + row = df_season[df_season['Player'] == player_name] + if not row.empty: + row = row.copy() + row['Season'] = season_str + all_rows.append(row) + except Exception as e: + logging.warning(f"Could not fetch {season_str} for {player_name}: {e}") + + if not all_rows: + return pd.DataFrame() + df = pd.concat(all_rows, ignore_index=True) + + mapping = { + 'G':'GP','GS':'GS','MP':'MIN', 'FG%':'FG_PCT','3P%':'FG3_PCT','FT%':'FT_PCT', + 'TRB':'REB','AST':'AST','STL':'STL','BLK':'BLK','TOV':'TO', + 'PF':'PF','PTS':'PTS','ORB':'OREB','DRB':'DREB', + 'FG':'FGM','FGA':'FGA','3P':'FG3M','3PA':'3PA', + '2P':'FGM2','2PA':'FGA2','2P%':'FG2_PCT','eFG%':'EFG_PCT', + 'FT':'FTM','FTA':'FTA' + } + df = df.rename(columns={o:n for o,n in mapping.items() if o in df.columns}) + + non_num = {'Season','Player','Tm','Lg','Pos'} + for col in df.columns: + if col not in non_num: + df[col] = pd.to_numeric(df[col], errors='coerce') + + df['Player'] = player_name + return df + +# Perplexity API +PERP_KEY = os.getenv("PERPLEXITY_API_KEY") +PERP_URL = "https://api.perplexity.ai/chat/completions" + +def ask_perp(prompt, system="You are a helpful NBA analyst AI.", max_tokens=500, temp=0.2): + if not PERP_KEY: + logging.error("PERPLEXITY_API_KEY env var not set.") + return "Perplexity API key is not configured." + hdr = {'Authorization':f'Bearer {PERP_KEY}','Content-Type':'application/json'} + payload = { + "model":"sonar-medium-online", + "messages":[{"role":"system","content":system},{"role":"user","content":prompt}], + "max_tokens":max_tokens, "temperature":temp + } + try: + r = requests.post(PERP_URL, json=payload, headers=hdr, timeout=45) + r.raise_for_status() + return r.json().get("choices", [])[0].get("message", {}).get("content", "") + except requests.exceptions.RequestException as e: + error_message = f"Error communicating with Perplexity API: {e}" + if hasattr(e, 'response') and e.response is not None: + try: + error_detail = e.response.json().get("error", {}).get("message", e.response.text) + error_message = f"Perplexity API error: {r.status_code} - {r.reason}" + except ValueError: + error_message = f"Perplexity API error: {r.status_code} - {r.reason}" + logging.error(f"Perplexity API request failed: {error_message}") + return f"Error from AI: {error_message}" + except Exception as e: + logging.error(f"An unexpected error occurred with Perplexity API: {e}") + return f"An unexpected error occurred with AI: {str(e)}" -@app.route('/tree//members/', methods=['DELETE']) -@token_required -def delete_member_api(owner_phone_of_tree, member_id): - current_user_phone = flask_g.user["phone_number"] - norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) - if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format"}), 400 - if current_user_phone != norm_owner_phone_of_tree: - return jsonify({"error": "Forbidden: You can only delete members from your own tree directly."}), 403 +# ---------- NBA Analytics Hub Endpoints ---------- - tree_data, load_error = load_tree_data(norm_owner_phone_of_tree) - if load_error: return jsonify({"error": "Could not load tree data", "detail": load_error}), 500 +@app.route('/api/nba/players', methods=['GET']) +@cross_origin() +def get_players(): + try: + players_df = get_player_index_brscraper() + if players_df.empty: + return jsonify({'error': 'Could not retrieve player list'}), 500 + return jsonify({'players': players_df['name'].tolist()}) + except Exception as e: + logging.error(f"Error in /api/nba/players: {e}") + return jsonify({'error': str(e)}), 500 - member_to_check = find_person_by_id(tree_data, member_id) - if not member_to_check: return jsonify({"error": "Member not found"}), 404 - if member_id == "Me" and member_to_check.get("phone") == norm_owner_phone_of_tree: - return jsonify({"error": "Cannot delete the owner's 'Me' user node."}), 403 - - original_tree_data_for_save = deepcopy(tree_data) - member_index = next((i for i, p in enumerate(tree_data.get("family_members", [])) if p.get("id") == member_id), -1) - # Already checked if member exists, so index should be found - - deleted_member_name = tree_data["family_members"][member_index].get("name", "Unknown") - del tree_data["family_members"][member_index] - tree_data["relationships"] = [ - r for r in tree_data.get("relationships", []) - if isinstance(r,dict) and r.get("from_id") != member_id and r.get("to_id") != member_id - ] - - save_success, save_error = save_tree_data(norm_owner_phone_of_tree, tree_data, original_tree_data_for_save) - if not save_success: - return jsonify({"error": "Failed to save after deleting member", "detail": save_error}), 500 - - response_message = f"Member '{deleted_member_name}' deleted successfully." - if save_error: response_message += f" Warning: {save_error}" - return jsonify({"message": response_message}), 200 - - -# --- Relationship Management Endpoints (Protected) --- -@app.route('/tree//relationships', methods=['POST']) -@token_required -def add_relationship_api(owner_phone_of_tree): - current_user_phone = flask_g.user["phone_number"] - norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) - if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format"}), 400 - - if current_user_phone != norm_owner_phone_of_tree: - return jsonify({"error": "Forbidden: You can only add relationships to your own tree directly."}), 403 - - req_data = request.json - if not req_data or not all(k in req_data for k in ["from_id", "to_id", "type"]): - return jsonify({"error": "Missing from_id, to_id, or type in request body"}), 400 - - from_id, to_id, rel_type = req_data["from_id"], req_data["to_id"], req_data["type"] - valid_rel_types = ['parent', 'spouse', 'sibling'] - if rel_type not in valid_rel_types: - return jsonify({"error": f"Invalid relationship type. Must be one of {valid_rel_types}"}), 400 - if from_id == to_id: - return jsonify({"error": "Cannot define a relationship between a person and themselves."}), 400 - - tree_data, load_error = load_tree_data(norm_owner_phone_of_tree) - if load_error: return jsonify({"error": "Could not load tree data", "detail": load_error}), 500 - - original_tree_data_for_save = deepcopy(tree_data) - if not find_person_by_id(tree_data, from_id) or not find_person_by_id(tree_data, to_id): - return jsonify({"error": "One or both persons in the relationship not found."}), 404 - - relationships = tree_data.setdefault("relationships", []) - exists = False - for rel in relationships: - if not isinstance(rel, dict): continue - rf, rt, rtype_existing = rel.get('from_id'), rel.get('to_id'), rel.get('type') - if rel_type == 'parent' and rtype_existing == 'parent' and rf == from_id and rt == to_id: exists = True; break - if rel_type in ['spouse', 'sibling'] and rtype_existing == rel_type and frozenset([rf, rt]) == frozenset([from_id, to_id]): exists = True; break - if exists: - return jsonify({"error": "This relationship already exists."}), 409 - - new_rel_entry = {"from_id": from_id, "to_id": to_id, "type": rel_type} - relationships.append(new_rel_entry) - - save_success, save_error = save_tree_data(norm_owner_phone_of_tree, tree_data, original_tree_data_for_save) - if not save_success: - return jsonify({"error": "Failed to save new relationship", "detail": save_error}), 500 - - response_message = "Relationship added successfully." - if save_error: response_message += f" Warning: {save_error}" - return jsonify({"message": response_message, "relationship": new_rel_entry}), 201 +@app.route('/api/nba/seasons', methods=['GET']) +@cross_origin() +def get_seasons(): + try: + seasons_list = get_available_seasons_util() + return jsonify({'seasons': seasons_list}) + except Exception as e: + logging.error(f"Error in /api/nba/seasons: {e}") + return jsonify({'error': str(e)}), 500 +@app.route('/api/nba/player_stats', methods=['POST']) +@cross_origin() +# No credit_required decorator here, as this is data fetching, not AI interaction +def get_player_stats(): + try: + data = request.get_json() + selected_players = data.get('players') + selected_seasons = data.get('seasons') + + if not selected_players or not selected_seasons: + return jsonify({'error': 'Players and seasons are required'}), 400 + + all_player_season_data = [] + players_with_no_data = [] + + for player_name in selected_players: + df_player_career = get_player_career_stats_brscraper(player_name) + if not df_player_career.empty: + filtered_df = df_player_career[df_player_career['Season'].isin(selected_seasons)].copy() + if not filtered_df.empty: + all_player_season_data.append(filtered_df) + else: + players_with_no_data.append(player_name) + else: + players_with_no_data.append(player_name) -@app.route('/tree//relationships/delete', methods=['POST']) -@token_required -def delete_relationship_api(owner_phone_of_tree): - current_user_phone = flask_g.user["phone_number"] - norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) - if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format"}), 400 + if not all_player_season_data: + return jsonify({ + 'error': 'No data available for selected players and seasons.', + 'players_with_no_data': players_with_no_data + }), 404 - if current_user_phone != norm_owner_phone_of_tree: - return jsonify({"error": "Forbidden: You can only delete relationships from your own tree directly."}), 403 - - req_data = request.json - if not req_data or not all(k in req_data for k in ["from_id", "to_id", "type"]): - return jsonify({"error": "Missing from_id, to_id, or type in request body for deletion"}), 400 - - del_from_id, del_to_id, del_rel_type = req_data["from_id"], req_data["to_id"], req_data["type"] + comparison_df_raw = pd.concat(all_player_season_data, ignore_index=True) - tree_data, load_error = load_tree_data(norm_owner_phone_of_tree) - if load_error: return jsonify({"error": "Could not load tree data", "detail": load_error}), 500 - - original_tree_data_for_save = deepcopy(tree_data) - relationships = tree_data.get("relationships", []) - new_relationships = [] - deleted = False - for rel in relationships: - if not isinstance(rel, dict): new_relationships.append(rel); continue - rf, rt, rtype = rel.get('from_id'), rel.get('to_id'), rel.get('type') - is_match = False - if del_rel_type == 'parent' and rtype == 'parent' and rf == del_from_id and rt == del_to_id: is_match = True - elif del_rel_type in ['spouse', 'sibling'] and rtype == del_rel_type and frozenset([rf, rt]) == frozenset([del_from_id, del_to_id]): is_match = True + # Calculate basic stats + if len(selected_seasons) > 1: + basic_display_df = comparison_df_raw.groupby('Player').mean(numeric_only=True).reset_index() + else: + basic_display_df = comparison_df_raw.copy() + + basic_cols = ['Player', 'Season', 'GP', 'MIN', 'PTS', 'REB', 'AST', 'STL', 'BLK', 'FG_PCT', 'FT_PCT', '3P_PCT'] + basic_display_df = basic_display_df[[c for c in basic_cols if c in basic_display_df.columns]].round(2) + + # Calculate advanced stats + advanced_df = comparison_df_raw.copy() + advanced_df['FGA'] = pd.to_numeric(advanced_df.get('FGA', 0), errors='coerce').fillna(0) + advanced_df['FTA'] = pd.to_numeric(advanced_df.get('FTA', 0), errors='coerce').fillna(0) + advanced_df['PTS'] = pd.to_numeric(advanced_df.get('PTS', 0), errors='coerce').fillna(0) + advanced_df['TS_PCT'] = advanced_df.apply( + lambda r: r['PTS'] / (2 * (r['FGA'] + 0.44 * r['FTA'])) if (r['FGA'] + 0.44 * r['FTA']) else 0, + axis=1 + ) + if len(selected_seasons) > 1: + advanced_display_df = advanced_df.groupby('Player').mean(numeric_only=True).reset_index() + else: + advanced_display_df = advanced_df.copy() - if is_match: deleted = True - else: new_relationships.append(rel) + advanced_cols = ['Player', 'Season', 'PTS', 'REB', 'AST', 'FG_PCT', 'TS_PCT'] + advanced_display_df = advanced_display_df[[c for c in advanced_cols if c in advanced_display_df.columns]].round(3) - if not deleted: return jsonify({"error": "Relationship to delete not found."}), 404 + return jsonify({ + 'basic_stats': basic_display_df.to_dict(orient='records'), + 'advanced_stats': advanced_display_df.to_dict(orient='records'), + 'players_with_no_data': players_with_no_data + }) + except Exception as e: + logging.error(f"Error in /api/nba/player_stats: {e}") + return jsonify({'error': str(e)}), 500 - tree_data["relationships"] = new_relationships - save_success, save_error = save_tree_data(norm_owner_phone_of_tree, tree_data, original_tree_data_for_save) - if not save_success: - return jsonify({"error": "Failed to save after deleting relationship", "detail": save_error}), 500 - - response_message = "Relationship deleted successfully." - if save_error: response_message += f" Warning: {save_error}" - return jsonify({"message": response_message}), 200 - - -# --- Story Management Endpoints (Protected) --- -@app.route('/tree//members//stories', methods=['POST']) -@token_required -def add_story_api(owner_phone_of_tree, member_id): - current_user_phone = flask_g.user["phone_number"] # Authenticated user adding the story - norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) - if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format"}), 400 - - # Anyone authenticated can add a story to any member of any tree they can see/access. - # The "added_by" field will track who added it. - # If stricter control is needed (e.g., only tree owner or proposer), add checks here. - - req_data = request.json - if not req_data or "text" not in req_data: - return jsonify({"error": "Missing story text in request body"}), 400 - story_text = req_data["text"].strip() - if not story_text: return jsonify({"error": "Story text cannot be empty"}), 400 - - tree_data, load_error = load_tree_data(norm_owner_phone_of_tree) - if load_error: return jsonify({"error": "Could not load tree data", "detail": load_error}), 500 - - original_tree_data_for_save = deepcopy(tree_data) # For save if current_user is owner - member = find_person_by_id(tree_data, member_id) - if not member: return jsonify({"error": "Member not found to add story to"}), 404 +@app.route('/api/nba/team_stats', methods=['POST']) +@cross_origin() +# No credit_required decorator here, as this is data fetching, not AI interaction +def get_team_stats(): + try: + data = request.get_json() + selected_teams = data.get('teams') + selected_season_str = data.get('season') - if "stories" not in member or not isinstance(member["stories"], list): - member["stories"] = [] - - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - new_story_entry = deepcopy(DEFAULT_STORY_STRUCTURE) - new_story_entry.update({ - "timestamp": timestamp, "text": story_text, "added_by": current_user_phone - }) - member["stories"].append(new_story_entry) - member["last_edited_at"] = timestamp # Also update member's last edit - member["last_edited_by"] = current_user_phone # User who added story is last editor of member for this change - - # If the authenticated user is the owner of the tree, save directly. - # Otherwise, this change should be part of a proposal. - if current_user_phone == norm_owner_phone_of_tree: - save_success, save_error = save_tree_data(norm_owner_phone_of_tree, tree_data, original_tree_data_for_save) - if not save_success: - return jsonify({"error": "Failed to save new story", "detail": save_error}), 500 - response_message = "Story added and saved successfully." - if save_error: response_message += f" Warning: {save_error}" - return jsonify({"message": response_message, "story": new_story_entry}), 201 - else: - # This endpoint is now for direct save by owner. Collaborators use /proposals. - # So, if not owner, this path shouldn't be hit for adding stories directly. - # However, if we allow anyone to add stories to any tree (public edit), this logic would change. - # For now, sticking to owner-edit or proposal. - # This implies that if a collaborator wants to add a story, they modify their copy of tree_data - # and then submit the entire tree_data via the /proposals endpoint. - return jsonify({"error": "Forbidden: Use proposals to add stories to trees you do not own."}), 403 - - -@app.route('/tree//members//stories/', methods=['DELETE']) -@token_required -def delete_story_api(owner_phone_of_tree, member_id, story_timestamp): - current_user_phone = flask_g.user["phone_number"] - norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) - if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format"}), 400 - - tree_data, load_error = load_tree_data(norm_owner_phone_of_tree) - if load_error: return jsonify({"error": "Could not load tree data", "detail": load_error}), 500 - - original_tree_data_for_save = deepcopy(tree_data) # For save if current_user is owner - member = find_person_by_id(tree_data, member_id) - if not member: return jsonify({"error": "Member not found"}), 404 - - stories = member.get("stories", []) - story_to_delete_index = -1 - for i, story in enumerate(stories): - if story.get("timestamp") == story_timestamp: - # Authorization: Only story adder or tree owner can delete. - can_delete = (story.get("added_by") == current_user_phone or current_user_phone == norm_owner_phone_of_tree) - if not can_delete: - return jsonify({"error": "Forbidden: You can only delete stories you added or if you are the tree owner."}), 403 - story_to_delete_index = i - break - - if story_to_delete_index == -1: - return jsonify({"error": "Story with given timestamp not found or you do not have permission to delete it."}), 404 - - del member["stories"][story_to_delete_index] - member["last_edited_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - member["last_edited_by"] = current_user_phone - - if current_user_phone == norm_owner_phone_of_tree: - save_success, save_error = save_tree_data(norm_owner_phone_of_tree, tree_data, original_tree_data_for_save) - if not save_success: - return jsonify({"error": "Failed to save after deleting story", "detail": save_error}), 500 - response_message = "Story deleted successfully." - if save_error: response_message += f" Warning: {save_error}" - return jsonify({"message": response_message}), 200 - else: - # Similar to add story, direct delete is for owner. Collaborators use proposals. - return jsonify({"error": "Forbidden: Use proposals to delete stories from trees you do not own (unless you added the story and it's a direct delete)."}), 403 + if not selected_teams or not selected_season_str: + return jsonify({'error': 'Teams and season are required'}), 400 + year_for_team_stats = int(selected_season_str.split('–')[1]) + tm_df = get_team_stats_bs(year_for_team_stats) -# --- Collaboration Endpoints (Protected) --- -@app.route('/tree//proposals', methods=['POST']) -@token_required -def propose_changes_api(owner_phone_of_tree): - proposer_phone_from_token = flask_g.user["phone_number"] # This is the authenticated user making the proposal - norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) + if tm_df.empty: + return jsonify({'error': f'No team data available for {selected_season_str}'}), 404 - if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format in URL"}), 400 - if norm_owner_phone_of_tree == proposer_phone_from_token: - return jsonify({"error": "Cannot propose changes to your own tree. Edit directly."}), 400 + stats = [] + teams_with_no_data = [] - req_data = request.json - # The "proposer_phone" in req_data is redundant if we use token, but client might send it. We trust the token. - if not req_data or "proposed_data" not in req_data: - return jsonify({"error": "Missing proposed_data in request"}), 400 - - proposed_data = req_data["proposed_data"] + for t in selected_teams: + df = tm_df[tm_df.Team == t].copy() + if not df.empty: + df_dict = df.iloc[0].to_dict() + df_dict['Season'] = selected_season_str + stats.append(df_dict) + else: + teams_with_no_data.append(t) - success, error = propose_changes(norm_owner_phone_of_tree, proposer_phone_from_token, proposed_data) - if not success: - return jsonify({"error": "Failed to propose changes", "detail": error}), 500 - return jsonify({"message": "Changes proposed successfully."}), 201 + if not stats: + return jsonify({ + 'error': 'No data available for selected teams.', + 'teams_with_no_data': teams_with_no_data + }), 404 + comp = pd.DataFrame(stats) + for col in ['PTS', 'REB', 'AST', 'STL', 'BLK', 'FG_PCT', '3P_PCT', 'FT_PCT']: + if col in comp.columns: + comp[col] = pd.to_numeric(comp[col], errors='coerce') -@app.route('/tree/my_proposals/pending', methods=['GET']) # Get pending proposals FOR the authenticated user's tree(s) -@token_required -def get_my_pending_changes_api(): - owner_phone_from_token = flask_g.user["phone_number"] # Authenticated user is the owner - - pending_data, error = load_pending_changes(owner_phone_from_token) - if error: - return jsonify({"error": "Could not load pending changes for your tree(s)", "detail": error}), 500 - return jsonify(pending_data), 200 - - -@app.route('/tree/my_proposals//accept', methods=['POST']) -@token_required -def accept_changes_api(proposer_phone_to_manage): - owner_phone_from_token = flask_g.user["phone_number"] # Authenticated user is the owner - norm_proposer_phone = normalize_phone(proposer_phone_to_manage) - if not norm_proposer_phone: return jsonify({"error": "Invalid proposer_phone_to_manage format"}), 400 - - success, error = accept_changes(owner_phone_from_token, norm_proposer_phone) - if not success: - return jsonify({"error": "Failed to accept changes", "detail": error}), 500 - return jsonify({"message": "Changes accepted and merged successfully."}), 200 - - -@app.route('/tree/my_proposals//reject', methods=['POST']) -@token_required -def reject_changes_api(proposer_phone_to_manage): - owner_phone_from_token = flask_g.user["phone_number"] # Authenticated user is the owner - norm_proposer_phone = normalize_phone(proposer_phone_to_manage) - if not norm_proposer_phone: return jsonify({"error": "Invalid proposer_phone_to_manage format"}), 400 - - success, error = reject_changes(owner_phone_from_token, norm_proposer_phone) - if not success: - return jsonify({"error": "Failed to reject changes", "detail": error}), 500 - return jsonify({"message": "Changes rejected successfully."}), 200 - -@app.route('/tree/my_proposals//diff', methods=['GET']) -@token_required -def get_proposal_diff_api(proposer_phone_to_review): - owner_phone_from_token = flask_g.user["phone_number"] # Authenticated user is the owner - norm_proposer_phone = normalize_phone(proposer_phone_to_review) - if not norm_proposer_phone: return jsonify({"error": "Invalid proposer_phone_to_review format"}), 400 - - current_owner_data, load_err = load_tree_data(owner_phone_from_token) - if load_err: return jsonify({"error": "Could not load current owner tree data", "detail": load_err}), 500 - - pending_proposals, pend_err = load_pending_changes(owner_phone_from_token) - if pend_err: return jsonify({"error": "Could not load pending proposals", "detail": pend_err}), 500 - - # The keys in pending_proposals are already normalized proposer phones - proposal_payload = pending_proposals.get(norm_proposer_phone) - if not proposal_payload or "tree_data" not in proposal_payload: - return jsonify({"error": f"Proposal from {norm_proposer_phone} not found or invalid."}), 404 - - proposed_data = proposal_payload["tree_data"] - diff_summary_text = generate_diff_summary(current_owner_data, proposed_data) - return jsonify({"diff_summary": diff_summary_text}), 200 - - -# --- AI Feature Endpoints (Protected as they operate on user's tree or consume user quota) --- -@app.route('/ai/build_tree_from_text', methods=['POST']) -@token_required # AI features should be protected -def ai_build_tree_api(): - # This endpoint doesn't directly modify a tree, just provides analysis. - # The owner_phone_of_tree context isn't strictly needed here unless we log usage against a tree. - # For now, it's a general utility for an authenticated user. - if api_key_error: return jsonify({"error": "Gemini API not configured"}), 503 - - req_data = request.json - if not req_data or "description" not in req_data: - return jsonify({"error": "Missing description in request body"}), 400 - description = req_data["description"] - - ai_result, error = generate_tree_from_description_gemini(description) - if error: - return jsonify({"error": "AI analysis failed", "detail": error}), 500 - return jsonify(ai_result), 200 - - -@app.route('/tree//ai_merge_suggestions', methods=['POST']) -@token_required -def ai_merge_tree_api(owner_phone_of_tree): - current_user_phone = flask_g.user["phone_number"] - norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) - if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format"}), 400 - - if current_user_phone != norm_owner_phone_of_tree: - return jsonify({"error": "Forbidden: You can only merge AI suggestions into your own tree."}), 403 - if api_key_error: return jsonify({"error": "Gemini API features disabled."}), 503 - - - req_data = request.json - if not req_data or "ai_result" not in req_data: - return jsonify({"error": "Missing ai_result in request body"}), 400 - - ai_result_data = req_data["ai_result"] - if not isinstance(ai_result_data, dict) or "people" not in ai_result_data or "relationships" not in ai_result_data: - return jsonify({"error": "Invalid ai_result format"}), 400 - - tree_data, load_error = load_tree_data(norm_owner_phone_of_tree) - if load_error: return jsonify({"error": "Could not load tree data for merge", "detail": load_error}), 500 - - original_tree_data_for_save = deepcopy(tree_data) - - # (Merge logic from previous version - slightly adapted for current_user_phone) - ai_people = ai_result_data.get("people", []) - ai_rels = ai_result_data.get("relationships", []) - valid_people = [p for p in ai_people if isinstance(p, dict) and p.get("name")] - valid_rel_structs = [r for r in ai_rels if isinstance(r, dict) and all(k in r for k in ["person1_name", "person2_name", "type"])] - ai_people_names = {p.get('name') for p in valid_people} - valid_rels_final = [r for r in valid_rel_structs if r.get('person1_name') in ai_people_names and r.get('person2_name') in ai_people_names] - - if not valid_people and not valid_rels_final: - return jsonify({"message": "No valid people or relationships from AI to merge."}), 200 - - added_p_count, added_r_count, updated_p_names, skipped_p_names, skipped_r_desc = 0, 0, [], [], [] - id_map = {} - current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - for person_ai in valid_people: - ai_name = person_ai.get("name") - if not ai_name: continue - existing_people_with_name = find_person_by_name(tree_data, ai_name) - existing_person = existing_people_with_name if existing_people_with_name else None - person_updated = False - if existing_person: - id_map[ai_name] = existing_person['id'] - for key in ['dob', 'dod', 'gender', 'totem']: - if not existing_person.get(key) and person_ai.get(key): - if key == 'gender' and person_ai.get(key) == 'Unknown': continue - existing_person[key] = person_ai.get(key); person_updated = True - if person_updated: - existing_person["last_edited_at"] = current_time; existing_person["last_edited_by"] = current_user_phone - updated_p_names.append(ai_name) - else: skipped_p_names.append(ai_name + " (exists, no new info)") - else: - new_id = generate_unique_id(); id_map[ai_name] = new_id - new_data = deepcopy(DEFAULT_MEMBER_STRUCTURE) - valid_keys = ['name', 'dob', 'dod', 'gender', 'totem'] - new_data.update({k: v for k, v in person_ai.items() if k in valid_keys and v}) - new_data.update({"id": new_id, "created_at": current_time, "created_by": current_user_phone, - "last_edited_at": current_time, "last_edited_by": current_user_phone}) - tree_data.setdefault("family_members", []).append(new_data) - added_p_count += 1 - - current_rels_list = tree_data.setdefault("relationships", []) - existing_rel_set = set() - for r_exist in current_rels_list: # Build set of existing relationships - if not isinstance(r_exist, dict): continue - from_id_e, to_id_e, r_type_e = r_exist.get('from_id'), r_exist.get('to_id'), r_exist.get('type') - if not from_id_e or not to_id_e or not r_type_e: continue - if r_type_e == 'parent': existing_rel_set.add( (from_id_e, to_id_e, r_type_e) ) - elif r_type_e in ['spouse', 'sibling']: existing_rel_set.add( (tuple(sorted((from_id_e, to_id_e))), r_type_e) ) - - rel_type_map = {"parent_of":'parent', "spouse_of":'spouse', "sibling_of":'sibling'} - for rel_ai in valid_rels_final: - p1n, p2n, rta = rel_ai.get("person1_name"), rel_ai.get("person2_name"), rel_ai.get("type") - id1, id2 = id_map.get(p1n), id_map.get(p2n) - rti = rel_type_map.get(rta) - if id1 and id2 and rti: - exists, rel_key = False, None - if rti == 'parent': rel_key = (id1, id2, rti) - elif rti in ['spouse', 'sibling']: rel_key = (tuple(sorted((id1, id2))), rti) - if rel_key and rel_key in existing_rel_set: exists = True - if exists: skipped_r_desc.append(f"{p1n} {rta.replace('_',' ')} {p2n} (exists)") - else: - current_rels_list.append({"from_id": id1, "to_id": id2, "type": rti}); added_r_count += 1 - if rel_key: existing_rel_set.add(rel_key) - else: skipped_r_desc.append(f"{p1n} {rta.replace('_',' ')} {p2n} (missing person ID or invalid type)") - - if added_p_count > 0 or added_r_count > 0 or updated_p_names: - save_success, save_error = save_tree_data(norm_owner_phone_of_tree, tree_data, original_tree_data_for_save) - if not save_success: - return jsonify({"error": "Failed to save merged AI data", "detail": save_error}), 500 - merge_summary_msg = f"AI Merge: Added {added_p_count} people, {added_r_count} relationships. Updated {len(updated_p_names)} people." - if save_error: merge_summary_msg += f" Warning: {save_error}" - return jsonify({"message": merge_summary_msg, "skipped_people": skipped_p_names, "skipped_relationships": skipped_r_desc}), 200 - else: - return jsonify({"message": "No new information from AI to merge.", "skipped_people": skipped_p_names, "skipped_relationships": skipped_r_desc}), 200 + return jsonify({ + 'team_stats': comp.to_dict(orient='records'), + 'teams_with_no_data': teams_with_no_data + }) + except Exception as e: + logging.error(f"Error in /api/nba/team_stats: {e}") + return jsonify({'error': str(e)}), 500 +@app.route('/api/nba/perplexity_explain', methods=['POST']) +@credit_required(cost=1) # Costs 1 credit +@cross_origin() +def perplexity_explain(): + try: + data = request.get_json() + prompt = data.get('prompt') + + if not prompt: + return jsonify({'error': 'Prompt is required'}), 400 + + explanation = ask_perp(prompt, system="You are an NBA expert analyst AI.") + if "Error from AI" in explanation: + return jsonify({'error': explanation}), 500 + + return jsonify({'explanation': explanation}) + except Exception as e: + logging.error(f"Error in /api/nba/perplexity_explain: {e}") + return jsonify({'error': str(e)}), 500 -@app.route('/tree//quiz/generate', methods=['POST']) -@token_required # Quiz generation might be resource-intensive or use API quotas -def generate_quiz_api(owner_phone_of_tree): - if api_key_error: return jsonify({"error": "Gemini API not configured"}), 503 - norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree) - if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format"}), 400 +@app.route('/api/nba/perplexity_chat', methods=['POST']) +@credit_required(cost=1) # Costs 1 credit +@cross_origin() +def perplexity_chat(): + try: + data = request.get_json() + prompt = data.get('prompt') + + if not prompt: + return jsonify({'error': 'Prompt is required'}), 400 + + # Get UID from the authenticated request for chat history storage + auth_header = request.headers.get('Authorization', '') + token = auth_header.split(' ')[1] + uid = verify_token(token) # Already verified by decorator, but need uid here + + # Get AI response + response_content = ask_perp(prompt, system="You are an NBA expert analyst AI.") + if "Error from AI" in response_content: + return jsonify({'error': response_content}), 500 + + # Store chat history in Firebase + if FIREBASE_INITIALIZED: + user_chat_ref = db.reference(f'users/{uid}/chat_history') + # Push user's message + user_chat_ref.push({ + 'role': 'user', + 'content': prompt, + 'timestamp': datetime.utcnow().isoformat() + }) + # Push AI's response + user_chat_ref.push({ + 'role': 'assistant', + 'content': response_content, + 'timestamp': datetime.utcnow().isoformat() + }) + logging.info(f"Chat history updated for user {uid}.") + else: + logging.warning("Firebase not initialized. Chat history will not be saved.") - # Authenticated user can generate quiz for any tree they can view. - # The owner_phone_of_tree in URL specifies which tree's data to use. + return jsonify({'response': response_content}) + except Exception as e: + logging.error(f"Error in /api/nba/perplexity_chat: {e}") + return jsonify({'error': str(e)}), 500 - num_questions = request.json.get("num_questions", 5) if request.json else 5 +@app.route('/api/nba/awards_predictor', methods=['POST']) +@credit_required(cost=1) # Costs 1 credit +@cross_origin() +def awards_predictor(): + try: + data = request.get_json() + award_type = data.get('award_type') + criteria = data.get('criteria') + + if not award_type or not criteria: + return jsonify({'error': 'Award type and criteria are required'}), 400 + + prompt = f"Predict top 5 {award_type} candidates based on {criteria}. Focus on 2024-25 season." + prediction = ask_perp(prompt, system="You are an NBA awards expert AI.") + if "Error from AI" in prediction: + return jsonify({'error': prediction}), 500 + + return jsonify({'prediction': prediction}) + except Exception as e: + logging.error(f"Error in /api/nba/awards_predictor: {e}") + return jsonify({'error': str(e)}), 500 - tree_data, load_error = load_tree_data(norm_owner_phone_of_tree) - if load_error or not tree_data: return jsonify({"error": "Could not load tree data for quiz", "detail": load_error or "Tree data is empty"}), 500 +@app.route('/api/nba/young_player_projection', methods=['POST']) +@credit_required(cost=1) # Costs 1 credit +@cross_origin() +def young_player_projection(): + try: + data = request.get_json() + player_name = data.get('player_name') + age = data.get('age') + years_in_nba = data.get('years_in_nba') + ppg = data.get('ppg') + rpg = data.get('rpg') + apg = data.get('apg') + + if not all([player_name, age, years_in_nba, ppg, rpg, apg]): + return jsonify({'error': 'All player details are required for projection'}), 400 + + prompt = ( + f"Analyze and project the future potential of NBA player {player_name}: " + f"Current Stats: Age={age}, Years in NBA={years_in_nba}, PPG={ppg}, RPG={rpg}, APG={apg}. " + "Please provide: 1. 3-year projection of their stats. " + "2. Peak potential analysis. 3. Areas for improvement. " + "4. Comparison to similar players at the same age. 5. Career trajectory prediction. " + "Base your analysis on historical player development patterns and current NBA trends." + ) + projection = ask_perp(prompt, system="You are an NBA projection expert AI.") + if "Error from AI" in projection: + return jsonify({'error': projection}), 500 + + return jsonify({'projection': projection}) + except Exception as e: + logging.error(f"Error in /api/nba/young_player_projection: {e}") + return jsonify({'error': str(e)}), 500 - members = tree_data.get("family_members", []) - relationships = tree_data.get("relationships", []) +@app.route('/api/nba/similar_players', methods=['POST']) +@credit_required(cost=1) # Costs 1 credit +@cross_origin() +def similar_players(): + try: + data = request.get_json() + target_player = data.get('target_player') + criteria = data.get('criteria') - questions, error = generate_quiz_questions_gemini(members, relationships, num_questions=num_questions) - if error: return jsonify({"error": "Failed to generate quiz questions", "detail": error}), 500 - if not questions or not isinstance(questions, list) or len(questions) == 0: - return jsonify({"error": "AI returned no valid questions or in an unexpected format."}), 500 - - valid_qs = [q for q in questions if isinstance(q, dict) and all(k in q for k in ['text','options','correct']) and isinstance(q['options'],list) and len(q['options']) > 1 and q['correct'] in q['options']] - if not valid_qs: - app.logger.error(f"AI Quiz Response Invalid Structure: {questions}") - return jsonify({"error": "AI returned questions in an invalid structure."}), 500 + if not target_player or not criteria: + return jsonify({'error': 'Target player and criteria are required'}), 400 + + prompt = f"Find top 5 current and top 3 historical similar to {target_player} based on the following criteria: {', '.join(criteria)}. Provide detailed reasoning." + similar_players_analysis = ask_perp(prompt, system="You are a similarity expert AI.") + if "Error from AI" in similar_players_analysis: + return jsonify({'error': similar_players_analysis}), 500 - return jsonify(valid_qs), 200 + return jsonify({'similar_players': similar_players_analysis}) + except Exception as e: + logging.error(f"Error in /api/nba/similar_players: {e}") + return jsonify({'error': str(e)}), 500 +@app.route('/api/nba/manual_player_compare', methods=['POST']) +@credit_required(cost=1) # Costs 1 credit +@cross_origin() +def manual_player_compare(): + try: + data = request.get_json() + player1 = data.get('player1') + player2 = data.get('player2') -# --- Settings & Profile Endpoints (Protected, for authenticated user's own tree) --- -@app.route('/user/me/settings', methods=['PUT']) -@token_required -def update_my_settings_api(): - current_user_phone = flask_g.user["phone_number"] # Settings are for the authenticated user's own tree - - req_data = request.json - if not req_data: return jsonify({"error": "Missing request body for settings"}), 400 + if not player1 or not player2: + return jsonify({'error': 'Both player1 and player2 are required'}), 400 + + prompt = ( + f"Compare {player1} vs {player2} in detail: 1. Statistical comparison (current season). " + "2. Playing style similarities and differences. 3. Strengths and weaknesses of each. " + "4. Team impact and role. 5. Overall similarity score (1-10). Provide a comprehensive comparison with specific examples." + ) + comparison = ask_perp(prompt, system="You are a comparison expert AI.") + if "Error from AI" in comparison: + return jsonify({'error': comparison}), 500 + + return jsonify({'comparison': comparison}) + except Exception as e: + logging.error(f"Error in /api/nba/manual_player_compare: {e}") + return jsonify({'error': str(e)}), 500 - tree_data, load_error = load_tree_data(current_user_phone) # Load user's own tree - if load_error: return jsonify({"error": "Could not load tree data for settings", "detail": load_error}), 500 - - original_tree_data_for_save = deepcopy(tree_data) - settings = tree_data.setdefault("settings", deepcopy(DEFAULT_PROFILE["settings"])) - metadata = tree_data.setdefault("metadata", deepcopy(DEFAULT_PROFILE["metadata"])) - changed = False - - if "tree_name" in req_data and metadata.get("tree_name") != req_data["tree_name"]: - metadata["tree_name"] = req_data["tree_name"]; changed = True - if "theme" in req_data and settings.get("theme") != req_data["theme"]: - settings["theme"] = req_data["theme"]; changed = True - if "privacy" in req_data and settings.get("privacy") != req_data["privacy"]: - settings["privacy"] = req_data["privacy"]; changed = True # Conceptual - - if changed: - save_success, save_error = save_tree_data(current_user_phone, tree_data, original_tree_data_for_save) - if not save_success: - return jsonify({"error": "Failed to save settings", "detail": save_error}), 500 - response_message = "Settings updated successfully." - if save_error: response_message += f" Warning: {save_error}" - return jsonify({"message": response_message, "settings": settings, "metadata": metadata}), 200 - else: - return jsonify({"message": "No changes made to settings."}), 200 +@app.route('/api/nba/roster_suggestions', methods=['POST']) +@credit_required(cost=1) # Costs 1 credit +@cross_origin() +def roster_suggestions(): + try: + data = request.get_json() + salary_cap = data.get('salary_cap') + strategy = data.get('strategy') + priority_positions = data.get('priority_positions') + budgets = data.get('budgets') + + if not all([salary_cap, strategy, priority_positions, budgets]): + return jsonify({'error': 'All roster building parameters are required'}), 400 + + total_allocated = sum(budgets.values()) + if total_allocated > salary_cap: + return jsonify({'error': 'Budget exceeds salary cap!'}), 400 + + prompt = ( + f"Build an NBA roster with the following constraints: " + f"Salary Cap: ${salary_cap} million, Team Strategy: {strategy}, " + f"Priority Positions: {', '.join(priority_positions)}, " + f"Position Budgets: {budgets}. " + "Please provide: 1. Starting lineup with specific player recommendations. " + "2. Key bench players (6th man, backup center, etc.). " + "3. Total estimated salary breakdown. " + "4. Rationale for each major signing. " + "5. How this roster fits the chosen strategy. " + "6. Potential weaknesses and how to address them. " + "Focus on realistic player availability and current market values." + ) + suggestions = ask_perp(prompt, system="You are an NBA roster building expert AI.") + if "Error from AI" in suggestions: + return jsonify({'error': suggestions}), 500 + + return jsonify({'suggestions': suggestions}) + except Exception as e: + logging.error(f"Error in /api/nba/roster_suggestions: {e}") + return jsonify({'error': str(e)}), 500 +@app.route('/api/nba/trade_analysis', methods=['POST']) +@credit_required(cost=1) # Costs 1 credit +@cross_origin() +def trade_analysis(): + try: + data = request.get_json() + team1_trades = data.get('team1_trades') + team2_trades = data.get('team2_trades') -@app.route('/user/me/profile', methods=['PUT']) -@token_required -def update_my_profile_api(): - current_user_phone = flask_g.user["phone_number"] # Profile is for the authenticated user's own tree - - req_data = request.json - if not req_data: return jsonify({"error": "Missing request body for profile"}), 400 - - tree_data, load_error = load_tree_data(current_user_phone) - if load_error: return jsonify({"error": "Could not load tree data for profile", "detail": load_error}), 500 - - original_tree_data_for_save = deepcopy(tree_data) - profile = tree_data.setdefault("profile", deepcopy(DEFAULT_PROFILE["profile"])) - profile_updated, me_node_updated = False, False - - # Update profile section - if "name" in req_data and profile.get("name") != req_data["name"]: - profile["name"] = req_data["name"]; profile_updated = True - if "dob" in req_data and profile.get("dob") != req_data["dob"]: - profile["dob"] = req_data["dob"]; profile_updated = True - if "gender" in req_data and profile.get("gender") != req_data["gender"]: - profile["gender"] = req_data["gender"]; profile_updated = True - - # Sync relevant profile changes to the 'Me' node in family_members - me_node = find_person_by_id(tree_data, "Me") - if me_node and me_node.get("phone") == current_user_phone: # Ensure it's the correct 'Me' node - # Use profile name for 'Me' node if available and different, else keep 'Me' or existing name - me_node_name_update = profile.get("name") if profile.get("name") else me_node.get("name", "Me") - if me_node.get("name") != me_node_name_update: me_node["name"] = me_node_name_update; me_node_updated = True + if not team1_trades or not team2_trades: + return jsonify({'error': 'Both team1_trades and team2_trades are required'}), 400 - if "dob" in req_data and me_node.get("dob") != req_data["dob"]: - me_node["dob"] = req_data["dob"]; me_node_updated = True - if "gender" in req_data and me_node.get("gender") != req_data["gender"]: - me_node["gender"] = req_data["gender"]; me_node_updated = True + prompt = ( + f"Analyze this potential NBA trade: Team 1 trades: {team1_trades}. Team 2 trades: {team2_trades}. " + "Please evaluate: 1. Fair value assessment. 2. How this trade helps each team. " + "3. Salary cap implications. 4. Impact on team chemistry and performance. " + "5. Likelihood of this trade happening. 6. Alternative trade suggestions. " + "Consider current team needs and player contracts." + ) + analysis = ask_perp(prompt, system="You are a trade analysis AI.") + if "Error from AI" in analysis: + return jsonify({'error': analysis}), 500 - if me_node_updated: - me_node["last_edited_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - me_node["last_edited_by"] = current_user_phone - elif not me_node: - app.logger.warning(f"Could not find 'Me' node for owner {current_user_phone} to sync profile.") - - if profile_updated or me_node_updated: - save_success, save_error = save_tree_data(current_user_phone, tree_data, original_tree_data_for_save) - if not save_success: - return jsonify({"error": "Failed to save profile updates", "detail": save_error}), 500 - response_message = "Profile updated successfully." - if save_error: response_message += f" Warning: {save_error}" - return jsonify({"message": response_message, "profile": profile, "me_node_updated": me_node_updated}), 200 - else: - return jsonify({"message": "No changes made to profile."}), 200 - -# --- Family Timeline Endpoint (Public GET or @token_required if private) --- -@app.route('/tree//timeline', methods=['GET']) -def get_timeline_api(owner_phone_of_tree): - system_error_response = check_system_health() - if system_error_response: return system_error_response - norm_phone = normalize_phone(owner_phone_of_tree) - if not norm_phone: return jsonify({"error": "Invalid owner_phone_of_tree format"}), 400 - - tree_data, load_error = load_tree_data(norm_phone) - if load_error or not tree_data: return jsonify({"error": "Could not load tree data for timeline", "detail": load_error or "Tree data is empty"}), 500 - - members = tree_data.get("family_members", []) - events = [] - valid_date_format = "%Y-%m-%d" - for person in members: - if not isinstance(person, dict): continue - name = person.get("name", "Unknown") - dob_str, dod_str = person.get("dob"), person.get("dod") - try: - if dob_str: - datetime.strptime(dob_str, valid_date_format) # Validate format - events.append({"date": dob_str, "type": "Birth", "desc": f"{name} born."}) - if dod_str: - datetime.strptime(dod_str, valid_date_format) # Validate format - events.append({"date": dod_str, "type": "Death", "desc": f"{name} passed."}) - except (ValueError, TypeError): pass - - try: events.sort(key=lambda x: x.get("date", "0000-00-00")) - except Exception as e: app.logger.warning(f"Timeline sort error: {e}.") - - return jsonify(events), 200 + return jsonify({'analysis': analysis}) + except Exception as e: + logging.error(f"Error in /api/nba/trade_analysis: {e}") + return jsonify({'error': str(e)}), 500 -# --------- Run the App --------- -if __name__ == "__main__": - app.run(host="0.0.0.0", port=7860, debug=True) \ No newline at end of file +# ---------- Main ---------- +if __name__ == '__main__': + app.run(debug=True, host="0.0.0.0", port=7860) \ No newline at end of file