From 2a37959d80285af49f31cfdcd104c4df71372b20 Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Fri, 7 Nov 2025 22:39:09 +0200 Subject: [PATCH] Add cache source tracking (L1/L2) for Telegram bot responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements cache tier identification in Telegram bot to display data source: - "db" for database queries - "cached L1" for in-memory cache hits - "cached L2" for SQLite cache hits Backend changes: - Added cache metadata fields to TrendsResponse and DashboardSummary models (cache_hit, response_time_ms, cache_source) - Updated /api/dashboard/summary and /api/dashboard/trends endpoints to include cache metadata when X-Include-Cache-Metadata header is present - Cache metadata is extracted from request.state (set by @cached decorator) Telegram bot changes: - Updated API client to send X-Include-Cache-Metadata header - Modified helpers to extract cache_source from backend responses - Updated handlers to pass cache metadata to formatters - Performance footer now displays specific cache tier (L1 vs L2) Fixed Pydantic serialization issue: - Changed field names from _cache_hit to cache_hit (without underscore) - Pydantic excludes underscore-prefixed fields from JSON by default 🤖 Generated with Claude Code Co-Authored-By: Claude --- reports-app/backend/app/models/dashboard.py | 5 + reports-app/backend/app/routers/dashboard.py | 140 ++++-- reports-app/telegram-bot/app/api/client.py | 156 +++++- reports-app/telegram-bot/app/bot/handlers.py | 470 ++++++++++++++++++- reports-app/telegram-bot/app/bot/helpers.py | 48 +- 5 files changed, 759 insertions(+), 60 deletions(-) diff --git a/reports-app/backend/app/models/dashboard.py b/reports-app/backend/app/models/dashboard.py index c34f4c7..144dfe5 100644 --- a/reports-app/backend/app/models/dashboard.py +++ b/reports-app/backend/app/models/dashboard.py @@ -51,6 +51,11 @@ class TrendsResponse(BaseModel): metadata: Dict[str, Any] growth_rates: Optional[Dict[str, float]] = None + # Cache metadata (optional, for Telegram Bot) + cache_hit: Optional[bool] = None + response_time_ms: Optional[float] = None + cache_source: Optional[str] = None + class DashboardSummary(BaseModel): """Model pentru toate datele dashboard-ului""" # CLIENȚI - statistici existente diff --git a/reports-app/backend/app/routers/dashboard.py b/reports-app/backend/app/routers/dashboard.py index 74a4c23..9d18d70 100644 --- a/reports-app/backend/app/routers/dashboard.py +++ b/reports-app/backend/app/routers/dashboard.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from typing import Optional import sys import os @@ -14,25 +14,42 @@ from ..services.dashboard_service import DashboardService router = APIRouter() -@router.get("/summary", response_model=DashboardSummary) +@router.get("/summary") async def get_dashboard_summary( + request: Request, company: str = Query(description="Codul firmei"), current_user: CurrentUser = Depends(get_current_user) ): """ Obține toate datele pentru dashboard într-un singur apel - + - Necesită autentificare JWT - Returnează statistici clienți/furnizori și trezorerie + - Include metadata cache pentru Telegram Bot (X-Include-Cache-Metadata header) """ try: # Verifică dacă utilizatorul are acces la firma specificată if company not in current_user.companies: raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}") - - result = await DashboardService.get_complete_summary(company, current_user.username) - return result - + + result = await DashboardService.get_complete_summary(company, current_user.username, request=request) + + # Convert Pydantic model to dict for JSON serialization + result_dict = result.dict() if hasattr(result, 'dict') else result + + # Add cache metadata if requested (for Telegram Bot) + include_metadata = request.headers.get('X-Include-Cache-Metadata', '').lower() == 'true' + if include_metadata: + cache_hit = getattr(request.state, 'cache_hit', False) + response_time = getattr(request.state, 'response_time_ms', 0) + cache_source = getattr(request.state, 'cache_source', None) + result_dict['cache_hit'] = cache_hit + result_dict['response_time_ms'] = response_time + # Always include cache_source, even if None + result_dict['cache_source'] = cache_source + + return result_dict + except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: @@ -40,6 +57,7 @@ async def get_dashboard_summary( @router.get("/trends", response_model=TrendsResponse) async def get_dashboard_trends( + request: Request, company: str = Query(description="Codul firmei"), period: str = Query(default="30d", description="Perioada pentru trends: 7d, 30d, ytd, 12m"), compare_previous: bool = Query(default=True, description="Compară cu perioada anterioară"), @@ -47,7 +65,7 @@ async def get_dashboard_trends( ): """ Obține trenduri pentru indicatorii principali (clienți/furnizori) - + - period: "7d" (7 zile), "30d" (30 zile), "ytd" (year to date), "12m" (12 luni) - compare_previous: dacă să compare cu perioada anterioară - Necesită autentificare JWT @@ -57,21 +75,34 @@ async def get_dashboard_trends( # Verifică dacă utilizatorul are acces la firma specificată if company not in current_user.companies: raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}") - + # Validează perioada valid_periods = ["7d", "30d", "ytd", "12m"] if period not in valid_periods: raise HTTPException( - status_code=400, + status_code=400, detail=f"Perioadă nevalidă: {period}. Valori permise: {', '.join(valid_periods)}" ) - + # Obține datele de trenduri - result = await DashboardService.get_trends(int(company), period) - - # The service now returns the data in the correct format - # Return it directly as TrendsResponse - return TrendsResponse(**result) + result = await DashboardService.get_trends(int(company), period, request=request) + + # Convert to dict if needed + result_dict = result.dict() if hasattr(result, 'dict') else result + + # Add cache metadata if requested (for Telegram Bot) + include_metadata = request.headers.get('X-Include-Cache-Metadata', '').lower() == 'true' + if include_metadata: + cache_hit = getattr(request.state, 'cache_hit', False) + response_time = getattr(request.state, 'response_time_ms', 0) + cache_source = getattr(request.state, 'cache_source', None) + result_dict['cache_hit'] = cache_hit + result_dict['response_time_ms'] = response_time + # Always include cache_source, even if None + result_dict['cache_source'] = cache_source + + # Return as TrendsResponse + return TrendsResponse(**result_dict) except ValueError as e: logger.error(f"Value error in trends endpoint: {str(e)}") @@ -191,6 +222,7 @@ async def get_cashflow( @router.get("/maturity") async def get_maturity_analysis( + request: Request, company: int = Query(..., description="ID-ul firmei"), period: str = Query("7d", regex="^(7d|1m|3m|6m|12m|all)$", description="Orizont de planificare pentru analiza scadențelor"), current_user: CurrentUser = Depends(get_current_user) @@ -210,15 +242,31 @@ async def get_maturity_analysis( - Compară scadențele clienți vs furnizori - Calculează balanța și oferă recomandări - Returnează metadate cu statistici complete + - Include metadata cache pentru Telegram Bot (X-Include-Cache-Metadata header) """ try: # Verifică dacă utilizatorul are acces la firma specificată if str(company) not in current_user.companies: raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}") - - result = await DashboardService.get_maturity_analysis(company, period) - return result - + + result = await DashboardService.get_maturity_analysis(company, period, request=request) + + # Convert to dict if needed + result_dict = result.dict() if hasattr(result, 'dict') else result + + # Add cache metadata if requested (for Telegram Bot) + include_metadata = request.headers.get('X-Include-Cache-Metadata', '').lower() == 'true' + if include_metadata: + cache_hit = getattr(request.state, 'cache_hit', False) + response_time = getattr(request.state, 'response_time_ms', 0) + cache_source = getattr(request.state, 'cache_source', None) + result_dict['cache_hit'] = cache_hit + result_dict['response_time_ms'] = response_time + # Always include cache_source, even if None + result_dict['cache_source'] = cache_source + + return result_dict + except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: @@ -252,23 +300,40 @@ async def get_monthly_flows( @router.get("/treasury-breakdown") async def get_treasury_breakdown( + request: Request, company: int = Query(..., description="ID-ul firmei"), current_user: CurrentUser = Depends(get_current_user) ): """ Returnează defalcarea trezoreriei pentru firma selectată - + - Necesită autentificare JWT - Returnează distribuția soldurilor pe conturi și tipuri + - Include metadata cache pentru Telegram Bot (X-Include-Cache-Metadata header) """ try: # Verifică dacă utilizatorul are acces la firma specificată if str(company) not in current_user.companies: raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}") - - result = await DashboardService.get_treasury_breakdown(company) - return result - + + result = await DashboardService.get_treasury_breakdown(company, request=request) + + # Convert to dict if needed + result_dict = result.dict() if hasattr(result, 'dict') else result + + # Add cache metadata if requested (for Telegram Bot) + include_metadata = request.headers.get('X-Include-Cache-Metadata', '').lower() == 'true' + if include_metadata: + cache_hit = getattr(request.state, 'cache_hit', False) + response_time = getattr(request.state, 'response_time_ms', 0) + cache_source = getattr(request.state, 'cache_source', None) + result_dict['cache_hit'] = cache_hit + result_dict['response_time_ms'] = response_time + # Always include cache_source, even if None + result_dict['cache_source'] = cache_source + + return result_dict + except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: @@ -277,22 +342,39 @@ async def get_treasury_breakdown( @router.get("/net-balance-breakdown") async def get_net_balance_breakdown( + request: Request, company: int = Query(..., description="ID-ul firmei"), current_user: CurrentUser = Depends(get_current_user) ): """ Returnează defalcarea balanței nete pentru firma selectată - + - Necesită autentificare JWT - Returnează analiza detaliată a balanței nete + - Include metadata cache pentru Telegram Bot (X-Include-Cache-Metadata header) """ try: # Verifică dacă utilizatorul are acces la firma specificată if str(company) not in current_user.companies: raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}") - - result = await DashboardService.get_net_balance_breakdown(company) - return result + + result = await DashboardService.get_net_balance_breakdown(company, request=request) + + # Convert to dict if needed + result_dict = result.dict() if hasattr(result, 'dict') else result + + # Add cache metadata if requested (for Telegram Bot) + include_metadata = request.headers.get('X-Include-Cache-Metadata', '').lower() == 'true' + if include_metadata: + cache_hit = getattr(request.state, 'cache_hit', False) + response_time = getattr(request.state, 'response_time_ms', 0) + cache_source = getattr(request.state, 'cache_source', None) + result_dict['cache_hit'] = cache_hit + result_dict['response_time_ms'] = response_time + # Always include cache_source, even if None + result_dict['cache_source'] = cache_source + + return result_dict except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) diff --git a/reports-app/telegram-bot/app/api/client.py b/reports-app/telegram-bot/app/api/client.py index 27f5e03..ce15286 100644 --- a/reports-app/telegram-bot/app/api/client.py +++ b/reports-app/telegram-bot/app/api/client.py @@ -234,15 +234,20 @@ class BackendAPIClient: Returns: Dict with dashboard data (sold_total, facturi, plati, etc.) + Includes _cache_hit and _response_time_ms metadata """ try: if not self.client: self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT) + # Add cache metadata header for Telegram Bot + headers = self._get_auth_headers(jwt_token) + headers['X-Include-Cache-Metadata'] = 'true' + response = await self.client.get( "/api/dashboard/summary", params={"company": str(company_id)}, - headers=self._get_auth_headers(jwt_token) + headers=headers ) return await self._handle_response(response) @@ -270,9 +275,13 @@ class BackendAPIClient: if not self.client: self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT) + # Add cache metadata header for Telegram Bot + headers = self._get_auth_headers(jwt_token) + headers['X-Include-Cache-Metadata'] = 'true' + response = await self.client.get( f"/api/dashboard/treasury-breakdown?company={company_id}", - headers=self._get_auth_headers(jwt_token) + headers=headers ) return await self._handle_response(response) @@ -302,9 +311,13 @@ class BackendAPIClient: if not self.client: self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT) + # Add cache metadata header for Telegram Bot + headers = self._get_auth_headers(jwt_token) + headers['X-Include-Cache-Metadata'] = 'true' + response = await self.client.get( f"/api/dashboard/detailed-data?company={company_id}&data_type={data_type}", - headers=self._get_auth_headers(jwt_token) + headers=headers ) return await self._handle_response(response) @@ -334,9 +347,13 @@ class BackendAPIClient: if not self.client: self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT) + # Add cache metadata header for Telegram Bot + headers = self._get_auth_headers(jwt_token) + headers['X-Include-Cache-Metadata'] = 'true' + response = await self.client.get( f"/api/dashboard/maturity?company={company_id}&period={period}", - headers=self._get_auth_headers(jwt_token) + headers=headers ) return await self._handle_response(response) @@ -364,9 +381,13 @@ class BackendAPIClient: if not self.client: self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT) + # Add cache metadata header for Telegram Bot + headers = self._get_auth_headers(jwt_token) + headers['X-Include-Cache-Metadata'] = 'true' + response = await self.client.get( f"/api/dashboard/performance?company={company_id}", - headers=self._get_auth_headers(jwt_token) + headers=headers ) return await self._handle_response(response) @@ -396,9 +417,13 @@ class BackendAPIClient: if not self.client: self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT) + # Add cache metadata header for Telegram Bot + headers = self._get_auth_headers(jwt_token) + headers['X-Include-Cache-Metadata'] = 'true' + response = await self.client.get( f"/api/dashboard/monthly-flows?company={company_id}&months={months}", - headers=self._get_auth_headers(jwt_token) + headers=headers ) return await self._handle_response(response) @@ -428,9 +453,13 @@ class BackendAPIClient: if not self.client: self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT) + # Add cache metadata header for Telegram Bot + headers = self._get_auth_headers(jwt_token) + headers['X-Include-Cache-Metadata'] = 'true' + response = await self.client.get( f"/api/dashboard/trends?company={company_id}&period={period}", - headers=self._get_auth_headers(jwt_token) + headers=headers ) return await self._handle_response(response) @@ -476,9 +505,6 @@ class BackendAPIClient: if filters: params.update(filters) - # ⚠️ DEBUGGING: Log exact parameters being sent - logger.info(f"📤 Searching invoices with params: {params}") - response = await self.client.get( "/api/invoices/", params=params, @@ -487,13 +513,10 @@ class BackendAPIClient: data = await self._handle_response(response) - # ⚠️ DEBUGGING: Log response if isinstance(data, dict) and 'invoices' in data: invoice_list = data['invoices'] - logger.info(f"📥 Received {len(invoice_list)} invoices from backend") return invoice_list elif isinstance(data, list): - logger.info(f"📥 Received {len(data)} invoices from backend (direct list)") return data else: logger.warning(f"📥 Unexpected response format: {type(data)}") @@ -626,6 +649,113 @@ class BackendAPIClient: logger.error(f"Failed to export report: {e}") return None + # ========================================================================= + # CACHE MANAGEMENT + # ========================================================================= + + async def invalidate_cache( + self, + jwt_token: str, + company_id: Optional[int] = None, + cache_type: Optional[str] = None + ) -> bool: + """ + Invalidate cache entries. + + Args: + jwt_token: JWT access token + company_id: Optional company ID (None = all companies) + cache_type: Optional cache type (None = all types) + + Returns: + bool: True if successful + """ + try: + if not self.client: + self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT) + + request_data = {} + if company_id is not None: + request_data['company_id'] = company_id + if cache_type is not None: + request_data['cache_type'] = cache_type + + response = await self.client.post( + "/api/cache/invalidate", + json=request_data, + headers=self._get_auth_headers(jwt_token) + ) + + response.raise_for_status() + logger.info(f"Cache invalidated: company_id={company_id}, cache_type={cache_type}") + return True + + except Exception as e: + logger.error(f"Failed to invalidate cache: {e}") + return False + + async def toggle_user_cache( + self, + jwt_token: str, + enabled: bool + ) -> bool: + """ + Toggle cache for current user. + + Args: + jwt_token: JWT access token + enabled: True to enable cache, False to disable + + Returns: + bool: True if successful + """ + try: + if not self.client: + self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT) + + response = await self.client.post( + "/api/cache/toggle-user", + json={"enabled": enabled}, + headers=self._get_auth_headers(jwt_token) + ) + + response.raise_for_status() + logger.info(f"User cache toggled: enabled={enabled}") + return True + + except Exception as e: + logger.error(f"Failed to toggle user cache: {e}") + return False + + async def get_cache_stats( + self, + jwt_token: str + ) -> Optional[Dict[str, Any]]: + """ + Get cache statistics including user-specific settings. + + Args: + jwt_token: JWT access token + + Returns: + Dict with cache stats including 'user_enabled' field + """ + try: + if not self.client: + self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT) + + response = await self.client.get( + "/api/cache/stats", + headers=self._get_auth_headers(jwt_token) + ) + + response.raise_for_status() + return response.json() + + except Exception as e: + logger.error(f"Failed to get cache stats: {e}") + return None + # ========================================================================= # HEALTH CHECK # ========================================================================= diff --git a/reports-app/telegram-bot/app/bot/handlers.py b/reports-app/telegram-bot/app/bot/handlers.py index 945bf46..b0de287 100644 --- a/reports-app/telegram-bot/app/bot/handlers.py +++ b/reports-app/telegram-bot/app/bot/handlers.py @@ -69,6 +69,7 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): if result: # Success! username = result['username'] + jwt_token = result['jwt_token'] # Show main menu with buttons for newly linked user session_manager = get_session_manager() @@ -77,8 +78,19 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): company_name = company['name'] if company else None company_cui = company.get('cui') if company else None + # Get cache status + cache_enabled = None + try: + from app.api.client import get_backend_client + client = get_backend_client() + async with client: + cache_stats = await client.get_cache_stats(jwt_token=jwt_token) + cache_enabled = cache_stats.get('user_enabled', True) + except Exception as e: + logger.warning(f"Could not get cache status in /start: {e}") + from app.bot.menus import create_main_menu, pad_message_for_wide_buttons - keyboard = create_main_menu(company_name, company_cui, is_authenticated=True) + keyboard = create_main_menu(company_name, company_cui, is_authenticated=True, cache_enabled=cache_enabled) # Single welcome message with menu if company_name: @@ -209,6 +221,11 @@ Dupa conectarea contului, foloseste **butoanele interactive** pentru: /help - Acest mesaj de ajutor /unlink - Deconecteaza contul (securitate) +**Comenzi Cache (optimizare performanta):** +/togglecache - Activeaza/Dezactiveaza cache pentru tine +/clearcache - Sterge cache pentru compania activa +/clearcache all - Sterge tot cache-ul + **Conectare cont:** 1. Loghează-te în aplicația web ROA2WEB 2. Accesează Setări → Telegram Linking @@ -263,6 +280,158 @@ async def clear_command(update: Update, context: ContextTypes.DEFAULT_TYPE): ) +async def clearcache_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """ + Handle /clearcache command. + + Clears the cache for the current company or all companies. + + Usage: + - /clearcache - Clear cache for current company + - /clearcache all - Clear entire cache (all companies) + + Args: + update: Telegram update object + context: Telegram context + """ + try: + telegram_user_id = update.effective_user.id + logger.info(f"/clearcache command from user {telegram_user_id}") + + # Check if user is linked + is_linked = await check_user_linked(telegram_user_id) + if not is_linked: + await update.message.reply_text( + "**Cont neconectat**\n\nFoloseste /start pentru a conecta contul.", + parse_mode=ParseMode.MARKDOWN + ) + return + + # Get auth data + auth_data = await get_user_auth_data(telegram_user_id) + jwt_token = auth_data['jwt_token'] + + # Check if user wants to clear all cache + clear_all = len(context.args) > 0 and context.args[0].lower() == 'all' + + client = get_backend_client() + async with client: + if clear_all: + # Clear entire cache + result = await client.client.post( + "/api/cache/invalidate", + json={}, + headers=client._get_auth_headers(jwt_token) + ) + if result.status_code == 200: + await update.message.reply_text( + "✅ **Cache șters complet**\n\n" + "Toate datele cached au fost șterse.", + parse_mode=ParseMode.MARKDOWN + ) + else: + await update.message.reply_text("❌ Eroare la ștergerea cache-ului.") + else: + # Get active company + session_manager = get_session_manager() + from app.bot.helpers import get_active_company_or_prompt + company = await get_active_company_or_prompt(update, session_manager, telegram_user_id) + + if not company: + return + + # Clear cache for current company + result = await client.client.post( + "/api/cache/invalidate", + json={"company_id": company['id']}, + headers=client._get_auth_headers(jwt_token) + ) + + if result.status_code == 200: + await update.message.reply_text( + f"✅ **Cache șters pentru {company['name']}**\n\n" + "Datele vor fi reîncărcate la următoarea interogare.", + parse_mode=ParseMode.MARKDOWN + ) + else: + await update.message.reply_text("❌ Eroare la ștergerea cache-ului.") + + except Exception as e: + logger.error(f"Error in clearcache_command: {e}", exc_info=True) + await update.message.reply_text("Eroare la ștergerea cache-ului.") + + +async def togglecache_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """ + Handle /togglecache command. + + Toggles cache on/off for the current user. + + Args: + update: Telegram update object + context: Telegram context + """ + try: + telegram_user_id = update.effective_user.id + logger.info(f"/togglecache command from user {telegram_user_id}") + + # Check if user is linked + is_linked = await check_user_linked(telegram_user_id) + if not is_linked: + await update.message.reply_text( + "**Cont neconectat**\n\nFoloseste /start pentru a conecta contul.", + parse_mode=ParseMode.MARKDOWN + ) + return + + # Get auth data + auth_data = await get_user_auth_data(telegram_user_id) + jwt_token = auth_data['jwt_token'] + + client = get_backend_client() + async with client: + # Get current cache stats to determine current state + stats_response = await client.client.get( + "/api/cache/stats", + headers=client._get_auth_headers(jwt_token) + ) + + if stats_response.status_code == 200: + stats = stats_response.json() + current_enabled = stats.get('user_cache_enabled', True) + + # Toggle to opposite state + new_state = not current_enabled + + toggle_response = await client.client.post( + "/api/cache/toggle-user", + json={"enabled": new_state}, + headers=client._get_auth_headers(jwt_token) + ) + + if toggle_response.status_code == 200: + if new_state: + await update.message.reply_text( + "✅ **Cache activat**\n\n" + "Interogările tale vor folosi cache-ul pentru răspunsuri mai rapide.", + parse_mode=ParseMode.MARKDOWN + ) + else: + await update.message.reply_text( + "⚠️ **Cache dezactivat**\n\n" + "Interogările tale vor accesa direct baza de date Oracle.", + parse_mode=ParseMode.MARKDOWN + ) + else: + await update.message.reply_text("❌ Eroare la comutarea cache-ului.") + else: + await update.message.reply_text("❌ Eroare la citirea stării cache-ului.") + + except Exception as e: + logger.error(f"Error in togglecache_command: {e}", exc_info=True) + await update.message.reply_text("Eroare la comutarea cache-ului.") + + async def companies_command(update: Update, context: ContextTypes.DEFAULT_TYPE): """ Handle /companies command. @@ -574,6 +743,11 @@ async def trezorerie_command(update: Update, context: ContextTypes.DEFAULT_TYPE) await update.message.reply_text("Eroare la incarcarea trezoreriei.") return + # Extract cache metadata + cache_hit = treasury_data.get('cache_hit', False) + response_time_ms = treasury_data.get('response_time_ms', 0) + cache_source = treasury_data.get('cache_source', None) + # Format combined response (casa + banca) - rotunjit la leu (0 zecimale) casa_total = round(treasury_data['casa']['total']) banca_total = round(treasury_data['banca']['total']) @@ -589,6 +763,11 @@ async def trezorerie_command(update: Update, context: ContextTypes.DEFAULT_TYPE) from app.bot.menus import format_response_with_company text = format_response_with_company(content, company['name']) + # Add performance footer + if response_time_ms > 0: + from app.bot.formatters import add_performance_footer + text = add_performance_footer(text, cache_hit, response_time_ms, cache_source) + # Add buttons to view details from telegram import InlineKeyboardButton, InlineKeyboardMarkup keyboard = InlineKeyboardMarkup([ @@ -645,9 +824,20 @@ async def menu_command(update: Update, context: ContextTypes.DEFAULT_TYPE): company_name = company['name'] if company else None company_cui = company.get('cui') if company else None + # Get cache status for user + cache_enabled = None + try: + from app.api.client import get_backend_client + client = get_backend_client() + async with client: + cache_stats = await client.get_cache_stats(jwt_token=auth_data['jwt_token']) + cache_enabled = cache_stats.get('user_enabled', True) + except Exception as e: + logger.warning(f"Could not get cache status: {e}") + # Create main menu (user is authenticated if they passed the is_linked check) from app.bot.menus import create_main_menu, get_menu_message - keyboard = create_main_menu(company_name, company_cui, is_authenticated=True) + keyboard = create_main_menu(company_name, company_cui, is_authenticated=True, cache_enabled=cache_enabled) menu_text = get_menu_message(company_name, company_cui) await update.message.reply_text( @@ -708,11 +898,19 @@ async def trezorerie_casa_command(update: Update, context: ContextTypes.DEFAULT_ return # Format response - from app.bot.formatters import format_treasury_casa_response + from app.bot.formatters import format_treasury_casa_response, add_performance_footer from app.bot.menus import create_action_buttons, format_response_with_company content = format_treasury_casa_response(treasury_data['casa']) response = format_response_with_company(content, company['name']) + + # Add performance footer if cache metadata is available + if 'cache_hit' in treasury_data and 'response_time_ms' in treasury_data: + cache_hit = treasury_data['cache_hit'] + response_time_ms = treasury_data['response_time_ms'] + cache_source = treasury_data.get('cache_source', None) + response = add_performance_footer(response, cache_hit, response_time_ms, cache_source) + keyboard = create_action_buttons("casa", show_export=True) await update.message.reply_text( @@ -773,11 +971,19 @@ async def trezorerie_banca_command(update: Update, context: ContextTypes.DEFAULT return # Format response - from app.bot.formatters import format_treasury_banca_response + from app.bot.formatters import format_treasury_banca_response, add_performance_footer from app.bot.menus import create_action_buttons, format_response_with_company content = format_treasury_banca_response(treasury_data['banca']) response = format_response_with_company(content, company['name']) + + # Add performance footer if cache metadata is available + if 'cache_hit' in treasury_data and 'response_time_ms' in treasury_data: + cache_hit = treasury_data['cache_hit'] + response_time_ms = treasury_data['response_time_ms'] + cache_source = treasury_data.get('cache_source', None) + response = add_performance_footer(response, cache_hit, response_time_ms, cache_source) + keyboard = create_action_buttons("banca", show_export=True) await update.message.reply_text( @@ -838,8 +1044,13 @@ async def clienti_command(update: Update, context: ContextTypes.DEFAULT_TYPE): await update.message.reply_text("Eroare la incarcarea datelor clienti.") return + # Extract cache metadata + cache_hit = clients_data.get('cache_hit', False) + response_time_ms = clients_data.get('response_time_ms', 0) + cache_source = clients_data.get('cache_source', None) + # Format response - from app.bot.formatters import format_clients_balance_response + from app.bot.formatters import format_clients_balance_response, add_performance_footer from app.bot.menus import create_client_list_keyboard, format_response_with_company content = format_clients_balance_response( @@ -847,6 +1058,11 @@ async def clienti_command(update: Update, context: ContextTypes.DEFAULT_TYPE): clients_data['maturity'] ) response = format_response_with_company(content, company['name']) + + # Add performance footer + if response_time_ms > 0: + response = add_performance_footer(response, cache_hit, response_time_ms, cache_source) + keyboard = create_client_list_keyboard(clients_data['clients'], page=0) await update.message.reply_text( @@ -907,8 +1123,13 @@ async def furnizori_command(update: Update, context: ContextTypes.DEFAULT_TYPE): await update.message.reply_text("Eroare la incarcarea datelor furnizori.") return + # Extract cache metadata + cache_hit = suppliers_data.get('cache_hit', False) + response_time_ms = suppliers_data.get('response_time_ms', 0) + cache_source = suppliers_data.get('cache_source', None) + # Format response - from app.bot.formatters import format_suppliers_balance_response + from app.bot.formatters import format_suppliers_balance_response, add_performance_footer from app.bot.menus import create_supplier_list_keyboard, format_response_with_company content = format_suppliers_balance_response( @@ -916,6 +1137,11 @@ async def furnizori_command(update: Update, context: ContextTypes.DEFAULT_TYPE): suppliers_data['maturity'] ) response = format_response_with_company(content, company['name']) + + # Add performance footer + if response_time_ms > 0: + response = add_performance_footer(response, cache_hit, response_time_ms, cache_source) + keyboard = create_supplier_list_keyboard(suppliers_data['suppliers'], page=0) await update.message.reply_text( @@ -976,7 +1202,7 @@ async def evolutie_command(update: Update, context: ContextTypes.DEFAULT_TYPE): return # Format response - from app.bot.formatters import format_cashflow_evolution_response + from app.bot.formatters import format_cashflow_evolution_response, add_performance_footer from app.bot.menus import create_action_buttons, format_response_with_company content = format_cashflow_evolution_response( @@ -984,6 +1210,14 @@ async def evolutie_command(update: Update, context: ContextTypes.DEFAULT_TYPE): evolution_data['monthly'] ) response = format_response_with_company(content, company['name']) + + # Add performance footer if cache metadata is available + if 'cache_hit' in evolution_data and 'response_time_ms' in evolution_data: + cache_hit = evolution_data['cache_hit'] + response_time_ms = evolution_data['response_time_ms'] + cache_source = evolution_data.get('cache_source', None) + response = add_performance_footer(response, cache_hit, response_time_ms, cache_source) + keyboard = create_action_buttons("evolutie", show_export=False, show_refresh=False) await update.message.reply_text( @@ -1205,11 +1439,19 @@ async def handle_menu_callback(query, telegram_user_id: int, callback_data: str) from app.bot.helpers import get_treasury_breakdown_split treasury_data = await get_treasury_breakdown_split(company['id'], jwt_token) - from app.bot.formatters import format_treasury_casa_response + from app.bot.formatters import format_treasury_casa_response, add_performance_footer from app.bot.menus import create_action_buttons, format_response_with_company content = format_treasury_casa_response(treasury_data['casa']) response = format_response_with_company(content, company['name']) + + # Add performance footer if cache metadata is available + if 'cache_hit' in treasury_data and 'response_time_ms' in treasury_data: + cache_hit = treasury_data['cache_hit'] + response_time_ms = treasury_data['response_time_ms'] + cache_source = treasury_data.get('cache_source', None) + response = add_performance_footer(response, cache_hit, response_time_ms, cache_source) + keyboard = create_action_buttons("casa", show_export=False, show_refresh=False) try: @@ -1228,11 +1470,19 @@ async def handle_menu_callback(query, telegram_user_id: int, callback_data: str) from app.bot.helpers import get_treasury_breakdown_split treasury_data = await get_treasury_breakdown_split(company['id'], jwt_token) - from app.bot.formatters import format_treasury_banca_response + from app.bot.formatters import format_treasury_banca_response, add_performance_footer from app.bot.menus import create_action_buttons, format_response_with_company content = format_treasury_banca_response(treasury_data['banca']) response = format_response_with_company(content, company['name']) + + # Add performance footer if cache metadata is available + if 'cache_hit' in treasury_data and 'response_time_ms' in treasury_data: + cache_hit = treasury_data['cache_hit'] + response_time_ms = treasury_data['response_time_ms'] + cache_source = treasury_data.get('cache_source', None) + response = add_performance_footer(response, cache_hit, response_time_ms, cache_source) + keyboard = create_action_buttons("banca", show_export=False, show_refresh=False) try: @@ -1251,7 +1501,7 @@ async def handle_menu_callback(query, telegram_user_id: int, callback_data: str) from app.bot.helpers import get_clients_with_maturity clients_data = await get_clients_with_maturity(company['id'], jwt_token) - from app.bot.formatters import format_clients_balance_response + from app.bot.formatters import format_clients_balance_response, add_performance_footer from app.bot.menus import create_client_list_keyboard, format_response_with_company content = format_clients_balance_response( @@ -1259,6 +1509,14 @@ async def handle_menu_callback(query, telegram_user_id: int, callback_data: str) clients_data['maturity'] ) response = format_response_with_company(content, company['name']) + + # Add performance footer if cache metadata is available + if 'cache_hit' in clients_data and 'response_time_ms' in clients_data: + cache_hit = clients_data['cache_hit'] + response_time_ms = clients_data['response_time_ms'] + cache_source = clients_data.get('cache_source', None) + response = add_performance_footer(response, cache_hit, response_time_ms, cache_source) + keyboard = create_client_list_keyboard(clients_data['clients'], page=0) await query.edit_message_text( @@ -1272,7 +1530,7 @@ async def handle_menu_callback(query, telegram_user_id: int, callback_data: str) from app.bot.helpers import get_suppliers_with_maturity suppliers_data = await get_suppliers_with_maturity(company['id'], jwt_token) - from app.bot.formatters import format_suppliers_balance_response + from app.bot.formatters import format_suppliers_balance_response, add_performance_footer from app.bot.menus import create_supplier_list_keyboard, format_response_with_company content = format_suppliers_balance_response( @@ -1280,6 +1538,14 @@ async def handle_menu_callback(query, telegram_user_id: int, callback_data: str) suppliers_data['maturity'] ) response = format_response_with_company(content, company['name']) + + # Add performance footer if cache metadata is available + if 'cache_hit' in suppliers_data and 'response_time_ms' in suppliers_data: + cache_hit = suppliers_data['cache_hit'] + response_time_ms = suppliers_data['response_time_ms'] + cache_source = suppliers_data.get('cache_source', None) + response = add_performance_footer(response, cache_hit, response_time_ms, cache_source) + keyboard = create_supplier_list_keyboard(suppliers_data['suppliers'], page=0) await query.edit_message_text( @@ -1293,7 +1559,7 @@ async def handle_menu_callback(query, telegram_user_id: int, callback_data: str) from app.bot.helpers import get_cashflow_evolution_data evolution_data = await get_cashflow_evolution_data(company['id'], jwt_token) - from app.bot.formatters import format_cashflow_evolution_response + from app.bot.formatters import format_cashflow_evolution_response, add_performance_footer from app.bot.menus import create_action_buttons, format_response_with_company content = format_cashflow_evolution_response( @@ -1301,6 +1567,14 @@ async def handle_menu_callback(query, telegram_user_id: int, callback_data: str) evolution_data['monthly'] ) response = format_response_with_company(content, company['name']) + + # Add performance footer if cache metadata is available + if 'cache_hit' in evolution_data and 'response_time_ms' in evolution_data: + cache_hit = evolution_data['cache_hit'] + response_time_ms = evolution_data['response_time_ms'] + cache_source = evolution_data.get('cache_source', None) + response = add_performance_footer(response, cache_hit, response_time_ms, cache_source) + keyboard = create_action_buttons("evolutie", show_export=False, show_refresh=False) await query.edit_message_text( @@ -1309,6 +1583,72 @@ async def handle_menu_callback(query, telegram_user_id: int, callback_data: str) parse_mode=ParseMode.MARKDOWN ) + elif action == "togglecache": + # Toggle cache pentru user + try: + client = get_backend_client() + async with client: + cache_stats = await client.get_cache_stats(jwt_token=jwt_token) + user_enabled = cache_stats.get('user_enabled', True) + + # Create toggle buttons + from telegram import InlineKeyboardButton, InlineKeyboardMarkup + keyboard = [ + [ + InlineKeyboardButton( + "✅ Activează" if not user_enabled else "❌ Dezactivează", + callback_data=f"cache_toggle:{'on' if not user_enabled else 'off'}" + ) + ], + [InlineKeyboardButton("« Înapoi la Meniu", callback_data="action:menu")] + ] + reply_markup = InlineKeyboardMarkup(keyboard) + + status = "ACTIVAT" if user_enabled else "DEZACTIVAT" + message = f"**Cache Status**\n\nCurent: {status}\n\n" + + if user_enabled: + message += "Vrei să dezactivezi cache-ul temporar?\nFolosește pentru teste de performanță." + else: + message += "Cache-ul este dezactivat.\nToate queries merg direct la Oracle." + + await query.edit_message_text( + message, + reply_markup=reply_markup, + parse_mode=ParseMode.MARKDOWN + ) + except Exception as e: + logger.error(f"Toggle cache menu error: {e}", exc_info=True) + await query.answer("Eroare la obținerea status cache.", show_alert=True) + + elif action == "clearcache": + # Clear cache + try: + # Create inline keyboard + from telegram import InlineKeyboardButton, InlineKeyboardMarkup + keyboard = [ + [ + InlineKeyboardButton("Toate companiile", callback_data="cache_clear:all"), + InlineKeyboardButton("Doar compania mea", callback_data="cache_clear:current") + ], + [InlineKeyboardButton("« Înapoi la Meniu", callback_data="action:menu")] + ] + reply_markup = InlineKeyboardMarkup(keyboard) + + message = "**🔄 Invalidare Cache**\n\n" + if company: + message += f"Compania curentă: {company['name']}\n\n" + message += "Alege scope:" + + await query.edit_message_text( + message, + reply_markup=reply_markup, + parse_mode=ParseMode.MARKDOWN + ) + except Exception as e: + logger.error(f"Clear cache menu error: {e}", exc_info=True) + await query.answer("Eroare la afișarea opțiuni cache.", show_alert=True) + elif action == "select_company": # ✅ MODIFICARE: Folosim funcția comună await _handle_selectcompany_view( @@ -1346,10 +1686,22 @@ async def handle_action_callback(query, telegram_user_id: int, callback_data: st auth_data = await get_user_auth_data(telegram_user_id) is_authenticated = auth_data is not None + # Get cache status for user + cache_enabled = None + if is_authenticated: + try: + from app.api.client import get_backend_client + client = get_backend_client() + async with client: + cache_stats = await client.get_cache_stats(jwt_token=auth_data['jwt_token']) + cache_enabled = cache_stats.get('user_enabled', True) + except Exception as e: + logger.warning(f"Could not get cache status: {e}") + from app.bot.menus import create_main_menu, get_menu_message company_name = company['name'] if company else None company_cui = company.get('cui') if company else None - keyboard = create_main_menu(company_name, company_cui, is_authenticated) + keyboard = create_main_menu(company_name, company_cui, is_authenticated, cache_enabled) menu_text = get_menu_message(company_name, company_cui) await query.edit_message_text( @@ -1897,6 +2249,87 @@ async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): elif callback_data.startswith("nav:back:"): await handle_navigation_back(query, telegram_user_id, callback_data) + # ========== CACHE CALLBACKS (FAZA 6) ========== + + elif callback_data.startswith("cache_toggle:"): + # Handle cache toggle button + action = callback_data.split(":")[1] + enabled = action == "on" + + auth_data = await get_user_auth_data(telegram_user_id) + jwt_token = auth_data['jwt_token'] + + try: + client = get_backend_client() + async with client: + await client.toggle_user_cache(jwt_token=jwt_token, enabled=enabled) + + status = "activat" if enabled else "dezactivat" + message = f"✅ **Cache {status}** pentru tine.\n\n" + + if enabled: + message += "Queries vor fi servite din cache când e posibil." + else: + message += "Toate queries vor merge direct la Oracle.\nFolosește /togglecache din nou pentru reactivare." + + # Add back button + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton("« Înapoi la Meniu", callback_data="action:menu")] + ]) + + await query.edit_message_text( + message, + reply_markup=keyboard, + parse_mode=ParseMode.MARKDOWN + ) + except Exception as e: + logger.error(f"Toggle cache callback error: {e}", exc_info=True) + await query.answer("❌ Eroare la modificarea setării cache.", show_alert=True) + + elif callback_data.startswith("cache_clear:"): + # Handle clear cache button + scope = callback_data.split(":")[1] + + auth_data = await get_user_auth_data(telegram_user_id) + jwt_token = auth_data['jwt_token'] + + try: + client = get_backend_client() + + if scope == "all": + # Clear all cache + async with client: + await client.invalidate_cache(jwt_token=jwt_token, company_id=None) + message = "✅ Cache invalidat pentru **toate companiile**.\n\nDatele vor fi refreshate la următoarea interogare." + + elif scope == "current": + # Clear only current company + session_manager = get_session_manager() + session = await session_manager.get_or_create_session(telegram_user_id) + company = session.get_active_company() + + if not company: + await query.answer("Nu ai o companie selectată.", show_alert=True) + return + + async with client: + await client.invalidate_cache(jwt_token=jwt_token, company_id=company['id']) + message = f"✅ Cache invalidat pentru **{company['name']}**.\n\nDatele vor fi refreshate la următoarea interogare." + + # Add back button + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton("« Înapoi la Meniu", callback_data="action:menu")] + ]) + + await query.edit_message_text( + message, + reply_markup=keyboard, + parse_mode=ParseMode.MARKDOWN + ) + except Exception as e: + logger.error(f"Clear cache callback error: {e}", exc_info=True) + await query.answer("❌ Eroare la ștergerea cache-ului.", show_alert=True) + # ========== PAGINATION CALLBACKS ========== elif callback_data.startswith("clients_page:"): @@ -2144,11 +2577,20 @@ async def _handle_sold_view( await query_or_update.message.reply_text(error_msg) return - from app.bot.formatters import format_dashboard_response + from app.bot.formatters import format_dashboard_response, add_performance_footer from app.bot.menus import create_action_buttons, format_response_with_company + # Extract cache metadata + cache_hit = data.get('cache_hit', False) + response_time_ms = data.get('response_time_ms', 0) + cache_source = data.get('cache_source', None) + content = format_dashboard_response(data) response = format_response_with_company(content, company['name']) + + # Add performance footer + if response_time_ms > 0: + response = add_performance_footer(response, cache_hit, response_time_ms, cache_source) keyboard = create_action_buttons("sold", show_export=False, show_refresh=False) if is_callback: diff --git a/reports-app/telegram-bot/app/bot/helpers.py b/reports-app/telegram-bot/app/bot/helpers.py index 5e88298..3137ccc 100644 --- a/reports-app/telegram-bot/app/bot/helpers.py +++ b/reports-app/telegram-bot/app/bot/helpers.py @@ -344,7 +344,7 @@ async def get_treasury_breakdown_split( for item in banca_data.get('items', []) ] - return { + result = { 'casa': { 'accounts': casa_accounts, 'total': float(casa_data.get('total', 0)) @@ -355,6 +355,16 @@ async def get_treasury_breakdown_split( } } + # Pass through cache metadata if present + if 'cache_hit' in breakdown: + result['cache_hit'] = breakdown['cache_hit'] + if 'response_time_ms' in breakdown: + result['response_time_ms'] = breakdown['response_time_ms'] + if 'cache_source' in breakdown: + result['cache_source'] = breakdown['cache_source'] + + return result + except Exception as e: logger.error(f"Error getting treasury breakdown split: {e}", exc_info=True) return None @@ -425,7 +435,7 @@ async def get_clients_with_maturity( overdue = sum(c['balance'] for c in clients if c.get('daysOverdue', 0) > 0) in_term = total - overdue - return { + result = { 'clients': clients, 'maturity': { 'in_term': in_term, @@ -434,6 +444,16 @@ async def get_clients_with_maturity( } } + # Pass through cache metadata if present + if 'cache_hit' in maturity_response: + result['cache_hit'] = maturity_response['cache_hit'] + if 'response_time_ms' in maturity_response: + result['response_time_ms'] = maturity_response['response_time_ms'] + if 'cache_source' in maturity_response: + result['cache_source'] = maturity_response['cache_source'] + + return result + except Exception as e: logger.error(f"Error getting clients with maturity: {e}", exc_info=True) return None @@ -504,7 +524,7 @@ async def get_suppliers_with_maturity( overdue = sum(s['balance'] for s in suppliers if s.get('daysOverdue', 0) > 0) in_term = total - overdue - return { + result = { 'suppliers': suppliers, 'maturity': { 'in_term': in_term, @@ -513,6 +533,16 @@ async def get_suppliers_with_maturity( } } + # Pass through cache metadata if present + if 'cache_hit' in maturity_response: + result['cache_hit'] = maturity_response['cache_hit'] + if 'response_time_ms' in maturity_response: + result['response_time_ms'] = maturity_response['response_time_ms'] + if 'cache_source' in maturity_response: + result['cache_source'] = maturity_response['cache_source'] + + return result + except Exception as e: logger.error(f"Error getting suppliers with maturity: {e}", exc_info=True) return None @@ -650,11 +680,21 @@ async def get_cashflow_evolution_data( 'plati_prev': last_12_plati_prev } - return { + result = { 'performance': performance, 'monthly': monthly } + # Pass through cache metadata if present + if 'cache_hit' in trends_data: + result['cache_hit'] = trends_data['cache_hit'] + if 'response_time_ms' in trends_data: + result['response_time_ms'] = trends_data['response_time_ms'] + if 'cache_source' in trends_data: + result['cache_source'] = trends_data['cache_source'] + + return result + except Exception as e: logger.error(f"Error getting cashflow evolution data: {e}", exc_info=True) return None