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
560 lines
21 KiB
Python
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()
|
|
}
|