- Add A-Z alphabetical filter keyboard for clients and suppliers lists (same pattern as company selection, without emoji) - Increase clients/suppliers list pagination from 10 to 20 items per page - Remove emoji from company A-Z filter button for consistency - Add 6 new callback handlers: clients_alpha_menu, clients_alpha:LETTER, clients_alpha_page:PAGE:LETTER, and supplier equivalents - Dashboard service and models updates - Telegram bot: email handlers, auth, DB operations, internal API improvements - Frontend: dashboard cards updates (CashFlow, Clienti, Furnizori, Treasury) - Frontend: SolduriCompactCard and CollapsibleCard improvements - DashboardView enhancements - start.sh and run-with-restart.sh script updates - IIS web.config and service worker updates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
359 lines
11 KiB
Python
359 lines
11 KiB
Python
"""
|
|
Internal API for Backend Communication
|
|
|
|
This FastAPI application provides internal endpoints for the ROA2WEB backend
|
|
to communicate with the Telegram bot service. Main purpose is to save
|
|
authentication codes generated in the web frontend.
|
|
|
|
This API runs alongside the Telegram bot and is accessible only internally
|
|
(not exposed to public internet).
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, HTTPException, status
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel, Field
|
|
|
|
from backend.modules.telegram.db.operations import create_auth_code, get_auth_code
|
|
from backend.modules.telegram.db.database import get_database_stats
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Initialize APIRouter (converted from FastAPI app for unified backend)
|
|
internal_api = APIRouter()
|
|
|
|
|
|
# ============================================================================
|
|
# REQUEST/RESPONSE MODELS
|
|
# ============================================================================
|
|
|
|
class SaveAuthCodeRequest(BaseModel):
|
|
"""
|
|
Request model for saving an authentication code.
|
|
"""
|
|
code: str = Field(
|
|
...,
|
|
description="8-character authentication code",
|
|
min_length=8,
|
|
max_length=8
|
|
)
|
|
telegram_user_id: int = Field(
|
|
...,
|
|
description="Telegram user ID (if known, otherwise 0)",
|
|
ge=0
|
|
)
|
|
oracle_username: str = Field(
|
|
...,
|
|
description="Oracle username to link"
|
|
)
|
|
expires_in_minutes: int = Field(
|
|
default=5,
|
|
description="Code expiration time in minutes",
|
|
ge=1,
|
|
le=60
|
|
)
|
|
server_id: Optional[str] = Field(
|
|
default=None,
|
|
description="Oracle server ID (for multi-server mode)"
|
|
)
|
|
|
|
|
|
class SaveAuthCodeResponse(BaseModel):
|
|
"""
|
|
Response model for save auth code endpoint.
|
|
"""
|
|
success: bool = Field(..., description="Whether the operation succeeded")
|
|
code: str = Field(..., description="The saved authentication code")
|
|
expires_at: Optional[str] = Field(None, description="Expiration timestamp (ISO format)")
|
|
message: Optional[str] = Field(None, description="Additional message")
|
|
|
|
|
|
class VerifyAuthCodeRequest(BaseModel):
|
|
"""
|
|
Request model for verifying an authentication code.
|
|
"""
|
|
code: str = Field(..., description="Authentication code to verify")
|
|
|
|
|
|
class VerifyAuthCodeResponse(BaseModel):
|
|
"""
|
|
Response model for verify auth code endpoint.
|
|
"""
|
|
valid: bool = Field(..., description="Whether the code is valid")
|
|
oracle_username: Optional[str] = Field(None, description="Oracle username if valid")
|
|
telegram_user_id: Optional[int] = Field(None, description="Telegram user ID if set")
|
|
message: Optional[str] = Field(None, description="Additional message")
|
|
|
|
|
|
class HealthResponse(BaseModel):
|
|
"""
|
|
Response model for health check endpoint.
|
|
"""
|
|
status: str = Field(..., description="Service status")
|
|
timestamp: str = Field(..., description="Current timestamp")
|
|
database_stats: Optional[dict] = Field(None, description="Database statistics")
|
|
|
|
|
|
# ============================================================================
|
|
# ENDPOINTS
|
|
# ============================================================================
|
|
|
|
@internal_api.post(
|
|
"/internal/save-code",
|
|
response_model=SaveAuthCodeResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
summary="Save Authentication Code",
|
|
description="Save an authentication code for Telegram linking (called by backend)"
|
|
)
|
|
async def save_auth_code(request: SaveAuthCodeRequest):
|
|
"""
|
|
Save an authentication code to SQLite database.
|
|
|
|
This endpoint is called by the FastAPI backend when a user generates
|
|
a linking code in the web frontend.
|
|
|
|
**Flow:**
|
|
1. User logs in to web frontend
|
|
2. User clicks "Link Telegram Account"
|
|
3. Backend generates 8-character code
|
|
4. Backend calls this endpoint to save code
|
|
5. Backend returns code to user for display
|
|
6. User sends code to Telegram bot via /start command
|
|
|
|
Args:
|
|
request: SaveAuthCodeRequest with code, oracle_username, etc.
|
|
|
|
Returns:
|
|
SaveAuthCodeResponse with success status and code details
|
|
|
|
Raises:
|
|
HTTPException 400: If code already exists or invalid data
|
|
HTTPException 500: If database operation fails
|
|
"""
|
|
try:
|
|
logger.info(
|
|
f"Saving auth code for Oracle user: {request.oracle_username}, "
|
|
f"code: {request.code}"
|
|
)
|
|
|
|
# Check if code already exists
|
|
existing_code = await get_auth_code(request.code)
|
|
|
|
if existing_code:
|
|
logger.warning(f"Code {request.code} already exists")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Code {request.code} already exists. Generate a new unique code."
|
|
)
|
|
|
|
# Create auth code in database
|
|
success = await create_auth_code(
|
|
code=request.code,
|
|
telegram_user_id=request.telegram_user_id,
|
|
oracle_username=request.oracle_username,
|
|
expires_in_minutes=request.expires_in_minutes,
|
|
server_id=request.server_id
|
|
)
|
|
|
|
if not success:
|
|
logger.error(f"Failed to save auth code {request.code}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to save authentication code to database"
|
|
)
|
|
|
|
# Calculate expiration time
|
|
from datetime import timedelta
|
|
expires_at = (datetime.now() + timedelta(minutes=request.expires_in_minutes)).isoformat()
|
|
|
|
logger.info(f"Auth code {request.code} saved successfully")
|
|
|
|
return SaveAuthCodeResponse(
|
|
success=True,
|
|
code=request.code,
|
|
expires_at=expires_at,
|
|
message=f"Code saved successfully, expires in {request.expires_in_minutes} minutes"
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error saving auth code: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Internal server error: {str(e)}"
|
|
)
|
|
|
|
|
|
@internal_api.post(
|
|
"/internal/verify-code",
|
|
response_model=VerifyAuthCodeResponse,
|
|
summary="Verify Authentication Code",
|
|
description="Verify if an authentication code is valid (without using it)"
|
|
)
|
|
async def verify_auth_code(request: VerifyAuthCodeRequest):
|
|
"""
|
|
Verify if an authentication code exists and is valid.
|
|
|
|
This is a read-only check that does NOT mark the code as used.
|
|
Useful for backend to verify codes before user links Telegram account.
|
|
|
|
Args:
|
|
request: VerifyAuthCodeRequest with code to verify
|
|
|
|
Returns:
|
|
VerifyAuthCodeResponse with validation status
|
|
|
|
Raises:
|
|
HTTPException 404: If code not found
|
|
"""
|
|
try:
|
|
logger.info(f"Verifying auth code: {request.code}")
|
|
|
|
code_data = await get_auth_code(request.code)
|
|
|
|
if not code_data:
|
|
return VerifyAuthCodeResponse(
|
|
valid=False,
|
|
message="Code not found"
|
|
)
|
|
|
|
# Check if code is expired
|
|
expires_at_str = code_data.get('expires_at')
|
|
expires_at = datetime.fromisoformat(expires_at_str) if expires_at_str else None
|
|
|
|
is_expired = expires_at and datetime.now() >= expires_at
|
|
is_used = code_data.get('used', 0) == 1
|
|
|
|
if is_expired:
|
|
return VerifyAuthCodeResponse(
|
|
valid=False,
|
|
oracle_username=code_data.get('oracle_username'),
|
|
message="Code expired"
|
|
)
|
|
|
|
if is_used:
|
|
return VerifyAuthCodeResponse(
|
|
valid=False,
|
|
oracle_username=code_data.get('oracle_username'),
|
|
message="Code already used"
|
|
)
|
|
|
|
# Code is valid
|
|
return VerifyAuthCodeResponse(
|
|
valid=True,
|
|
oracle_username=code_data.get('oracle_username'),
|
|
telegram_user_id=code_data.get('telegram_user_id'),
|
|
message="Code is valid"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error verifying auth code: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Internal server error: {str(e)}"
|
|
)
|
|
|
|
|
|
@internal_api.get(
|
|
"/internal/health",
|
|
response_model=HealthResponse,
|
|
summary="Health Check",
|
|
description="Check if the internal API and database are healthy"
|
|
)
|
|
async def health_check():
|
|
"""
|
|
Health check endpoint.
|
|
|
|
Returns service status and database statistics.
|
|
|
|
Returns:
|
|
HealthResponse with status and stats
|
|
"""
|
|
try:
|
|
# Get database stats
|
|
stats = await get_database_stats()
|
|
|
|
return HealthResponse(
|
|
status="healthy",
|
|
timestamp=datetime.now().isoformat(),
|
|
database_stats=stats
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Health check failed: {e}", exc_info=True)
|
|
return HealthResponse(
|
|
status="unhealthy",
|
|
timestamp=datetime.now().isoformat(),
|
|
database_stats={"error": str(e)}
|
|
)
|
|
|
|
|
|
@internal_api.get(
|
|
"/internal/stats",
|
|
summary="Database Statistics",
|
|
description="Get detailed database statistics"
|
|
)
|
|
async def get_stats():
|
|
"""
|
|
Get detailed database statistics.
|
|
|
|
Returns:
|
|
JSON with database statistics
|
|
"""
|
|
try:
|
|
stats = await get_database_stats()
|
|
|
|
return JSONResponse(
|
|
status_code=status.HTTP_200_OK,
|
|
content={
|
|
"success": True,
|
|
"timestamp": datetime.now().isoformat(),
|
|
"stats": stats
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting stats: {e}", exc_info=True)
|
|
return JSONResponse(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
content={
|
|
"success": False,
|
|
"error": str(e)
|
|
}
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# EXCEPTION HANDLERS
|
|
# ============================================================================
|
|
|
|
# ============================================================================
|
|
# NOTE: Exception handlers and startup/shutdown events removed
|
|
# These are FastAPI-specific and don't work with APIRouter
|
|
# The unified backend (main.py) handles these at the app level
|
|
# ============================================================================
|
|
|
|
# @internal_api.exception_handler(Exception) - Not supported by APIRouter
|
|
# async def global_exception_handler(request, exc):
|
|
# """Global exception handler - moved to main.py"""
|
|
# pass
|
|
|
|
# @internal_api.on_event("startup") - Not supported by APIRouter
|
|
# async def startup_event():
|
|
# """Startup event - handled by main.py lifespan"""
|
|
# pass
|
|
|
|
# @internal_api.on_event("shutdown") - Not supported by APIRouter
|
|
# async def shutdown_event():
|
|
# """Shutdown event - handled by main.py lifespan"""
|
|
# pass
|
|
|
|
|
|
# Export the APIRouter
|
|
__all__ = ['internal_api']
|