import datetime import hashlib import json import logging import os import requests from functools import wraps from typing import Optional from fastapi import HTTPException, Response from fastapi.security import OAuth2PasswordBearer from redis import Redis date = f'{datetime.datetime.now().year}-{datetime.datetime.now().month}-{datetime.datetime.now().day}' logger = logging.getLogger('discord_app') # date = f'{datetime.datetime.now().year}-{datetime.datetime.now().month}-{datetime.datetime.now().day}' # log_level = logger.info if os.environ.get('LOG_LEVEL') == 'INFO' else 'WARN' # logging.basicConfig( # filename=f'logs/database/{date}.log', # format='%(asctime)s - sba-database - %(levelname)s - %(message)s', # level=log_level # ) # Redis configuration REDIS_HOST = os.environ.get('REDIS_HOST', 'localhost') REDIS_PORT = int(os.environ.get('REDIS_PORT', '6379')) REDIS_DB = int(os.environ.get('REDIS_DB', '0')) CACHE_ENABLED = os.environ.get('CACHE_ENABLED', 'true').lower() == 'true' # Initialize Redis client with connection error handling if not CACHE_ENABLED: logger.info("Caching disabled via CACHE_ENABLED=false") redis_client = None else: try: redis_client = Redis( host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB, decode_responses=True, socket_connect_timeout=5, socket_timeout=5 ) # Test connection redis_client.ping() logger.info(f"Redis connected successfully at {REDIS_HOST}:{REDIS_PORT}") except Exception as e: logger.warning(f"Redis connection failed: {e}. Caching will be disabled.") redis_client = None oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") priv_help = False if not os.environ.get('PRIVATE_IN_SCHEMA') else os.environ.get('PRIVATE_IN_SCHEMA').upper() PRIVATE_IN_SCHEMA = True if priv_help == 'TRUE' else False def valid_token(token): return token == os.environ.get('API_TOKEN') def update_season_batting_stats(player_ids, season, db_connection): """ Update season batting stats for specific players in a given season. Recalculates stats from stratplay data and upserts into seasonbattingstats table. """ if not player_ids: logger.warning("update_season_batting_stats called with empty player_ids list") return # Convert single player_id to list for consistency if isinstance(player_ids, int): player_ids = [player_ids] logger.info(f"Updating season batting stats for {len(player_ids)} players in season {season}") try: # SQL query to recalculate and upsert batting stats query = """ WITH batting_stats AS ( SELECT p.id AS player_id, p.name, p.sbaplayer_id, p.team_id AS player_team_id, t.abbrev AS player_team_abbrev, sg.season, -- Counting statistics (summed from StratPlays) SUM(sp.pa) AS pa, SUM(sp.ab) AS ab, SUM(sp.run) AS run, SUM(sp.hit) AS hit, SUM(sp.double) AS double, SUM(sp.triple) AS triple, SUM(sp.homerun) AS homerun, SUM(sp.rbi) AS rbi, SUM(sp.bb) AS bb, SUM(sp.so) AS so, SUM(sp.bphr) AS bphr, SUM(sp.bpfo) AS bpfo, SUM(sp.bp1b) AS bp1b, SUM(sp.bplo) AS bplo, SUM(sp.gidp) AS gidp, SUM(sp.hbp) AS hbp, SUM(sp.sac) AS sac, SUM(sp.ibb) AS ibb, -- Calculated statistics using formulas CASE WHEN SUM(sp.ab) > 0 THEN ROUND(SUM(sp.hit)::DECIMAL / SUM(sp.ab), 3) ELSE 0.000 END AS avg, CASE WHEN SUM(sp.pa) > 0 THEN ROUND((SUM(sp.hit) + SUM(sp.bb) + SUM(sp.hbp) + SUM(sp.ibb))::DECIMAL / SUM(sp.pa), 3) ELSE 0.000 END AS obp, CASE WHEN SUM(sp.ab) > 0 THEN ROUND((SUM(sp.hit) + SUM(sp.double) + 2 * SUM(sp.triple) + 3 * SUM(sp.homerun))::DECIMAL / SUM(sp.ab), 3) ELSE 0.000 END AS slg, CASE WHEN SUM(sp.pa) > 0 AND SUM(sp.ab) > 0 THEN ROUND( ((SUM(sp.hit) + SUM(sp.bb) + SUM(sp.hbp) + SUM(sp.ibb))::DECIMAL / SUM(sp.pa)) + ((SUM(sp.hit) + SUM(sp.double) + 2 * SUM(sp.triple) + 3 * SUM(sp.homerun))::DECIMAL / SUM(sp.ab)), 3) ELSE 0.000 END AS ops, -- wOBA calculation (simplified version) CASE WHEN SUM(sp.pa) > 0 THEN ROUND((0.690 * SUM(sp.bb) + 0.722 * SUM(sp.hbp) + 0.888 * (SUM(sp.hit) - SUM(sp.double) - SUM(sp.triple) - SUM(sp.homerun)) + 1.271 * SUM(sp.double) + 1.616 * SUM(sp.triple) + 2.101 * SUM(sp.homerun))::DECIMAL / SUM(sp.pa), 3) ELSE 0.000 END AS woba, CASE WHEN SUM(sp.pa) > 0 THEN ROUND(SUM(sp.so)::DECIMAL / SUM(sp.pa) * 100, 1) ELSE 0.0 END AS k_pct FROM stratplay sp JOIN stratgame sg ON sg.id = sp.game_id JOIN player p ON p.id = sp.batter_id JOIN team t ON t.id = p.team_id WHERE sg.season = %s AND p.id = ANY(%s) GROUP BY p.id, p.name, p.sbaplayer_id, p.team_id, t.abbrev, sg.season ), running_stats AS ( SELECT sp.runner_id AS player_id, sg.season, SUM(sp.sb) AS sb, SUM(sp.cs) AS cs FROM stratplay sp JOIN stratgame sg ON sg.id = sp.game_id WHERE sg.season = %s AND sp.runner_id IS NOT NULL AND sp.runner_id = ANY(%s) GROUP BY sp.runner_id, sg.season ) INSERT INTO seasonbattingstats ( player_id, sbaplayer_id, team_id, season, name, player_team_id, player_team_abbrev, pa, ab, run, hit, double, triple, homerun, rbi, bb, so, bphr, bpfo, bp1b, bplo, gidp, hbp, sac, ibb, avg, obp, slg, ops, woba, k_pct, sb, cs ) SELECT bs.player_id, bs.sbaplayer_id, bs.player_team_id, bs.season, bs.name, bs.player_team_id, bs.player_team_abbrev, bs.pa, bs.ab, bs.run, bs.hit, bs.double, bs.triple, bs.homerun, bs.rbi, bs.bb, bs.so, bs.bphr, bs.bpfo, bs.bp1b, bs.bplo, bs.gidp, bs.hbp, bs.sac, bs.ibb, bs.avg, bs.obp, bs.slg, bs.ops, bs.woba, bs.k_pct, COALESCE(rs.sb, 0) AS sb, COALESCE(rs.cs, 0) AS cs FROM batting_stats bs LEFT JOIN running_stats rs ON bs.player_id = rs.player_id AND bs.season = rs.season ON CONFLICT (player_id, season) DO UPDATE SET sbaplayer_id = EXCLUDED.sbaplayer_id, team_id = EXCLUDED.team_id, name = EXCLUDED.name, player_team_id = EXCLUDED.player_team_id, player_team_abbrev = EXCLUDED.player_team_abbrev, pa = EXCLUDED.pa, ab = EXCLUDED.ab, run = EXCLUDED.run, hit = EXCLUDED.hit, double = EXCLUDED.double, triple = EXCLUDED.triple, homerun = EXCLUDED.homerun, rbi = EXCLUDED.rbi, bb = EXCLUDED.bb, so = EXCLUDED.so, bphr = EXCLUDED.bphr, bpfo = EXCLUDED.bpfo, bp1b = EXCLUDED.bp1b, bplo = EXCLUDED.bplo, gidp = EXCLUDED.gidp, hbp = EXCLUDED.hbp, sac = EXCLUDED.sac, ibb = EXCLUDED.ibb, avg = EXCLUDED.avg, obp = EXCLUDED.obp, slg = EXCLUDED.slg, ops = EXCLUDED.ops, woba = EXCLUDED.woba, k_pct = EXCLUDED.k_pct, sb = EXCLUDED.sb, cs = EXCLUDED.cs; """ # Execute the query with parameters using the passed database connection db_connection.execute_sql(query, [season, player_ids, season, player_ids]) logger.info(f"Successfully updated season batting stats for {len(player_ids)} players in season {season}") except Exception as e: logger.error(f"Error updating season batting stats: {e}") raise def update_season_pitching_stats(player_ids, season, db_connection): """ Update season pitching stats for specific players in a given season. Recalculates stats from stratplay and decision data and upserts into seasonpitchingstats table. """ if not player_ids: logger.warning("update_season_pitching_stats called with empty player_ids list") return # Convert single player_id to list for consistency if isinstance(player_ids, int): player_ids = [player_ids] logger.info(f"Updating season pitching stats for {len(player_ids)} players in season {season}") try: # SQL query to recalculate and upsert pitching stats query = """ WITH pitching_stats AS ( SELECT p.id AS player_id, p.name, p.sbaplayer_id, p.team_id AS player_team_id, t.abbrev AS player_team_abbrev, sg.season, -- Counting statistics (summed from StratPlays) SUM(sp.pa) AS tbf, SUM(sp.ab) AS ab, SUM(sp.run) AS run, SUM(sp.e_run) AS e_run, SUM(sp.hit) AS hits, SUM(sp.double) AS double, SUM(sp.triple) AS triple, SUM(sp.homerun) AS homerun, SUM(sp.bb) AS bb, SUM(sp.so) AS so, SUM(sp.hbp) AS hbp, SUM(sp.sac) AS sac, SUM(sp.ibb) AS ibb, SUM(sp.gidp) AS gidp, SUM(sp.sb) AS sb, SUM(sp.cs) AS cs, SUM(sp.bphr) AS bphr, SUM(sp.bpfo) AS bpfo, SUM(sp.bp1b) AS bp1b, SUM(sp.bplo) AS bplo, SUM(sp.wild_pitch) AS wp, SUM(sp.balk) AS balk, SUM(sp.outs) AS outs, COALESCE(SUM(sp.wpa), 0) AS wpa, COALESCE(SUM(sp.re24_primary), 0) AS re24, -- Calculated statistics using formulas CASE WHEN SUM(sp.outs) > 0 THEN ROUND((SUM(sp.e_run) * 27)::DECIMAL / SUM(sp.outs), 2) ELSE 0.00 END AS era, CASE WHEN SUM(sp.outs) > 0 THEN ROUND(((SUM(sp.bb) + SUM(sp.hit) + SUM(sp.ibb)) * 3)::DECIMAL / SUM(sp.outs), 2) ELSE 0.00 END AS whip, CASE WHEN SUM(sp.ab) > 0 THEN ROUND(SUM(sp.hit)::DECIMAL / SUM(sp.ab), 3) ELSE 0.000 END AS avg, CASE WHEN SUM(sp.pa) > 0 THEN ROUND((SUM(sp.hit) + SUM(sp.bb) + SUM(sp.hbp) + SUM(sp.ibb))::DECIMAL / SUM(sp.pa), 3) ELSE 0.000 END AS obp, CASE WHEN SUM(sp.ab) > 0 THEN ROUND((SUM(sp.hit) + SUM(sp.double) + 2 * SUM(sp.triple) + 3 * SUM(sp.homerun))::DECIMAL / SUM(sp.ab), 3) ELSE 0.000 END AS slg, CASE WHEN SUM(sp.pa) > 0 AND SUM(sp.ab) > 0 THEN ROUND( ((SUM(sp.hit) + SUM(sp.bb) + SUM(sp.hbp) + SUM(sp.ibb))::DECIMAL / SUM(sp.pa)) + ((SUM(sp.hit) + SUM(sp.double) + 2 * SUM(sp.triple) + 3 * SUM(sp.homerun))::DECIMAL / SUM(sp.ab)), 3) ELSE 0.000 END AS ops, -- wOBA calculation (same as batting) CASE WHEN SUM(sp.pa) > 0 THEN ROUND((0.690 * SUM(sp.bb) + 0.722 * SUM(sp.hbp) + 0.888 * (SUM(sp.hit) - SUM(sp.double) - SUM(sp.triple) - SUM(sp.homerun)) + 1.271 * SUM(sp.double) + 1.616 * SUM(sp.triple) + 2.101 * SUM(sp.homerun))::DECIMAL / SUM(sp.pa), 3) ELSE 0.000 END AS woba, -- Rate stats CASE WHEN SUM(sp.outs) > 0 THEN ROUND((SUM(sp.hit) * 9)::DECIMAL / (SUM(sp.outs) / 3.0), 1) ELSE 0.0 END AS hper9, CASE WHEN SUM(sp.outs) > 0 THEN ROUND((SUM(sp.so) * 9)::DECIMAL / (SUM(sp.outs) / 3.0), 1) ELSE 0.0 END AS kper9, CASE WHEN SUM(sp.outs) > 0 THEN ROUND((SUM(sp.bb) * 9)::DECIMAL / (SUM(sp.outs) / 3.0), 1) ELSE 0.0 END AS bbper9, CASE WHEN SUM(sp.bb) > 0 THEN ROUND(SUM(sp.so)::DECIMAL / SUM(sp.bb), 2) ELSE 0.0 END AS kperbb FROM stratplay sp JOIN stratgame sg ON sg.id = sp.game_id JOIN player p ON p.id = sp.pitcher_id JOIN team t ON t.id = p.team_id WHERE sg.season = %s AND p.id = ANY(%s) AND sp.pitcher_id IS NOT NULL GROUP BY p.id, p.name, p.sbaplayer_id, p.team_id, t.abbrev, sg.season ), decision_stats AS ( SELECT d.pitcher_id AS player_id, sg.season, SUM(d.win) AS win, SUM(d.loss) AS loss, SUM(d.hold) AS hold, SUM(d.is_save) AS saves, SUM(d.b_save) AS bsave, SUM(d.irunners) AS ir, SUM(d.irunners_scored) AS irs, SUM(d.is_start::INTEGER) AS gs, COUNT(d.game_id) AS games FROM decision d JOIN stratgame sg ON sg.id = d.game_id WHERE sg.season = %s AND d.pitcher_id = ANY(%s) GROUP BY d.pitcher_id, sg.season ) INSERT INTO seasonpitchingstats ( player_id, sbaplayer_id, team_id, season, name, player_team_id, player_team_abbrev, tbf, outs, games, gs, win, loss, hold, saves, bsave, ir, irs, ab, run, e_run, hits, double, triple, homerun, bb, so, hbp, sac, ibb, gidp, sb, cs, bphr, bpfo, bp1b, bplo, wp, balk, wpa, era, whip, avg, obp, slg, ops, woba, hper9, kper9, bbper9, kperbb, lob_2outs, rbipercent, re24 ) SELECT ps.player_id, ps.sbaplayer_id, ps.player_team_id, ps.season, ps.name, ps.player_team_id, ps.player_team_abbrev, ps.tbf, ps.outs, COALESCE(ds.games, 0), COALESCE(ds.gs, 0), COALESCE(ds.win, 0), COALESCE(ds.loss, 0), COALESCE(ds.hold, 0), COALESCE(ds.saves, 0), COALESCE(ds.bsave, 0), COALESCE(ds.ir, 0), COALESCE(ds.irs, 0), ps.ab, ps.run, ps.e_run, ps.hits, ps.double, ps.triple, ps.homerun, ps.bb, ps.so, ps.hbp, ps.sac, ps.ibb, ps.gidp, ps.sb, ps.cs, ps.bphr, ps.bpfo, ps.bp1b, ps.bplo, ps.wp, ps.balk, ps.wpa * -1, ps.era, ps.whip, ps.avg, ps.obp, ps.slg, ps.ops, ps.woba, ps.hper9, ps.kper9, ps.bbper9, ps.kperbb, 0.0, 0.0, COALESCE(ps.re24 * -1, 0.0) FROM pitching_stats ps LEFT JOIN decision_stats ds ON ps.player_id = ds.player_id AND ps.season = ds.season ON CONFLICT (player_id, season) DO UPDATE SET sbaplayer_id = EXCLUDED.sbaplayer_id, team_id = EXCLUDED.team_id, name = EXCLUDED.name, player_team_id = EXCLUDED.player_team_id, player_team_abbrev = EXCLUDED.player_team_abbrev, tbf = EXCLUDED.tbf, outs = EXCLUDED.outs, games = EXCLUDED.games, gs = EXCLUDED.gs, win = EXCLUDED.win, loss = EXCLUDED.loss, hold = EXCLUDED.hold, saves = EXCLUDED.saves, bsave = EXCLUDED.bsave, ir = EXCLUDED.ir, irs = EXCLUDED.irs, ab = EXCLUDED.ab, run = EXCLUDED.run, e_run = EXCLUDED.e_run, hits = EXCLUDED.hits, double = EXCLUDED.double, triple = EXCLUDED.triple, homerun = EXCLUDED.homerun, bb = EXCLUDED.bb, so = EXCLUDED.so, hbp = EXCLUDED.hbp, sac = EXCLUDED.sac, ibb = EXCLUDED.ibb, gidp = EXCLUDED.gidp, sb = EXCLUDED.sb, cs = EXCLUDED.cs, bphr = EXCLUDED.bphr, bpfo = EXCLUDED.bpfo, bp1b = EXCLUDED.bp1b, bplo = EXCLUDED.bplo, wp = EXCLUDED.wp, balk = EXCLUDED.balk, wpa = EXCLUDED.wpa, era = EXCLUDED.era, whip = EXCLUDED.whip, avg = EXCLUDED.avg, obp = EXCLUDED.obp, slg = EXCLUDED.slg, ops = EXCLUDED.ops, woba = EXCLUDED.woba, hper9 = EXCLUDED.hper9, kper9 = EXCLUDED.kper9, bbper9 = EXCLUDED.bbper9, kperbb = EXCLUDED.kperbb, lob_2outs = EXCLUDED.lob_2outs, rbipercent = EXCLUDED.rbipercent, re24 = EXCLUDED.re24; """ # Execute the query with parameters using the passed database connection db_connection.execute_sql(query, [season, player_ids, season, player_ids]) logger.info(f"Successfully updated season pitching stats for {len(player_ids)} players in season {season}") except Exception as e: logger.error(f"Error updating season pitching stats: {e}") raise def send_webhook_message(message: str) -> bool: """ Send a message to Discord via webhook. Args: message: The message content to send Returns: bool: True if successful, False otherwise """ webhook_url = "https://discord.com/api/webhooks/1408811717424840876/7RXG_D5IqovA3Jwa9YOobUjVcVMuLc6cQyezABcWuXaHo5Fvz1en10M7J43o3OJ3bzGW" try: payload = { "content": message } response = requests.post(webhook_url, json=payload, timeout=10) response.raise_for_status() logger.info(f"Webhook message sent successfully: {message[:100]}...") return True except requests.exceptions.RequestException as e: logger.error(f"Failed to send webhook message: {e}") return False except Exception as e: logger.error(f"Unexpected error sending webhook message: {e}") return False def cache_result(ttl: int = 300, key_prefix: str = "api", normalize_params: bool = True): """ Decorator to cache function results in Redis with parameter normalization. Args: ttl: Time to live in seconds (default: 5 minutes) key_prefix: Prefix for cache keys (default: "api") normalize_params: Remove None/empty values to reduce cache variations (default: True) Usage: @cache_result(ttl=600, key_prefix="stats") async def get_player_stats(player_id: int, season: Optional[int] = None): # expensive operation return stats # These will use the same cache entry when normalize_params=True: # get_player_stats(123, None) and get_player_stats(123) """ def decorator(func): @wraps(func) async def wrapper(*args, **kwargs): # Skip caching if Redis is not available if redis_client is None: return await func(*args, **kwargs) try: # Normalize parameters to reduce cache variations normalized_kwargs = kwargs.copy() if normalize_params: # Remove None values and empty collections normalized_kwargs = { k: v for k, v in kwargs.items() if v is not None and v != [] and v != "" and v != {} } # Generate more readable cache key args_str = "_".join(str(arg) for arg in args if arg is not None) kwargs_str = "_".join([ f"{k}={v}" for k, v in sorted(normalized_kwargs.items()) ]) # Combine args and kwargs for cache key key_parts = [key_prefix, func.__name__] if args_str: key_parts.append(args_str) if kwargs_str: key_parts.append(kwargs_str) cache_key = ":".join(key_parts) # Truncate very long cache keys to prevent Redis key size limits if len(cache_key) > 200: cache_key = f"{key_prefix}:{func.__name__}:{hash(cache_key)}" # Try to get from cache cached_result = redis_client.get(cache_key) if cached_result is not None: logger.debug(f"Cache hit for key: {cache_key}") return json.loads(cached_result) # Cache miss - execute function logger.debug(f"Cache miss for key: {cache_key}") result = await func(*args, **kwargs) # Skip caching for Response objects (like CSV downloads) as they can't be properly serialized if not isinstance(result, Response): # Store in cache with TTL redis_client.setex( cache_key, ttl, json.dumps(result, default=str, ensure_ascii=False) ) else: logger.debug(f"Skipping cache for Response object from {func.__name__}") return result except Exception as e: # If caching fails, log error and continue without caching logger.error(f"Cache error for {func.__name__}: {e}") return await func(*args, **kwargs) return wrapper return decorator def invalidate_cache(pattern: str = "*"): """ Invalidate cache entries matching a pattern. Args: pattern: Redis pattern to match keys (default: "*" for all) Usage: invalidate_cache("stats:*") # Clear all stats cache invalidate_cache("api:get_player_*") # Clear specific player cache """ if redis_client is None: logger.warning("Cannot invalidate cache: Redis not available") return 0 try: keys = redis_client.keys(pattern) if keys: deleted = redis_client.delete(*keys) logger.info(f"Invalidated {deleted} cache entries matching pattern: {pattern}") return deleted else: logger.debug(f"No cache entries found matching pattern: {pattern}") return 0 except Exception as e: logger.error(f"Error invalidating cache: {e}") return 0 def get_cache_stats() -> dict: """ Get Redis cache statistics. Returns: dict: Cache statistics including memory usage, key count, etc. """ if redis_client is None: return {"status": "unavailable", "message": "Redis not connected"} try: info = redis_client.info() return { "status": "connected", "memory_used": info.get("used_memory_human", "unknown"), "total_keys": redis_client.dbsize(), "connected_clients": info.get("connected_clients", 0), "uptime_seconds": info.get("uptime_in_seconds", 0) } except Exception as e: logger.error(f"Error getting cache stats: {e}") return {"status": "error", "message": str(e)} def add_cache_headers( max_age: int = 300, cache_type: str = "public", vary: Optional[str] = None, etag: bool = False ): """ Decorator to add HTTP cache headers to FastAPI responses. Args: max_age: Cache duration in seconds (default: 5 minutes) cache_type: "public", "private", or "no-cache" (default: "public") vary: Vary header value (e.g., "Accept-Encoding, Authorization") etag: Whether to generate ETag based on response content Usage: @add_cache_headers(max_age=1800, cache_type="public") async def get_static_data(): return {"data": "static content"} @add_cache_headers(max_age=60, cache_type="private", vary="Authorization") async def get_user_data(): return {"data": "user specific"} """ def decorator(func): @wraps(func) async def wrapper(*args, **kwargs): result = await func(*args, **kwargs) # Handle different response types if isinstance(result, Response): response = result elif isinstance(result, dict) or isinstance(result, list): # Convert to Response with JSON content response = Response( content=json.dumps(result, default=str, ensure_ascii=False), media_type="application/json" ) else: # Handle other response types response = Response(content=str(result)) # Build Cache-Control header cache_control_parts = [cache_type] if cache_type != "no-cache" and max_age > 0: cache_control_parts.append(f"max-age={max_age}") response.headers["Cache-Control"] = ", ".join(cache_control_parts) # Add Vary header if specified if vary: response.headers["Vary"] = vary # Add ETag if requested if etag and (hasattr(result, '__dict__') or isinstance(result, (dict, list))): content_hash = hashlib.md5( json.dumps(result, default=str, sort_keys=True).encode() ).hexdigest() response.headers["ETag"] = f'"{content_hash}"' # Add Last-Modified header with current time for dynamic content response.headers["Last-Modified"] = datetime.datetime.now(datetime.timezone.utc).strftime( "%a, %d %b %Y %H:%M:%S GMT" ) return response return wrapper return decorator def handle_db_errors(func): """ Decorator to handle database connection errors and transaction rollbacks. Ensures proper cleanup of database connections and provides consistent error handling. Includes comprehensive logging with function context, timing, and stack traces. """ @wraps(func) async def wrapper(*args, **kwargs): import time import traceback from .db_engine import db # Import here to avoid circular imports start_time = time.time() func_name = f"{func.__module__}.{func.__name__}" # Sanitize arguments for logging (exclude sensitive data) safe_args = [] safe_kwargs = {} try: # Log sanitized arguments (avoid logging tokens, passwords, etc.) for arg in args: if hasattr(arg, '__dict__') and hasattr(arg, 'url'): # FastAPI Request object safe_args.append(f"Request({getattr(arg, 'method', 'UNKNOWN')} {getattr(arg, 'url', 'unknown')})") else: safe_args.append(str(arg)[:100]) # Truncate long values for key, value in kwargs.items(): if key.lower() in ['token', 'password', 'secret', 'key']: safe_kwargs[key] = '[REDACTED]' else: safe_kwargs[key] = str(value)[:100] # Truncate long values logger.info(f"Starting {func_name} - args: {safe_args}, kwargs: {safe_kwargs}") result = await func(*args, **kwargs) elapsed_time = time.time() - start_time logger.info(f"Completed {func_name} successfully in {elapsed_time:.3f}s") return result except Exception as e: elapsed_time = time.time() - start_time error_trace = traceback.format_exc() logger.error(f"Database error in {func_name} after {elapsed_time:.3f}s") logger.error(f"Function args: {safe_args}") logger.error(f"Function kwargs: {safe_kwargs}") logger.error(f"Exception: {str(e)}") logger.error(f"Full traceback:\n{error_trace}") try: logger.info(f"Attempting database rollback for {func_name}") db.rollback() logger.info(f"Database rollback successful for {func_name}") except Exception as rollback_error: logger.error(f"Rollback failed in {func_name}: {rollback_error}") finally: try: db.close() logger.info(f"Database connection closed for {func_name}") except Exception as close_error: logger.error(f"Error closing database connection in {func_name}: {close_error}") raise HTTPException(status_code=500, detail=f'Database error in {func_name}: {str(e)}') return wrapper