Files
roa2web-service-auto/reports-app/backend/app/routers/telegram.py
Marius Mutu 6b13ffa183 Initial commit: ROA2WEB - FastAPI + Vue.js + Telegram Bot
Modern ERP Reports Application with microservices architecture

Tech Stack:
- Backend: FastAPI + python-oracledb (Oracle DB integration)
- Frontend: Vue.js 3 + PrimeVue + Vite
- Telegram Bot: python-telegram-bot + SQLite
- Infrastructure: Shared database pool, JWT authentication, SSH tunnel

Features:
- FastAPI backend with async Oracle connection pool
- Vue.js 3 responsive frontend with PrimeVue components
- Telegram bot alternative interface
- Microservices architecture with shared components
- Complete deployment support (Linux Docker + Windows IIS)
- Comprehensive testing (Playwright E2E + pytest)

Repository Structure:
- reports-app/ - Main application (backend, frontend, telegram-bot)
- shared/ - Shared components (database pool, auth, utils)
- deployment/ - Deployment scripts (Linux & Windows)
- docs/ - Project documentation
- security/ - Security scanning and git hooks
2025-10-25 14:55:08 +03:00

560 lines
21 KiB
Python

"""
API Router pentru Telegram Bot Integration
Furnizează endpoint-uri pentru autentificare, linking și export rapoarte pentru Telegram bot
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from typing import List, Optional, Dict, Any
import sys
import os
import secrets
import string
import httpx
from datetime import datetime, timedelta
from pydantic import BaseModel, Field
sys.path.append(os.path.join(os.path.dirname(__file__), '../../../../shared'))
from auth.dependencies import get_current_user
from auth.models import CurrentUser
from auth.jwt_handler import jwt_handler
from database.oracle_pool import oracle_pool
# Telegram bot internal API URL (running on same server)
TELEGRAM_BOT_INTERNAL_API = os.getenv("TELEGRAM_BOT_INTERNAL_API", "http://localhost:8002")
router = APIRouter(redirect_slashes=False)
# ==================== Schemas ====================
class GenerateCodeRequest(BaseModel):
"""Request pentru generarea unui cod de linking"""
telegram_user_id: int = Field(description="ID-ul utilizatorului Telegram")
telegram_username: Optional[str] = Field(default=None, description="Username-ul Telegram")
telegram_first_name: Optional[str] = Field(default=None, description="Prenumele utilizatorului")
telegram_last_name: Optional[str] = Field(default=None, description="Numele utilizatorului")
class GenerateCodeResponse(BaseModel):
"""Response pentru generarea unui cod de linking"""
linking_code: str = Field(description="Codul de linking generat (8 caractere)")
expires_at: datetime = Field(description="Data și ora expirării codului")
expires_in_minutes: int = Field(description="Minutele până la expirare")
class VerifyUserRequest(BaseModel):
"""
Request pentru verificarea utilizatorului în Oracle
Suportă 2 flow-uri:
1. Auto-linking (recomandat): doar linking_code și oracle_username
- Bot-ul verifică codul în SQLite, extrage oracle_username
- Backend face lookup în Oracle fără verificare parolă
- Codul valid este proof-of-authorization
2. Full verification (opțional): username, password, linking_code
- Verificare completă cu parolă în Oracle
"""
linking_code: str = Field(description="Codul de linking de la /generate-code")
oracle_username: Optional[str] = Field(default=None, description="Username Oracle (pentru auto-linking)")
username: Optional[str] = Field(default=None, description="Username pentru verificare completă")
password: Optional[str] = Field(default=None, description="Parolă pentru verificare completă")
class VerifyUserResponse(BaseModel):
"""Response pentru verificarea utilizatorului"""
success: bool = Field(description="True dacă verificarea a avut succes")
access_token: Optional[str] = Field(default=None, description="JWT access token")
refresh_token: Optional[str] = Field(default=None, description="JWT refresh token")
user: Optional[Dict[str, Any]] = Field(default=None, description="Detalii utilizator")
message: str = Field(description="Mesaj de status")
class RefreshTokenRequest(BaseModel):
"""Request pentru refresh JWT token"""
refresh_token: str = Field(description="Refresh token-ul obținut la autentificare")
class RefreshTokenResponse(BaseModel):
"""Response pentru refresh token"""
access_token: str = Field(description="Noul JWT access token")
expires_in: int = Field(description="Timpul de expirare în secunde")
token_type: str = Field(default="bearer", description="Tipul token-ului")
class ExportReportRequest(BaseModel):
"""Request pentru exportul unui raport"""
company_id: int = Field(description="ID-ul firmei")
report_type: str = Field(description="Tipul raportului (invoices, payments, dashboard)")
format: str = Field(default="excel", description="Formatul exportului (excel, pdf, csv)")
filters: Optional[Dict[str, Any]] = Field(default=None, description="Filtre pentru raport")
class ExportReportResponse(BaseModel):
"""Response pentru exportul raportului"""
success: bool = Field(description="True dacă exportul a avut succes")
file_url: Optional[str] = Field(default=None, description="URL-ul fișierului generat")
file_name: Optional[str] = Field(default=None, description="Numele fișierului generat")
file_size_bytes: Optional[int] = Field(default=None, description="Mărimea fișierului în bytes")
message: str = Field(description="Mesaj de status")
# ==================== Helper Functions ====================
def generate_linking_code(length: int = 8) -> str:
"""
Generează un cod alfanumeric aleatoriu pentru linking
Args:
length: Lungimea codului (default: 8)
Returns:
Codul generat (uppercase alphanumeric)
"""
alphabet = string.ascii_uppercase + string.digits
# Exclude caractere care pot fi confundate: 0, O, I, 1
alphabet = alphabet.replace('0', '').replace('O', '').replace('I', '').replace('1', '')
return ''.join(secrets.choice(alphabet) for _ in range(length))
async def get_oracle_user_by_username(username: str) -> Optional[Dict[str, Any]]:
"""
Obține informații despre utilizator din Oracle FĂRĂ verificare parolă.
Folosit pentru auto-linking când utilizatorul a fost deja autentificat
prin generarea unui linking code valid în aplicația web.
Args:
username: Username-ul utilizatorului Oracle
Returns:
Dict cu informații despre utilizator sau None dacă nu există
"""
try:
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# Obține detalii utilizator
cursor.execute("""
SELECT ID_UTIL, UTILIZATOR
FROM UTILIZATORI
WHERE UPPER(UTILIZATOR) = :username
""", {'username': username.upper()})
user_row = cursor.fetchone()
if not user_row:
return None
user_id = user_row[0]
actual_username = user_row[1]
# Obține companiile utilizatorului
cursor.execute("""
SELECT A.ID_FIRMA, A.FIRMA
FROM V_NOM_FIRME A
WHERE A.ID_FIRMA IN (
SELECT ID_FIRMA
FROM VDEF_UTIL_FIRME
WHERE ID_PROGRAM = 2
AND ID_UTIL = :user_id
)
ORDER BY A.FIRMA
""", {'user_id': user_id})
companies_result = cursor.fetchall()
companies = [str(row[0]) for row in companies_result]
return {
'user_id': user_id,
'username': actual_username,
'companies': companies,
'permissions': ['read', 'reports']
}
except Exception as e:
print(f"Error getting Oracle user by username: {e}")
return None
async def verify_oracle_user(username: str, password: str) -> Optional[Dict[str, Any]]:
"""
Verifică utilizatorul în Oracle folosind pack_drepturi.verificautilizator
Args:
username: Username-ul utilizatorului
password: Parola utilizatorului
Returns:
Dict cu informații despre utilizator sau None dacă verificarea eșuează
"""
try:
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# Verifică autentificarea
cursor.execute("""
SELECT pack_drepturi.verificautilizator(:username, :password)
FROM DUAL
""", {
'username': username.upper(),
'password': password
})
result = cursor.fetchone()
verification_result = result[0] if result else -1
if verification_result == -1:
return None
# Obține detalii utilizator
cursor.execute("""
SELECT ID_UTIL, UTILIZATOR
FROM UTILIZATORI
WHERE UPPER(UTILIZATOR) = :username
""", {'username': username.upper()})
user_row = cursor.fetchone()
if not user_row:
return None
user_id = user_row[0]
# Obține companiile utilizatorului
cursor.execute("""
SELECT A.ID_FIRMA, A.FIRMA
FROM V_NOM_FIRME A
WHERE A.ID_FIRMA IN (
SELECT ID_FIRMA
FROM VDEF_UTIL_FIRME
WHERE ID_PROGRAM = 2
AND ID_UTIL = :user_id
)
ORDER BY A.FIRMA
""", {'user_id': user_id})
companies_result = cursor.fetchall()
companies = [str(row[0]) for row in companies_result]
return {
'user_id': user_id,
'username': username,
'companies': companies,
'permissions': ['read', 'reports']
}
except Exception as e:
print(f"Error verifying Oracle user: {e}")
return None
# ==================== Endpoints ====================
@router.post("/auth/generate-code", response_model=GenerateCodeResponse)
async def generate_linking_code_endpoint(
current_user: CurrentUser = Depends(get_current_user)
):
"""
Generează un cod de linking pentru conectarea unui utilizator Telegram
Flow:
1. Utilizatorul autentificat în aplicație solicită un cod
2. Se generează un cod unic de 8 caractere
3. Codul este trimis la Telegram bot pentru salvare în SQLite cu TTL de 15 minute
4. Utilizatorul introduce codul în Telegram bot pentru linking
Note:
- Acest endpoint necesită autentificare JWT (utilizatorul trebuie să fie logat în aplicație)
- Codul expiră după 15 minute
- Fiecare request generează un cod nou (codurile vechi devin invalide)
- Nu este nevoie de telegram_user_id în acest moment (utilizatorul nu e încă conectat la Telegram)
"""
try:
# Generează cod unic
linking_code = generate_linking_code()
# Setează expirarea la 15 minute
expires_at = datetime.utcnow() + timedelta(minutes=15)
expires_in_minutes = 15
# Salvează codul în database-ul Telegram bot (SQLite) via internal API
try:
async with httpx.AsyncClient(timeout=5.0) as client:
save_code_response = await client.post(
f"{TELEGRAM_BOT_INTERNAL_API}/internal/save-code",
json={
"code": linking_code,
"telegram_user_id": 0, # Not known yet (user hasn't linked)
"oracle_username": current_user.username,
"expires_in_minutes": expires_in_minutes
}
)
# Accept both 200 (OK) and 201 (Created) as success
if save_code_response.status_code not in [200, 201]:
raise HTTPException(
status_code=500,
detail=f"Failed to save code to Telegram bot: {save_code_response.text}"
)
except httpx.TimeoutException:
raise HTTPException(
status_code=503,
detail="Telegram bot service is not responding. Please try again later."
)
except httpx.ConnectError:
raise HTTPException(
status_code=503,
detail="Cannot connect to Telegram bot service. Please contact administrator."
)
return GenerateCodeResponse(
linking_code=linking_code,
expires_at=expires_at,
expires_in_minutes=expires_in_minutes
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Eroare la generarea codului de linking: {str(e)}"
)
@router.post("/auth/verify-user", response_model=VerifyUserResponse)
async def verify_user_endpoint(request: VerifyUserRequest):
"""
Verifică utilizatorul în Oracle și returnează JWT tokens
Suportă 2 flow-uri de autentificare:
Flow A - Auto-linking (RECOMANDAT):
1. Bot verifică linking_code în SQLite (code valid = user s-a autentificat în web app)
2. Bot extrage oracle_username din cod
3. Bot trimite: {linking_code, oracle_username}
4. Backend face lookup în Oracle (FĂRĂ verificare parolă)
5. Backend generează și returnează JWT tokens
Flow B - Full verification (OPȚIONAL):
1. Bot cere username și parolă de la user în Telegram
2. Bot trimite: {linking_code, username, password}
3. Backend verifică credențialele în Oracle
4. Backend generează și returnează JWT tokens
Note:
- Acest endpoint NU necesită autentificare JWT (este public pentru bot)
- Flow A oferă UX superior (fără re-introducere parolă)
- Linking code-ul valid este proof-of-authorization
"""
try:
# Flow A: Auto-linking (oracle_username provided, no password)
if request.oracle_username and not request.password:
user_data = await get_oracle_user_by_username(request.oracle_username)
if not user_data:
return VerifyUserResponse(
success=False,
message=f"Utilizatorul {request.oracle_username} nu există în Oracle"
)
# Flow B: Full verification (username + password provided)
elif request.username and request.password:
user_data = await verify_oracle_user(request.username, request.password)
if not user_data:
return VerifyUserResponse(
success=False,
message="Username sau parolă incorectă"
)
# Invalid request (missing required fields)
else:
return VerifyUserResponse(
success=False,
message="Trebuie furnizat fie oracle_username (auto-linking) fie username+password (verificare completă)"
)
# Generează JWT tokens
access_token = jwt_handler.create_access_token(
username=user_data['username'],
companies=user_data['companies'],
user_id=user_data['user_id'],
permissions=user_data['permissions']
)
refresh_token = jwt_handler.create_refresh_token(
username=user_data['username'],
user_id=user_data['user_id']
)
return VerifyUserResponse(
success=True,
access_token=access_token,
refresh_token=refresh_token,
user={
'user_id': user_data['user_id'],
'username': user_data['username'],
'companies': user_data['companies'],
'permissions': user_data['permissions']
},
message="Autentificare reușită"
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Eroare la verificarea utilizatorului: {str(e)}"
)
@router.post("/auth/refresh-token", response_model=RefreshTokenResponse)
async def refresh_token_endpoint(request: RefreshTokenRequest):
"""
Refresh-uiește un JWT access token folosind refresh token-ul
Acest endpoint este folosit de Telegram bot pentru a obține un nou access token
când cel curent expiră, fără a solicita din nou username/password.
Flow:
1. Botul Telegram detectează că access token-ul a expirat
2. Trimite refresh token-ul la acest endpoint
3. Se validează refresh token-ul și se generează un nou access token
4. Botul stochează noul access token în SQLite
Note:
- Refresh token-ul este valid 7 zile (vs 30 minute pentru access token)
- Dacă refresh token-ul expiră, utilizatorul trebuie să se re-autentifice
"""
try:
# Verifică refresh token-ul
token_data = jwt_handler.verify_token(request.refresh_token)
if not token_data or token_data.token_type != "refresh":
raise HTTPException(
status_code=401,
detail="Refresh token invalid sau expirat"
)
# Obține companiile actualizate din Oracle
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT A.ID_FIRMA
FROM V_NOM_FIRME A
WHERE A.ID_FIRMA IN (
SELECT ID_FIRMA
FROM VDEF_UTIL_FIRME
WHERE ID_PROGRAM = 2
AND ID_UTIL = :user_id
)
ORDER BY A.FIRMA
""", {'user_id': token_data.user_id})
companies_result = cursor.fetchall()
companies = [str(row[0]) for row in companies_result]
# Generează nou access token
new_access_token = jwt_handler.create_access_token(
username=token_data.username,
companies=companies,
user_id=token_data.user_id,
permissions=token_data.permissions
)
return RefreshTokenResponse(
access_token=new_access_token,
expires_in=jwt_handler.access_token_expire_minutes * 60,
token_type="bearer"
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Eroare la refresh token: {str(e)}"
)
@router.post("/export", response_model=ExportReportResponse)
async def export_report_endpoint(
request: ExportReportRequest,
current_user: CurrentUser = Depends(get_current_user)
):
"""
Exportă un raport în format Excel, PDF sau CSV
Acest endpoint este folosit de Telegram bot pentru a genera rapoarte
și a le trimite utilizatorului.
Flow:
1. Botul trimite cerere de export cu parametrii raportului
2. Se validează că utilizatorul are acces la firma specificată
3. Se generează raportul în formatul solicitat
4. Se returnează URL-ul sau conținutul fișierului
Tipuri de rapoarte suportate:
- invoices: Facturi (cu filtre: dată, status, client)
- payments: Încasări (cu filtre: dată, metodă plată)
- dashboard: Statistici dashboard (rezumat)
Formate suportate:
- excel: XLSX (cel mai complet)
- pdf: PDF (pentru printing)
- csv: CSV (pentru import în alte sisteme)
Note:
- Utilizatorul trebuie să aibă acces la firma specificată
- Fișierele generate sunt temporare (șterse după 1 oră)
"""
try:
# Verifică accesul la firmă
company_id_str = str(request.company_id)
if company_id_str not in current_user.companies:
raise HTTPException(
status_code=403,
detail=f"Nu aveți acces la firma {request.company_id}"
)
# TODO: Implementare export în funcție de report_type și format
# Deocamdată returnăm un placeholder
return ExportReportResponse(
success=True,
file_url=f"/api/telegram/downloads/report_{request.report_type}_{request.company_id}.{request.format}",
file_name=f"raport_{request.report_type}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.{request.format}",
file_size_bytes=0,
message=f"Raport {request.report_type} generat cu succes în format {request.format}"
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Eroare la generarea raportului: {str(e)}"
)
@router.get("/health")
async def telegram_health_check():
"""
Health check pentru routerul Telegram
Verifică conectivitatea la Oracle și disponibilitatea serviciilor
"""
try:
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("SELECT 1 FROM DUAL")
return {
"status": "healthy",
"service": "telegram-router",
"database": "connected",
"timestamp": datetime.utcnow().isoformat()
}
except Exception as e:
return {
"status": "degraded",
"service": "telegram-router",
"database": f"error: {str(e)}",
"timestamp": datetime.utcnow().isoformat()
}