fix telegram
This commit is contained in:
0
deploy-package-20260223-151231/shared/__init__.py
Normal file
0
deploy-package-20260223-151231/shared/__init__.py
Normal file
649
deploy-package-20260223-151231/shared/auth/README.md
Normal file
649
deploy-package-20260223-151231/shared/auth/README.md
Normal file
@@ -0,0 +1,649 @@
|
||||
# 🔐 ROA2WEB Shared Authentication System
|
||||
|
||||
Sistem de autentificare JWT partajat între toate aplicațiile din ecosistemul ROA2WEB, integrat cu Oracle Database și optimizat pentru aplicații FastAPI.
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Architecture](#architecture)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Components](#components)
|
||||
- [Integration Guide](#integration-guide)
|
||||
- [Security Features](#security-features)
|
||||
- [API Reference](#api-reference)
|
||||
- [Testing](#testing)
|
||||
- [Deployment](#deployment)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### Core Features
|
||||
- **JWT Authentication**: Secure token-based authentication cu access și refresh tokens
|
||||
- **Oracle Database Integration**: Folosește `pack_drepturi.verificautilizator` pentru autentificare
|
||||
- **Multi-Company Support**: Acces controlat la multiple firme/schemas Oracle
|
||||
- **Permission System**: Sistem granular de permisiuni (read, write, admin, reports)
|
||||
- **FastAPI Integration**: Dependencies și middleware native pentru FastAPI
|
||||
- **Rate Limiting**: Protecție împotriva brute force attacks
|
||||
- **Caching**: Cache inteligent pentru performanță optimă
|
||||
|
||||
### Security Features
|
||||
- **Token Expiration**: Configurabil pentru access și refresh tokens
|
||||
- **SQL Injection Protection**: Parametri legați în toate query-urile
|
||||
- **Rate Limiting**: Configurabil per IP și endpoint
|
||||
- **CORS Protection**: Configurare flexibilă pentru origins
|
||||
- **Header Security**: Security headers automate
|
||||
- **Token Blacklisting**: Suport pentru invalidarea token-urilor (în dezvoltare)
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
ROA2WEB Authentication Flow:
|
||||
┌─────────────┐ ┌──────────────┐ ┌─────────────┐ ┌──────────────┐
|
||||
│ Client │───▶│ FastAPI │───▶│ JWT │───▶│ Oracle │
|
||||
│ (Frontend) │ │ Application │ │ Handler │ │ Database │
|
||||
└─────────────┘ └──────────────┘ └─────────────┘ └──────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌─────────────┐ ┌──────────────┐
|
||||
│ Auth Service │ │ Middleware │ │ User Cache │
|
||||
└──────────────┘ └─────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
### Components Overview
|
||||
|
||||
```
|
||||
shared/auth/
|
||||
├── jwt_handler.py # JWT token creation și validation
|
||||
├── auth_service.py # Oracle database integration
|
||||
├── models.py # Pydantic models pentru data validation
|
||||
├── middleware.py # FastAPI middleware pentru auto-authentication
|
||||
├── dependencies.py # FastAPI dependencies pentru protected routes
|
||||
├── routes.py # Pre-built authentication routes
|
||||
├── test_auth.py # Comprehensive test suite
|
||||
├── demo_app.py # Demo application cu examples
|
||||
└── README.md # Această documentație
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Environment Setup
|
||||
|
||||
```bash
|
||||
# Copy și configurează environment variables
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env cu configurările tale
|
||||
JWT_SECRET_KEY=your-super-secret-jwt-key-change-in-production
|
||||
ORACLE_USER=your_oracle_username
|
||||
ORACLE_PASSWORD=your_oracle_password
|
||||
ORACLE_DSN=your_oracle_connection_string
|
||||
```
|
||||
|
||||
### 2. Basic Integration
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI, Depends
|
||||
from roa2web.shared.auth import (
|
||||
AuthenticationMiddleware, create_auth_router,
|
||||
get_current_user, CurrentUser
|
||||
)
|
||||
from roa2web.shared.database import oracle_pool
|
||||
|
||||
app = FastAPI(title="My ROA2WEB App")
|
||||
|
||||
# Add authentication middleware
|
||||
app.add_middleware(
|
||||
AuthenticationMiddleware,
|
||||
excluded_paths=["/", "/docs", "/health", "/auth/login"]
|
||||
)
|
||||
|
||||
# Include authentication routes
|
||||
auth_router = create_auth_router()
|
||||
app.include_router(auth_router)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
await oracle_pool.initialize()
|
||||
|
||||
@app.get("/protected")
|
||||
async def protected_endpoint(
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
return {"message": f"Hello {current_user.username}!"}
|
||||
```
|
||||
|
||||
### 3. Test Authentication
|
||||
|
||||
```bash
|
||||
# Start demo application
|
||||
cd roa2web/shared/auth
|
||||
python demo_app.py
|
||||
|
||||
# Open browser
|
||||
open http://localhost:8000/docs
|
||||
|
||||
# Login prin Swagger UI cu credențialele Oracle
|
||||
```
|
||||
|
||||
## 🧩 Components
|
||||
|
||||
### JWT Handler (`jwt_handler.py`)
|
||||
|
||||
Gestionează crearea, validarea și refresh-ul token-urilor JWT.
|
||||
|
||||
```python
|
||||
from roa2web.shared.auth import jwt_handler
|
||||
|
||||
# Create access token
|
||||
token = jwt_handler.create_access_token(
|
||||
username="admin",
|
||||
companies=["COMP1", "COMP2"],
|
||||
permissions=["read", "write", "reports"]
|
||||
)
|
||||
|
||||
# Verify token
|
||||
token_data = jwt_handler.verify_token(token)
|
||||
if token_data:
|
||||
print(f"Valid token for user: {token_data.username}")
|
||||
```
|
||||
|
||||
### Auth Service (`auth_service.py`)
|
||||
|
||||
Integrează cu Oracle Database pentru autentificare și management utilizatori.
|
||||
|
||||
```python
|
||||
from roa2web.shared.auth import auth_service
|
||||
|
||||
# Authenticate user
|
||||
success, token_response, error = await auth_service.authenticate_and_create_tokens(
|
||||
"username", "password"
|
||||
)
|
||||
|
||||
if success:
|
||||
print(f"Access token: {token_response.access_token}")
|
||||
else:
|
||||
print(f"Authentication failed: {error}")
|
||||
```
|
||||
|
||||
### FastAPI Dependencies (`dependencies.py`)
|
||||
|
||||
Oferă dependencies pentru protejarea endpoint-urilor.
|
||||
|
||||
```python
|
||||
from fastapi import Depends
|
||||
from roa2web.shared.auth import (
|
||||
get_current_user, require_company_access,
|
||||
require_permissions, PermissionType
|
||||
)
|
||||
|
||||
@app.get("/admin-only")
|
||||
async def admin_endpoint(
|
||||
user: CurrentUser = Depends(require_permissions([PermissionType.ADMIN]))
|
||||
):
|
||||
return {"message": "Admin access granted"}
|
||||
|
||||
@app.get("/company/{company_code}/data")
|
||||
async def company_data(
|
||||
company_code: str,
|
||||
user: CurrentUser = Depends(require_company_access(company_code))
|
||||
):
|
||||
return {"company": company_code, "data": "..."}
|
||||
```
|
||||
|
||||
### Authentication Routes (`routes.py`)
|
||||
|
||||
Pre-built routes pentru operații de autentificare.
|
||||
|
||||
```python
|
||||
from roa2web.shared.auth import create_auth_router
|
||||
|
||||
# Basic auth router
|
||||
auth_router = create_auth_router()
|
||||
app.include_router(auth_router)
|
||||
|
||||
# Auth router cu admin routes
|
||||
auth_router_admin = create_auth_router(include_admin_routes=True)
|
||||
app.include_router(auth_router_admin)
|
||||
```
|
||||
|
||||
Available routes:
|
||||
- `POST /auth/login` - User authentication
|
||||
- `POST /auth/refresh` - Token refresh
|
||||
- `POST /auth/logout` - User logout
|
||||
- `GET /auth/me` - Current user info
|
||||
- `GET /auth/companies` - User companies
|
||||
- `GET /auth/status` - Authentication status
|
||||
|
||||
## 🔧 Integration Guide
|
||||
|
||||
### Full FastAPI Application
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI, Depends, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from roa2web.shared.auth import (
|
||||
AuthenticationMiddleware, create_auth_router,
|
||||
get_current_user, require_company_access,
|
||||
CurrentUser, PermissionType
|
||||
)
|
||||
from roa2web.shared.database import oracle_pool
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
await oracle_pool.initialize()
|
||||
yield
|
||||
# Shutdown
|
||||
await oracle_pool.close_pool()
|
||||
|
||||
app = FastAPI(
|
||||
title="ROA2WEB Application",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Authentication
|
||||
app.add_middleware(
|
||||
AuthenticationMiddleware,
|
||||
excluded_paths=["/", "/docs", "/health"],
|
||||
rate_limit_paths=["/auth/login"]
|
||||
)
|
||||
|
||||
# Routes
|
||||
auth_router = create_auth_router()
|
||||
app.include_router(auth_router)
|
||||
|
||||
# Protected endpoints
|
||||
@app.get("/")
|
||||
async def public_endpoint():
|
||||
return {"message": "Public endpoint"}
|
||||
|
||||
@app.get("/me")
|
||||
async def my_info(current_user: CurrentUser = Depends(get_current_user)):
|
||||
return current_user
|
||||
|
||||
@app.get("/company/{company_code}/invoices")
|
||||
async def get_invoices(
|
||||
company_code: str,
|
||||
current_user: CurrentUser = Depends(require_company_access(company_code))
|
||||
):
|
||||
# Business logic here
|
||||
return {"company": company_code, "invoices": []}
|
||||
```
|
||||
|
||||
### Custom Permissions
|
||||
|
||||
```python
|
||||
from roa2web.shared.auth import require_permissions, PermissionType
|
||||
|
||||
# Define custom permissions
|
||||
class CustomPermissionType(str, Enum):
|
||||
INVOICE_READ = "invoice_read"
|
||||
INVOICE_WRITE = "invoice_write"
|
||||
REPORT_EXPORT = "report_export"
|
||||
|
||||
# Use in endpoints
|
||||
@app.get("/invoices")
|
||||
async def get_invoices(
|
||||
user: CurrentUser = Depends(require_permissions([CustomPermissionType.INVOICE_READ]))
|
||||
):
|
||||
return {"invoices": []}
|
||||
```
|
||||
|
||||
### Company-Specific Endpoints
|
||||
|
||||
```python
|
||||
from fastapi import Header
|
||||
from roa2web.shared.auth import get_current_company_from_header
|
||||
|
||||
@app.get("/current-company-data")
|
||||
async def get_current_company_data(
|
||||
company_code: str = Depends(get_current_company_from_header),
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
# company_code is automatically extracted from X-Company-Code header
|
||||
# and validated against user's accessible companies
|
||||
return {"company": company_code, "data": "..."}
|
||||
```
|
||||
|
||||
## 🔒 Security Features
|
||||
|
||||
### JWT Configuration
|
||||
|
||||
```python
|
||||
# Environment variables
|
||||
JWT_SECRET_KEY=your-super-secret-jwt-key-change-in-production
|
||||
JWT_ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
```python
|
||||
from roa2web.shared.auth import RateLimiter, AuthenticationMiddleware
|
||||
|
||||
# Custom rate limiter
|
||||
custom_rate_limiter = RateLimiter(
|
||||
max_requests=10, # 10 requests
|
||||
time_window=60 # per minute
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
AuthenticationMiddleware,
|
||||
rate_limit_paths=["/auth/login", "/auth/register"],
|
||||
rate_limiter=custom_rate_limiter
|
||||
)
|
||||
```
|
||||
|
||||
### Security Headers
|
||||
|
||||
Middleware-ul adaugă automat header-e de securitate:
|
||||
|
||||
```
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Frame-Options: DENY
|
||||
X-XSS-Protection: 1; mode=block
|
||||
```
|
||||
|
||||
## 📚 API Reference
|
||||
|
||||
### JWT Handler Methods
|
||||
|
||||
```python
|
||||
class JWTHandler:
|
||||
def create_access_token(username, companies, user_id=None, permissions=None) -> str
|
||||
def create_refresh_token(username, user_id=None) -> str
|
||||
def verify_token(token) -> Optional[TokenData]
|
||||
def refresh_access_token(refresh_token, companies, permissions=None) -> Optional[str]
|
||||
def create_token_response(username, companies, ...) -> TokenResponse
|
||||
```
|
||||
|
||||
### Auth Service Methods
|
||||
|
||||
```python
|
||||
class UserAuthService:
|
||||
async def verify_user_credentials(username, password) -> bool
|
||||
async def get_user_companies(username) -> List[str]
|
||||
async def get_user_permissions(username, company) -> List[str]
|
||||
async def authenticate_and_create_tokens(username, password) -> Tuple[bool, TokenResponse, str]
|
||||
async def validate_user_company_access(username, company) -> bool
|
||||
```
|
||||
|
||||
### FastAPI Dependencies
|
||||
|
||||
```python
|
||||
# User dependencies
|
||||
get_current_user() -> CurrentUser
|
||||
get_optional_user() -> Optional[CurrentUser]
|
||||
|
||||
# Permission dependencies
|
||||
require_permissions(permissions: List[PermissionType])
|
||||
require_company_access(company_code: str)
|
||||
require_company_and_permissions(company_code: str, permissions: List[PermissionType])
|
||||
|
||||
# Utility dependencies
|
||||
get_current_company_from_header() -> str
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Install test dependencies
|
||||
pip install pytest pytest-asyncio httpx
|
||||
|
||||
# Run all tests
|
||||
cd roa2web/shared/auth
|
||||
python -m pytest test_auth.py -v
|
||||
|
||||
# Run specific test categories
|
||||
python -m pytest test_auth.py::TestJWTHandler -v
|
||||
python -m pytest test_auth.py::TestUserAuthService -v
|
||||
python -m pytest test_auth.py::TestSecurityFeatures -v
|
||||
|
||||
# Run with coverage
|
||||
python -m pytest test_auth.py --cov=. --cov-report=html
|
||||
```
|
||||
|
||||
### Test Categories
|
||||
|
||||
- **Unit Tests**: JWT operations, auth service methods
|
||||
- **Integration Tests**: Database integration, full auth flow
|
||||
- **Security Tests**: Token tampering, SQL injection, rate limiting
|
||||
- **Performance Tests**: Token creation/verification speed
|
||||
|
||||
### Demo Application
|
||||
|
||||
```bash
|
||||
# Start demo app for manual testing
|
||||
cd roa2web/shared/auth
|
||||
python demo_app.py
|
||||
|
||||
# Available demo endpoints:
|
||||
# http://localhost:8000/ - Home page cu documentație
|
||||
# http://localhost:8000/docs - Swagger UI pentru testare
|
||||
# http://localhost:8000/demo/* - Various demo endpoints
|
||||
```
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### Production Configuration
|
||||
|
||||
```bash
|
||||
# Strong JWT secret key
|
||||
JWT_SECRET_KEY=$(python -c "import secrets; print(secrets.token_urlsafe(32))")
|
||||
|
||||
# Shorter token expiration
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=1
|
||||
|
||||
# Strict rate limiting
|
||||
RATE_LIMIT_MAX_REQUESTS=3
|
||||
RATE_LIMIT_TIME_WINDOW=300
|
||||
|
||||
# Secure headers
|
||||
SECURE_SSL_REDIRECT=true
|
||||
SESSION_COOKIE_SECURE=true
|
||||
```
|
||||
|
||||
### Docker Integration
|
||||
|
||||
```dockerfile
|
||||
# În Dockerfile-ul aplicației
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Environment pentru container
|
||||
ENV JWT_SECRET_KEY=${JWT_SECRET_KEY}
|
||||
ENV ORACLE_USER=${ORACLE_USER}
|
||||
ENV ORACLE_PASSWORD=${ORACLE_PASSWORD}
|
||||
ENV ORACLE_DSN=${ORACLE_DSN}
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
|
||||
```python
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
# Test database connection
|
||||
try:
|
||||
async with oracle_pool.get_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SELECT 1 FROM DUAL")
|
||||
db_status = "healthy"
|
||||
except Exception as e:
|
||||
db_status = f"error: {str(e)}"
|
||||
|
||||
return {
|
||||
"status": "healthy" if db_status == "healthy" else "degraded",
|
||||
"database": db_status,
|
||||
"jwt": "functional",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. "Invalid token" errors
|
||||
|
||||
```python
|
||||
# Check JWT secret key consistency
|
||||
print(f"JWT Secret: {os.getenv('JWT_SECRET_KEY')}")
|
||||
|
||||
# Verify token creation and validation
|
||||
token = jwt_handler.create_access_token("test", ["COMP1"])
|
||||
token_data = jwt_handler.verify_token(token)
|
||||
print(f"Token valid: {token_data is not None}")
|
||||
```
|
||||
|
||||
#### 2. Database connection errors
|
||||
|
||||
```python
|
||||
# Test Oracle connection
|
||||
try:
|
||||
async with oracle_pool.get_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SELECT 1 FROM DUAL")
|
||||
result = cursor.fetchone()
|
||||
print("Database connection: OK")
|
||||
except Exception as e:
|
||||
print(f"Database error: {e}")
|
||||
```
|
||||
|
||||
#### 3. Rate limiting issues
|
||||
|
||||
```python
|
||||
# Check rate limiter stats
|
||||
from roa2web.shared.auth import default_rate_limiter
|
||||
|
||||
client_ip = "192.168.1.1"
|
||||
allowed = default_rate_limiter.is_allowed(client_ip)
|
||||
reset_time = default_rate_limiter.get_reset_time(client_ip)
|
||||
print(f"IP {client_ip} allowed: {allowed}, resets at: {reset_time}")
|
||||
```
|
||||
|
||||
#### 4. Permission denied errors
|
||||
|
||||
```python
|
||||
# Check user companies and permissions
|
||||
companies = await auth_service.get_user_companies("username")
|
||||
permissions = await auth_service.get_user_permissions("username", "COMP1")
|
||||
print(f"User companies: {companies}")
|
||||
print(f"User permissions: {permissions}")
|
||||
```
|
||||
|
||||
### Debug Mode
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
# Enable debug logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
# Specific loggers
|
||||
logging.getLogger("roa2web.shared.auth").setLevel(logging.DEBUG)
|
||||
```
|
||||
|
||||
### Environment Validation
|
||||
|
||||
```python
|
||||
from roa2web.shared.utils.config import shared_config
|
||||
|
||||
# Validate configuration
|
||||
print(f"Oracle User: {shared_config.oracle_user}")
|
||||
print(f"JWT Secret set: {'***' if shared_config.jwt_secret_key else 'NOT SET'}")
|
||||
print(f"Token expiry: {shared_config.access_token_expire_minutes} minutes")
|
||||
```
|
||||
|
||||
## 📈 Performance Optimization
|
||||
|
||||
### Caching
|
||||
|
||||
```python
|
||||
# Cache configuration
|
||||
AUTH_CACHE_TTL_MINUTES=15 # User data cache TTL
|
||||
|
||||
# Monitor cache performance
|
||||
stats = auth_service.get_cache_stats()
|
||||
print(f"Cache hit ratio: {stats['cache_hit_ratio']:.2%}")
|
||||
```
|
||||
|
||||
### Connection Pooling
|
||||
|
||||
```python
|
||||
# Oracle pool configuration
|
||||
DB_MIN_CONNECTIONS=2
|
||||
DB_MAX_CONNECTIONS=10
|
||||
DB_CONNECTION_INCREMENT=1
|
||||
```
|
||||
|
||||
### Token Optimization
|
||||
|
||||
```python
|
||||
# Optimize token size by limiting payload
|
||||
token = jwt_handler.create_access_token(
|
||||
username="user",
|
||||
companies=["COMP1"], # Limit companies in token
|
||||
permissions=["read"] # Essential permissions only
|
||||
)
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Pentru contribuții la sistemul de autentificare:
|
||||
|
||||
1. **Fork repository-ul** și creează o ramură pentru feature
|
||||
2. **Implementează schimbările** cu tests comprehensive
|
||||
3. **Rulează toate testele** pentru a verifica compatibilitatea
|
||||
4. **Actualizează documentația** dacă este necesar
|
||||
5. **Creează Pull Request** cu descriere detaliată
|
||||
|
||||
### Development Setup
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone [repository-url]
|
||||
cd roa-flask
|
||||
|
||||
# Setup environment
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
# or
|
||||
venv\Scripts\activate # Windows
|
||||
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run tests
|
||||
cd roa2web/shared/auth
|
||||
python -m pytest test_auth.py -v
|
||||
```
|
||||
|
||||
## 📜 License
|
||||
|
||||
Acest sistem de autentificare face parte din proiectul ROA2WEB și este disponibil sub aceleași condiții de licențiere ca și proiectul principal.
|
||||
|
||||
---
|
||||
|
||||
**ROA2WEB Authentication System v1.0.0**
|
||||
*Secure, scalable, Oracle-integrated authentication pentru aplicații moderne*
|
||||
23
deploy-package-20260223-151231/shared/auth/__init__.py
Normal file
23
deploy-package-20260223-151231/shared/auth/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
ROA2WEB Shared Authentication Module
|
||||
|
||||
This module provides JWT-based authentication functionality that can be shared
|
||||
across all ROA2WEB microservices.
|
||||
|
||||
Components:
|
||||
- jwt_handler: JWT token creation, validation, and refresh
|
||||
- auth_service: Oracle database authentication integration
|
||||
- middleware: FastAPI middleware for token validation
|
||||
- dependencies: FastAPI dependencies for protected routes
|
||||
- models: Pydantic models for authentication data
|
||||
- routes: Template authentication routes for FastAPI apps
|
||||
"""
|
||||
|
||||
from .jwt_handler import jwt_handler, JWTHandler, TokenData, TokenResponse
|
||||
|
||||
__all__ = [
|
||||
'jwt_handler',
|
||||
'JWTHandler',
|
||||
'TokenData',
|
||||
'TokenResponse'
|
||||
]
|
||||
476
deploy-package-20260223-151231/shared/auth/auth_service.py
Normal file
476
deploy-package-20260223-151231/shared/auth/auth_service.py
Normal file
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
Authentication Service - Oracle Database Integration pentru ROA2WEB
|
||||
|
||||
Acest modul integrează sistemul de autentificare JWT cu baza de date Oracle,
|
||||
reutilizând funcționalitatea existentă din aplicația Flask originală.
|
||||
|
||||
Funcționalități:
|
||||
- Verificare utilizatori prin pack_drepturi.verificautilizator
|
||||
- Obținere lista firmelor din vdef_util_grup
|
||||
- Gestionarea sesiunilor și permisiunilor utilizatorilor
|
||||
- Caching pentru performanță optimă
|
||||
"""
|
||||
|
||||
import logging
|
||||
import hashlib
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
# Import shared database pool
|
||||
# IMPORTANT: Use shared.database.oracle_pool to ensure singleton consistency
|
||||
# DO NOT use relative imports like 'database.oracle_pool'
|
||||
from shared.database.oracle_pool import oracle_pool
|
||||
from .jwt_handler import jwt_handler
|
||||
from .models import TokenResponse, CurrentUser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
"""Excepție pentru erorile de autentificare"""
|
||||
pass
|
||||
|
||||
|
||||
class UserAuthService:
|
||||
"""
|
||||
Serviciu pentru autentificarea utilizatorilor folosind Oracle Database
|
||||
|
||||
Acest serviciu integrează:
|
||||
- Verificarea credențialelor prin pack_drepturi.verificautilizator
|
||||
- Obținerea listei de firme prin vdef_util_grup
|
||||
- Generarea token-urilor JWT
|
||||
- Cache pentru performanță
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Inițializează serviciul de autentificare"""
|
||||
self._user_cache: Dict[str, Dict[str, Any]] = {}
|
||||
self._cache_ttl = timedelta(minutes=15) # Cache 15 minute
|
||||
|
||||
def _get_cache_key(self, username: str) -> str:
|
||||
"""Generează cheia de cache pentru utilizator"""
|
||||
return f"auth_user_{username.lower()}"
|
||||
|
||||
def _is_cache_valid(self, cache_entry: Dict[str, Any]) -> bool:
|
||||
"""Verifică dacă entry-ul din cache este încă valid"""
|
||||
if not cache_entry or 'timestamp' not in cache_entry:
|
||||
return False
|
||||
|
||||
cache_time = cache_entry['timestamp']
|
||||
return datetime.now() - cache_time < self._cache_ttl
|
||||
|
||||
def _get_cached_user_data(self, username: str) -> Optional[Dict[str, Any]]:
|
||||
"""Obține datele utilizatorului din cache dacă sunt valide"""
|
||||
cache_key = self._get_cache_key(username)
|
||||
cache_entry = self._user_cache.get(cache_key)
|
||||
|
||||
if self._is_cache_valid(cache_entry):
|
||||
logger.debug(f"Cache hit for user {username}")
|
||||
return cache_entry['data']
|
||||
|
||||
return None
|
||||
|
||||
def _cache_user_data(self, username: str, data: Dict[str, Any]) -> None:
|
||||
"""Salvează datele utilizatorului în cache"""
|
||||
cache_key = self._get_cache_key(username)
|
||||
self._user_cache[cache_key] = {
|
||||
'data': data,
|
||||
'timestamp': datetime.now()
|
||||
}
|
||||
logger.debug(f"Cached data for user {username}")
|
||||
|
||||
async def get_username_by_email(
|
||||
self,
|
||||
email: str,
|
||||
server_id: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Obține username-ul Oracle corespunzător unui email.
|
||||
|
||||
Necesar pentru login cu email - convertește email-ul în username-ul
|
||||
real din tabelul UTILIZATORI pentru autentificare cu pack_drepturi.
|
||||
|
||||
Args:
|
||||
email: Email-ul utilizatorului
|
||||
server_id: ID-ul serverului Oracle (pentru multi-server mode)
|
||||
|
||||
Returns:
|
||||
Username-ul Oracle sau None dacă email-ul nu există
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT UTILIZATOR
|
||||
FROM CONTAFIN_ORACLE.UTILIZATORI
|
||||
WHERE LOWER(EMAIL) = :email
|
||||
AND INACTIV = 0
|
||||
AND STERS = 0
|
||||
""", {'email': email.lower().strip()})
|
||||
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
username = row[0]
|
||||
logger.info(f"Resolved email '{email}' to username '{username}' on server '{server_id}'")
|
||||
return username
|
||||
else:
|
||||
logger.warning(f"No username found for email '{email}' on server '{server_id}'")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Database error resolving email '{email}' to username: {str(e)}")
|
||||
return None
|
||||
|
||||
async def verify_user_credentials(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
server_id: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Verifică credențialele utilizatorului folosind pack_drepturi.verificautilizator
|
||||
|
||||
Args:
|
||||
username: Numele utilizatorului
|
||||
password: Parola utilizatorului
|
||||
server_id: ID-ul serverului Oracle (opțional, pentru multi-server mode)
|
||||
|
||||
Returns:
|
||||
True dacă credențialele sunt corecte, False altfel
|
||||
|
||||
Raises:
|
||||
AuthenticationError: Dacă apar erori în procesul de verificare
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
# Apelarea procedurii pack_drepturi.verificautilizator
|
||||
# Această procedură returnează ID-ul utilizatorului cu checksum pentru succes, -1 pentru eșec
|
||||
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
|
||||
|
||||
# DEBUG: Log the exact result from Oracle
|
||||
logger.info(f"[DEBUG] verificautilizator('{username.upper()}', '***') on server '{server_id}' = {verification_result}")
|
||||
|
||||
# Interpretarea rezultatului conform logicii VFP:
|
||||
# -1 = invalid credentials
|
||||
# > 0 = valid user ID with checksum
|
||||
# < -1000000 = admin/super user
|
||||
is_valid = verification_result != -1
|
||||
|
||||
if is_valid:
|
||||
# Extrage ID-ul real al utilizatorului conform logicii VFP
|
||||
if verification_result < -1000000:
|
||||
# Admin/Super user
|
||||
user_id = verification_result + 1000000
|
||||
logger.info(f"Admin/Super user {username} authenticated successfully (ID: {user_id})")
|
||||
else:
|
||||
# User normal - extrage ID-ul din checksum
|
||||
user_id = int(verification_result / 100)
|
||||
logger.info(f"User {username} authenticated successfully (ID: {user_id}, verification: {verification_result})")
|
||||
else:
|
||||
logger.warning(f"Authentication failed for user {username}")
|
||||
|
||||
return is_valid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Database error during authentication for user {username}: {str(e)}")
|
||||
raise AuthenticationError(f"Database authentication error: {str(e)}")
|
||||
|
||||
async def get_user_companies(
|
||||
self,
|
||||
username: str,
|
||||
server_id: Optional[str] = None
|
||||
) -> List[str]:
|
||||
"""
|
||||
Obține lista firmelor la care utilizatorul are acces din V_NOM_FIRME
|
||||
folosind ID-ul utilizatorului din UTILIZATORI
|
||||
|
||||
Args:
|
||||
username: Numele utilizatorului
|
||||
server_id: ID-ul serverului Oracle (opțional, pentru multi-server mode)
|
||||
|
||||
Returns:
|
||||
Lista codurilor firmelor la care utilizatorul are acces
|
||||
|
||||
Raises:
|
||||
AuthenticationError: Dacă apar erori în procesul de obținere
|
||||
"""
|
||||
# Verifică cache-ul mai întâi (include server_id în cheie pentru multi-server)
|
||||
cache_key_suffix = f"_{server_id}" if server_id else ""
|
||||
cached_data = self._get_cached_user_data(f"{username}{cache_key_suffix}")
|
||||
if cached_data and 'companies' in cached_data:
|
||||
return cached_data['companies']
|
||||
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
try:
|
||||
# Debug: să vedem ce utilizatori există în tabela UTILIZATORI
|
||||
cursor.execute("""
|
||||
SELECT ID_UTIL, UTILIZATOR
|
||||
FROM UTILIZATORI
|
||||
WHERE UPPER(UTILIZATOR) LIKE '%MARIUS%'
|
||||
ORDER BY UTILIZATOR
|
||||
""")
|
||||
|
||||
debug_users = cursor.fetchall()
|
||||
logger.info(f"DEBUG: Users with MARIUS in name: {debug_users}")
|
||||
|
||||
# Primul pas: obținem ID-ul utilizatorului din UTILIZATORI
|
||||
cursor.execute("""
|
||||
SELECT ID_UTIL, UTILIZATOR
|
||||
FROM UTILIZATORI
|
||||
WHERE UPPER(UTILIZATOR) = :username
|
||||
""", {'username': username.upper()})
|
||||
|
||||
user_row = cursor.fetchone()
|
||||
if not user_row:
|
||||
logger.warning(f"User {username} not found in UTILIZATORI table")
|
||||
# Să încercăm să găsim utilizatori similari
|
||||
cursor.execute("""
|
||||
SELECT ID_UTIL, UTILIZATOR
|
||||
FROM UTILIZATORI
|
||||
WHERE UPPER(UTILIZATOR) LIKE :username_pattern
|
||||
ORDER BY UTILIZATOR
|
||||
""", {'username_pattern': f'%{username.upper()}%'})
|
||||
similar_users = cursor.fetchall()
|
||||
logger.info(f"Similar users found: {similar_users}")
|
||||
return []
|
||||
|
||||
user_id = user_row[0]
|
||||
actual_name = user_row[1]
|
||||
logger.info(f"Found user {username} with ID: {user_id}, actual name: {actual_name}")
|
||||
|
||||
# Al doilea pas: obținem firmele folosind query-ul corect (cu ID_FIRMA)
|
||||
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_rows = cursor.fetchall()
|
||||
companies = [str(row[0]) for row in companies_rows if row[0]]
|
||||
|
||||
if not companies:
|
||||
logger.warning(f"No companies found for user {username} (ID: {user_id})")
|
||||
return []
|
||||
|
||||
logger.info(f"User {username} has access to {len(companies)} companies: {companies}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Could not query companies for user {username}: {e}")
|
||||
# În caz de eroare, returnăm listă goală în loc de TEST_COMPANY
|
||||
return []
|
||||
|
||||
# Cache rezultatul (include server_id pentru multi-server)
|
||||
cache_key = f"{username}{cache_key_suffix}"
|
||||
self._cache_user_data(cache_key, {'companies': companies})
|
||||
|
||||
return companies
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Database error getting companies for user {username}: {str(e)}")
|
||||
raise AuthenticationError(f"Error retrieving user companies: {str(e)}")
|
||||
|
||||
async def get_user_permissions(
|
||||
self,
|
||||
username: str,
|
||||
company: str,
|
||||
server_id: Optional[str] = None
|
||||
) -> List[str]:
|
||||
"""
|
||||
Obține permisiunile utilizatorului pentru o anumită firmă
|
||||
|
||||
Args:
|
||||
username: Numele utilizatorului
|
||||
company: Codul firmei
|
||||
server_id: ID-ul serverului Oracle (pentru multi-server mode)
|
||||
|
||||
Returns:
|
||||
Lista permisiunilor pentru firma specificată
|
||||
"""
|
||||
# Implementare de bază - poate fi extinsă în viitor
|
||||
companies = await self.get_user_companies(username, server_id)
|
||||
|
||||
# Dacă nu există companii sau compania nu este în listă, returnează permisiuni minime
|
||||
if not companies or company not in companies:
|
||||
return ["read"] if not companies else []
|
||||
|
||||
# Pentru moment, toți utilizatorii autentificați au permisiuni de citire
|
||||
# Acest sistem poate fi extins cu permisiuni granulare în viitor
|
||||
return ["read", "reports"]
|
||||
|
||||
async def authenticate_and_create_tokens(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
server_id: Optional[str] = None
|
||||
) -> Tuple[bool, Optional[TokenResponse], Optional[str]]:
|
||||
"""
|
||||
Autentifică utilizatorul și creează token-urile JWT
|
||||
|
||||
Suportă atât username clasic cât și email pentru login.
|
||||
Dacă input-ul conține '@', se tratează ca email și se convertește
|
||||
în username-ul Oracle corespunzător.
|
||||
|
||||
Args:
|
||||
username: Numele utilizatorului sau email-ul
|
||||
password: Parola utilizatorului
|
||||
server_id: ID-ul serverului Oracle (opțional, pentru multi-server mode)
|
||||
|
||||
Returns:
|
||||
Tuple cu (success, token_response, error_message)
|
||||
"""
|
||||
try:
|
||||
# Detectăm dacă input-ul este email sau username clasic
|
||||
actual_username = username
|
||||
if '@' in username:
|
||||
# Este email - convertim în username Oracle
|
||||
resolved_username = await self.get_username_by_email(username, server_id)
|
||||
if not resolved_username:
|
||||
logger.warning(f"Could not resolve email '{username}' to username on server '{server_id}'")
|
||||
return False, None, "Invalid username or password"
|
||||
actual_username = resolved_username
|
||||
logger.info(f"Login with email '{username}' resolved to username '{actual_username}'")
|
||||
|
||||
# Verifică credențialele pe serverul specificat
|
||||
is_valid = await self.verify_user_credentials(actual_username, password, server_id)
|
||||
|
||||
if not is_valid:
|
||||
return False, None, "Invalid username or password"
|
||||
|
||||
# Obține firmele utilizatorului de pe serverul specificat
|
||||
companies = await self.get_user_companies(actual_username, server_id)
|
||||
|
||||
# Nu blocăm login-ul dacă utilizatorul nu are firme - îl lăsăm să vadă mesajul în frontend
|
||||
if not companies:
|
||||
logger.info(f"User {actual_username} has no companies assigned - allowing login but with empty companies list")
|
||||
|
||||
# Obține permisiunile (pentru prima firmă ca default sau lista goală)
|
||||
permissions = await self.get_user_permissions(actual_username, companies[0] if companies else "", server_id)
|
||||
|
||||
# Creează token-urile folosind jwt_handler
|
||||
# Include server_id în JWT pentru ca request-urile ulterioare să știe pe care server să execute query-uri
|
||||
jwt_tokens = jwt_handler.create_token_response(
|
||||
username=actual_username,
|
||||
companies=companies,
|
||||
user_id=None, # Poate fi adăugat în viitor dacă avem user_id în DB
|
||||
permissions=permissions,
|
||||
server_id=server_id
|
||||
)
|
||||
|
||||
# Creează obiectul CurrentUser
|
||||
current_user = CurrentUser(
|
||||
username=actual_username,
|
||||
user_id=None,
|
||||
companies=companies,
|
||||
permissions=permissions
|
||||
)
|
||||
|
||||
# Creează TokenResponse-ul complet cu user info
|
||||
token_response = TokenResponse(
|
||||
access_token=jwt_tokens.access_token,
|
||||
refresh_token=jwt_tokens.refresh_token,
|
||||
token_type=jwt_tokens.token_type,
|
||||
expires_in=jwt_tokens.expires_in,
|
||||
user=current_user
|
||||
)
|
||||
|
||||
logger.info(f"Successfully created tokens for user {actual_username} on server {server_id or 'default'}")
|
||||
return True, token_response, None
|
||||
|
||||
except AuthenticationError as e:
|
||||
logger.error(f"Authentication error for user {username}: {str(e)}")
|
||||
return False, None, str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during authentication for user {username}: {str(e)}")
|
||||
return False, None, "Internal authentication error"
|
||||
|
||||
async def validate_user_company_access(self, username: str, company: str) -> bool:
|
||||
"""
|
||||
Validează dacă utilizatorul are acces la o anumită firmă
|
||||
|
||||
Args:
|
||||
username: Numele utilizatorului
|
||||
company: Codul firmei de verificat
|
||||
|
||||
Returns:
|
||||
True dacă utilizatorul are acces, False altfel
|
||||
"""
|
||||
try:
|
||||
companies = await self.get_user_companies(username)
|
||||
has_access = company in companies
|
||||
|
||||
if not has_access:
|
||||
logger.warning(f"User {username} attempted to access unauthorized company {company}")
|
||||
|
||||
return has_access
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating company access for user {username}: {str(e)}")
|
||||
return False
|
||||
|
||||
async def refresh_user_data(self, username: str) -> bool:
|
||||
"""
|
||||
Reîmprospătează datele utilizatorului din cache
|
||||
|
||||
Args:
|
||||
username: Numele utilizatorului
|
||||
|
||||
Returns:
|
||||
True dacă refresh-ul a fost cu succes
|
||||
"""
|
||||
try:
|
||||
# Șterge din cache
|
||||
cache_key = self._get_cache_key(username)
|
||||
if cache_key in self._user_cache:
|
||||
del self._user_cache[cache_key]
|
||||
|
||||
# Reîncarcă datele
|
||||
await self.get_user_companies(username)
|
||||
logger.info(f"Refreshed user data for {username}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing user data for {username}: {str(e)}")
|
||||
return False
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Șterge tot cache-ul utilizatorilor"""
|
||||
self._user_cache.clear()
|
||||
logger.info("User cache cleared")
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""Returnează statistici despre cache"""
|
||||
total_entries = len(self._user_cache)
|
||||
valid_entries = sum(
|
||||
1 for entry in self._user_cache.values()
|
||||
if self._is_cache_valid(entry)
|
||||
)
|
||||
|
||||
return {
|
||||
'total_entries': total_entries,
|
||||
'valid_entries': valid_entries,
|
||||
'cache_hit_ratio': valid_entries / total_entries if total_entries > 0 else 0
|
||||
}
|
||||
|
||||
|
||||
# Instance globală pentru folosire în toate aplicațiile
|
||||
auth_service = UserAuthService()
|
||||
540
deploy-package-20260223-151231/shared/auth/demo_app.py
Normal file
540
deploy-package-20260223-151231/shared/auth/demo_app.py
Normal file
@@ -0,0 +1,540 @@
|
||||
"""
|
||||
FastAPI Demo App demonstrând sistemul de autentificare ROA2WEB
|
||||
|
||||
Această aplicație demonstrează integrarea completă a sistemului de autentificare:
|
||||
- Login și logout cu Oracle database
|
||||
- Protected routes cu JWT authentication
|
||||
- Company-specific access control
|
||||
- Permission-based authorization
|
||||
- Rate limiting și security features
|
||||
|
||||
Funcționează ca:
|
||||
1. Exemplu de integrare pentru dezvoltatori
|
||||
2. Tool de testare pentru sistemul de autentificare
|
||||
3. Demonstrație pentru managementul proiectului
|
||||
|
||||
Pentru a rula demo-ul:
|
||||
1. Configurează variabilele de mediu în .env
|
||||
2. Asigură-te că Oracle database este accesibil
|
||||
3. Rulează: python demo_app.py
|
||||
4. Acesează http://localhost:8000/docs pentru Swagger UI
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Depends, HTTPException, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
# Adaugă calea pentru modulele shared
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
# Import modulele de autentificare
|
||||
from .jwt_handler import jwt_handler
|
||||
from .auth_service import auth_service
|
||||
from .models import CurrentUser, LoginRequest, PermissionType
|
||||
from .routes import create_auth_router
|
||||
from .middleware import AuthenticationMiddleware, default_rate_limiter
|
||||
from .dependencies import (
|
||||
get_current_user, get_optional_user, require_company_access,
|
||||
require_permissions, get_current_company_from_header
|
||||
)
|
||||
|
||||
# Import componente shared
|
||||
from database.oracle_pool import oracle_pool
|
||||
from utils.config import shared_config
|
||||
|
||||
# Configurare logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""
|
||||
Lifecycle events pentru demo app
|
||||
"""
|
||||
# Startup
|
||||
logger.info("🚀 Starting ROA2WEB Authentication Demo")
|
||||
|
||||
try:
|
||||
# Inițializează Oracle pool
|
||||
await oracle_pool.initialize(
|
||||
user=shared_config.oracle_user,
|
||||
password=shared_config.oracle_password,
|
||||
dsn=shared_config.oracle_dsn,
|
||||
min_connections=2,
|
||||
max_connections=5
|
||||
)
|
||||
logger.info("✅ Oracle connection pool initialized")
|
||||
|
||||
# Test database connection
|
||||
async with oracle_pool.get_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SELECT 'Database connected successfully' FROM DUAL")
|
||||
result = cursor.fetchone()
|
||||
logger.info(f"✅ Database test: {result[0]}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Startup error: {str(e)}")
|
||||
logger.warning("Demo will continue but database features may not work")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("🛑 Shutting down ROA2WEB Authentication Demo")
|
||||
try:
|
||||
await oracle_pool.close_pool()
|
||||
logger.info("✅ Oracle connection pool closed")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Shutdown error: {str(e)}")
|
||||
|
||||
|
||||
# Crearea aplicației FastAPI
|
||||
app = FastAPI(
|
||||
title="ROA2WEB Authentication Demo",
|
||||
description="""
|
||||
Demonstrație completă a sistemului de autentificare ROA2WEB
|
||||
|
||||
Această aplicație demonstrează:
|
||||
- JWT Authentication cu Oracle Database
|
||||
- Protected routes și company access control
|
||||
- Permission-based authorization
|
||||
- Rate limiting și security features
|
||||
- Integration patterns pentru aplicații ROA2WEB
|
||||
""",
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# CORS pentru development
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://localhost:5173", "http://localhost:8080"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Authentication middleware
|
||||
app.add_middleware(
|
||||
AuthenticationMiddleware,
|
||||
excluded_paths=["/", "/docs", "/redoc", "/openapi.json", "/health", "/demo", "/auth/login"],
|
||||
rate_limit_paths=["/auth/login"],
|
||||
rate_limiter=default_rate_limiter
|
||||
)
|
||||
|
||||
# Include authentication router
|
||||
auth_router = create_auth_router(include_admin_routes=True)
|
||||
app.include_router(auth_router)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DEMO ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def demo_home():
|
||||
"""
|
||||
Pagina principală cu informații despre demo
|
||||
"""
|
||||
html_content = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>ROA2WEB Authentication Demo</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; background: #f5f5f5; }
|
||||
.container { max-width: 800px; margin: 0 auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
||||
h1 { color: #2c3e50; border-bottom: 3px solid #3498db; padding-bottom: 10px; }
|
||||
h2 { color: #34495e; margin-top: 30px; }
|
||||
.endpoint { background: #ecf0f1; padding: 15px; margin: 10px 0; border-radius: 5px; border-left: 4px solid #3498db; }
|
||||
.method { font-weight: bold; color: #e74c3c; }
|
||||
.protected { border-left-color: #f39c12; }
|
||||
.public { border-left-color: #27ae60; }
|
||||
code { background: #34495e; color: white; padding: 2px 6px; border-radius: 3px; }
|
||||
.status { padding: 10px; margin: 15px 0; border-radius: 5px; }
|
||||
.success { background: #d5edda; border: 1px solid #c3e6cb; color: #155724; }
|
||||
.info { background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🔐 ROA2WEB Authentication Demo</h1>
|
||||
|
||||
<div class="status info">
|
||||
<strong>Status:</strong> Demo aplicație ROA2WEB Authentication System<br>
|
||||
<strong>Versiune:</strong> 1.0.0<br>
|
||||
<strong>Timp:</strong> """ + datetime.now().strftime("%Y-%m-%d %H:%M:%S") + """
|
||||
</div>
|
||||
|
||||
<h2>📋 Endpoints Disponibile</h2>
|
||||
|
||||
<div class="endpoint public">
|
||||
<div class="method">GET</div>
|
||||
<strong>/docs</strong> - Swagger UI pentru testarea API-ului
|
||||
</div>
|
||||
|
||||
<div class="endpoint public">
|
||||
<div class="method">GET</div>
|
||||
<strong>/health</strong> - Health check pentru aplicație și database
|
||||
</div>
|
||||
|
||||
<div class="endpoint public">
|
||||
<div class="method">POST</div>
|
||||
<strong>/auth/login</strong> - Autentificare utilizator cu username/password
|
||||
</div>
|
||||
|
||||
<div class="endpoint protected">
|
||||
<div class="method">GET</div>
|
||||
<strong>/auth/me</strong> - Informații utilizator curent (protejat)
|
||||
</div>
|
||||
|
||||
<div class="endpoint protected">
|
||||
<div class="method">GET</div>
|
||||
<strong>/demo/protected</strong> - Endpoint protejat simplu
|
||||
</div>
|
||||
|
||||
<div class="endpoint protected">
|
||||
<div class="method">GET</div>
|
||||
<strong>/demo/company/{company_code}</strong> - Endpoint cu verificare acces firmă
|
||||
</div>
|
||||
|
||||
<div class="endpoint protected">
|
||||
<div class="method">GET</div>
|
||||
<strong>/demo/admin</strong> - Endpoint cu verificare admin permissions
|
||||
</div>
|
||||
|
||||
<h2>🧪 Cum să testezi</h2>
|
||||
|
||||
<ol>
|
||||
<li>Accesează <a href="/docs">/docs</a> pentru Swagger UI</li>
|
||||
<li>Folosește <code>POST /auth/login</code> cu credențiale valide</li>
|
||||
<li>Copiază <code>access_token</code> din răspuns</li>
|
||||
<li>Click pe "Authorize" în Swagger UI și introdu: <code>Bearer YOUR_TOKEN</code></li>
|
||||
<li>Testează endpoint-urile protejate</li>
|
||||
</ol>
|
||||
|
||||
<h2>🔧 Configurare</h2>
|
||||
<p>Pentru a funcționa complet, demo-ul necesită:</p>
|
||||
<ul>
|
||||
<li>Variabile de mediu configurate în <code>.env</code></li>
|
||||
<li>Conexiune la Oracle Database</li>
|
||||
<li>Utilizatori valizi în sistemul Oracle</li>
|
||||
</ul>
|
||||
|
||||
<div class="status success">
|
||||
<strong>💡 Tip:</strong> Pentru dezvoltare rapidă, vezi <code>demo_app.py</code>
|
||||
pentru exemple de integrare a autentificării în aplicațiile tale FastAPI.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HTMLResponse(content=html_content)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""
|
||||
Health check complet pentru demo
|
||||
"""
|
||||
health_status = {
|
||||
"service": "ROA2WEB Authentication Demo",
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
# Test database connection
|
||||
try:
|
||||
async with oracle_pool.get_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SELECT 1 FROM DUAL")
|
||||
cursor.fetchone()
|
||||
health_status["database"] = "connected"
|
||||
except Exception as e:
|
||||
health_status["database"] = f"error: {str(e)}"
|
||||
health_status["status"] = "degraded"
|
||||
|
||||
# Test JWT handler
|
||||
try:
|
||||
test_token = jwt_handler.create_access_token("healthcheck", ["TEST"])
|
||||
token_data = jwt_handler.verify_token(test_token)
|
||||
if token_data and token_data.username == "healthcheck":
|
||||
health_status["jwt"] = "functional"
|
||||
else:
|
||||
health_status["jwt"] = "error: token verification failed"
|
||||
health_status["status"] = "degraded"
|
||||
except Exception as e:
|
||||
health_status["jwt"] = f"error: {str(e)}"
|
||||
health_status["status"] = "degraded"
|
||||
|
||||
# Authentication service status
|
||||
try:
|
||||
cache_stats = auth_service.get_cache_stats()
|
||||
health_status["auth_cache"] = {
|
||||
"total_entries": cache_stats["total_entries"],
|
||||
"cache_hit_ratio": cache_stats["cache_hit_ratio"]
|
||||
}
|
||||
except Exception as e:
|
||||
health_status["auth_cache"] = f"error: {str(e)}"
|
||||
|
||||
status_code = 200 if health_status["status"] == "healthy" else 503
|
||||
return JSONResponse(content=health_status, status_code=status_code)
|
||||
|
||||
|
||||
@app.get("/demo/public")
|
||||
async def demo_public_endpoint():
|
||||
"""
|
||||
Endpoint public - nu necesită autentificare
|
||||
"""
|
||||
return {
|
||||
"message": "Acesta este un endpoint public",
|
||||
"authenticated": False,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"info": "Acest endpoint poate fi accesat fără autentificare"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/demo/optional-auth")
|
||||
async def demo_optional_auth(
|
||||
current_user: Optional[CurrentUser] = Depends(get_optional_user)
|
||||
):
|
||||
"""
|
||||
Endpoint cu autentificare opțională
|
||||
"""
|
||||
if current_user:
|
||||
return {
|
||||
"message": f"Salut, {current_user.username}!",
|
||||
"authenticated": True,
|
||||
"user": current_user.username,
|
||||
"companies": current_user.companies,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"message": "Acesta este un endpoint cu autentificare opțională",
|
||||
"authenticated": False,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"info": "Poți accesa și fără autentificare, dar cu token obții mai multe informații"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/demo/protected")
|
||||
async def demo_protected_endpoint(
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Endpoint protejat - necesită autentificare
|
||||
"""
|
||||
return {
|
||||
"message": f"Bună ziua, {current_user.username}!",
|
||||
"authenticated": True,
|
||||
"user_info": {
|
||||
"username": current_user.username,
|
||||
"companies": current_user.companies,
|
||||
"permissions": current_user.permissions,
|
||||
"companies_count": len(current_user.companies)
|
||||
},
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"info": "Acest endpoint necesită JWT token valid pentru acces"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/demo/company/{company_code}")
|
||||
async def demo_company_specific_endpoint(
|
||||
company_code: str,
|
||||
current_user: CurrentUser = Depends(require_company_access("")) # Will be overridden
|
||||
):
|
||||
"""
|
||||
Endpoint cu verificare acces la firmă specifică
|
||||
|
||||
Demonstrează cum să verifici dacă utilizatorul are acces la o anumită firmă
|
||||
"""
|
||||
# Verificare manuală pentru demonstrație (în practică folosești dependency)
|
||||
if company_code not in current_user.companies:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Nu aveți acces la firma {company_code}"
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"Acces permis la firma {company_code}",
|
||||
"company_code": company_code,
|
||||
"user": current_user.username,
|
||||
"user_companies": current_user.companies,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"info": "Utilizatorul are acces la această firmă"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/demo/admin")
|
||||
async def demo_admin_endpoint(
|
||||
current_user: CurrentUser = Depends(require_permissions([PermissionType.ADMIN]))
|
||||
):
|
||||
"""
|
||||
Endpoint cu verificare permisiuni admin
|
||||
"""
|
||||
return {
|
||||
"message": f"Bună ziua, admin {current_user.username}!",
|
||||
"admin_info": {
|
||||
"username": current_user.username,
|
||||
"permissions": current_user.permissions,
|
||||
"companies": current_user.companies,
|
||||
"admin_since": datetime.now().isoformat()
|
||||
},
|
||||
"system_stats": {
|
||||
"total_companies": len(current_user.companies),
|
||||
"demo_version": "1.0.0",
|
||||
"auth_system": "ROA2WEB JWT"
|
||||
},
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"info": "Acest endpoint necesită permisiuni de administrator"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/demo/reports")
|
||||
async def demo_reports_endpoint(
|
||||
request: Request,
|
||||
current_user: CurrentUser = Depends(require_permissions([PermissionType.REPORTS]))
|
||||
):
|
||||
"""
|
||||
Endpoint pentru rapoarte - demonstrează integrarea cu header-ul Company
|
||||
"""
|
||||
# Obține company din header (X-Company-Code) sau folosește prima disponibilă
|
||||
company_code = request.headers.get("X-Company-Code")
|
||||
if not company_code:
|
||||
company_code = current_user.companies[0] if current_user.companies else None
|
||||
|
||||
if not company_code:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Nu s-a specificat codul firmei (X-Company-Code header)"
|
||||
)
|
||||
|
||||
if company_code not in current_user.companies:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Nu aveți acces la rapoartele firmei {company_code}"
|
||||
)
|
||||
|
||||
# Simulează generarea unui raport
|
||||
mock_report_data = {
|
||||
"company_code": company_code,
|
||||
"report_type": "demo_report",
|
||||
"generated_by": current_user.username,
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"data": {
|
||||
"total_invoices": 150,
|
||||
"total_amount": 125000.50,
|
||||
"paid_invoices": 120,
|
||||
"outstanding_amount": 25000.00
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"message": f"Raport generat pentru firma {company_code}",
|
||||
"report": mock_report_data,
|
||||
"user_info": {
|
||||
"username": current_user.username,
|
||||
"permissions": current_user.permissions
|
||||
},
|
||||
"info": "Acesta este un exemplu de endpoint pentru rapoarte cu verificare company access"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/demo/rate-limited")
|
||||
async def demo_rate_limited_endpoint():
|
||||
"""
|
||||
Endpoint cu rate limiting pentru demonstrație
|
||||
"""
|
||||
return {
|
||||
"message": "Acest endpoint are rate limiting aplicat",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"info": "Încercați să faceți mai multe request-uri rapid pentru a vedea rate limiting-ul"
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DEMO UTILITIES
|
||||
# =============================================================================
|
||||
|
||||
@app.get("/demo/token-info")
|
||||
async def demo_token_info(
|
||||
request: Request,
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Endpoint pentru afișarea informațiilor despre token-ul curent
|
||||
"""
|
||||
# Extrage token-ul din header
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
|
||||
# Decodează token-ul pentru informații (fără verificare pentru demo)
|
||||
payload = jwt_handler.decode_token_payload(token)
|
||||
|
||||
return {
|
||||
"message": "Informații despre token-ul curent",
|
||||
"token_info": {
|
||||
"user": current_user.username,
|
||||
"companies": current_user.companies,
|
||||
"permissions": current_user.permissions,
|
||||
"token_type": payload.get("type") if payload else "unknown",
|
||||
"issued_at": payload.get("iat") if payload else None,
|
||||
"expires_at": payload.get("exp") if payload else None
|
||||
},
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"error": "Nu s-a găsit token în header-ul Authorization"
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MAIN EXECUTION
|
||||
# =============================================================================
|
||||
|
||||
def main():
|
||||
"""
|
||||
Funcția principală pentru rularea demo-ului
|
||||
"""
|
||||
print("🚀 Starting ROA2WEB Authentication Demo")
|
||||
print("📋 Available endpoints:")
|
||||
print(" • http://localhost:8000/ - Demo home page")
|
||||
print(" • http://localhost:8000/docs - Swagger UI")
|
||||
print(" • http://localhost:8000/health - Health check")
|
||||
print(" • http://localhost:8000/demo/* - Demo endpoints")
|
||||
print("")
|
||||
print("💡 Pentru testare completă:")
|
||||
print(" 1. Configurează .env cu credențialele Oracle")
|
||||
print(" 2. Asigură-te că database-ul este accesibil")
|
||||
print(" 3. Folosește /docs pentru testarea interactivă")
|
||||
print("")
|
||||
|
||||
uvicorn.run(
|
||||
"demo_app:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=True,
|
||||
log_level="info"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
402
deploy-package-20260223-151231/shared/auth/dependencies.py
Normal file
402
deploy-package-20260223-151231/shared/auth/dependencies.py
Normal file
@@ -0,0 +1,402 @@
|
||||
"""
|
||||
FastAPI Authentication Dependencies pentru ROA2WEB
|
||||
|
||||
Acest modul oferă dependency functions pentru FastAPI care pot fi folosite
|
||||
pentru a proteja endpoint-urile și a obține informații despre utilizatorul curent.
|
||||
|
||||
Dependencies disponibile:
|
||||
- get_current_user: Obține utilizatorul curent (obligatoriu)
|
||||
- get_optional_user: Obține utilizatorul curent (opțional)
|
||||
- require_company_access: Verifică accesul la o firmă specifică
|
||||
- require_permissions: Verifică permisiunile necesare
|
||||
- get_current_company: Obține firma curentă din context
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, List, Callable, Any
|
||||
from functools import wraps
|
||||
|
||||
from fastapi import Depends, HTTPException, status, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
from .middleware import security_required, security_optional
|
||||
from .jwt_handler import jwt_handler, TokenData
|
||||
from .auth_service import auth_service
|
||||
from .models import CurrentUser, PermissionType, AuthError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthenticationRequired(Exception):
|
||||
"""Excepție pentru când autentificarea este obligatorie"""
|
||||
pass
|
||||
|
||||
|
||||
class InsufficientPermissions(Exception):
|
||||
"""Excepție pentru permisiuni insuficiente"""
|
||||
pass
|
||||
|
||||
|
||||
class CompanyAccessDenied(Exception):
|
||||
"""Excepție pentru acces refuzat la firmă"""
|
||||
pass
|
||||
|
||||
|
||||
async def get_current_user_from_token(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security_required)
|
||||
) -> CurrentUser:
|
||||
"""
|
||||
Extrage și validează utilizatorul curent din token JWT
|
||||
|
||||
Args:
|
||||
credentials: Credențialele HTTP de autentificare din header
|
||||
|
||||
Returns:
|
||||
Utilizatorul curent autentificat
|
||||
|
||||
Raises:
|
||||
HTTPException: Dacă token-ul este invalid sau utilizatorul nu există
|
||||
"""
|
||||
if not credentials:
|
||||
logger.warning("No credentials provided for protected endpoint")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Validează token-ul
|
||||
token_data = jwt_handler.verify_token(credentials.credentials)
|
||||
|
||||
if not token_data:
|
||||
logger.warning("Invalid token provided")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if token_data.token_type != "access":
|
||||
logger.warning(f"Invalid token type: {token_data.token_type}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Creează obiectul CurrentUser
|
||||
current_user = CurrentUser(
|
||||
username=token_data.username,
|
||||
user_id=token_data.user_id,
|
||||
companies=token_data.companies,
|
||||
permissions=token_data.permissions
|
||||
)
|
||||
|
||||
logger.debug(f"Successfully authenticated user: {current_user.username}")
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_current_user_from_request(request: Request) -> CurrentUser:
|
||||
"""
|
||||
Obține utilizatorul curent din request state (setat de middleware)
|
||||
|
||||
Args:
|
||||
request: Request-ul HTTP curent
|
||||
|
||||
Returns:
|
||||
Utilizatorul curent autentificat
|
||||
|
||||
Raises:
|
||||
HTTPException: Dacă utilizatorul nu este autentificat
|
||||
"""
|
||||
if not hasattr(request.state, 'is_authenticated') or not request.state.is_authenticated:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not hasattr(request.state, 'user') or not request.state.user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found in request",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return request.state.user
|
||||
|
||||
|
||||
async def get_optional_user_from_request(request: Request) -> Optional[CurrentUser]:
|
||||
"""
|
||||
Obține utilizatorul curent din request (opțional)
|
||||
|
||||
Args:
|
||||
request: Request-ul HTTP curent
|
||||
|
||||
Returns:
|
||||
Utilizatorul curent sau None dacă nu este autentificat
|
||||
"""
|
||||
if (hasattr(request.state, 'is_authenticated') and
|
||||
request.state.is_authenticated and
|
||||
hasattr(request.state, 'user')):
|
||||
return request.state.user
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_optional_user_from_token(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_optional)
|
||||
) -> Optional[CurrentUser]:
|
||||
"""
|
||||
Extrage utilizatorul curent din token (opțional)
|
||||
|
||||
Args:
|
||||
credentials: Credențialele HTTP Bearer (opționale)
|
||||
|
||||
Returns:
|
||||
Utilizatorul curent sau None
|
||||
"""
|
||||
if not credentials:
|
||||
return None
|
||||
|
||||
try:
|
||||
return await get_current_user_from_token(credentials)
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
|
||||
def require_company_access(company_code: str):
|
||||
"""
|
||||
Dependency factory care verifică accesul la o firmă specifică
|
||||
|
||||
Args:
|
||||
company_code: Codul firmei la care se verifică accesul
|
||||
|
||||
Returns:
|
||||
Dependency function pentru FastAPI
|
||||
"""
|
||||
async def check_company_access(
|
||||
current_user: CurrentUser = Depends(get_current_user_from_request)
|
||||
) -> CurrentUser:
|
||||
"""
|
||||
Verifică dacă utilizatorul curent are acces la firma specificată
|
||||
|
||||
Args:
|
||||
current_user: Utilizatorul curent autentificat
|
||||
|
||||
Returns:
|
||||
Utilizatorul curent dacă are acces
|
||||
|
||||
Raises:
|
||||
HTTPException: Dacă nu are acces la firmă
|
||||
"""
|
||||
if company_code not in current_user.companies:
|
||||
logger.warning(
|
||||
f"User {current_user.username} attempted to access "
|
||||
f"unauthorized company {company_code}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Access denied to company {company_code}"
|
||||
)
|
||||
|
||||
# Verifică și în baza de date pentru siguranță
|
||||
has_access = await auth_service.validate_user_company_access(
|
||||
current_user.username, company_code
|
||||
)
|
||||
|
||||
if not has_access:
|
||||
logger.error(
|
||||
f"Database access check failed for user {current_user.username} "
|
||||
f"and company {company_code}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Database access denied to company {company_code}"
|
||||
)
|
||||
|
||||
logger.debug(f"User {current_user.username} granted access to company {company_code}")
|
||||
return current_user
|
||||
|
||||
return check_company_access
|
||||
|
||||
|
||||
def require_permissions(required_permissions: List[PermissionType]):
|
||||
"""
|
||||
Dependency factory care verifică permisiunile necesare
|
||||
|
||||
Args:
|
||||
required_permissions: Lista permisiunilor necesare
|
||||
|
||||
Returns:
|
||||
Dependency function pentru FastAPI
|
||||
"""
|
||||
async def check_permissions(
|
||||
current_user: CurrentUser = Depends(get_current_user_from_request)
|
||||
) -> CurrentUser:
|
||||
"""
|
||||
Verifică dacă utilizatorul are permisiunile necesare
|
||||
|
||||
Args:
|
||||
current_user: Utilizatorul curent autentificat
|
||||
|
||||
Returns:
|
||||
Utilizatorul curent dacă are permisiunile
|
||||
|
||||
Raises:
|
||||
HTTPException: Dacă nu are permisiunile necesare
|
||||
"""
|
||||
user_permissions = set(current_user.permissions)
|
||||
missing_permissions = [
|
||||
perm for perm in required_permissions
|
||||
if perm not in user_permissions
|
||||
]
|
||||
|
||||
if missing_permissions:
|
||||
logger.warning(
|
||||
f"User {current_user.username} missing permissions: {missing_permissions}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing required permissions: {missing_permissions}"
|
||||
)
|
||||
|
||||
logger.debug(f"User {current_user.username} has required permissions")
|
||||
return current_user
|
||||
|
||||
return check_permissions
|
||||
|
||||
|
||||
def require_company_and_permissions(
|
||||
company_code: str,
|
||||
required_permissions: List[PermissionType]
|
||||
):
|
||||
"""
|
||||
Dependency factory care verifică atât accesul la firmă cât și permisiunile
|
||||
|
||||
Args:
|
||||
company_code: Codul firmei
|
||||
required_permissions: Lista permisiunilor necesare
|
||||
|
||||
Returns:
|
||||
Dependency function pentru FastAPI
|
||||
"""
|
||||
async def check_company_and_permissions(
|
||||
current_user: CurrentUser = Depends(get_current_user_from_request)
|
||||
) -> CurrentUser:
|
||||
"""
|
||||
Verifică accesul la firmă și permisiunile
|
||||
|
||||
Args:
|
||||
current_user: Utilizatorul curent
|
||||
|
||||
Returns:
|
||||
Utilizatorul curent dacă are acces și permisiuni
|
||||
"""
|
||||
# Verifică accesul la firmă
|
||||
company_checker = require_company_access(company_code)
|
||||
await company_checker(current_user)
|
||||
|
||||
# Verifică permisiunile
|
||||
permissions_checker = require_permissions(required_permissions)
|
||||
await permissions_checker(current_user)
|
||||
|
||||
return current_user
|
||||
|
||||
return check_company_and_permissions
|
||||
|
||||
|
||||
async def get_current_company_from_header(
|
||||
request: Request,
|
||||
current_user: CurrentUser = Depends(get_current_user_from_request)
|
||||
) -> str:
|
||||
"""
|
||||
Obține codul firmei curente din header-ul X-Company-Code
|
||||
|
||||
Args:
|
||||
request: Request-ul HTTP
|
||||
current_user: Utilizatorul curent
|
||||
|
||||
Returns:
|
||||
Codul firmei curente
|
||||
|
||||
Raises:
|
||||
HTTPException: Dacă header-ul lipsește sau utilizatorul nu are acces
|
||||
"""
|
||||
company_code = request.headers.get("X-Company-Code")
|
||||
|
||||
if not company_code:
|
||||
# Folosește prima firmă ca default dacă nu este specificată
|
||||
if current_user.companies:
|
||||
company_code = current_user.companies[0]
|
||||
logger.debug(f"Using default company {company_code} for user {current_user.username}")
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Company code required (X-Company-Code header or user default)"
|
||||
)
|
||||
|
||||
# Verifică accesul
|
||||
if company_code not in current_user.companies:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Access denied to company {company_code}"
|
||||
)
|
||||
|
||||
return company_code
|
||||
|
||||
|
||||
# Aliasuri pentru folosire mai ușoară
|
||||
get_current_user = get_current_user_from_request
|
||||
get_optional_user = get_optional_user_from_request
|
||||
|
||||
# Dependency-uri predefinite pentru permisiuni comune
|
||||
require_read_permission = require_permissions([PermissionType.READ])
|
||||
require_write_permission = require_permissions([PermissionType.WRITE])
|
||||
require_admin_permission = require_permissions([PermissionType.ADMIN])
|
||||
require_reports_permission = require_permissions([PermissionType.REPORTS])
|
||||
|
||||
# Decorator pentru validarea companiei în funcții
|
||||
def validate_company_access(company_param: str = "company"):
|
||||
"""
|
||||
Decorator pentru validarea automată a accesului la firmă
|
||||
|
||||
Args:
|
||||
company_param: Numele parametrului care conține codul firmei
|
||||
|
||||
Returns:
|
||||
Decorator function
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Caută utilizatorul curent în argumentele funcției
|
||||
current_user = None
|
||||
for arg in args:
|
||||
if isinstance(arg, CurrentUser):
|
||||
current_user = arg
|
||||
break
|
||||
|
||||
if not current_user:
|
||||
# Caută în kwargs
|
||||
current_user = kwargs.get('current_user')
|
||||
|
||||
if not current_user:
|
||||
raise ValueError("CurrentUser not found in function arguments")
|
||||
|
||||
# Obține codul firmei
|
||||
company_code = kwargs.get(company_param)
|
||||
if not company_code:
|
||||
raise ValueError(f"Company parameter '{company_param}' not found")
|
||||
|
||||
# Validează accesul
|
||||
if company_code not in current_user.companies:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Access denied to company {company_code}"
|
||||
)
|
||||
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
362
deploy-package-20260223-151231/shared/auth/email_server_cache.py
Normal file
362
deploy-package-20260223-151231/shared/auth/email_server_cache.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
Email-Server Cache for Multi-Oracle Auto-Discovery
|
||||
|
||||
Builds and maintains a cache mapping emails to server IDs:
|
||||
- At startup, connects to each Oracle server and extracts emails from CONTAFIN_ORACLE.UTILIZATORI
|
||||
- Cache structure: {email: [server_ids]}
|
||||
- Auto-refresh every 15 minutes (configurable)
|
||||
- Thread-safe with asyncio.Lock
|
||||
|
||||
US-003: Auto-Discovery Email-Server Cache
|
||||
US-013: Added username lookup support (direct query, no caching)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailServerCache:
|
||||
"""
|
||||
Cache for email-to-server mapping.
|
||||
|
||||
Builds a dictionary {email: [server_ids]} by querying CONTAFIN_ORACLE.UTILIZATORI
|
||||
on each configured Oracle server.
|
||||
|
||||
Features:
|
||||
- Lazy initialization (build on first access or explicit call)
|
||||
- Auto-refresh at configurable intervals
|
||||
- Thread-safe operations
|
||||
- Graceful handling of server connection failures
|
||||
"""
|
||||
|
||||
_instance: Optional['EmailServerCache'] = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(EmailServerCache, cls).__new__(cls)
|
||||
cls._instance._cache: Dict[str, List[str]] = {}
|
||||
cls._instance._last_refresh: Optional[datetime] = None
|
||||
cls._instance._refresh_interval = timedelta(minutes=15)
|
||||
cls._instance._lock = asyncio.Lock()
|
||||
cls._instance._initialized = False
|
||||
cls._instance._refresh_task: Optional[asyncio.Task] = None
|
||||
return cls._instance
|
||||
|
||||
def set_refresh_interval(self, minutes: int) -> None:
|
||||
"""
|
||||
Set the cache refresh interval.
|
||||
|
||||
Args:
|
||||
minutes: Refresh interval in minutes (default: 15)
|
||||
"""
|
||||
self._refresh_interval = timedelta(minutes=minutes)
|
||||
logger.info(f"Email cache refresh interval set to {minutes} minutes")
|
||||
|
||||
async def build_cache(self) -> None:
|
||||
"""
|
||||
Build the email-server cache by querying all configured Oracle servers.
|
||||
|
||||
Connects to each server and extracts active user emails from
|
||||
CONTAFIN_ORACLE.UTILIZATORI table.
|
||||
"""
|
||||
from shared.database.oracle_pool import oracle_pool
|
||||
from backend.config import settings
|
||||
|
||||
async with self._lock:
|
||||
logger.info("[EMAIL-CACHE] Building email-server cache...")
|
||||
new_cache: Dict[str, Set[str]] = {} # Use set to avoid duplicates
|
||||
|
||||
servers = settings.get_oracle_servers()
|
||||
if not servers:
|
||||
logger.warning("[EMAIL-CACHE] No Oracle servers configured")
|
||||
self._cache = {}
|
||||
self._last_refresh = datetime.now()
|
||||
self._initialized = True
|
||||
return
|
||||
|
||||
for server in servers:
|
||||
try:
|
||||
logger.info(f"[EMAIL-CACHE] Querying server '{server.id}' ({server.name})...")
|
||||
|
||||
# Get connection from the multi-pool
|
||||
async with oracle_pool.get_connection(server.id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
# Query emails from UTILIZATORI table
|
||||
# Only active users (INACTIV=0, STERS=0) with valid emails
|
||||
cursor.execute("""
|
||||
SELECT LOWER(EMAIL) as email
|
||||
FROM CONTAFIN_ORACLE.UTILIZATORI
|
||||
WHERE EMAIL IS NOT NULL
|
||||
AND TRIM(EMAIL) IS NOT NULL
|
||||
AND INACTIV = 0
|
||||
AND STERS = 0
|
||||
""")
|
||||
|
||||
rows = cursor.fetchall()
|
||||
email_count = 0
|
||||
|
||||
for row in rows:
|
||||
email = row[0].strip().lower() if row[0] else None
|
||||
if email and '@' in email: # Basic email validation
|
||||
if email not in new_cache:
|
||||
new_cache[email] = set()
|
||||
new_cache[email].add(server.id)
|
||||
email_count += 1
|
||||
|
||||
logger.info(f"[EMAIL-CACHE] Found {email_count} valid emails on server '{server.id}'")
|
||||
|
||||
except Exception as e:
|
||||
# Log error but continue with other servers
|
||||
logger.error(f"[EMAIL-CACHE] Failed to query server '{server.id}': {e}")
|
||||
continue
|
||||
|
||||
# Convert sets to sorted lists for consistent ordering
|
||||
self._cache = {email: sorted(list(server_ids)) for email, server_ids in new_cache.items()}
|
||||
self._last_refresh = datetime.now()
|
||||
self._initialized = True
|
||||
|
||||
total_emails = len(self._cache)
|
||||
multi_server_emails = sum(1 for servers in self._cache.values() if len(servers) > 1)
|
||||
|
||||
logger.info(f"[EMAIL-CACHE] ✅ Cache built: {total_emails} unique emails")
|
||||
logger.info(f"[EMAIL-CACHE] {multi_server_emails} emails exist on multiple servers")
|
||||
|
||||
async def refresh_if_needed(self) -> bool:
|
||||
"""
|
||||
Refresh cache if the refresh interval has passed.
|
||||
|
||||
Returns:
|
||||
True if cache was refreshed, False otherwise
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.build_cache()
|
||||
return True
|
||||
|
||||
if self._last_refresh is None:
|
||||
await self.build_cache()
|
||||
return True
|
||||
|
||||
time_since_refresh = datetime.now() - self._last_refresh
|
||||
if time_since_refresh >= self._refresh_interval:
|
||||
await self.build_cache()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_servers_for_email(self, email: str) -> List[str]:
|
||||
"""
|
||||
Get list of server IDs where the email exists.
|
||||
|
||||
Args:
|
||||
email: User email address
|
||||
|
||||
Returns:
|
||||
List of server_ids where this email exists.
|
||||
Empty list if email not found (NOT an error).
|
||||
"""
|
||||
if not email:
|
||||
return []
|
||||
|
||||
normalized_email = email.strip().lower()
|
||||
servers = self._cache.get(normalized_email, [])
|
||||
|
||||
if servers:
|
||||
logger.debug(f"[EMAIL-CACHE] Email '{normalized_email}' found on servers: {servers}")
|
||||
else:
|
||||
logger.debug(f"[EMAIL-CACHE] Email '{normalized_email}' not found in cache")
|
||||
|
||||
return servers.copy() # Return a copy to prevent external modification
|
||||
|
||||
def is_initialized(self) -> bool:
|
||||
"""Check if cache has been built at least once."""
|
||||
return self._initialized
|
||||
|
||||
def get_cache_stats(self) -> Dict:
|
||||
"""
|
||||
Get cache statistics.
|
||||
|
||||
Returns:
|
||||
Dict with cache stats (total_emails, multi_server_count, last_refresh, etc.)
|
||||
"""
|
||||
if not self._initialized:
|
||||
return {
|
||||
'initialized': False,
|
||||
'total_emails': 0,
|
||||
'last_refresh': None,
|
||||
'refresh_interval_minutes': self._refresh_interval.total_seconds() / 60
|
||||
}
|
||||
|
||||
multi_server = sum(1 for servers in self._cache.values() if len(servers) > 1)
|
||||
server_distribution = {}
|
||||
for servers in self._cache.values():
|
||||
count = len(servers)
|
||||
server_distribution[count] = server_distribution.get(count, 0) + 1
|
||||
|
||||
return {
|
||||
'initialized': True,
|
||||
'total_emails': len(self._cache),
|
||||
'multi_server_count': multi_server,
|
||||
'server_distribution': server_distribution,
|
||||
'last_refresh': self._last_refresh.isoformat() if self._last_refresh else None,
|
||||
'refresh_interval_minutes': self._refresh_interval.total_seconds() / 60
|
||||
}
|
||||
|
||||
async def start_auto_refresh(self) -> None:
|
||||
"""
|
||||
Start background task for automatic cache refresh.
|
||||
|
||||
Runs refresh at the configured interval (default: 15 minutes).
|
||||
"""
|
||||
if self._refresh_task and not self._refresh_task.done():
|
||||
logger.warning("[EMAIL-CACHE] Auto-refresh task already running")
|
||||
return
|
||||
|
||||
async def refresh_loop():
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(self._refresh_interval.total_seconds())
|
||||
logger.info("[EMAIL-CACHE] Auto-refresh triggered")
|
||||
await self.build_cache()
|
||||
except asyncio.CancelledError:
|
||||
logger.info("[EMAIL-CACHE] Auto-refresh task cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"[EMAIL-CACHE] Auto-refresh error: {e}")
|
||||
# Continue running, will retry on next interval
|
||||
|
||||
self._refresh_task = asyncio.create_task(refresh_loop())
|
||||
logger.info(f"[EMAIL-CACHE] Auto-refresh started (every {self._refresh_interval.total_seconds() / 60:.0f} minutes)")
|
||||
|
||||
async def stop_auto_refresh(self) -> None:
|
||||
"""Stop the auto-refresh background task."""
|
||||
if self._refresh_task and not self._refresh_task.done():
|
||||
self._refresh_task.cancel()
|
||||
try:
|
||||
await self._refresh_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._refresh_task = None
|
||||
logger.info("[EMAIL-CACHE] Auto-refresh stopped")
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the cache (useful for testing)."""
|
||||
self._cache = {}
|
||||
self._initialized = False
|
||||
self._last_refresh = None
|
||||
logger.info("[EMAIL-CACHE] Cache cleared")
|
||||
|
||||
async def get_servers_for_username(self, username: str) -> List[str]:
|
||||
"""
|
||||
Get list of server IDs where the username exists (US-013).
|
||||
|
||||
Unlike email lookup which uses the cache, username lookup queries
|
||||
Oracle directly on each server. This is because:
|
||||
- Usernames are less commonly used for login
|
||||
- Direct query ensures fresh data
|
||||
- Avoids bloating the cache with both email and username mappings
|
||||
|
||||
Args:
|
||||
username: Username to look up (case-insensitive, converted to uppercase)
|
||||
|
||||
Returns:
|
||||
List of server_ids where this username exists.
|
||||
Empty list if username not found (NOT an error).
|
||||
"""
|
||||
if not username:
|
||||
return []
|
||||
|
||||
from shared.database.oracle_pool import oracle_pool
|
||||
from backend.config import settings
|
||||
|
||||
normalized_username = username.strip().upper()
|
||||
found_servers: List[str] = []
|
||||
|
||||
servers = settings.get_oracle_servers()
|
||||
if not servers:
|
||||
logger.warning("[EMAIL-CACHE] No Oracle servers configured for username lookup")
|
||||
return []
|
||||
|
||||
for server in servers:
|
||||
try:
|
||||
async with oracle_pool.get_connection(server.id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
# Query for username in UTILIZATORI table
|
||||
# Only active users (INACTIV=0, STERS=0)
|
||||
cursor.execute("""
|
||||
SELECT 1
|
||||
FROM CONTAFIN_ORACLE.UTILIZATORI
|
||||
WHERE UPPER(UTILIZATOR) = :username
|
||||
AND INACTIV = 0
|
||||
AND STERS = 0
|
||||
AND ROWNUM = 1
|
||||
""", {"username": normalized_username})
|
||||
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
found_servers.append(server.id)
|
||||
logger.debug(f"[EMAIL-CACHE] Username '{normalized_username}' found on server '{server.id}'")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[EMAIL-CACHE] Failed to query username on server '{server.id}': {e}")
|
||||
continue
|
||||
|
||||
if found_servers:
|
||||
logger.info(f"[EMAIL-CACHE] Username '{normalized_username}' found on {len(found_servers)} server(s): {found_servers}")
|
||||
else:
|
||||
logger.debug(f"[EMAIL-CACHE] Username '{normalized_username}' not found on any server")
|
||||
|
||||
return sorted(found_servers)
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
email_server_cache = EmailServerCache()
|
||||
|
||||
|
||||
# Convenience functions for external use
|
||||
def get_servers_for_email(email: str) -> List[str]:
|
||||
"""
|
||||
Get list of server IDs where the email exists.
|
||||
|
||||
This is a convenience function that wraps the singleton instance.
|
||||
|
||||
Args:
|
||||
email: User email address
|
||||
|
||||
Returns:
|
||||
List of server_ids. Empty list if email not found (NOT an error).
|
||||
"""
|
||||
return email_server_cache.get_servers_for_email(email)
|
||||
|
||||
|
||||
async def build_email_cache() -> None:
|
||||
"""Build/refresh the email-server cache."""
|
||||
await email_server_cache.build_cache()
|
||||
|
||||
|
||||
async def start_email_cache_refresh() -> None:
|
||||
"""Start automatic cache refresh."""
|
||||
await email_server_cache.start_auto_refresh()
|
||||
|
||||
|
||||
async def stop_email_cache_refresh() -> None:
|
||||
"""Stop automatic cache refresh."""
|
||||
await email_server_cache.stop_auto_refresh()
|
||||
|
||||
|
||||
async def get_servers_for_username(username: str) -> List[str]:
|
||||
"""
|
||||
Get list of server IDs where the username exists (US-013).
|
||||
|
||||
This is a convenience function that wraps the singleton instance.
|
||||
|
||||
Args:
|
||||
username: Username to look up (case-insensitive)
|
||||
|
||||
Returns:
|
||||
List of server_ids. Empty list if username not found (NOT an error).
|
||||
"""
|
||||
return await email_server_cache.get_servers_for_username(username)
|
||||
264
deploy-package-20260223-151231/shared/auth/jwt_handler.py
Normal file
264
deploy-package-20260223-151231/shared/auth/jwt_handler.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
JWT Authentication Handler - Shared între toate aplicațiile ROA2WEB
|
||||
|
||||
Acest modul gestionează crearea, validarea și refresh-ul token-urilor JWT
|
||||
pentru autentificarea utilizatorilor în ecosistemul ROA2WEB.
|
||||
|
||||
Payload structure:
|
||||
{
|
||||
"username": "string",
|
||||
"user_id": "integer",
|
||||
"companies": ["schema1", "schema2"],
|
||||
"permissions": ["read", "write", "admin"],
|
||||
"server_id": "string|null", // ID-ul serverului Oracle (multi-server mode)
|
||||
"exp": "timestamp",
|
||||
"iat": "timestamp",
|
||||
"type": "access|refresh"
|
||||
}
|
||||
"""
|
||||
from jose import jwt
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any, List
|
||||
from pydantic import BaseModel, Field
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
"""Date conținute în token"""
|
||||
username: str = Field(description="Numele utilizatorului")
|
||||
user_id: Optional[int] = Field(default=None, description="ID-ul utilizatorului")
|
||||
companies: List[str] = Field(default_factory=list, description="Lista firmelor accesibile")
|
||||
permissions: List[str] = Field(default_factory=list, description="Lista permisiunilor")
|
||||
server_id: Optional[str] = Field(default=None, description="ID-ul serverului Oracle (pentru multi-server mode)")
|
||||
exp: datetime = Field(description="Data expirării")
|
||||
iat: datetime = Field(description="Data creării")
|
||||
token_type: str = Field(alias="type", description="Tipul token-ului (access/refresh)")
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Răspuns pentru token-uri"""
|
||||
access_token: str = Field(description="JWT access token")
|
||||
refresh_token: Optional[str] = Field(default=None, description="JWT refresh token")
|
||||
token_type: str = Field(default="bearer", description="Tipul token-ului")
|
||||
expires_in: int = Field(description="Timpul de expirare în secunde")
|
||||
|
||||
|
||||
class JWTHandler:
|
||||
"""
|
||||
Gestionarea JWT tokens pentru autentificare
|
||||
|
||||
Această clasă oferă funcționalități pentru:
|
||||
- Crearea token-urilor access și refresh
|
||||
- Validarea și decodificarea token-urilor
|
||||
- Gestionarea expirării token-urilor
|
||||
"""
|
||||
|
||||
def __init__(self, secret_key: Optional[str] = None, algorithm: str = "HS256"):
|
||||
"""
|
||||
Inițializează JWT handler
|
||||
|
||||
Args:
|
||||
secret_key: Cheia secretă pentru semnarea token-urilor
|
||||
algorithm: Algoritmul de criptare (default: HS256)
|
||||
"""
|
||||
self.secret_key = secret_key or os.getenv('JWT_SECRET_KEY', 'your-secret-key-change-in-production')
|
||||
self.algorithm = algorithm
|
||||
self.access_token_expire_minutes = int(os.getenv('ACCESS_TOKEN_EXPIRE_MINUTES', 30))
|
||||
self.refresh_token_expire_days = int(os.getenv('REFRESH_TOKEN_EXPIRE_DAYS', 7))
|
||||
|
||||
# Warning pentru development
|
||||
if self.secret_key == 'your-secret-key-change-in-production':
|
||||
logger.warning("Using default JWT secret key! Change JWT_SECRET_KEY in production!")
|
||||
|
||||
def create_access_token(
|
||||
self,
|
||||
username: str,
|
||||
companies: List[str],
|
||||
user_id: Optional[int] = None,
|
||||
permissions: Optional[List[str]] = None,
|
||||
server_id: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Creează un JWT access token
|
||||
|
||||
Args:
|
||||
username: Numele utilizatorului
|
||||
companies: Lista firmelor la care utilizatorul are acces
|
||||
user_id: ID-ul utilizatorului în baza de date
|
||||
permissions: Lista permisiunilor utilizatorului
|
||||
server_id: ID-ul serverului Oracle (pentru multi-server mode)
|
||||
|
||||
Returns:
|
||||
Token JWT ca string
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
expire = now + timedelta(minutes=self.access_token_expire_minutes)
|
||||
|
||||
payload = {
|
||||
"username": username,
|
||||
"user_id": user_id,
|
||||
"companies": companies or [],
|
||||
"permissions": permissions or ["read"],
|
||||
"server_id": server_id,
|
||||
"exp": expire,
|
||||
"iat": now,
|
||||
"type": "access"
|
||||
}
|
||||
|
||||
token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
|
||||
logger.debug(f"Created access token for user {username} on server {server_id or 'default'} with companies: {companies}")
|
||||
|
||||
return token
|
||||
|
||||
def create_refresh_token(
|
||||
self,
|
||||
username: str,
|
||||
user_id: Optional[int] = None,
|
||||
server_id: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Creează un refresh token cu durată mai mare
|
||||
|
||||
Args:
|
||||
username: Numele utilizatorului
|
||||
user_id: ID-ul utilizatorului
|
||||
server_id: ID-ul serverului Oracle (pentru multi-server mode)
|
||||
|
||||
Returns:
|
||||
Refresh token JWT ca string
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
expire = now + timedelta(days=self.refresh_token_expire_days)
|
||||
|
||||
payload = {
|
||||
"username": username,
|
||||
"user_id": user_id,
|
||||
"server_id": server_id,
|
||||
"exp": expire,
|
||||
"iat": now,
|
||||
"type": "refresh"
|
||||
}
|
||||
|
||||
token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
|
||||
logger.debug(f"Created refresh token for user {username} on server {server_id or 'default'}")
|
||||
|
||||
return token
|
||||
|
||||
def verify_token(self, token: str) -> Optional[TokenData]:
|
||||
"""
|
||||
Verifică și decodează un JWT token
|
||||
|
||||
Args:
|
||||
token: Token-ul JWT de verificat
|
||||
|
||||
Returns:
|
||||
TokenData cu informațiile din token sau None dacă token-ul e invalid
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"Using JWT secret key (first 10 chars): {self.secret_key[:10]}...")
|
||||
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
|
||||
token_data = TokenData(**payload)
|
||||
logger.debug(f"Token verified successfully for user {token_data.username}")
|
||||
return token_data
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.warning("Token has expired")
|
||||
return None
|
||||
except jwt.JWTError as e:
|
||||
logger.warning(f"Invalid token: {str(e)}")
|
||||
logger.debug(f"Token that failed verification: {token[:50]}...")
|
||||
return None
|
||||
|
||||
def refresh_access_token(
|
||||
self,
|
||||
refresh_token: str,
|
||||
companies: List[str],
|
||||
permissions: Optional[List[str]] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Creează un nou access token folosind refresh token-ul
|
||||
|
||||
Args:
|
||||
refresh_token: Refresh token-ul valid
|
||||
companies: Lista actualizată a firmelor (poate fi modificată între refresh-uri)
|
||||
permissions: Lista actualizată a permisiunilor
|
||||
|
||||
Returns:
|
||||
Noul access token sau None dacă refresh token-ul e invalid
|
||||
"""
|
||||
token_data = self.verify_token(refresh_token)
|
||||
|
||||
if not token_data or token_data.token_type != "refresh":
|
||||
logger.warning("Invalid refresh token")
|
||||
return None
|
||||
|
||||
# Creează nou access token cu datele din refresh token
|
||||
# Păstrează server_id din refresh token pentru consistență multi-server
|
||||
return self.create_access_token(
|
||||
username=token_data.username,
|
||||
companies=companies,
|
||||
user_id=token_data.user_id,
|
||||
permissions=permissions,
|
||||
server_id=token_data.server_id
|
||||
)
|
||||
|
||||
def create_token_response(
|
||||
self,
|
||||
username: str,
|
||||
companies: List[str],
|
||||
user_id: Optional[int] = None,
|
||||
permissions: Optional[List[str]] = None,
|
||||
include_refresh: bool = True,
|
||||
server_id: Optional[str] = None
|
||||
) -> TokenResponse:
|
||||
"""
|
||||
Creează un răspuns complet cu access și refresh token
|
||||
|
||||
Args:
|
||||
username: Numele utilizatorului
|
||||
companies: Lista firmelor accesibile
|
||||
user_id: ID-ul utilizatorului
|
||||
permissions: Lista permisiunilor
|
||||
include_refresh: Dacă să includă și refresh token
|
||||
server_id: ID-ul serverului Oracle (pentru multi-server mode)
|
||||
|
||||
Returns:
|
||||
TokenResponse cu toate token-urile
|
||||
"""
|
||||
access_token = self.create_access_token(
|
||||
username, companies, user_id, permissions, server_id
|
||||
)
|
||||
refresh_token = self.create_refresh_token(
|
||||
username, user_id, server_id
|
||||
) if include_refresh else None
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer",
|
||||
expires_in=self.access_token_expire_minutes * 60
|
||||
)
|
||||
|
||||
def decode_token_payload(self, token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Decodează token-ul fără verificare (pentru debugging)
|
||||
|
||||
Args:
|
||||
token: Token-ul de decodat
|
||||
|
||||
Returns:
|
||||
Payload-ul token-ului sau None
|
||||
"""
|
||||
try:
|
||||
# Decodare fără verificare - doar pentru debugging
|
||||
payload = jwt.decode(token, key="", algorithms=[self.algorithm], options={"verify_signature": False})
|
||||
return payload
|
||||
except Exception as e:
|
||||
logger.error(f"Error decoding token payload: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
# Instance globală pentru folosire în toate aplicațiile
|
||||
jwt_handler = JWTHandler()
|
||||
375
deploy-package-20260223-151231/shared/auth/middleware.py
Normal file
375
deploy-package-20260223-151231/shared/auth/middleware.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""
|
||||
FastAPI Authentication Middleware pentru ROA2WEB
|
||||
|
||||
Acest modul oferă middleware pentru autentificarea automată în aplicațiile FastAPI,
|
||||
incluzând extragerea token-urilor, validarea și injectarea datelor utilizatorului
|
||||
în contextul request-ului.
|
||||
|
||||
Funcționalități:
|
||||
- Extragere automată token JWT din header Authorization
|
||||
- Validare token și user data injection
|
||||
- Rate limiting pentru endpoint-urile de autentificare
|
||||
- Logging pentru securitate și monitoring
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional, Callable, Dict, Any, List, Set
|
||||
from collections import defaultdict, deque
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import Request, Response, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from .jwt_handler import jwt_handler, TokenData
|
||||
from .auth_service import auth_service
|
||||
from .models import CurrentUser, AuthError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""
|
||||
Rate limiter pentru protejarea endpoint-urilor de autentificare
|
||||
"""
|
||||
|
||||
def __init__(self, max_requests: int = 5, time_window: int = 300):
|
||||
"""
|
||||
Inițializează rate limiter
|
||||
|
||||
Args:
|
||||
max_requests: Numărul maxim de request-uri permise
|
||||
time_window: Fereastra de timp în secunde
|
||||
"""
|
||||
self.max_requests = max_requests
|
||||
self.time_window = time_window
|
||||
self.requests: Dict[str, deque] = defaultdict(deque)
|
||||
|
||||
def is_allowed(self, client_ip: str) -> bool:
|
||||
"""
|
||||
Verifică dacă request-ul este permis pentru acest IP
|
||||
|
||||
Args:
|
||||
client_ip: Adresa IP a clientului
|
||||
|
||||
Returns:
|
||||
True dacă request-ul este permis
|
||||
"""
|
||||
now = time.time()
|
||||
client_requests = self.requests[client_ip]
|
||||
|
||||
# Șterge request-urile vechi
|
||||
while client_requests and client_requests[0] < now - self.time_window:
|
||||
client_requests.popleft()
|
||||
|
||||
# Verifică dacă putem accepta încă un request
|
||||
if len(client_requests) >= self.max_requests:
|
||||
return False
|
||||
|
||||
# Adaugă request-ul curent
|
||||
client_requests.append(now)
|
||||
return True
|
||||
|
||||
def get_reset_time(self, client_ip: str) -> int:
|
||||
"""
|
||||
Returnează timpul când rate limiting se resetează pentru acest IP
|
||||
|
||||
Args:
|
||||
client_ip: Adresa IP a clientului
|
||||
|
||||
Returns:
|
||||
Timestamp când se resetează
|
||||
"""
|
||||
client_requests = self.requests[client_ip]
|
||||
if not client_requests:
|
||||
return int(time.time())
|
||||
|
||||
return int(client_requests[0] + self.time_window)
|
||||
|
||||
|
||||
class AuthenticationMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware pentru autentificarea automată în FastAPI
|
||||
|
||||
Acest middleware:
|
||||
- Extrage token-ul JWT din header-ul Authorization
|
||||
- Validează token-ul și obține datele utilizatorului
|
||||
- Injectează utilizatorul curent în request.state
|
||||
- Aplică rate limiting pentru endpoint-urile sensibile
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app,
|
||||
excluded_paths: Optional[List[str]] = None,
|
||||
rate_limit_paths: Optional[List[str]] = None,
|
||||
rate_limiter: Optional[RateLimiter] = None
|
||||
):
|
||||
"""
|
||||
Inițializează middleware-ul
|
||||
|
||||
Args:
|
||||
app: Aplicația FastAPI
|
||||
excluded_paths: Căile care nu necesită autentificare
|
||||
rate_limit_paths: Căile cu rate limiting
|
||||
rate_limiter: Instance de rate limiter personalizat
|
||||
"""
|
||||
super().__init__(app)
|
||||
|
||||
self.excluded_paths = excluded_paths or [
|
||||
"/docs", "/redoc", "/openapi.json", "/health", "/",
|
||||
"/auth/login", "/auth/register"
|
||||
]
|
||||
|
||||
self.rate_limit_paths = rate_limit_paths or [
|
||||
"/auth/login", "/auth/register", "/auth/forgot-password"
|
||||
]
|
||||
|
||||
self.rate_limiter = rate_limiter or RateLimiter(max_requests=5, time_window=300)
|
||||
|
||||
logger.info(f"Authentication middleware initialized with {len(self.excluded_paths)} excluded paths")
|
||||
|
||||
def _get_client_ip(self, request: Request) -> str:
|
||||
"""Obține adresa IP a clientului"""
|
||||
# Verifică header-ele proxy
|
||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if forwarded_for:
|
||||
return forwarded_for.split(",")[0].strip()
|
||||
|
||||
real_ip = request.headers.get("X-Real-IP")
|
||||
if real_ip:
|
||||
return real_ip
|
||||
|
||||
# Fallback la client IP direct
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
def _should_exclude_path(self, path: str) -> bool:
|
||||
"""Verifică dacă path-ul trebuie exclus de la autentificare"""
|
||||
# Special case for root path to avoid excluding all paths that start with "/"
|
||||
if "/" in self.excluded_paths and path == "/":
|
||||
return True
|
||||
# Check other excluded paths (excluding "/" to avoid matching all paths)
|
||||
excluded_paths_no_root = [p for p in self.excluded_paths if p != "/"]
|
||||
return any(path.startswith(excluded) for excluded in excluded_paths_no_root)
|
||||
|
||||
def _should_rate_limit_path(self, path: str) -> bool:
|
||||
"""Verifică dacă path-ul necesită rate limiting"""
|
||||
return any(path.startswith(limited) for limited in self.rate_limit_paths)
|
||||
|
||||
def _extract_token_from_header(self, request: Request) -> Optional[str]:
|
||||
"""
|
||||
Extrage token-ul JWT în header-ul Authorization
|
||||
|
||||
Args:
|
||||
request: Request-ul HTTP
|
||||
|
||||
Returns:
|
||||
Token-ul JWT sau None
|
||||
"""
|
||||
authorization = request.headers.get("Authorization")
|
||||
if not authorization:
|
||||
return None
|
||||
|
||||
if not authorization.startswith("Bearer "):
|
||||
return None
|
||||
|
||||
return authorization[7:] # Elimină "Bearer "
|
||||
|
||||
async def _create_current_user(self, token_data: TokenData) -> CurrentUser:
|
||||
"""
|
||||
Creează obiectul CurrentUser din token data
|
||||
|
||||
Args:
|
||||
token_data: Datele din token
|
||||
|
||||
Returns:
|
||||
Obiectul CurrentUser
|
||||
"""
|
||||
return CurrentUser(
|
||||
username=token_data.username,
|
||||
user_id=token_data.user_id,
|
||||
companies=token_data.companies,
|
||||
permissions=token_data.permissions,
|
||||
last_login=datetime.now()
|
||||
)
|
||||
|
||||
async def _handle_rate_limiting(self, request: Request, path: str) -> Optional[Response]:
|
||||
"""
|
||||
Gestionează rate limiting pentru căile sensibile
|
||||
|
||||
Args:
|
||||
request: Request-ul HTTP
|
||||
path: Calea request-ului
|
||||
|
||||
Returns:
|
||||
Response cu eroare dacă este rate limited, None altfel
|
||||
"""
|
||||
if not self._should_rate_limit_path(path):
|
||||
return None
|
||||
|
||||
client_ip = self._get_client_ip(request)
|
||||
|
||||
if not self.rate_limiter.is_allowed(client_ip):
|
||||
reset_time = self.rate_limiter.get_reset_time(client_ip)
|
||||
|
||||
logger.warning(f"Rate limit exceeded for IP {client_ip} on path {path}")
|
||||
|
||||
error = AuthError(
|
||||
error="rate_limit_exceeded",
|
||||
error_description="Too many requests. Please try again later.",
|
||||
error_code="RATE_LIMIT_001"
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
content=error.dict(),
|
||||
headers={
|
||||
"X-RateLimit-Limit": str(self.rate_limiter.max_requests),
|
||||
"X-RateLimit-Remaining": "0",
|
||||
"X-RateLimit-Reset": str(reset_time),
|
||||
"Retry-After": str(reset_time - int(time.time()))
|
||||
}
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
"""
|
||||
Procesează request-ul prin middleware
|
||||
|
||||
Args:
|
||||
request: Request-ul HTTP
|
||||
call_next: Următorul handler din pipeline
|
||||
|
||||
Returns:
|
||||
Response-ul HTTP
|
||||
"""
|
||||
start_time = time.time()
|
||||
path = request.url.path
|
||||
|
||||
# IMPORTANT: Allow OPTIONS requests (CORS preflight) to pass through
|
||||
if request.method == "OPTIONS":
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
# Rate limiting pentru căile sensibile
|
||||
rate_limit_response = await self._handle_rate_limiting(request, path)
|
||||
if rate_limit_response:
|
||||
return rate_limit_response
|
||||
|
||||
# Skip autentificare pentru căile excluse
|
||||
if self._should_exclude_path(path):
|
||||
request.state.user = None
|
||||
request.state.is_authenticated = False
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
# Extrage token-ul
|
||||
token = self._extract_token_from_header(request)
|
||||
|
||||
if not token:
|
||||
# Nu există token - pentru endpoint-urile protejate returnează 401
|
||||
logger.warning(f"No token provided for protected path {path}")
|
||||
|
||||
error = AuthError(
|
||||
error="authentication_required",
|
||||
error_description="Authentication required",
|
||||
error_code="AUTH_003"
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content=error.dict(),
|
||||
headers={"WWW-Authenticate": "Bearer"}
|
||||
)
|
||||
|
||||
# Validează token-ul
|
||||
token_data = jwt_handler.verify_token(token)
|
||||
|
||||
if not token_data:
|
||||
# Token invalid
|
||||
logger.warning(f"Invalid token used for path {path}")
|
||||
|
||||
error = AuthError(
|
||||
error="invalid_token",
|
||||
error_description="The provided token is invalid or expired.",
|
||||
error_code="AUTH_001"
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content=error.dict(),
|
||||
headers={"WWW-Authenticate": "Bearer"}
|
||||
)
|
||||
|
||||
# Token valid - creează utilizatorul curent
|
||||
try:
|
||||
current_user = await self._create_current_user(token_data)
|
||||
request.state.user = current_user
|
||||
request.state.is_authenticated = True
|
||||
request.state.token_data = token_data
|
||||
# Extrage server_id din token pentru a fi folosit în query-uri Oracle
|
||||
request.state.server_id = token_data.server_id
|
||||
|
||||
logger.debug(f"User {current_user.username} authenticated successfully for path {path} (server: {token_data.server_id or 'default'})")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating current user: {str(e)}")
|
||||
|
||||
error = AuthError(
|
||||
error="authentication_error",
|
||||
error_description="Authentication processing error.",
|
||||
error_code="AUTH_002"
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content=error.dict()
|
||||
)
|
||||
|
||||
# Procesează request-ul
|
||||
response = await call_next(request)
|
||||
|
||||
# Adaugă header-e de securitate
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
|
||||
# Log timpul de procesare
|
||||
process_time = time.time() - start_time
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class HTTPBearerOptional(HTTPBearer):
|
||||
"""
|
||||
Versiune opțională pentru autentificare care nu aruncă excepții
|
||||
dacă token-ul lipsește - utile pentru endpoint-urile care
|
||||
pot funcționa atât cu cât și fără autentificare
|
||||
"""
|
||||
|
||||
async def __call__(self, request: Request) -> Optional[HTTPAuthorizationCredentials]:
|
||||
"""
|
||||
Extrage credențialele de autentificare fără să arunce excepții
|
||||
|
||||
Args:
|
||||
request: Request-ul HTTP
|
||||
|
||||
Returns:
|
||||
Credențialele sau None
|
||||
"""
|
||||
try:
|
||||
return await super().__call__(request)
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
|
||||
# Instance predefinite pentru folosire rapidă
|
||||
security_optional = HTTPBearerOptional(auto_error=False)
|
||||
security_required = HTTPBearer()
|
||||
|
||||
# Rate limiter default
|
||||
default_rate_limiter = RateLimiter(max_requests=5, time_window=300)
|
||||
344
deploy-package-20260223-151231/shared/auth/models.py
Normal file
344
deploy-package-20260223-151231/shared/auth/models.py
Normal file
@@ -0,0 +1,344 @@
|
||||
"""
|
||||
Authentication Pydantic Models pentru ROA2WEB
|
||||
|
||||
Acest modul definește toate modelele de date folosite în sistemul de autentificare,
|
||||
incluzând request/response models și modele pentru user data.
|
||||
|
||||
Modelele acoperă:
|
||||
- Login request și response
|
||||
- Token data și management
|
||||
- User information și permisiuni
|
||||
- Company access control
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, validator, EmailStr
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class PermissionType(str, Enum):
|
||||
"""Tipurile de permisiuni disponibile în sistem"""
|
||||
READ = "read"
|
||||
WRITE = "write"
|
||||
DELETE = "delete"
|
||||
ADMIN = "admin"
|
||||
REPORTS = "reports"
|
||||
EXPORT = "export"
|
||||
|
||||
|
||||
class TokenType(str, Enum):
|
||||
"""Tipurile de token-uri JWT"""
|
||||
ACCESS = "access"
|
||||
REFRESH = "refresh"
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Model pentru request-ul de login"""
|
||||
username: str = Field(
|
||||
...,
|
||||
min_length=3,
|
||||
max_length=50,
|
||||
description="Numele utilizatorului",
|
||||
example="admin"
|
||||
)
|
||||
password: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Parola utilizatorului"
|
||||
)
|
||||
remember_me: bool = Field(
|
||||
default=False,
|
||||
description="Dacă să păstreze utilizatorul autentificat mai mult timp"
|
||||
)
|
||||
server_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="ID-ul serverului Oracle pentru autentificare (opțional în modul single-server)",
|
||||
example="romfast"
|
||||
)
|
||||
|
||||
@validator('username')
|
||||
def username_alphanumeric(cls, v):
|
||||
"""Validează că username-ul conține doar caractere permise (inclusiv email-uri)
|
||||
|
||||
Pentru backward compatibility:
|
||||
- Permite username-uri clasice: litere, cifre, spații, _, -
|
||||
- Permite email-uri pentru noul flow multi-server: @, .
|
||||
"""
|
||||
# Permitem litere, cifre, spații, _, -, @, și . (pentru email-uri)
|
||||
allowed_chars = v.replace(' ', '').replace('_', '').replace('-', '').replace('@', '').replace('.', '')
|
||||
if not allowed_chars.isalnum():
|
||||
raise ValueError('Username-ul poate conține doar litere, cifre, spații, _, -, @ și .')
|
||||
|
||||
# Detectăm dacă este email sau username clasic
|
||||
if '@' in v:
|
||||
# Email: păstrăm lowercase pentru consistență cu email-urile
|
||||
return v.lower().strip()
|
||||
else:
|
||||
# Username clasic: uppercase pentru consistență cu Oracle
|
||||
return v.upper().strip()
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Model pentru răspunsul de autentificare cu token-uri"""
|
||||
access_token: str = Field(description="JWT access token")
|
||||
refresh_token: Optional[str] = Field(
|
||||
default=None,
|
||||
description="JWT refresh token (opțional)"
|
||||
)
|
||||
token_type: str = Field(
|
||||
default="bearer",
|
||||
description="Tipul token-ului (întotdeauna 'bearer')"
|
||||
)
|
||||
expires_in: int = Field(
|
||||
description="Timpul de expirare al access token-ului în secunde"
|
||||
)
|
||||
user: 'CurrentUser' = Field(description="Informațiile utilizatorului autentificat")
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""Model pentru request-ul de refresh token"""
|
||||
refresh_token: str = Field(description="Refresh token-ul valid")
|
||||
|
||||
|
||||
class LogoutRequest(BaseModel):
|
||||
"""Model pentru request-ul de logout"""
|
||||
refresh_token: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Refresh token de invalidat (opțional)"
|
||||
)
|
||||
|
||||
|
||||
class CurrentUser(BaseModel):
|
||||
"""Model pentru utilizatorul curent autentificat"""
|
||||
username: str = Field(description="Numele utilizatorului")
|
||||
user_id: Optional[int] = Field(
|
||||
default=None,
|
||||
description="ID-ul utilizatorului în baza de date"
|
||||
)
|
||||
email: Optional[EmailStr] = Field(
|
||||
default=None,
|
||||
description="Email-ul utilizatorului"
|
||||
)
|
||||
companies: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Lista codurilor firmelor la care utilizatorul are acces"
|
||||
)
|
||||
permissions: List[PermissionType] = Field(
|
||||
default_factory=lambda: [PermissionType.READ],
|
||||
description="Lista permisiunilor utilizatorului"
|
||||
)
|
||||
is_active: bool = Field(
|
||||
default=True,
|
||||
description="Dacă utilizatorul este activ"
|
||||
)
|
||||
last_login: Optional[datetime] = Field(
|
||||
default=None,
|
||||
description="Data ultimei autentificări"
|
||||
)
|
||||
|
||||
@validator('companies')
|
||||
def companies_not_empty_if_active(cls, v, values):
|
||||
"""Validează că utilizatorii activi au cel puțin o firmă"""
|
||||
if values.get('is_active', True) and not v:
|
||||
raise ValueError('Utilizatorii activi trebuie să aibă acces la cel puțin o firmă')
|
||||
return v
|
||||
|
||||
|
||||
class UserCompany(BaseModel):
|
||||
"""Model pentru o firmă la care utilizatorul are acces"""
|
||||
code: str = Field(description="Codul firmei (schema Oracle)")
|
||||
name: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Numele firmei (dacă este disponibil)"
|
||||
)
|
||||
permissions: List[PermissionType] = Field(
|
||||
default_factory=lambda: [PermissionType.READ],
|
||||
description="Permisiunile utilizatorului pentru această firmă"
|
||||
)
|
||||
is_default: bool = Field(
|
||||
default=False,
|
||||
description="Dacă aceasta este firma implicită pentru utilizator"
|
||||
)
|
||||
|
||||
|
||||
class CompanyAccessRequest(BaseModel):
|
||||
"""Model pentru verificarea accesului la o firmă"""
|
||||
company_code: str = Field(description="Codul firmei de verificat")
|
||||
required_permissions: Optional[List[PermissionType]] = Field(
|
||||
default=None,
|
||||
description="Permisiunile necesare (opțional)"
|
||||
)
|
||||
|
||||
|
||||
class CompanyAccessResponse(BaseModel):
|
||||
"""Model pentru răspunsul de verificare acces firmă"""
|
||||
has_access: bool = Field(description="Dacă utilizatorul are acces")
|
||||
company: Optional[UserCompany] = Field(
|
||||
default=None,
|
||||
description="Detaliile firmei dacă utilizatorul are acces"
|
||||
)
|
||||
missing_permissions: Optional[List[PermissionType]] = Field(
|
||||
default=None,
|
||||
description="Permisiunile lipsă (dacă aplicabil)"
|
||||
)
|
||||
|
||||
|
||||
class AuthError(BaseModel):
|
||||
"""Model pentru erorile de autentificare"""
|
||||
error: str = Field(description="Tipul erorii")
|
||||
error_description: str = Field(description="Descrierea detaliată a erorii")
|
||||
error_code: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Codul de eroare pentru procesare automată"
|
||||
)
|
||||
|
||||
|
||||
class AuthStats(BaseModel):
|
||||
"""Model pentru statisticile de autentificare"""
|
||||
total_users: int = Field(description="Numărul total de utilizatori")
|
||||
active_sessions: int = Field(description="Sesiuni active curente")
|
||||
cache_hit_ratio: float = Field(
|
||||
description="Rata de hit a cache-ului pentru date utilizatori"
|
||||
)
|
||||
last_cleanup: Optional[datetime] = Field(
|
||||
default=None,
|
||||
description="Ultima curățare a cache-ului"
|
||||
)
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
"""Model pentru schimbarea parolei (pentru viitor)"""
|
||||
current_password: str = Field(description="Parola curentă")
|
||||
new_password: str = Field(
|
||||
min_length=8,
|
||||
description="Noua parolă (minim 8 caractere)"
|
||||
)
|
||||
confirm_password: str = Field(description="Confirmarea noii parole")
|
||||
|
||||
@validator('confirm_password')
|
||||
def passwords_match(cls, v, values):
|
||||
"""Validează că parolele coincid"""
|
||||
if 'new_password' in values and v != values['new_password']:
|
||||
raise ValueError('Parolele nu coincid')
|
||||
return v
|
||||
|
||||
|
||||
class SessionInfo(BaseModel):
|
||||
"""Model pentru informațiile despre sesiune"""
|
||||
session_id: str = Field(description="ID-ul sesiunii")
|
||||
username: str = Field(description="Numele utilizatorului")
|
||||
created_at: datetime = Field(description="Data creării sesiunii")
|
||||
last_activity: datetime = Field(description="Ultima activitate")
|
||||
ip_address: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Adresa IP a utilizatorului"
|
||||
)
|
||||
user_agent: Optional[str] = Field(
|
||||
default=None,
|
||||
description="User agent-ul browserului"
|
||||
)
|
||||
is_active: bool = Field(
|
||||
default=True,
|
||||
description="Dacă sesiunea este încă activă"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MULTI-ORACLE IDENTITY CHECK MODELS (US-004, US-013)
|
||||
# ============================================================================
|
||||
|
||||
class CheckIdentityRequest(BaseModel):
|
||||
"""
|
||||
Model pentru verificarea identității în sistemul multi-Oracle (US-013)
|
||||
|
||||
Suportă atât email cât și username:
|
||||
- Cu '@': tratează ca email și caută în EmailServerCache
|
||||
- Fără '@': tratează ca username și caută în Oracle pe toate serverele
|
||||
"""
|
||||
identity: str = Field(
|
||||
...,
|
||||
min_length=2,
|
||||
max_length=100,
|
||||
description="Email sau username de verificat",
|
||||
example="user@example.com sau MARIUS"
|
||||
)
|
||||
|
||||
@validator('identity')
|
||||
def validate_identity(cls, v):
|
||||
"""Validează și normalizează identitatea"""
|
||||
stripped = v.strip()
|
||||
if not stripped:
|
||||
raise ValueError('Identitatea nu poate fi goală')
|
||||
# Pentru email-uri, normalizăm la lowercase
|
||||
if '@' in stripped:
|
||||
return stripped.lower()
|
||||
# Pentru username-uri, normalizăm la uppercase (convenție Oracle)
|
||||
return stripped.upper()
|
||||
|
||||
|
||||
class CheckEmailRequest(BaseModel):
|
||||
"""
|
||||
Model pentru verificarea email-ului în sistemul multi-Oracle (US-004)
|
||||
|
||||
DEPRECATED: Folosește CheckIdentityRequest pentru suport dual email/username
|
||||
Păstrat pentru backward compatibility.
|
||||
"""
|
||||
email: EmailStr = Field(
|
||||
...,
|
||||
description="Adresa email a utilizatorului de verificat",
|
||||
example="user@example.com"
|
||||
)
|
||||
|
||||
|
||||
class ServerInfo(BaseModel):
|
||||
"""Informații despre un server Oracle disponibil pentru utilizator"""
|
||||
id: str = Field(description="ID-ul serverului (ex: 'romfast')")
|
||||
name: str = Field(description="Numele human-readable al serverului (ex: 'Romfast - Producție')")
|
||||
|
||||
|
||||
class CheckIdentityResponse(BaseModel):
|
||||
"""
|
||||
Răspunsul pentru verificarea identității (email sau username) (US-013).
|
||||
|
||||
SECURITATE:
|
||||
- Pentru identitate validă: returnează exists=True și lista serverelor
|
||||
- Pentru identitate invalidă: returnează exists=False și listă goală de servere
|
||||
(NU expunem serverele disponibile pentru a preveni enumerarea!)
|
||||
"""
|
||||
exists: bool = Field(
|
||||
description="True dacă identitatea există în sistem pe cel puțin un server"
|
||||
)
|
||||
servers: List[ServerInfo] = Field(
|
||||
default_factory=list,
|
||||
description="Lista serverelor pe care există identitatea (goală pentru identitate invalidă)"
|
||||
)
|
||||
identity_type: str = Field(
|
||||
default="unknown",
|
||||
description="Tipul identității: 'email' sau 'username'"
|
||||
)
|
||||
|
||||
|
||||
class CheckEmailResponse(BaseModel):
|
||||
"""
|
||||
Răspunsul pentru verificarea email-ului (US-004).
|
||||
|
||||
DEPRECATED: Folosește CheckIdentityResponse pentru suport dual email/username
|
||||
Păstrat pentru backward compatibility.
|
||||
|
||||
SECURITATE:
|
||||
- Pentru email valid: returnează exists=True și lista serverelor
|
||||
- Pentru email invalid: returnează exists=False și listă goală de servere
|
||||
(NU expunem serverele disponibile pentru a preveni enumerarea!)
|
||||
"""
|
||||
exists: bool = Field(
|
||||
description="True dacă email-ul există în sistem pe cel puțin un server"
|
||||
)
|
||||
servers: List[ServerInfo] = Field(
|
||||
default_factory=list,
|
||||
description="Lista serverelor pe care există email-ul (goală pentru email invalid)"
|
||||
)
|
||||
|
||||
|
||||
# Update la forward references pentru TokenResponse
|
||||
TokenResponse.model_rebuild()
|
||||
681
deploy-package-20260223-151231/shared/auth/routes.py
Normal file
681
deploy-package-20260223-151231/shared/auth/routes.py
Normal file
@@ -0,0 +1,681 @@
|
||||
"""
|
||||
Authentication Routes Template pentru ROA2WEB FastAPI Applications
|
||||
|
||||
Acest modul oferă rute predefinite pentru autentificare care pot fi integrate
|
||||
în orice aplicație FastAPI din ecosistemul ROA2WEB.
|
||||
|
||||
Endpoints disponibile:
|
||||
- POST /auth/login - Autentificare utilizator
|
||||
- POST /auth/refresh - Refresh access token
|
||||
- POST /auth/logout - Deconectare utilizator
|
||||
- GET /auth/me - Informații utilizator curent
|
||||
- GET /auth/companies - Firmele utilizatorului
|
||||
- GET /auth/status - Status autentificare
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Response
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
from .models import (
|
||||
LoginRequest, TokenResponse, RefreshTokenRequest, LogoutRequest,
|
||||
CurrentUser, UserCompany, CompanyAccessRequest, CompanyAccessResponse,
|
||||
AuthError, AuthStats, CheckEmailRequest, CheckEmailResponse, ServerInfo,
|
||||
CheckIdentityRequest, CheckIdentityResponse
|
||||
)
|
||||
from .auth_service import auth_service, AuthenticationError
|
||||
from .jwt_handler import jwt_handler
|
||||
from .dependencies import (
|
||||
get_current_user, get_optional_user,
|
||||
security_required, security_optional
|
||||
)
|
||||
from .middleware import default_rate_limiter, RateLimiter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_auth_router(
|
||||
prefix: str = "/auth",
|
||||
tags: Optional[List[str]] = None,
|
||||
include_admin_routes: bool = False
|
||||
) -> APIRouter:
|
||||
"""
|
||||
Creează un router FastAPI cu toate rutele de autentificare
|
||||
|
||||
Args:
|
||||
prefix: Prefix-ul pentru toate rutele
|
||||
tags: Tag-urile pentru documentația OpenAPI
|
||||
include_admin_routes: Dacă să includă rutele de administrare
|
||||
|
||||
Returns:
|
||||
Router-ul FastAPI configurat
|
||||
"""
|
||||
router = APIRouter(prefix=prefix, tags=tags or ["authentication"])
|
||||
|
||||
# Rate limiter pentru check-identity/check-email: 5 requests per minut per IP
|
||||
check_identity_rate_limiter = RateLimiter(max_requests=5, time_window=60)
|
||||
|
||||
@router.post("/check-identity", response_model=CheckIdentityResponse, status_code=status.HTTP_200_OK)
|
||||
async def check_identity(
|
||||
check_data: CheckIdentityRequest,
|
||||
request: Request
|
||||
) -> CheckIdentityResponse:
|
||||
"""
|
||||
Verifică dacă un email sau username există în sistem și pe câte servere Oracle (US-013).
|
||||
|
||||
Acest endpoint suportă dual login:
|
||||
- Input cu '@': tratează ca email și caută în EmailServerCache
|
||||
- Input fără '@': tratează ca username și caută direct în Oracle
|
||||
|
||||
SECURITATE:
|
||||
- Rate limited: max 5 requests/minut per IP
|
||||
- NU expune serverele disponibile pentru identități invalide
|
||||
- Identități invalide returnează {exists: false, servers: []}
|
||||
|
||||
Args:
|
||||
check_data: Identitatea de verificat (email sau username)
|
||||
request: Request-ul HTTP (pentru rate limiting)
|
||||
|
||||
Returns:
|
||||
CheckIdentityResponse cu exists, servers[] și identity_type
|
||||
|
||||
Raises:
|
||||
HTTPException 429: Rate limit exceeded
|
||||
"""
|
||||
# Rate limiting - 5 req/min per IP
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
if not check_identity_rate_limiter.is_allowed(client_ip):
|
||||
reset_time = check_identity_rate_limiter.get_reset_time(client_ip)
|
||||
logger.warning(f"Rate limit exceeded for check-identity from IP {client_ip}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many requests. Please try again later.",
|
||||
headers={
|
||||
"X-RateLimit-Limit": "5",
|
||||
"X-RateLimit-Remaining": "0",
|
||||
"X-RateLimit-Reset": str(reset_time),
|
||||
"Retry-After": str(max(1, reset_time - int(__import__('time').time())))
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
from .email_server_cache import email_server_cache
|
||||
from backend.config import settings
|
||||
|
||||
identity = check_data.identity # Already normalized by validator
|
||||
is_email = '@' in identity
|
||||
|
||||
identity_type = "email" if is_email else "username"
|
||||
logger.info(f"Check-identity request for '{identity}' (type: {identity_type}) from IP {client_ip}")
|
||||
|
||||
# Get server IDs based on identity type
|
||||
if is_email:
|
||||
# Email lookup from cache
|
||||
server_ids = email_server_cache.get_servers_for_email(identity)
|
||||
else:
|
||||
# Username lookup directly from Oracle (async)
|
||||
server_ids = await email_server_cache.get_servers_for_username(identity)
|
||||
|
||||
if not server_ids:
|
||||
# Identity not found - return empty response (don't expose available servers!)
|
||||
logger.info(f"Identity '{identity}' not found in any server")
|
||||
return CheckIdentityResponse(exists=False, servers=[], identity_type=identity_type)
|
||||
|
||||
# Build server info list with human-readable names
|
||||
servers: List[ServerInfo] = []
|
||||
for server_id in server_ids:
|
||||
server_config = settings.get_oracle_server(server_id)
|
||||
if server_config:
|
||||
servers.append(ServerInfo(
|
||||
id=server_config.id,
|
||||
name=server_config.name
|
||||
))
|
||||
else:
|
||||
# Fallback if server config not found (shouldn't happen)
|
||||
logger.warning(f"Server '{server_id}' not found in config")
|
||||
servers.append(ServerInfo(id=server_id, name=server_id))
|
||||
|
||||
logger.info(f"Identity '{identity}' found on {len(servers)} server(s): {[s.id for s in servers]}")
|
||||
return CheckIdentityResponse(exists=True, servers=servers, identity_type=identity_type)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking identity '{check_data.identity}': {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Error checking identity"
|
||||
)
|
||||
|
||||
@router.post("/check-email", response_model=CheckEmailResponse, status_code=status.HTTP_200_OK)
|
||||
async def check_email(
|
||||
check_data: CheckEmailRequest,
|
||||
request: Request
|
||||
) -> CheckEmailResponse:
|
||||
"""
|
||||
Verifică dacă un email există în sistem și pe câte servere Oracle.
|
||||
|
||||
DEPRECATED: Folosește /check-identity pentru suport dual email/username.
|
||||
Păstrat pentru backward compatibility.
|
||||
|
||||
Args:
|
||||
check_data: Email-ul de verificat
|
||||
request: Request-ul HTTP (pentru rate limiting)
|
||||
|
||||
Returns:
|
||||
CheckEmailResponse cu exists și servers[]
|
||||
"""
|
||||
# Rate limiting - shared with check-identity
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
if not check_identity_rate_limiter.is_allowed(client_ip):
|
||||
reset_time = check_identity_rate_limiter.get_reset_time(client_ip)
|
||||
logger.warning(f"Rate limit exceeded for check-email from IP {client_ip}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many requests. Please try again later.",
|
||||
headers={
|
||||
"X-RateLimit-Limit": "5",
|
||||
"X-RateLimit-Remaining": "0",
|
||||
"X-RateLimit-Reset": str(reset_time),
|
||||
"Retry-After": str(max(1, reset_time - int(__import__('time').time())))
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
from .email_server_cache import email_server_cache
|
||||
from backend.config import settings
|
||||
|
||||
email = check_data.email.lower().strip()
|
||||
logger.info(f"Check-email request for '{email}' from IP {client_ip}")
|
||||
|
||||
# Get server IDs from cache
|
||||
server_ids = email_server_cache.get_servers_for_email(email)
|
||||
|
||||
if not server_ids:
|
||||
# Email not found - return empty response (don't expose available servers!)
|
||||
logger.info(f"Email '{email}' not found in any server")
|
||||
return CheckEmailResponse(exists=False, servers=[])
|
||||
|
||||
# Build server info list with human-readable names
|
||||
servers: List[ServerInfo] = []
|
||||
for server_id in server_ids:
|
||||
server_config = settings.get_oracle_server(server_id)
|
||||
if server_config:
|
||||
servers.append(ServerInfo(
|
||||
id=server_config.id,
|
||||
name=server_config.name
|
||||
))
|
||||
else:
|
||||
# Fallback if server config not found (shouldn't happen)
|
||||
logger.warning(f"Server '{server_id}' not found in config")
|
||||
servers.append(ServerInfo(id=server_id, name=server_id))
|
||||
|
||||
logger.info(f"Email '{email}' found on {len(servers)} server(s): {[s.id for s in servers]}")
|
||||
return CheckEmailResponse(exists=True, servers=servers)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking email '{check_data.email}': {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Error checking email"
|
||||
)
|
||||
|
||||
@router.post("/login", response_model=TokenResponse, status_code=status.HTTP_200_OK)
|
||||
async def login(
|
||||
login_data: LoginRequest,
|
||||
request: Request,
|
||||
response: Response
|
||||
) -> TokenResponse:
|
||||
"""
|
||||
Autentifică un utilizator și returnează token-urile JWT
|
||||
|
||||
Acest endpoint:
|
||||
- Validează credențialele utilizatorului în Oracle
|
||||
- Obține firmele la care utilizatorul are acces
|
||||
- Generează access și refresh token-uri JWT
|
||||
- Aplică rate limiting pentru securitate
|
||||
- Suportă modul multi-server (server_id opțional)
|
||||
|
||||
Args:
|
||||
login_data: Datele de autentificare (username, password, server_id opțional)
|
||||
request: Request-ul HTTP (pentru rate limiting)
|
||||
response: Response-ul HTTP (pentru header-e)
|
||||
|
||||
Returns:
|
||||
Token-urile JWT și informațiile utilizatorului
|
||||
|
||||
Raises:
|
||||
HTTPException 400: Pentru server_id invalid
|
||||
HTTPException 401: Pentru credențiale invalide
|
||||
HTTPException 500: Pentru erori de sistem
|
||||
"""
|
||||
try:
|
||||
# Log tentativa de autentificare
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
server_info = f" on server {login_data.server_id}" if login_data.server_id else ""
|
||||
logger.info(f"Login attempt for user {login_data.username}{server_info} from IP {client_ip}")
|
||||
|
||||
# Validare server_id dacă specificat (multi-server mode)
|
||||
if login_data.server_id:
|
||||
from backend.config import settings
|
||||
from shared.database.oracle_pool import oracle_pool
|
||||
|
||||
# Verifică dacă serverul există în configurație
|
||||
server_config = settings.get_oracle_server(login_data.server_id)
|
||||
if not server_config:
|
||||
logger.warning(f"Invalid server_id '{login_data.server_id}' in login request")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid server_id: '{login_data.server_id}'. Server not found in configuration."
|
||||
)
|
||||
|
||||
# Verifică dacă serverul este înregistrat în pool
|
||||
if not oracle_pool.is_server_registered(login_data.server_id):
|
||||
logger.warning(f"Server '{login_data.server_id}' not registered in pool")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Server '{login_data.server_id}' is not available."
|
||||
)
|
||||
|
||||
# Autentifică și creează token-urile
|
||||
success, token_response, error_message = await auth_service.authenticate_and_create_tokens(
|
||||
login_data.username,
|
||||
login_data.password,
|
||||
login_data.server_id
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.warning(f"Failed login attempt for user {login_data.username}{server_info}: {error_message}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=error_message or "Authentication failed"
|
||||
)
|
||||
|
||||
# token_response.user este deja populat corect de auth_service.authenticate_and_create_tokens
|
||||
# cu username-ul Oracle rezolvat (nu email-ul) și lista de firme
|
||||
|
||||
# Header-e de securitate
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
|
||||
logger.info(f"Successful login for user {login_data.username}{server_info}")
|
||||
return token_response
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions as-is (e.g., 401 for invalid credentials)
|
||||
raise
|
||||
except AuthenticationError as e:
|
||||
logger.error(f"Authentication error for user {login_data.username}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during login for user {login_data.username}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal authentication error"
|
||||
)
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse, status_code=status.HTTP_200_OK)
|
||||
async def refresh_token(refresh_data: RefreshTokenRequest) -> TokenResponse:
|
||||
"""
|
||||
Reîmprospătează access token-ul folosind refresh token-ul
|
||||
|
||||
Args:
|
||||
refresh_data: Refresh token-ul valid
|
||||
|
||||
Returns:
|
||||
Noul access token și informațiile utilizatorului
|
||||
|
||||
Raises:
|
||||
HTTPException: Pentru refresh token-uri invalide
|
||||
"""
|
||||
try:
|
||||
# Validează refresh token-ul
|
||||
token_data = jwt_handler.verify_token(refresh_data.refresh_token)
|
||||
|
||||
if not token_data or token_data.token_type != "refresh":
|
||||
logger.warning("Invalid refresh token provided")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
# Obține datele actualizate ale utilizatorului
|
||||
companies = await auth_service.get_user_companies(token_data.username)
|
||||
permissions = ["read", "reports"] # Poate fi extins în viitor
|
||||
|
||||
# Creează noul access token
|
||||
new_access_token = jwt_handler.create_access_token(
|
||||
username=token_data.username,
|
||||
companies=companies,
|
||||
user_id=token_data.user_id,
|
||||
permissions=permissions
|
||||
)
|
||||
|
||||
# Informațiile utilizatorului
|
||||
current_user = CurrentUser(
|
||||
username=token_data.username,
|
||||
user_id=token_data.user_id,
|
||||
companies=companies,
|
||||
permissions=permissions
|
||||
)
|
||||
|
||||
token_response = TokenResponse(
|
||||
access_token=new_access_token,
|
||||
token_type="bearer",
|
||||
expires_in=jwt_handler.access_token_expire_minutes * 60,
|
||||
user=current_user
|
||||
)
|
||||
|
||||
logger.info(f"Token refreshed for user {token_data.username}")
|
||||
return token_response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing token: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token refresh failed"
|
||||
)
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_200_OK)
|
||||
async def logout(
|
||||
logout_data: Optional[LogoutRequest] = None,
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
"""
|
||||
Deconectează utilizatorul (invalidează token-urile)
|
||||
|
||||
Note: În implementarea curentă, token-urile JWT sunt stateless,
|
||||
deci nu pot fi invalidate direct. În viitor poate fi implementat
|
||||
un blacklist pentru token-uri.
|
||||
|
||||
Args:
|
||||
logout_data: Date pentru logout (opțional)
|
||||
current_user: Utilizatorul curent autentificat
|
||||
|
||||
Returns:
|
||||
Confirmarea deconectării
|
||||
"""
|
||||
logger.info(f"User {current_user.username} logged out")
|
||||
|
||||
# În viitor, aici se poate implementa:
|
||||
# - Adăugarea token-ului într-un blacklist
|
||||
# - Invalidarea tuturor sesiunilor utilizatorului
|
||||
# - Notificări de securitate
|
||||
|
||||
return {
|
||||
"message": "Successfully logged out",
|
||||
"username": current_user.username,
|
||||
"logout_time": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
@router.get("/me", response_model=CurrentUser)
|
||||
async def get_current_user_info(
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> CurrentUser:
|
||||
"""
|
||||
Returnează informațiile despre utilizatorul curent
|
||||
|
||||
Args:
|
||||
current_user: Utilizatorul curent autentificat
|
||||
|
||||
Returns:
|
||||
Informațiile complete ale utilizatorului
|
||||
"""
|
||||
logger.debug(f"User info requested for {current_user.username}")
|
||||
return current_user
|
||||
|
||||
@router.get("/companies", response_model=List[UserCompany])
|
||||
async def get_user_companies(
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> List[UserCompany]:
|
||||
"""
|
||||
Returnează lista firmelor la care utilizatorul are acces
|
||||
|
||||
Args:
|
||||
current_user: Utilizatorul curent autentificat
|
||||
|
||||
Returns:
|
||||
Lista firmelor cu permisiunile asociate
|
||||
"""
|
||||
try:
|
||||
# Obține firmele actualizate din baza de date
|
||||
companies = await auth_service.get_user_companies(current_user.username)
|
||||
|
||||
user_companies = []
|
||||
for i, company_code in enumerate(companies):
|
||||
# Obține permisiunile pentru fiecare firmă
|
||||
permissions = await auth_service.get_user_permissions(
|
||||
current_user.username,
|
||||
company_code
|
||||
)
|
||||
|
||||
user_company = UserCompany(
|
||||
code=company_code,
|
||||
permissions=permissions,
|
||||
is_default=(i == 0) # Prima firmă ca default
|
||||
)
|
||||
user_companies.append(user_company)
|
||||
|
||||
logger.debug(f"Returned {len(user_companies)} companies for user {current_user.username}")
|
||||
return user_companies
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting companies for user {current_user.username}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Error retrieving user companies"
|
||||
)
|
||||
|
||||
@router.post("/check-company-access", response_model=CompanyAccessResponse)
|
||||
async def check_company_access(
|
||||
access_request: CompanyAccessRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> CompanyAccessResponse:
|
||||
"""
|
||||
Verifică dacă utilizatorul are acces la o firmă specifică
|
||||
|
||||
Args:
|
||||
access_request: Request-ul de verificare acces
|
||||
current_user: Utilizatorul curent autentificat
|
||||
|
||||
Returns:
|
||||
Răspunsul cu informații despre acces
|
||||
"""
|
||||
try:
|
||||
has_access = await auth_service.validate_user_company_access(
|
||||
current_user.username,
|
||||
access_request.company_code
|
||||
)
|
||||
|
||||
if not has_access:
|
||||
return CompanyAccessResponse(
|
||||
has_access=False,
|
||||
company=None,
|
||||
missing_permissions=None
|
||||
)
|
||||
|
||||
# Obține permisiunile pentru firmă
|
||||
permissions = await auth_service.get_user_permissions(
|
||||
current_user.username,
|
||||
access_request.company_code
|
||||
)
|
||||
|
||||
# Verifică permisiunile cerute
|
||||
missing_permissions = []
|
||||
if access_request.required_permissions:
|
||||
missing_permissions = [
|
||||
perm for perm in access_request.required_permissions
|
||||
if perm not in permissions
|
||||
]
|
||||
|
||||
user_company = UserCompany(
|
||||
code=access_request.company_code,
|
||||
permissions=permissions
|
||||
)
|
||||
|
||||
return CompanyAccessResponse(
|
||||
has_access=True,
|
||||
company=user_company,
|
||||
missing_permissions=missing_permissions if missing_permissions else None
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking company access: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Error checking company access"
|
||||
)
|
||||
|
||||
@router.get("/my-servers", response_model=dict)
|
||||
async def get_my_servers(
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
"""
|
||||
Returnează lista serverelor la care utilizatorul autentificat are acces (US-006).
|
||||
|
||||
Acest endpoint este folosit de frontend pentru a popula dropdown-ul de server switch.
|
||||
Lookup-ul se face pe baza email-ului sau username-ului utilizatorului curent.
|
||||
|
||||
Args:
|
||||
current_user: Utilizatorul curent autentificat
|
||||
|
||||
Returns:
|
||||
Dict cu lista de servere: {servers: [{id: string, name: string}, ...]}
|
||||
"""
|
||||
try:
|
||||
from .email_server_cache import email_server_cache
|
||||
from backend.config import settings
|
||||
|
||||
logger.info(f"Get my-servers request for user '{current_user.username}'")
|
||||
|
||||
# Try email lookup first (faster, from cache)
|
||||
server_ids: List[str] = []
|
||||
if current_user.email:
|
||||
server_ids = email_server_cache.get_servers_for_email(current_user.email)
|
||||
logger.debug(f"Email lookup for '{current_user.email}': {server_ids}")
|
||||
|
||||
# If no email or no results, try username lookup (queries Oracle directly)
|
||||
if not server_ids:
|
||||
server_ids = await email_server_cache.get_servers_for_username(current_user.username)
|
||||
logger.debug(f"Username lookup for '{current_user.username}': {server_ids}")
|
||||
|
||||
# Build server info list with human-readable names
|
||||
servers: List[ServerInfo] = []
|
||||
for server_id in server_ids:
|
||||
server_config = settings.get_oracle_server(server_id)
|
||||
if server_config:
|
||||
servers.append(ServerInfo(
|
||||
id=server_config.id,
|
||||
name=server_config.name
|
||||
))
|
||||
else:
|
||||
# Fallback if server config not found
|
||||
logger.warning(f"Server '{server_id}' not found in config")
|
||||
servers.append(ServerInfo(id=server_id, name=server_id))
|
||||
|
||||
logger.info(f"User '{current_user.username}' has access to {len(servers)} server(s)")
|
||||
return {"servers": [s.model_dump() for s in servers]}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting servers for user '{current_user.username}': {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Error retrieving user servers"
|
||||
)
|
||||
|
||||
@router.get("/status")
|
||||
async def get_auth_status(
|
||||
current_user: Optional[CurrentUser] = Depends(get_optional_user)
|
||||
) -> dict:
|
||||
"""
|
||||
Returnează statusul de autentificare (endpoint public)
|
||||
|
||||
Args:
|
||||
current_user: Utilizatorul curent (opțional)
|
||||
|
||||
Returns:
|
||||
Statusul de autentificare
|
||||
"""
|
||||
if current_user:
|
||||
return {
|
||||
"authenticated": True,
|
||||
"username": current_user.username,
|
||||
"companies_count": len(current_user.companies),
|
||||
"permissions": current_user.permissions
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"authenticated": False,
|
||||
"username": None,
|
||||
"companies_count": 0,
|
||||
"permissions": []
|
||||
}
|
||||
|
||||
# Rute de administrare (opționale)
|
||||
if include_admin_routes:
|
||||
|
||||
@router.get("/admin/stats", response_model=AuthStats)
|
||||
async def get_auth_stats(
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> AuthStats:
|
||||
"""
|
||||
Returnează statistici despre sistemul de autentificare
|
||||
|
||||
Necesită permisiuni de admin.
|
||||
"""
|
||||
# Verifică permisiuni admin
|
||||
if "admin" not in current_user.permissions:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin permissions required"
|
||||
)
|
||||
|
||||
cache_stats = auth_service.get_cache_stats()
|
||||
|
||||
return AuthStats(
|
||||
total_users=1, # Placeholder - poate fi implementat
|
||||
active_sessions=1, # Placeholder - poate fi implementat
|
||||
cache_hit_ratio=cache_stats.get('cache_hit_ratio', 0),
|
||||
last_cleanup=datetime.now()
|
||||
)
|
||||
|
||||
@router.post("/admin/refresh-cache")
|
||||
async def refresh_user_cache(
|
||||
username: Optional[str] = None,
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
"""
|
||||
Reîmprospătează cache-ul utilizatorilor
|
||||
|
||||
Necesită permisiuni de admin.
|
||||
"""
|
||||
if "admin" not in current_user.permissions:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin permissions required"
|
||||
)
|
||||
|
||||
if username:
|
||||
success = await auth_service.refresh_user_data(username)
|
||||
return {
|
||||
"message": f"Cache refreshed for user {username}",
|
||||
"success": success
|
||||
}
|
||||
else:
|
||||
auth_service.clear_cache()
|
||||
return {"message": "All user cache cleared"}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
# Router implicit pentru folosire rapidă
|
||||
auth_router = create_auth_router()
|
||||
|
||||
# Router cu rute de admin incluse
|
||||
auth_router_with_admin = create_auth_router(include_admin_routes=True)
|
||||
559
deploy-package-20260223-151231/shared/auth/test_auth.py
Normal file
559
deploy-package-20260223-151231/shared/auth/test_auth.py
Normal file
@@ -0,0 +1,559 @@
|
||||
"""
|
||||
Comprehensive Authentication Tests pentru ROA2WEB
|
||||
|
||||
Acest modul conține teste pentru toate componentele sistemului de autentificare:
|
||||
- JWT Handler functionality
|
||||
- Oracle authentication service
|
||||
- FastAPI dependencies și middleware
|
||||
- Rate limiting și security features
|
||||
|
||||
Testele acoperă:
|
||||
- Unit tests pentru funcționalitatea de bază
|
||||
- Integration tests cu Oracle database (mock)
|
||||
- Security tests pentru vulnerabilități comune
|
||||
- Performance tests pentru scalabilitate
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import Mock, AsyncMock, patch, MagicMock
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
import jwt as pyjwt
|
||||
from fastapi import FastAPI, HTTPException, status
|
||||
from fastapi.testclient import TestClient
|
||||
from httpx import AsyncClient
|
||||
|
||||
# Import modulele de testat
|
||||
from .jwt_handler import JWTHandler, TokenData, TokenResponse
|
||||
from .auth_service import UserAuthService, AuthenticationError
|
||||
from .models import (
|
||||
LoginRequest, CurrentUser, PermissionType,
|
||||
CompanyAccessRequest, CompanyAccessResponse
|
||||
)
|
||||
from .middleware import AuthenticationMiddleware, RateLimiter
|
||||
from .dependencies import (
|
||||
get_current_user_from_token, require_company_access,
|
||||
require_permissions, get_current_company_from_header
|
||||
)
|
||||
from .routes import create_auth_router
|
||||
|
||||
|
||||
class TestJWTHandler:
|
||||
"""Test suite pentru JWT Handler"""
|
||||
|
||||
@pytest.fixture
|
||||
def jwt_handler(self):
|
||||
"""Fixture pentru JWT handler cu configurare de test"""
|
||||
return JWTHandler(
|
||||
secret_key="test-secret-key",
|
||||
algorithm="HS256"
|
||||
)
|
||||
|
||||
def test_create_access_token(self, jwt_handler):
|
||||
"""Test pentru crearea access token-urilor"""
|
||||
username = "testuser"
|
||||
companies = ["COMP1", "COMP2"]
|
||||
permissions = ["read", "write"]
|
||||
|
||||
token = jwt_handler.create_access_token(
|
||||
username=username,
|
||||
companies=companies,
|
||||
user_id=123,
|
||||
permissions=permissions
|
||||
)
|
||||
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 0
|
||||
|
||||
# Verifică că token-ul poate fi decodat
|
||||
payload = pyjwt.decode(token, "test-secret-key", algorithms=["HS256"])
|
||||
assert payload["username"] == username
|
||||
assert payload["companies"] == companies
|
||||
assert payload["permissions"] == permissions
|
||||
assert payload["user_id"] == 123
|
||||
assert payload["type"] == "access"
|
||||
|
||||
def test_create_refresh_token(self, jwt_handler):
|
||||
"""Test pentru crearea refresh token-urilor"""
|
||||
username = "testuser"
|
||||
user_id = 123
|
||||
|
||||
token = jwt_handler.create_refresh_token(username, user_id)
|
||||
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 0
|
||||
|
||||
# Verifică payload-ul
|
||||
payload = pyjwt.decode(token, "test-secret-key", algorithms=["HS256"])
|
||||
assert payload["username"] == username
|
||||
assert payload["user_id"] == user_id
|
||||
assert payload["type"] == "refresh"
|
||||
|
||||
def test_verify_valid_token(self, jwt_handler):
|
||||
"""Test pentru verificarea token-urilor valide"""
|
||||
username = "testuser"
|
||||
companies = ["COMP1"]
|
||||
|
||||
token = jwt_handler.create_access_token(username, companies)
|
||||
token_data = jwt_handler.verify_token(token)
|
||||
|
||||
assert token_data is not None
|
||||
assert isinstance(token_data, TokenData)
|
||||
assert token_data.username == username
|
||||
assert token_data.companies == companies
|
||||
assert token_data.token_type == "access"
|
||||
|
||||
def test_verify_expired_token(self, jwt_handler):
|
||||
"""Test pentru token-uri expirate"""
|
||||
# Creează token cu expirare în trecut
|
||||
past_time = datetime.utcnow() - timedelta(minutes=10)
|
||||
payload = {
|
||||
"username": "testuser",
|
||||
"companies": ["COMP1"],
|
||||
"permissions": ["read"],
|
||||
"exp": past_time,
|
||||
"iat": past_time - timedelta(minutes=5),
|
||||
"type": "access"
|
||||
}
|
||||
|
||||
expired_token = pyjwt.encode(payload, "test-secret-key", algorithm="HS256")
|
||||
token_data = jwt_handler.verify_token(expired_token)
|
||||
|
||||
assert token_data is None
|
||||
|
||||
def test_verify_invalid_token(self, jwt_handler):
|
||||
"""Test pentru token-uri invalide"""
|
||||
invalid_tokens = [
|
||||
"invalid.token.here",
|
||||
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.invalid",
|
||||
"",
|
||||
None
|
||||
]
|
||||
|
||||
for invalid_token in invalid_tokens:
|
||||
if invalid_token is not None:
|
||||
token_data = jwt_handler.verify_token(invalid_token)
|
||||
assert token_data is None
|
||||
|
||||
def test_create_token_response(self, jwt_handler):
|
||||
"""Test pentru crearea răspunsului complet cu token-uri"""
|
||||
username = "testuser"
|
||||
companies = ["COMP1", "COMP2"]
|
||||
permissions = ["read", "reports"]
|
||||
|
||||
response = jwt_handler.create_token_response(
|
||||
username=username,
|
||||
companies=companies,
|
||||
permissions=permissions,
|
||||
include_refresh=True
|
||||
)
|
||||
|
||||
assert isinstance(response, TokenResponse)
|
||||
assert response.access_token is not None
|
||||
assert response.refresh_token is not None
|
||||
assert response.token_type == "bearer"
|
||||
assert response.expires_in > 0
|
||||
|
||||
def test_refresh_access_token(self, jwt_handler):
|
||||
"""Test pentru refresh-ul access token-urilor"""
|
||||
username = "testuser"
|
||||
refresh_token = jwt_handler.create_refresh_token(username, 123)
|
||||
companies = ["COMP1", "COMP2"]
|
||||
|
||||
new_access_token = jwt_handler.refresh_access_token(
|
||||
refresh_token, companies, ["read", "write"]
|
||||
)
|
||||
|
||||
assert new_access_token is not None
|
||||
|
||||
# Verifică noul token
|
||||
token_data = jwt_handler.verify_token(new_access_token)
|
||||
assert token_data.username == username
|
||||
assert token_data.companies == companies
|
||||
assert token_data.token_type == "access"
|
||||
|
||||
|
||||
class TestUserAuthService:
|
||||
"""Test suite pentru User Authentication Service"""
|
||||
|
||||
@pytest.fixture
|
||||
def auth_service(self):
|
||||
"""Fixture pentru auth service cu mock database"""
|
||||
return UserAuthService()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_oracle_pool(self):
|
||||
"""Mock pentru Oracle connection pool"""
|
||||
with patch('roa2web.shared.auth.auth_service.oracle_pool') as mock_pool:
|
||||
yield mock_pool
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_user_credentials_success(self, auth_service, mock_oracle_pool):
|
||||
"""Test pentru verificarea cu succes a credențialelor"""
|
||||
# Mock pentru conexiunea Oracle
|
||||
mock_connection = AsyncMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.fetchone.return_value = [1] # Success
|
||||
mock_connection.__aenter__.return_value = mock_connection
|
||||
mock_connection.cursor.return_value.__enter__.return_value = mock_cursor
|
||||
mock_oracle_pool.get_connection.return_value = mock_connection
|
||||
|
||||
result = await auth_service.verify_user_credentials("testuser", "password")
|
||||
|
||||
assert result is True
|
||||
mock_cursor.execute.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_user_credentials_failure(self, auth_service, mock_oracle_pool):
|
||||
"""Test pentru verificarea eșuată a credențialelor"""
|
||||
# Mock pentru conexiunea Oracle
|
||||
mock_connection = AsyncMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.fetchone.return_value = [0] # Failure
|
||||
mock_connection.__aenter__.return_value = mock_connection
|
||||
mock_connection.cursor.return_value.__enter__.return_value = mock_cursor
|
||||
mock_oracle_pool.get_connection.return_value = mock_connection
|
||||
|
||||
result = await auth_service.verify_user_credentials("testuser", "wrongpassword")
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_companies(self, auth_service, mock_oracle_pool):
|
||||
"""Test pentru obținerea firmelor utilizatorului"""
|
||||
# Mock pentru conexiunea Oracle
|
||||
mock_connection = AsyncMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.fetchall.return_value = [["COMP1"], ["COMP2"], ["COMP3"]]
|
||||
mock_connection.__aenter__.return_value = mock_connection
|
||||
mock_connection.cursor.return_value.__enter__.return_value = mock_cursor
|
||||
mock_oracle_pool.get_connection.return_value = mock_connection
|
||||
|
||||
companies = await auth_service.get_user_companies("testuser")
|
||||
|
||||
assert companies == ["COMP1", "COMP2", "COMP3"]
|
||||
mock_cursor.execute.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_and_create_tokens_success(self, auth_service, mock_oracle_pool):
|
||||
"""Test pentru autentificare completă cu succes"""
|
||||
# Mock pentru conexiunea Oracle
|
||||
mock_connection = AsyncMock()
|
||||
mock_cursor = MagicMock()
|
||||
|
||||
# Prima chiamată pentru verificare credențiale (succes)
|
||||
# A doua chiamată pentru obținerea firmelor
|
||||
mock_cursor.fetchone.return_value = [1]
|
||||
mock_cursor.fetchall.return_value = [["COMP1"], ["COMP2"]]
|
||||
|
||||
mock_connection.__aenter__.return_value = mock_connection
|
||||
mock_connection.cursor.return_value.__enter__.return_value = mock_cursor
|
||||
mock_oracle_pool.get_connection.return_value = mock_connection
|
||||
|
||||
success, token_response, error = await auth_service.authenticate_and_create_tokens(
|
||||
"testuser", "password"
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert token_response is not None
|
||||
assert isinstance(token_response, TokenResponse)
|
||||
assert error is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_and_create_tokens_failure(self, auth_service, mock_oracle_pool):
|
||||
"""Test pentru autentificare eșuată"""
|
||||
# Mock pentru conexiunea Oracle
|
||||
mock_connection = AsyncMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.fetchone.return_value = [0] # Credențiale invalide
|
||||
mock_connection.__aenter__.return_value = mock_connection
|
||||
mock_connection.cursor.return_value.__enter__.return_value = mock_cursor
|
||||
mock_oracle_pool.get_connection.return_value = mock_connection
|
||||
|
||||
success, token_response, error = await auth_service.authenticate_and_create_tokens(
|
||||
"testuser", "wrongpassword"
|
||||
)
|
||||
|
||||
assert success is False
|
||||
assert token_response is None
|
||||
assert error is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_user_company_access(self, auth_service, mock_oracle_pool):
|
||||
"""Test pentru validarea accesului la firmă"""
|
||||
# Mock pentru conexiunea Oracle
|
||||
mock_connection = AsyncMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.fetchall.return_value = [["COMP1"], ["COMP2"]]
|
||||
mock_connection.__aenter__.return_value = mock_connection
|
||||
mock_connection.cursor.return_value.__enter__.return_value = mock_cursor
|
||||
mock_oracle_pool.get_connection.return_value = mock_connection
|
||||
|
||||
# Test acces valid
|
||||
has_access = await auth_service.validate_user_company_access("testuser", "COMP1")
|
||||
assert has_access is True
|
||||
|
||||
# Test acces invalid
|
||||
has_access = await auth_service.validate_user_company_access("testuser", "COMP3")
|
||||
assert has_access is False
|
||||
|
||||
def test_cache_functionality(self, auth_service):
|
||||
"""Test pentru funcționalitatea de cache"""
|
||||
# Test cache stats
|
||||
stats = auth_service.get_cache_stats()
|
||||
assert isinstance(stats, dict)
|
||||
assert 'total_entries' in stats
|
||||
assert 'valid_entries' in stats
|
||||
assert 'cache_hit_ratio' in stats
|
||||
|
||||
# Test clear cache
|
||||
auth_service.clear_cache()
|
||||
stats_after_clear = auth_service.get_cache_stats()
|
||||
assert stats_after_clear['total_entries'] == 0
|
||||
|
||||
|
||||
class TestRateLimiter:
|
||||
"""Test suite pentru Rate Limiter"""
|
||||
|
||||
@pytest.fixture
|
||||
def rate_limiter(self):
|
||||
"""Fixture pentru rate limiter"""
|
||||
return RateLimiter(max_requests=3, time_window=5)
|
||||
|
||||
def test_rate_limiting_within_limit(self, rate_limiter):
|
||||
"""Test pentru request-uri în limita permisă"""
|
||||
client_ip = "192.168.1.1"
|
||||
|
||||
# Primele 3 request-uri trebuie să fie permise
|
||||
for i in range(3):
|
||||
assert rate_limiter.is_allowed(client_ip) is True
|
||||
|
||||
# Al 4-lea request trebuie refuzat
|
||||
assert rate_limiter.is_allowed(client_ip) is False
|
||||
|
||||
def test_rate_limiting_reset_after_time(self, rate_limiter):
|
||||
"""Test pentru resetarea rate limiting după expirarea ferestrei"""
|
||||
client_ip = "192.168.1.2"
|
||||
|
||||
# Consumă toate request-urile
|
||||
for i in range(3):
|
||||
assert rate_limiter.is_allowed(client_ip) is True
|
||||
|
||||
# Request-ul următor trebuie refuzat
|
||||
assert rate_limiter.is_allowed(client_ip) is False
|
||||
|
||||
# Simulează trecerea timpului
|
||||
time.sleep(6)
|
||||
|
||||
# Acum ar trebui să funcționeze din nou
|
||||
assert rate_limiter.is_allowed(client_ip) is True
|
||||
|
||||
def test_rate_limiting_different_ips(self, rate_limiter):
|
||||
"""Test pentru rate limiting pe IP-uri diferite"""
|
||||
ip1 = "192.168.1.1"
|
||||
ip2 = "192.168.1.2"
|
||||
|
||||
# Consumă toate request-urile pentru primul IP
|
||||
for i in range(3):
|
||||
assert rate_limiter.is_allowed(ip1) is True
|
||||
assert rate_limiter.is_allowed(ip1) is False
|
||||
|
||||
# Al doilea IP ar trebui să funcționeze normal
|
||||
for i in range(3):
|
||||
assert rate_limiter.is_allowed(ip2) is True
|
||||
|
||||
|
||||
class TestAuthenticationRoutes:
|
||||
"""Test suite pentru rutele de autentificare"""
|
||||
|
||||
@pytest.fixture
|
||||
def app(self):
|
||||
"""Fixture pentru aplicația FastAPI de test"""
|
||||
app = FastAPI()
|
||||
auth_router = create_auth_router()
|
||||
app.include_router(auth_router)
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def client(self, app):
|
||||
"""Fixture pentru client de test"""
|
||||
return TestClient(app)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_auth_service(self):
|
||||
"""Mock pentru auth service"""
|
||||
with patch('roa2web.shared.auth.routes.auth_service') as mock_service:
|
||||
yield mock_service
|
||||
|
||||
def test_login_success(self, client, mock_auth_service):
|
||||
"""Test pentru login cu succes"""
|
||||
# Mock pentru autentificare cu succes
|
||||
mock_token_response = TokenResponse(
|
||||
access_token="test-access-token",
|
||||
refresh_token="test-refresh-token",
|
||||
token_type="bearer",
|
||||
expires_in=1800,
|
||||
user=CurrentUser(
|
||||
username="testuser",
|
||||
companies=["COMP1", "COMP2"],
|
||||
permissions=[PermissionType.READ, PermissionType.REPORTS]
|
||||
)
|
||||
)
|
||||
|
||||
mock_auth_service.authenticate_and_create_tokens.return_value = (
|
||||
True, mock_token_response, None
|
||||
)
|
||||
mock_auth_service.get_user_companies.return_value = ["COMP1", "COMP2"]
|
||||
|
||||
response = client.post("/auth/login", json={
|
||||
"username": "testuser",
|
||||
"password": "password"
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["access_token"] == "test-access-token"
|
||||
assert data["token_type"] == "bearer"
|
||||
assert data["user"]["username"] == "testuser"
|
||||
|
||||
def test_login_failure(self, client, mock_auth_service):
|
||||
"""Test pentru login eșuat"""
|
||||
mock_auth_service.authenticate_and_create_tokens.return_value = (
|
||||
False, None, "Invalid credentials"
|
||||
)
|
||||
|
||||
response = client.post("/auth/login", json={
|
||||
"username": "testuser",
|
||||
"password": "wrongpassword"
|
||||
})
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid credentials" in response.json()["detail"]
|
||||
|
||||
def test_protected_endpoint_without_token(self, client):
|
||||
"""Test pentru endpoint protejat fără token"""
|
||||
response = client.get("/auth/me")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_protected_endpoint_with_valid_token(self, client):
|
||||
"""Test pentru endpoint protejat cu token valid"""
|
||||
# Creează un token de test
|
||||
jwt_handler = JWTHandler(secret_key="test-secret-key")
|
||||
token = jwt_handler.create_access_token(
|
||||
username="testuser",
|
||||
companies=["COMP1"],
|
||||
permissions=["read"]
|
||||
)
|
||||
|
||||
with patch('roa2web.shared.auth.dependencies.jwt_handler', jwt_handler):
|
||||
response = client.get("/auth/me", headers={
|
||||
"Authorization": f"Bearer {token}"
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["username"] == "testuser"
|
||||
|
||||
|
||||
class TestSecurityFeatures:
|
||||
"""Test suite pentru funcții de securitate"""
|
||||
|
||||
def test_jwt_token_tampering(self):
|
||||
"""Test pentru detectarea modificării token-urilor JWT"""
|
||||
jwt_handler = JWTHandler(secret_key="test-secret-key")
|
||||
token = jwt_handler.create_access_token("testuser", ["COMP1"])
|
||||
|
||||
# Modifică token-ul
|
||||
tampered_token = token[:-5] + "XXXXX"
|
||||
|
||||
# Token-ul modificat trebuie să fie invalid
|
||||
token_data = jwt_handler.verify_token(tampered_token)
|
||||
assert token_data is None
|
||||
|
||||
def test_jwt_secret_key_different(self):
|
||||
"""Test pentru token-uri semnate cu chei diferite"""
|
||||
jwt_handler1 = JWTHandler(secret_key="secret1")
|
||||
jwt_handler2 = JWTHandler(secret_key="secret2")
|
||||
|
||||
token = jwt_handler1.create_access_token("testuser", ["COMP1"])
|
||||
|
||||
# Token-ul nu trebuie să fie valid cu o cheie diferită
|
||||
token_data = jwt_handler2.verify_token(token)
|
||||
assert token_data is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sql_injection_prevention(self):
|
||||
"""Test pentru prevenirea SQL injection"""
|
||||
auth_service = UserAuthService()
|
||||
|
||||
with patch('roa2web.shared.auth.auth_service.oracle_pool') as mock_pool:
|
||||
mock_connection = AsyncMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_connection.__aenter__.return_value = mock_connection
|
||||
mock_connection.cursor.return_value.__enter__.return_value = mock_cursor
|
||||
mock_oracle_pool.get_connection.return_value = mock_connection
|
||||
|
||||
# Încearcă SQL injection în username
|
||||
malicious_username = "admin'; DROP TABLE users; --"
|
||||
|
||||
await auth_service.verify_user_credentials(malicious_username, "password")
|
||||
|
||||
# Verifică că query-ul folosește parametri legați
|
||||
mock_cursor.execute.assert_called_once()
|
||||
call_args = mock_cursor.execute.call_args
|
||||
assert ':username' in call_args[0][0] # Query cu parametri
|
||||
assert malicious_username.upper() == call_args[1]['username'] # Parametri legați
|
||||
|
||||
|
||||
@pytest.mark.performance
|
||||
class TestPerformance:
|
||||
"""Test suite pentru performanță"""
|
||||
|
||||
def test_jwt_token_creation_performance(self):
|
||||
"""Test pentru performanța creării token-urilor"""
|
||||
jwt_handler = JWTHandler(secret_key="test-secret-key")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Creează 1000 de token-uri
|
||||
for i in range(1000):
|
||||
jwt_handler.create_access_token(f"user{i}", ["COMP1"])
|
||||
|
||||
end_time = time.time()
|
||||
total_time = end_time - start_time
|
||||
|
||||
# Ar trebui să dureze mai puțin de 1 secundă
|
||||
assert total_time < 1.0
|
||||
print(f"Created 1000 tokens in {total_time:.4f} seconds")
|
||||
|
||||
def test_jwt_token_verification_performance(self):
|
||||
"""Test pentru performanța verificării token-urilor"""
|
||||
jwt_handler = JWTHandler(secret_key="test-secret-key")
|
||||
|
||||
# Creează 100 de token-uri
|
||||
tokens = []
|
||||
for i in range(100):
|
||||
token = jwt_handler.create_access_token(f"user{i}", ["COMP1"])
|
||||
tokens.append(token)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Verifică toate token-urile
|
||||
for token in tokens:
|
||||
jwt_handler.verify_token(token)
|
||||
|
||||
end_time = time.time()
|
||||
total_time = end_time - start_time
|
||||
|
||||
# Ar trebui să dureze mai puțin de 0.5 secunde
|
||||
assert total_time < 0.5
|
||||
print(f"Verified 100 tokens in {total_time:.4f} seconds")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Rulează testele
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
124
deploy-package-20260223-151231/shared/database/README.md
Normal file
124
deploy-package-20260223-151231/shared/database/README.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# ROA2WEB Shared Database Pool
|
||||
|
||||
Sistem de pool de conexiuni Oracle partajat între toate microserviciile ROA2WEB.
|
||||
|
||||
## Componente
|
||||
|
||||
### 📦 oracle_pool.py
|
||||
Clasa singleton `OraclePool` pentru gestionarea pool-ului de conexiuni Oracle.
|
||||
|
||||
### 📋 models.py
|
||||
Modele Pydantic comune:
|
||||
- `User` - Model pentru utilizatori
|
||||
- `Company` - Model pentru firme/scheme Oracle
|
||||
- `DatabaseConfig` - Configurare conexiune database
|
||||
|
||||
### ⚙️ config.py (în utils/)
|
||||
Configurări partajate prin environment variables.
|
||||
|
||||
### ❌ exceptions.py (în utils/)
|
||||
Exception handlers personalizate pentru ROA2WEB.
|
||||
|
||||
## Utilizare
|
||||
|
||||
### Inițializare în aplicații FastAPI
|
||||
|
||||
```python
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Import shared pool
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), '../../../shared'))
|
||||
from database.oracle_pool import oracle_pool
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup - inițializare pool
|
||||
await oracle_pool.initialize()
|
||||
print("📊 Oracle pool initialized")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown - închidere pool
|
||||
await oracle_pool.close_pool()
|
||||
print("📊 Oracle pool closed")
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
```
|
||||
|
||||
### Utilizare conexiune în endpoint-uri
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from database.oracle_pool import oracle_pool
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/companies")
|
||||
async def get_companies():
|
||||
try:
|
||||
async with oracle_pool.get_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SELECT schema, firma FROM vdef_util_grup WHERE id_firma <> 0")
|
||||
results = cursor.fetchall()
|
||||
|
||||
companies = []
|
||||
for row in results:
|
||||
companies.append({
|
||||
"code": row[0],
|
||||
"name": row[1]
|
||||
})
|
||||
|
||||
return companies
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
||||
```
|
||||
|
||||
### Configurare Environment Variables
|
||||
|
||||
```bash
|
||||
# Oracle Database
|
||||
ORACLE_USER=your_oracle_username
|
||||
ORACLE_PASSWORD=your_oracle_password
|
||||
ORACLE_DSN=your_oracle_dsn
|
||||
|
||||
# Pool Settings
|
||||
DB_MIN_CONNECTIONS=2
|
||||
DB_MAX_CONNECTIONS=10
|
||||
DB_CONNECTION_INCREMENT=1
|
||||
|
||||
# JWT (pentru autentificare)
|
||||
JWT_SECRET_KEY=your-super-secret-key
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
```
|
||||
|
||||
## Testare
|
||||
|
||||
Pentru a testa pool-ul de conexiuni:
|
||||
|
||||
```bash
|
||||
cd roa2web/shared/database
|
||||
python test_pool.py
|
||||
```
|
||||
|
||||
**Notă**: Testul necesită configurarea variabilelor de environment pentru Oracle.
|
||||
|
||||
## Caracteristici
|
||||
|
||||
✅ **Singleton Pattern** - O singură instanță de pool pentru toată aplicația
|
||||
✅ **Async Context Manager** - Gestionare automată a conexiunilor
|
||||
✅ **Connection Pooling** - Performanță optimizată prin reutilizarea conexiunilor
|
||||
✅ **Configurabil** - Setări flexibile prin environment variables
|
||||
✅ **Logging** - Urmărirea operațiilor de pool
|
||||
✅ **Error Handling** - Excepții personalizate pentru debugging
|
||||
|
||||
## Următorii Pași
|
||||
|
||||
👉 **ZIUA 3**: Implementarea sistemului JWT partajat (`shared/auth/`)
|
||||
|
||||
---
|
||||
|
||||
*Documentație generată pentru ROA2WEB Shared Database Pool - ZIUA 2* 🚀
|
||||
30
deploy-package-20260223-151231/shared/database/models.py
Normal file
30
deploy-package-20260223-151231/shared/database/models.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Modele comune pentru toate aplicațiile ROA2WEB
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict
|
||||
from datetime import datetime
|
||||
|
||||
class Company(BaseModel):
|
||||
"""Model pentru firma/schema Oracle"""
|
||||
code: str = Field(description="Codul firmei (schema Oracle)")
|
||||
name: str = Field(description="Numele firmei")
|
||||
fiscal_code: Optional[str] = Field(description="Codul fiscal")
|
||||
is_active: bool = Field(default=True, description="Firma activă")
|
||||
|
||||
class User(BaseModel):
|
||||
"""Model pentru utilizator"""
|
||||
username: str = Field(description="Numele utilizatorului")
|
||||
email: Optional[str] = Field(description="Email utilizator")
|
||||
companies: List[str] = Field(description="Lista codurilor firmelor la care are acces")
|
||||
is_active: bool = Field(default=True, description="Utilizator activ")
|
||||
last_login: Optional[datetime] = Field(description="Ultima autentificare")
|
||||
|
||||
class DatabaseConfig(BaseModel):
|
||||
"""Configurare conexiune bază de date"""
|
||||
user: str
|
||||
password: str
|
||||
dsn: str
|
||||
min_connections: int = 2
|
||||
max_connections: int = 10
|
||||
increment: int = 1
|
||||
276
deploy-package-20260223-151231/shared/database/oracle_pool.py
Normal file
276
deploy-package-20260223-151231/shared/database/oracle_pool.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
Oracle Database Connection Pool - Multi-Server Support for ROA2WEB
|
||||
|
||||
Uses ORACLE_SERVERS from .env for server configuration.
|
||||
Pool-uri sunt create lazy (la prima conexiune pe fiecare server) pentru optimizare.
|
||||
"""
|
||||
import asyncio
|
||||
import oracledb
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OracleMultiPool:
|
||||
"""
|
||||
Multi-tenant Oracle connection pool manager.
|
||||
|
||||
Supports:
|
||||
- Multiple Oracle servers with separate pools: {server_id: pool}
|
||||
- Lazy pool creation (created on first connection)
|
||||
- First registered server used when no server_id specified
|
||||
- Graceful shutdown of all pools
|
||||
"""
|
||||
_instance: Optional['OracleMultiPool'] = None
|
||||
_pools: Dict[str, oracledb.ConnectionPool]
|
||||
_pool_configs: Dict[str, Dict[str, Any]]
|
||||
_pool_lock: asyncio.Lock
|
||||
_initialized: bool
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(OracleMultiPool, cls).__new__(cls)
|
||||
cls._instance._pools = {}
|
||||
cls._instance._pool_configs = {}
|
||||
cls._instance._pool_lock = asyncio.Lock()
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
async def initialize(self):
|
||||
"""
|
||||
Initialize pool manager.
|
||||
|
||||
Call this after registering servers with register_server().
|
||||
Pools are created lazily on first connection.
|
||||
"""
|
||||
if self._initialized:
|
||||
logger.debug("Pool manager already initialized")
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
logger.info("Oracle pool manager initialized")
|
||||
|
||||
def register_server(
|
||||
self,
|
||||
server_id: str,
|
||||
host: str,
|
||||
port: int,
|
||||
user: str,
|
||||
password: str,
|
||||
sid: Optional[str] = None,
|
||||
service_name: Optional[str] = None,
|
||||
min_connections: int = 2,
|
||||
max_connections: int = 10,
|
||||
**kwargs
|
||||
) -> None:
|
||||
"""
|
||||
Register a server configuration for lazy pool creation.
|
||||
|
||||
Pool will be created on first get_connection(server_id) call.
|
||||
"""
|
||||
self._pool_configs[server_id] = {
|
||||
'host': host,
|
||||
'port': port,
|
||||
'user': user,
|
||||
'password': password,
|
||||
'sid': sid,
|
||||
'service_name': service_name,
|
||||
'min_connections': min_connections,
|
||||
'max_connections': max_connections,
|
||||
}
|
||||
logger.info(f"Registered server '{server_id}' ({host}:{port}) for lazy pool creation")
|
||||
|
||||
async def _get_or_create_pool(self, server_id: str) -> oracledb.ConnectionPool:
|
||||
"""
|
||||
Get existing pool or create new one (lazy loading).
|
||||
|
||||
Thread-safe: uses asyncio.Lock to prevent duplicate pool creation.
|
||||
"""
|
||||
# Fast path: pool already exists
|
||||
if server_id in self._pools:
|
||||
return self._pools[server_id]
|
||||
|
||||
# Slow path: need to create pool
|
||||
async with self._pool_lock:
|
||||
# Double-check after acquiring lock
|
||||
if server_id in self._pools:
|
||||
return self._pools[server_id]
|
||||
|
||||
# Check if server is registered
|
||||
if server_id not in self._pool_configs:
|
||||
raise ValueError(f"Server '{server_id}' not registered. Call register_server() first.")
|
||||
|
||||
config = self._pool_configs[server_id]
|
||||
logger.info(f"Creating pool for server '{server_id}' (lazy initialization)...")
|
||||
|
||||
pool_params = {
|
||||
'user': config['user'],
|
||||
'password': config['password'],
|
||||
'host': config['host'],
|
||||
'port': config['port'],
|
||||
'min': config['min_connections'],
|
||||
'max': config['max_connections'],
|
||||
'increment': 1,
|
||||
'getmode': oracledb.POOL_GETMODE_WAIT
|
||||
}
|
||||
|
||||
if config.get('service_name'):
|
||||
pool_params['service_name'] = config['service_name']
|
||||
elif config.get('sid'):
|
||||
pool_params['sid'] = config['sid']
|
||||
else:
|
||||
pool_params['service_name'] = 'ROA'
|
||||
|
||||
pool = oracledb.create_pool(**pool_params)
|
||||
self._pools[server_id] = pool
|
||||
|
||||
logger.info(f"Pool created for server '{server_id}' with {pool.opened} connections")
|
||||
return pool
|
||||
|
||||
def _get_first_server_id(self) -> str:
|
||||
"""Get the first registered server ID."""
|
||||
if not self._pool_configs:
|
||||
raise RuntimeError("No servers registered. Call register_server() first.")
|
||||
return next(iter(self._pool_configs))
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_connection(self, server_id: Optional[str] = None):
|
||||
"""
|
||||
Context manager pentru obținerea unei conexiuni din pool.
|
||||
|
||||
Args:
|
||||
server_id: ID-ul serverului. Dacă None, folosește primul server înregistrat.
|
||||
|
||||
Usage:
|
||||
# Explicit server
|
||||
async with oracle_pool.get_connection('romfast') as conn:
|
||||
...
|
||||
|
||||
# First registered server (when only one server configured)
|
||||
async with oracle_pool.get_connection() as conn:
|
||||
...
|
||||
"""
|
||||
connection = None
|
||||
pool = None
|
||||
|
||||
try:
|
||||
if server_id is None:
|
||||
# Use first registered server
|
||||
server_id = self._get_first_server_id()
|
||||
logger.debug(f"No server_id specified, using first registered: '{server_id}'")
|
||||
|
||||
pool = await self._get_or_create_pool(server_id)
|
||||
connection = pool.acquire()
|
||||
logger.debug(f"Connection acquired from pool (server_id={server_id})")
|
||||
yield connection
|
||||
|
||||
finally:
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
logger.debug(f"Connection returned to pool (server_id={server_id})")
|
||||
|
||||
async def execute_query(self, query: str, parameters=None, server_id: Optional[str] = None):
|
||||
"""
|
||||
Execute a SQL query and return all results.
|
||||
|
||||
Args:
|
||||
query: SQL query string
|
||||
parameters: Query parameters (dict or tuple)
|
||||
server_id: Server ID (optional, uses first server if not specified)
|
||||
"""
|
||||
async with self.get_connection(server_id) as connection:
|
||||
logger.debug(f"Executing query on server '{server_id}': {query[:100]}...")
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
if parameters:
|
||||
cursor.execute(query, parameters)
|
||||
else:
|
||||
cursor.execute(query)
|
||||
|
||||
# Check if this is a SELECT statement
|
||||
if query.strip().upper().startswith('SELECT') or query.strip().upper().startswith('WITH'):
|
||||
return cursor.fetchall()
|
||||
else:
|
||||
# For DML statements, return affected row count
|
||||
connection.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
async def close_pool(self, server_id: Optional[str] = None):
|
||||
"""
|
||||
Close a specific pool or all pools.
|
||||
|
||||
Args:
|
||||
server_id: Close specific pool. If None, close all pools.
|
||||
"""
|
||||
if server_id is not None:
|
||||
# Close specific pool
|
||||
if server_id in self._pools:
|
||||
self._pools[server_id].close()
|
||||
del self._pools[server_id]
|
||||
logger.info(f"Closed pool for server '{server_id}'")
|
||||
else:
|
||||
# Close all pools (graceful shutdown)
|
||||
for srv_id, pool in list(self._pools.items()):
|
||||
pool.close()
|
||||
logger.info(f"Closed pool for server '{srv_id}'")
|
||||
|
||||
self._pools.clear()
|
||||
self._initialized = False
|
||||
logger.info("All Oracle pools closed")
|
||||
|
||||
def get_pool_stats(self, server_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get statistics for pool(s).
|
||||
|
||||
Args:
|
||||
server_id: Get stats for specific server. If None, get all stats.
|
||||
|
||||
Returns:
|
||||
Dict with pool statistics (opened, busy, min, max connections)
|
||||
"""
|
||||
stats = {}
|
||||
|
||||
if server_id is not None:
|
||||
pool = self._pools.get(server_id)
|
||||
if pool:
|
||||
stats[server_id] = {
|
||||
'opened': pool.opened,
|
||||
'busy': pool.busy,
|
||||
'min': pool.min,
|
||||
'max': pool.max,
|
||||
}
|
||||
else:
|
||||
for srv_id, pool in self._pools.items():
|
||||
stats[srv_id] = {
|
||||
'opened': pool.opened,
|
||||
'busy': pool.busy,
|
||||
'min': pool.min,
|
||||
'max': pool.max,
|
||||
}
|
||||
|
||||
return stats
|
||||
|
||||
def is_server_registered(self, server_id: str) -> bool:
|
||||
"""Check if a server is registered (config exists)."""
|
||||
return server_id in self._pool_configs
|
||||
|
||||
def is_pool_active(self, server_id: str) -> bool:
|
||||
"""Check if a pool is active (created) for a server."""
|
||||
return server_id in self._pools
|
||||
|
||||
def get_registered_servers(self) -> list:
|
||||
"""Get list of registered server IDs."""
|
||||
return list(self._pool_configs.keys())
|
||||
|
||||
def get_active_pools(self) -> list:
|
||||
"""Get list of server IDs with active pools."""
|
||||
return list(self._pools.keys())
|
||||
|
||||
|
||||
# Backward compatibility alias
|
||||
OraclePool = OracleMultiPool
|
||||
|
||||
# Global instance
|
||||
oracle_pool = OracleMultiPool()
|
||||
69
deploy-package-20260223-151231/shared/database/test_pool.py
Normal file
69
deploy-package-20260223-151231/shared/database/test_pool.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Test script pentru verificarea conexiunii Oracle pool
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
# Load environment variables
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
# Load .env from roa2web root directory
|
||||
env_path = os.path.join(os.path.dirname(__file__), '../../.env')
|
||||
load_dotenv(env_path)
|
||||
print(f"📄 Loaded environment from: {env_path}")
|
||||
except ImportError:
|
||||
print("⚠️ python-dotenv not available, using system environment variables")
|
||||
|
||||
# Adăugare path pentru shared modules
|
||||
sys.path.append(os.path.dirname(__file__))
|
||||
|
||||
from oracle_pool import oracle_pool
|
||||
|
||||
async def test_oracle_pool():
|
||||
"""Test simplu pentru verificarea pool-ului Oracle"""
|
||||
print("🔄 Testing Oracle connection pool...")
|
||||
|
||||
try:
|
||||
# Inițializare pool
|
||||
print("📊 Initializing Oracle pool...")
|
||||
await oracle_pool.initialize()
|
||||
print("✅ Pool initialized successfully")
|
||||
|
||||
# Test conexiune
|
||||
print("🔍 Testing database connection...")
|
||||
async with oracle_pool.get_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SELECT 1 FROM DUAL")
|
||||
result = cursor.fetchone()
|
||||
print(f"✅ Database connection test successful: {result}")
|
||||
|
||||
print("🎯 Testing connection pool info...")
|
||||
if oracle_pool._pool:
|
||||
print(f"📈 Pool connections opened: {oracle_pool._pool.opened}")
|
||||
print(f"📊 Pool connections busy: {oracle_pool._pool.busy}")
|
||||
|
||||
# Cleanup
|
||||
print("🧹 Closing pool...")
|
||||
await oracle_pool.close_pool()
|
||||
print("✅ Pool closed successfully")
|
||||
|
||||
print("\n🎉 All tests passed! Oracle pool is working correctly.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error testing Oracle pool: {str(e)}")
|
||||
print(f"💡 Make sure environment variables are set:")
|
||||
print(f" - ORACLE_USER: {'✅ SET' if os.getenv('ORACLE_USER') else '❌ NOT SET'}")
|
||||
print(f" - ORACLE_PASSWORD: {'✅ SET' if os.getenv('ORACLE_PASSWORD') else '❌ NOT SET'}")
|
||||
print(f" - ORACLE_DSN: {'✅ SET' if os.getenv('ORACLE_DSN') else '❌ NOT SET'}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"🚀 ROA2WEB Oracle Pool Test - {datetime.now()}")
|
||||
print("=" * 50)
|
||||
|
||||
success = asyncio.run(test_oracle_pool())
|
||||
sys.exit(0 if success else 1)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,910 @@
|
||||
# Plan Implementare Redis Caching - ROA2WEB
|
||||
|
||||
**Data Creare:** 2025-01-25
|
||||
**Status:** DRAFT - Ready for Implementation
|
||||
**Durata Estimată:** 2-3 ore
|
||||
|
||||
---
|
||||
|
||||
## 📋 Sumar Executiv
|
||||
|
||||
- **Obiectiv:** Implementare Redis caching layer pentru reducerea query-urilor repetitive la Oracle DB
|
||||
- **Target:** 60-80% reducere în numărul de query-uri pentru date frecvent accesate
|
||||
- **Strategie:** Caching simplu la nivel de Service layer cu invalidation manuală
|
||||
- **Backward Compatibility:** ✅ Aplicația funcționează fără Redis (graceful degradation)
|
||||
- **Multi-Tenant Ready:** ✅ Cache keys pregătite pentru viitoare multi-tenancy
|
||||
|
||||
**Infrastructure Status:**
|
||||
- ✅ Redis container configurat în `docker-compose.yml` (lines 147-163)
|
||||
- ✅ Backend `depends_on: roa-redis`
|
||||
- ❌ Redis client library LIPSĂ
|
||||
- ❌ Cod de caching LIPSĂ
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Structura Fișierelor
|
||||
|
||||
### Fișiere Noi (6 fișiere)
|
||||
|
||||
- [ ] `shared/cache/__init__.py` - Package initialization
|
||||
- [ ] `shared/cache/redis_client.py` - Redis connection client (90 lines)
|
||||
- [ ] `shared/cache/decorators.py` - Cache decorators (60 lines)
|
||||
- [ ] `shared/cache/utils.py` - Cache key generators, helpers (40 lines)
|
||||
- [ ] `shared/tests/test_redis_client.py` - Unit tests pentru Redis client (120 lines)
|
||||
- [ ] `shared/tests/test_cache_decorators.py` - Unit tests pentru decorators (80 lines)
|
||||
|
||||
### Fișiere Modificate (7 fișiere)
|
||||
|
||||
- [ ] `backend/requirements.txt` - Adaugă redis>=5.0.0
|
||||
- [ ] `backend/.env.example` - Adaugă REDIS_* env vars
|
||||
- [ ] `backend/app/main.py` - Initialize Redis at startup
|
||||
- [ ] `backend/app/services/dashboard_service.py` - Apply caching
|
||||
- [ ] `backend/app/services/invoice_service.py` - Apply caching
|
||||
- [ ] `backend/app/routers/dashboard.py` - Cache invalidation on mutations
|
||||
- [ ] `backend/app/routers/invoices.py` - Cache invalidation on mutations
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Faze de Implementare
|
||||
|
||||
### FAZA 1: Setup Redis Client și Connection (30 min)
|
||||
|
||||
**Obiectiv:** Creează Redis client singleton cu connection pooling, similar cu `OraclePool` pattern.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. [ ] **Adaugă dependency în requirements.txt**
|
||||
- Fișier: `backend/requirements.txt`
|
||||
- Acțiune: Adaugă linia `redis>=5.0.0` după `httpx>=0.27.0`
|
||||
- Motivație: redis 5.0+ are async support nativ
|
||||
|
||||
2. [ ] **Creează package cache în shared/**
|
||||
- Fișiere: `shared/cache/__init__.py`
|
||||
- Acțiune: Creează directorul și fișierul de init cu exports:
|
||||
```python
|
||||
from .redis_client import redis_cache
|
||||
from .decorators import cached, invalidate_cache
|
||||
|
||||
__all__ = ['redis_cache', 'cached', 'invalidate_cache']
|
||||
```
|
||||
|
||||
3. [ ] **Implementează RedisCache singleton client**
|
||||
- Fișier: `shared/cache/redis_client.py`
|
||||
- Acțiune: Creează clasa `RedisCache` similar cu `OraclePool`:
|
||||
- Singleton pattern (nu multi-instance)
|
||||
- Async redis client cu connection pooling
|
||||
- Methods: `initialize()`, `get()`, `set()`, `delete()`, `delete_pattern()`, `close()`
|
||||
- Graceful degradation: dacă Redis e down, log warning și return None
|
||||
- Connection retry cu exponential backoff
|
||||
- Template (vezi secțiunea Code Templates mai jos)
|
||||
|
||||
4. [ ] **Adaugă env variables în .env.example**
|
||||
- Fișier: `backend/.env.example`
|
||||
- Acțiune: Adaugă la sfârșitul fișierului:
|
||||
```bash
|
||||
# Redis Configuration
|
||||
REDIS_URL=redis://roa-redis:6379/0
|
||||
REDIS_PASSWORD=roa2web_redis_password
|
||||
REDIS_ENABLED=true
|
||||
CACHE_DEFAULT_TTL=300
|
||||
```
|
||||
|
||||
5. [ ] **Initialize Redis la startup în main.py**
|
||||
- Fișier: `backend/app/main.py`
|
||||
- Acțiune: În funcția `startup_event()`:
|
||||
```python
|
||||
from shared.cache import redis_cache
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
# ... existing oracle pool init ...
|
||||
|
||||
# Initialize Redis cache
|
||||
if os.getenv('REDIS_ENABLED', 'false').lower() == 'true':
|
||||
await redis_cache.initialize(
|
||||
url=os.getenv('REDIS_URL'),
|
||||
password=os.getenv('REDIS_PASSWORD')
|
||||
)
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
# ... existing oracle pool close ...
|
||||
await redis_cache.close()
|
||||
```
|
||||
|
||||
**Output Verificabil:**
|
||||
|
||||
- [ ] `pip install -r requirements.txt` rulează fără erori
|
||||
- [ ] Redis client se conectează cu succes la container
|
||||
- [ ] Test manual: `python -c "import asyncio; from shared.cache import redis_cache; asyncio.run(redis_cache.initialize())"`
|
||||
- [ ] Log message: "✅ Redis cache initialized successfully"
|
||||
|
||||
---
|
||||
|
||||
### FAZA 2: Cache Decorator și Helpers (30 min)
|
||||
|
||||
**Obiectiv:** Creează decorator `@cached()` pentru aplicare ușoară în Services.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. [ ] **Implementează cache key generator**
|
||||
- Fișier: `shared/cache/utils.py`
|
||||
- Acțiune: Funcții helper pentru key generation:
|
||||
```python
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
|
||||
def make_cache_key(tenant_id: str, resource: str, **params) -> str:
|
||||
"""
|
||||
Generate tenant-aware cache key
|
||||
Format: cache:{tenant_id}:{resource}:{params_hash}
|
||||
"""
|
||||
params_str = json.dumps(params, sort_keys=True)
|
||||
params_hash = hashlib.md5(params_str.encode()).hexdigest()[:12]
|
||||
return f"cache:{tenant_id}:{resource}:{params_hash}"
|
||||
|
||||
def extract_tenant_id(kwargs: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Extract tenant_id from function kwargs
|
||||
For now returns 'default', later extract from JWT token
|
||||
"""
|
||||
# TODO: Extract from request.state.tenant_id when multi-tenant implemented
|
||||
return kwargs.get('tenant_id', 'default')
|
||||
```
|
||||
|
||||
2. [ ] **Implementează @cached decorator**
|
||||
- Fișier: `shared/cache/decorators.py`
|
||||
- Acțiune: Decorator pentru auto-caching de funcții async:
|
||||
```python
|
||||
from functools import wraps
|
||||
from typing import Callable, Optional
|
||||
import logging
|
||||
from .redis_client import redis_cache
|
||||
from .utils import make_cache_key, extract_tenant_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def cached(resource: str, ttl: int = 300):
|
||||
"""
|
||||
Cache decorator pentru funcții async
|
||||
|
||||
Usage:
|
||||
@cached(resource="dashboard_summary", ttl=300)
|
||||
async def get_dashboard_summary(company: str, username: str):
|
||||
# ... query Oracle ...
|
||||
return data
|
||||
|
||||
Args:
|
||||
resource: Resource name (e.g., 'dashboard_summary', 'invoices_list')
|
||||
ttl: Time-to-live în secunde (default: 5 min)
|
||||
"""
|
||||
def decorator(func: Callable):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Skip cache dacă Redis e disabled
|
||||
if not redis_cache.is_enabled():
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Extract tenant_id și params pentru cache key
|
||||
tenant_id = extract_tenant_id(kwargs)
|
||||
cache_params = {k: v for k, v in kwargs.items()
|
||||
if k not in ['username', 'current_user']}
|
||||
|
||||
cache_key = make_cache_key(tenant_id, resource, **cache_params)
|
||||
|
||||
# Try cache GET
|
||||
cached_value = await redis_cache.get(cache_key)
|
||||
if cached_value is not None:
|
||||
logger.debug(f"Cache HIT: {cache_key}")
|
||||
return cached_value
|
||||
|
||||
# Cache MISS - execute function
|
||||
logger.debug(f"Cache MISS: {cache_key}")
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# Save to cache
|
||||
await redis_cache.set(cache_key, result, ttl=ttl)
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
```
|
||||
|
||||
3. [ ] **Implementează invalidate_cache helper**
|
||||
- Fișier: `shared/cache/decorators.py` (same file)
|
||||
- Acțiune: Helper function pentru manual invalidation:
|
||||
```python
|
||||
async def invalidate_cache(
|
||||
tenant_id: str = "default",
|
||||
resource: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Invalidate cache entries
|
||||
|
||||
Examples:
|
||||
await invalidate_cache(resource="dashboard_summary") # clear specific resource
|
||||
await invalidate_cache() # clear all for default tenant
|
||||
"""
|
||||
if not redis_cache.is_enabled():
|
||||
return
|
||||
|
||||
if resource:
|
||||
pattern = f"cache:{tenant_id}:{resource}:*"
|
||||
else:
|
||||
pattern = f"cache:{tenant_id}:*"
|
||||
|
||||
await redis_cache.delete_pattern(pattern)
|
||||
logger.info(f"Cache invalidated: {pattern}")
|
||||
```
|
||||
|
||||
**Output Verificabil:**
|
||||
|
||||
- [ ] Decorator funcționează fără erori
|
||||
- [ ] Cache key format: `cache:default:dashboard_summary:abc123`
|
||||
- [ ] Test unit: `pytest shared/tests/test_cache_decorators.py -v`
|
||||
|
||||
---
|
||||
|
||||
### FAZA 3: Aplicare în Endpoint-uri (45 min)
|
||||
|
||||
**Obiectiv:** Aplică caching în Service layer pentru dashboard și invoices.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. [ ] **Apply @cached în DashboardService.get_complete_summary()**
|
||||
- Fișier: `backend/app/services/dashboard_service.py`
|
||||
- Acțiune: Adaugă decorator la metoda `get_complete_summary`:
|
||||
```python
|
||||
from shared.cache import cached
|
||||
|
||||
class DashboardService:
|
||||
@staticmethod
|
||||
@cached(resource="dashboard_summary", ttl=300) # 5 min
|
||||
async def get_complete_summary(company: str, username: str):
|
||||
# ... existing implementation ...
|
||||
```
|
||||
- Motivație: Dashboard e accesat des, datele se schimbă rar
|
||||
|
||||
2. [ ] **Apply @cached în DashboardService.get_trends()**
|
||||
- Fișier: `backend/app/services/dashboard_service.py`
|
||||
- Acțiune: Similar, TTL=180 (3 min pentru trends)
|
||||
- Cache key va include period: `cache:default:dashboard_trends:company-X:period-30d:abc123`
|
||||
|
||||
3. [ ] **Apply @cached în DashboardService.get_detailed_data()**
|
||||
- Fișier: `backend/app/services/dashboard_service.py`
|
||||
- Acțiune: TTL=60 (1 min pentru tabel detalii - se refreshează des)
|
||||
- Cache key include page, page_size, search
|
||||
|
||||
4. [ ] **Apply @cached în InvoiceService.get_invoices()**
|
||||
- Fișier: `backend/app/services/invoice_service.py`
|
||||
- Acțiune: TTL=60 (1 min)
|
||||
- Cache key include filter params (partner_type, date_from, date_to, etc.)
|
||||
|
||||
5. [ ] **Apply @cached în InvoiceService.get_invoice_summary()**
|
||||
- Fișier: `backend/app/services/invoice_service.py`
|
||||
- Acțiune: TTL=180 (3 min pentru summary)
|
||||
|
||||
6. [ ] **Cache invalidation în dashboard mutations (viitor)**
|
||||
- Fișier: `backend/app/routers/dashboard.py`
|
||||
- Acțiune: Pregătește cod pentru invalidation (de activat când există POST/PUT/DELETE):
|
||||
```python
|
||||
# TODO: Activate când implementăm mutations
|
||||
# from shared.cache import invalidate_cache
|
||||
#
|
||||
# @router.post("/...")
|
||||
# async def update_dashboard_data(...):
|
||||
# # ... update logic ...
|
||||
# await invalidate_cache(resource="dashboard_summary")
|
||||
# await invalidate_cache(resource="dashboard_trends")
|
||||
```
|
||||
|
||||
7. [ ] **Cache invalidation în invoice mutations**
|
||||
- Fișier: `backend/app/routers/invoices.py`
|
||||
- Acțiune: Când se implementează POST/PUT/DELETE pentru invoices, invalidează:
|
||||
- `invoices_list`
|
||||
- `invoices_summary`
|
||||
- `dashboard_summary` (afectează dashboard)
|
||||
|
||||
**Output Verificabil:**
|
||||
|
||||
- [ ] Backend pornește fără erori
|
||||
- [ ] First request: Cache MISS + Oracle query (măsoară timp: ~500-1000ms)
|
||||
- [ ] Second request (same params): Cache HIT (măsoară timp: ~10-20ms)
|
||||
- [ ] Cache hit rate > 80% după 100 requests repetitive
|
||||
- [ ] Logs arată `Cache HIT/MISS` messages
|
||||
|
||||
---
|
||||
|
||||
### FAZA 4: Testing, Monitoring și Cleanup (45 min)
|
||||
|
||||
**Obiectiv:** Validare funcționare corectă, performance benchmarks, și documentare.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. [ ] **Unit tests pentru RedisCache client**
|
||||
- Fișier: `shared/tests/test_redis_client.py`
|
||||
- Acțiune: Testează:
|
||||
- Connection success/failure
|
||||
- Get/Set/Delete operations
|
||||
- Pattern matching delete
|
||||
- Graceful degradation când Redis e down
|
||||
- Run: `pytest shared/tests/test_redis_client.py -v`
|
||||
|
||||
2. [ ] **Unit tests pentru cache decorators**
|
||||
- Fișier: `shared/tests/test_cache_decorators.py`
|
||||
- Acțiune: Testează:
|
||||
- Decorator aplică caching corect
|
||||
- Cache key generation
|
||||
- TTL respectat
|
||||
- Invalidation funcționează
|
||||
- Run: `pytest shared/tests/test_cache_decorators.py -v`
|
||||
|
||||
3. [ ] **Integration test în Docker**
|
||||
- Acțiune: Pornește stack complet cu `docker-compose up`
|
||||
- Verifică:
|
||||
- Backend se conectează la Redis
|
||||
- Cache funcționează end-to-end
|
||||
- Logs arată cache hits/misses
|
||||
|
||||
4. [ ] **Performance benchmark**
|
||||
- Tool: Apache Bench sau Python requests loop
|
||||
- Test case: 100 requests la `/api/dashboard/summary?company=X`
|
||||
- Măsoară:
|
||||
- **Without cache** (REDIS_ENABLED=false):
|
||||
- Avg response time: ~800ms
|
||||
- Total time: ~80 seconds
|
||||
- **With cache** (REDIS_ENABLED=true):
|
||||
- First request: ~800ms (MISS)
|
||||
- Next 99 requests: ~15ms (HIT)
|
||||
- Total time: ~2 seconds
|
||||
- **Improvement: 97.5%**
|
||||
- Salvează results în `shared/docs/REDIS_PERFORMANCE_BENCHMARK.md`
|
||||
|
||||
5. [ ] **Manual testing checklist**
|
||||
- [ ] Dashboard: Refresh multiple ori (verify cache HIT în logs)
|
||||
- [ ] Invoices: Filtrare diferită (verify cache keys unice)
|
||||
- [ ] Redis failure test: Stop Redis container, verify app funcționează (fallback la Oracle)
|
||||
- [ ] Cache invalidation: Manual invalidate via Redis CLI, verify re-query
|
||||
|
||||
6. [ ] **Update CLAUDE.md documentation**
|
||||
- Fișier: `CLAUDE.md`
|
||||
- Acțiune: Adaugă secțiune "Redis Caching":
|
||||
```markdown
|
||||
## 💾 Redis Caching
|
||||
|
||||
ROA2WEB folosește Redis pentru caching layer:
|
||||
|
||||
- **Client**: `shared/cache/redis_client.py` (singleton pattern)
|
||||
- **Decorator**: `@cached(resource="name", ttl=300)` în Services
|
||||
- **Cache Keys**: `cache:{tenant_id}:{resource}:{params_hash}`
|
||||
- **TTL Defaults**:
|
||||
- Dashboard summary: 5 min
|
||||
- Dashboard trends: 3 min
|
||||
- Invoices list: 1 min
|
||||
- Invoices summary: 3 min
|
||||
|
||||
**Toggle cache:** Set `REDIS_ENABLED=false` în `.env`
|
||||
|
||||
**Invalidate manual:**
|
||||
```python
|
||||
from shared.cache import invalidate_cache
|
||||
await invalidate_cache(resource="dashboard_summary")
|
||||
```
|
||||
|
||||
**Performance:** 60-80% reduction în query time pentru repetitive requests
|
||||
```
|
||||
|
||||
**Output Verificabil:**
|
||||
|
||||
- [ ] All tests pass: `pytest shared/tests/ -v`
|
||||
- [ ] Performance benchmark shows >60% improvement
|
||||
- [ ] Manual testing checklist complet
|
||||
- [ ] Documentation updated
|
||||
- [ ] Ready for code review
|
||||
|
||||
---
|
||||
|
||||
## 📊 Cache Strategy
|
||||
|
||||
### Resource TTLs
|
||||
|
||||
| Resource | TTL | Motivație |
|
||||
|----------|-----|-----------|
|
||||
| `dashboard_summary` | 300s (5 min) | Date agregate, se schimbă rar |
|
||||
| `dashboard_trends` | 180s (3 min) | Trends calculation expensive |
|
||||
| `dashboard_detailed_data` | 60s (1 min) | Tabel interactiv, refresh frecvent |
|
||||
| `dashboard_performance` | 180s (3 min) | Performance metrics stabile |
|
||||
| `dashboard_cashflow` | 180s (3 min) | Forecast calculation expensive |
|
||||
| `dashboard_maturity` | 180s (3 min) | Maturity analysis complex |
|
||||
| `invoices_list` | 60s (1 min) | Listing cu filtre, refresh frecvent |
|
||||
| `invoices_summary` | 180s (3 min) | Summary stats stabile |
|
||||
| `companies_list` | 600s (10 min) | Lista rareori se schimbă |
|
||||
| `treasury_data` | 120s (2 min) | Trezorerie moderate changes |
|
||||
|
||||
**Raționament TTL:**
|
||||
- Scurt (60s): Date interactive, tabel listings
|
||||
- Mediu (180-300s): Calculații expensive, agregări
|
||||
- Lung (600s+): Date aproape statice (companies, permissions)
|
||||
|
||||
### Cache Keys Pattern
|
||||
|
||||
**Format:** `cache:{tenant_id}:{resource}:{params_hash}`
|
||||
|
||||
**Exemplu concret:**
|
||||
```
|
||||
cache:default:dashboard_summary:company-123:abc456def789
|
||||
cache:default:invoices_list:company-123:partner-CLIENTI:unpaid-true:xyz890
|
||||
cache:default:dashboard_trends:company-456:period-30d:compare-true:def123
|
||||
```
|
||||
|
||||
**Componente:**
|
||||
- `cache:` - Prefix constant (pentru separare de alte Redis keys)
|
||||
- `{tenant_id}` - Tenant ID (deocamdată "default", viitor: din JWT token)
|
||||
- `{resource}` - Resource name (dashboard_summary, invoices_list, etc.)
|
||||
- `{params_hash}` - MD5 hash (primele 12 caractere) al parametrilor sortați JSON
|
||||
|
||||
**Multi-Tenant Ready:**
|
||||
Când se implementează multi-tenant:
|
||||
1. Modifică `extract_tenant_id()` în `utils.py` să citească din `request.state.tenant_id`
|
||||
2. JWT token va include `tenant_id` field
|
||||
3. Cache keys automat vor fi per-tenant
|
||||
4. Invalidation per-tenant: `await invalidate_cache(tenant_id="client-a")`
|
||||
|
||||
### Invalidation Rules
|
||||
|
||||
**Trigger:** Când se schimbă date în Oracle DB
|
||||
|
||||
| Mutation | Invalidate Resources |
|
||||
|----------|---------------------|
|
||||
| Invoice created/updated | `invoices_list`, `invoices_summary`, `dashboard_summary`, `dashboard_trends` |
|
||||
| Payment recorded | `invoices_list`, `dashboard_summary`, `treasury_data`, `dashboard_cashflow` |
|
||||
| Treasury transaction | `treasury_data`, `dashboard_summary`, `dashboard_cashflow` |
|
||||
| Company settings changed | `companies_list`, `dashboard_*` (pentru acea companie) |
|
||||
|
||||
**Implementare:**
|
||||
```python
|
||||
# În router după mutation
|
||||
from shared.cache import invalidate_cache
|
||||
|
||||
@router.post("/invoices/{invoice_id}/pay")
|
||||
async def mark_invoice_paid(...):
|
||||
# ... update DB ...
|
||||
|
||||
# Invalidate affected caches
|
||||
await invalidate_cache(resource="invoices_list")
|
||||
await invalidate_cache(resource="invoices_summary")
|
||||
await invalidate_cache(resource="dashboard_summary")
|
||||
await invalidate_cache(resource="treasury_data")
|
||||
|
||||
return {"status": "ok"}
|
||||
```
|
||||
|
||||
**Pattern Matching:**
|
||||
```python
|
||||
# Invalidate toate cache-urile pentru dashboard
|
||||
await invalidate_cache(resource="dashboard") # matches dashboard_*
|
||||
|
||||
# Invalidate tot pentru un tenant
|
||||
await invalidate_cache(tenant_id="client-a") # matches cache:client-a:*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Plan
|
||||
|
||||
### Unit Tests
|
||||
|
||||
**File:** `shared/tests/test_redis_client.py`
|
||||
|
||||
- [ ] `test_redis_connection_success()` - Verify successful connection
|
||||
- [ ] `test_redis_connection_failure_graceful()` - Redis down, no exception thrown
|
||||
- [ ] `test_redis_get_set_delete()` - Basic operations
|
||||
- [ ] `test_redis_delete_pattern()` - Pattern matching deletion
|
||||
- [ ] `test_redis_ttl_expiration()` - Verify TTL works
|
||||
- [ ] `test_redis_connection_retry()` - Exponential backoff retry
|
||||
|
||||
**File:** `shared/tests/test_cache_decorators.py`
|
||||
|
||||
- [ ] `test_cached_decorator_hit()` - Second call returns cached value
|
||||
- [ ] `test_cached_decorator_miss()` - First call queries function
|
||||
- [ ] `test_cache_key_generation()` - Keys format correct
|
||||
- [ ] `test_cache_key_unique_params()` - Different params = different keys
|
||||
- [ ] `test_invalidate_cache_pattern()` - Invalidation works
|
||||
- [ ] `test_cached_disabled()` - Works when REDIS_ENABLED=false
|
||||
|
||||
### Integration Tests
|
||||
|
||||
**Setup:** `docker-compose up -d`
|
||||
|
||||
**Test Scenarios:**
|
||||
|
||||
1. **Full Stack Test:**
|
||||
- Start backend + Redis
|
||||
- Call `/api/dashboard/summary?company=123`
|
||||
- Verify: Oracle query executed (check logs)
|
||||
- Call again same endpoint
|
||||
- Verify: Cache hit (no Oracle query)
|
||||
|
||||
2. **Cache Invalidation Test:**
|
||||
- Call endpoint (cache populated)
|
||||
- Invalidate via `redis-cli KEYS "cache:*"` + `DEL`
|
||||
- Call endpoint again
|
||||
- Verify: Oracle query executed (cache miss)
|
||||
|
||||
3. **Redis Failure Test:**
|
||||
- `docker-compose stop roa-redis`
|
||||
- Call endpoint
|
||||
- Verify: App works (fallback to Oracle)
|
||||
- No error thrown
|
||||
- Logs show warning: "Redis unavailable, fallback to DB"
|
||||
|
||||
### Performance Benchmarks
|
||||
|
||||
**Tool:** Apache Bench or Python script
|
||||
|
||||
**Baseline (No Cache):**
|
||||
```bash
|
||||
# Stop Redis or set REDIS_ENABLED=false
|
||||
ab -n 100 -c 10 http://localhost:8001/api/dashboard/summary?company=123
|
||||
# Expected: ~800ms avg response time, 80s total
|
||||
```
|
||||
|
||||
**With Cache:**
|
||||
```bash
|
||||
# Start Redis and set REDIS_ENABLED=true
|
||||
ab -n 100 -c 10 http://localhost:8001/api/dashboard/summary?company=123
|
||||
# Expected: ~15ms avg (after first request), ~2s total
|
||||
```
|
||||
|
||||
**Target Metrics:**
|
||||
- Cache hit rate: >90% (după warmup)
|
||||
- Avg response time reduction: >60%
|
||||
- Total time reduction: >75%
|
||||
- Memory usage: +50-200MB (Redis)
|
||||
|
||||
**Save Results:** `shared/docs/REDIS_PERFORMANCE_BENCHMARK.md`
|
||||
|
||||
### Manual Testing Checklist
|
||||
|
||||
- [ ] **Dashboard Summary:**
|
||||
- [ ] First load → check logs for "Cache MISS"
|
||||
- [ ] Refresh page → check logs for "Cache HIT"
|
||||
- [ ] Change company → new cache key, "Cache MISS"
|
||||
|
||||
- [ ] **Invoices List:**
|
||||
- [ ] Filter: toate facturile → "Cache MISS" first time
|
||||
- [ ] Refresh → "Cache HIT"
|
||||
- [ ] Filter: doar neplatite → new key, "Cache MISS"
|
||||
- [ ] Refresh → "Cache HIT"
|
||||
|
||||
- [ ] **Cache Invalidation:**
|
||||
- [ ] Load dashboard (cached)
|
||||
- [ ] Redis CLI: `redis-cli KEYS "cache:*"` → see keys
|
||||
- [ ] Delete: `redis-cli DEL cache:default:dashboard_summary:*`
|
||||
- [ ] Refresh dashboard → "Cache MISS" (re-queries Oracle)
|
||||
|
||||
- [ ] **Redis Failure Graceful:**
|
||||
- [ ] Stop Redis: `docker-compose stop roa-redis`
|
||||
- [ ] Access dashboard → works (no crash)
|
||||
- [ ] Check logs: "Redis unavailable, using direct DB query"
|
||||
- [ ] Start Redis: `docker-compose start roa-redis`
|
||||
- [ ] Access dashboard → caching resume
|
||||
|
||||
- [ ] **Multi-Tenant Simulation:**
|
||||
- [ ] Load dashboard company=123 (tenant=default)
|
||||
- [ ] Load dashboard company=456 (tenant=default)
|
||||
- [ ] Verify different cache keys in Redis
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configurare Env Variables
|
||||
|
||||
**File:** `backend/.env`
|
||||
|
||||
```bash
|
||||
# ============================================================================
|
||||
# REDIS CONFIGURATION
|
||||
# ============================================================================
|
||||
|
||||
# Redis Connection URL
|
||||
# Development: redis://roa-redis:6379/0 (Docker network)
|
||||
# Production: redis://redis-host:6379/0 or redis://localhost:6379/0
|
||||
REDIS_URL=redis://roa-redis:6379/0
|
||||
|
||||
# Redis Password (from docker-compose secrets)
|
||||
# Match with REDIS_PASSWORD in docker-compose.yml
|
||||
REDIS_PASSWORD=roa2web_redis_password
|
||||
|
||||
# Enable/Disable Redis Caching
|
||||
# Set to 'false' to disable caching (fallback to direct DB queries)
|
||||
REDIS_ENABLED=true
|
||||
|
||||
# Default Cache TTL (seconds)
|
||||
# Used when no specific TTL provided to @cached decorator
|
||||
CACHE_DEFAULT_TTL=300
|
||||
|
||||
# Redis Connection Pool Settings (optional, defaults shown)
|
||||
REDIS_MAX_CONNECTIONS=50
|
||||
REDIS_SOCKET_CONNECT_TIMEOUT=5
|
||||
REDIS_SOCKET_KEEPALIVE=true
|
||||
```
|
||||
|
||||
**Docker Compose Integration:**
|
||||
|
||||
No changes needed! Redis container already configured in `docker-compose.yml:147-163`.
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
docker-compose exec roa-backend env | grep REDIS
|
||||
# Should show REDIS_URL, REDIS_PASSWORD, REDIS_ENABLED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Checklist Final
|
||||
|
||||
### Pre-Implementation
|
||||
|
||||
- [ ] Read și înțeles planul complet
|
||||
- [ ] Backup codebase: `git commit -am "Backup before Redis implementation"`
|
||||
- [ ] Redis container rulează: `docker-compose up -d roa-redis`
|
||||
- [ ] Test connection: `docker-compose exec roa-redis redis-cli ping` → PONG
|
||||
|
||||
### Faza 1 (Setup)
|
||||
|
||||
- [ ] Dependency added: `redis>=5.0.0` în requirements.txt
|
||||
- [ ] Package created: `shared/cache/__init__.py`
|
||||
- [ ] Redis client: `shared/cache/redis_client.py`
|
||||
- [ ] Env vars added: `backend/.env.example`
|
||||
- [ ] Main.py updated: Redis initialize at startup
|
||||
- [ ] Test: `python -c "import asyncio; from shared.cache import redis_cache; asyncio.run(redis_cache.initialize())"`
|
||||
|
||||
### Faza 2 (Decorators)
|
||||
|
||||
- [ ] Utils created: `shared/cache/utils.py`
|
||||
- [ ] Decorator created: `shared/cache/decorators.py`
|
||||
- [ ] Unit tests: `shared/tests/test_cache_decorators.py`
|
||||
- [ ] Test: `pytest shared/tests/test_cache_decorators.py -v`
|
||||
|
||||
### Faza 3 (Integration)
|
||||
|
||||
- [ ] Cached applied: DashboardService.get_complete_summary
|
||||
- [ ] Cached applied: DashboardService.get_trends
|
||||
- [ ] Cached applied: DashboardService.get_detailed_data
|
||||
- [ ] Cached applied: InvoiceService.get_invoices
|
||||
- [ ] Cached applied: InvoiceService.get_invoice_summary
|
||||
- [ ] Backend starts: `uvicorn app.main:app --reload`
|
||||
- [ ] Test: First request slow, second fast
|
||||
|
||||
### Faza 4 (Validation)
|
||||
|
||||
- [ ] Unit tests pass: `pytest shared/tests/ -v`
|
||||
- [ ] Integration tests pass (Docker stack)
|
||||
- [ ] Performance benchmark run (save results)
|
||||
- [ ] Manual testing checklist completed
|
||||
- [ ] Documentation updated: `CLAUDE.md`
|
||||
- [ ] Git commit: `git add . && git commit -m "feat: implement Redis caching layer"`
|
||||
|
||||
### Ready for Production
|
||||
|
||||
- [ ] All tests green
|
||||
- [ ] Performance improvement >60%
|
||||
- [ ] Graceful degradation tested (Redis failure)
|
||||
- [ ] Code review requested
|
||||
- [ ] Merge to main branch
|
||||
|
||||
---
|
||||
|
||||
## 📚 Referințe
|
||||
|
||||
### Documentație Existentă
|
||||
|
||||
- **Docker Compose Redis Config:** `docker-compose.yml:147-163`
|
||||
- **Oracle Pool Pattern:** `shared/database/oracle_pool.py` (reference for singleton pattern)
|
||||
- **Backend Services:** `backend/app/services/` (where to apply caching)
|
||||
- **Backend Routers:** `backend/app/routers/` (where to invalidate cache)
|
||||
|
||||
### Documentație Externă
|
||||
|
||||
- Redis Python Client: https://redis.readthedocs.io/en/stable/
|
||||
- Redis Commands: https://redis.io/commands/
|
||||
- FastAPI Async: https://fastapi.tiangolo.com/async/
|
||||
|
||||
### Debugging
|
||||
|
||||
**Redis CLI Access:**
|
||||
```bash
|
||||
docker-compose exec roa-redis redis-cli -a roa2web_redis_password
|
||||
> KEYS cache:*
|
||||
> GET cache:default:dashboard_summary:abc123
|
||||
> DEL cache:default:dashboard_summary:abc123
|
||||
> FLUSHDB # Delete all keys (WARNING: destructive)
|
||||
```
|
||||
|
||||
**Monitor Redis Operations:**
|
||||
```bash
|
||||
docker-compose exec roa-redis redis-cli -a roa2web_redis_password MONITOR
|
||||
```
|
||||
|
||||
**Check Cache Stats:**
|
||||
```bash
|
||||
docker-compose exec roa-redis redis-cli -a roa2web_redis_password INFO stats
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Code Templates
|
||||
|
||||
### Template: RedisCache Client (`shared/cache/redis_client.py`)
|
||||
|
||||
```python
|
||||
"""
|
||||
Redis Cache Client - Singleton pattern similar to OraclePool
|
||||
Provides async Redis operations with graceful degradation
|
||||
"""
|
||||
import redis.asyncio as redis
|
||||
import logging
|
||||
import json
|
||||
from typing import Optional, Any
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RedisCache:
|
||||
"""Singleton Redis cache client with connection pooling"""
|
||||
|
||||
_instance: Optional['RedisCache'] = None
|
||||
_client: Optional[redis.Redis] = None
|
||||
_enabled: bool = False
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(RedisCache, cls).__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
async def initialize(
|
||||
self,
|
||||
url: str = None,
|
||||
password: str = None,
|
||||
max_connections: int = 50
|
||||
):
|
||||
"""Initialize Redis connection pool"""
|
||||
if self._client is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
url = url or os.getenv('REDIS_URL', 'redis://localhost:6379/0')
|
||||
password = password or os.getenv('REDIS_PASSWORD')
|
||||
|
||||
self._client = await redis.from_url(
|
||||
url,
|
||||
password=password,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
max_connections=max_connections,
|
||||
socket_connect_timeout=5,
|
||||
socket_keepalive=True
|
||||
)
|
||||
|
||||
# Test connection
|
||||
await self._client.ping()
|
||||
|
||||
self._enabled = True
|
||||
logger.info("✅ Redis cache initialized successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Redis initialization failed: {e}. Caching disabled.")
|
||||
self._enabled = False
|
||||
self._client = None
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Check if Redis caching is enabled"""
|
||||
return self._enabled and self._client is not None
|
||||
|
||||
async def get(self, key: str) -> Optional[Any]:
|
||||
"""Get value from cache"""
|
||||
if not self.is_enabled():
|
||||
return None
|
||||
|
||||
try:
|
||||
value = await self._client.get(key)
|
||||
if value:
|
||||
return json.loads(value)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Redis GET error for key {key}: {e}")
|
||||
return None
|
||||
|
||||
async def set(self, key: str, value: Any, ttl: int = 300):
|
||||
"""Set value in cache with TTL"""
|
||||
if not self.is_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
value_json = json.dumps(value, default=str)
|
||||
await self._client.setex(key, ttl, value_json)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis SET error for key {key}: {e}")
|
||||
|
||||
async def delete(self, key: str):
|
||||
"""Delete single key"""
|
||||
if not self.is_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
await self._client.delete(key)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis DELETE error for key {key}: {e}")
|
||||
|
||||
async def delete_pattern(self, pattern: str):
|
||||
"""Delete all keys matching pattern (e.g., 'cache:default:dashboard*')"""
|
||||
if not self.is_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
async for key in self._client.scan_iter(match=pattern):
|
||||
await self._client.delete(key)
|
||||
logger.debug(f"Deleted keys matching pattern: {pattern}")
|
||||
except Exception as e:
|
||||
logger.error(f"Redis DELETE_PATTERN error for {pattern}: {e}")
|
||||
|
||||
async def close(self):
|
||||
"""Close Redis connection"""
|
||||
if self._client:
|
||||
await self._client.close()
|
||||
self._client = None
|
||||
self._enabled = False
|
||||
logger.info("✅ Redis connection closed")
|
||||
|
||||
# Global singleton instance
|
||||
redis_cache = RedisCache()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Known Limitations & Future Work
|
||||
|
||||
### Current Limitations
|
||||
|
||||
1. **No Cache Warming:** Cache is cold on startup (first requests slow)
|
||||
- Future: Implement background task to pre-populate hot keys
|
||||
|
||||
2. **Manual Invalidation:** Invalidation must be coded manually in routers
|
||||
- Future: Auto-invalidation via database triggers or event system
|
||||
|
||||
3. **Single Tenant:** All cache keys use `tenant_id="default"`
|
||||
- Future: Extract tenant_id from JWT token when multi-tenant implemented
|
||||
|
||||
4. **No Cache Monitoring:** No dashboard/metrics for cache performance
|
||||
- Future: Integrate Prometheus metrics (hit/miss rate, latency, memory)
|
||||
|
||||
5. **Simple Serialization:** Uses JSON (no support for binary data, datetime needs str conversion)
|
||||
- Future: Consider msgpack for faster serialization
|
||||
|
||||
### Future Enhancements
|
||||
|
||||
- [ ] **Cache Warming:** Background task to pre-load hot keys at startup
|
||||
- [ ] **Smart Invalidation:** Event-driven invalidation based on DB changes
|
||||
- [ ] **Cache Monitoring Dashboard:** Redis metrics + hit/miss rates
|
||||
- [ ] **Cache Compression:** Compress large values (>10KB) before storing
|
||||
- [ ] **Multi-Level Cache:** L1 (in-memory LRU) + L2 (Redis) for ultra-fast access
|
||||
- [ ] **Cache Tagging:** Tag-based invalidation instead of pattern matching
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support & Questions
|
||||
|
||||
**Dacă întâmpini probleme:**
|
||||
|
||||
1. **Redis nu pornește:** Check `docker-compose logs roa-redis`
|
||||
2. **Connection failed:** Verify REDIS_URL și REDIS_PASSWORD în .env
|
||||
3. **Cache nu funcționează:** Verify REDIS_ENABLED=true și logs pentru errors
|
||||
4. **Performance nu se îmbunătățește:** Check cache hit rate în logs
|
||||
|
||||
**Contact:** Claude Code Implementation Team
|
||||
|
||||
---
|
||||
|
||||
**Planul este gata pentru implementare! Începe cu FAZA 1 și urmează pașii exact cum sunt descriși.**
|
||||
@@ -0,0 +1,577 @@
|
||||
<template>
|
||||
<div :class="selectorClass" ref="dropdownContainer">
|
||||
<div class="company-dropdown" ref="dropdown">
|
||||
<button
|
||||
class="company-trigger"
|
||||
@click="toggleDropdown"
|
||||
:aria-expanded="dropdownOpen"
|
||||
aria-label="Selectare firma"
|
||||
title="Alt+Q pentru selectare rapida"
|
||||
>
|
||||
<div class="company-info">
|
||||
<span class="company-name">{{ selectedCompanyName }}</span>
|
||||
<span v-if="showFiscalCode" class="company-code">{{ selectedCompanyCode }}</span>
|
||||
</div>
|
||||
<i
|
||||
class="pi pi-chevron-down"
|
||||
:class="{ 'rotate-180': dropdownOpen }"
|
||||
></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-show="dropdownOpen"
|
||||
class="company-dropdown-panel"
|
||||
:class="{ 'panel-open': dropdownOpen }"
|
||||
>
|
||||
<div class="dropdown-search">
|
||||
<div class="search-wrapper">
|
||||
<i class="pi pi-search search-icon"></i>
|
||||
<input
|
||||
ref="searchInput"
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
placeholder="Cauta firma..."
|
||||
class="search-input"
|
||||
@keydown="handleKeyDown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="company-list">
|
||||
<div
|
||||
v-for="(company, index) in filteredCompanies"
|
||||
:key="company.id_firma"
|
||||
class="company-item"
|
||||
:class="{
|
||||
active: company.id_firma === selectedCompany?.id_firma,
|
||||
'keyboard-highlighted': isHighlighted(index),
|
||||
}"
|
||||
@click="selectCompany(company)"
|
||||
@mouseenter="highlightedIndex = index"
|
||||
>
|
||||
<div class="company-details">
|
||||
<div class="company-main-name">{{ company.name }}</div>
|
||||
<div v-if="showFiscalCode" class="company-sub-info">
|
||||
<span class="company-cui">CUI: {{ company.fiscal_code || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<i
|
||||
v-if="company.id_firma === selectedCompany?.id_firma"
|
||||
class="pi pi-check company-selected-icon"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredCompanies.length === 0" class="no-results">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
<span>Nu s-au gasit firme</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from "vue";
|
||||
|
||||
export default {
|
||||
name: "CompanySelector",
|
||||
props: {
|
||||
// The companies store instance
|
||||
companiesStore: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
// Optional v-model binding
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
// Show fiscal code in display
|
||||
showFiscalCode: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// Variant: 'default' (white background) or 'header' (transparent for dark headers)
|
||||
variant: {
|
||||
type: String,
|
||||
default: "default",
|
||||
validator: (value) => ['default', 'header'].includes(value),
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue", "company-changed"],
|
||||
setup(props, { emit }) {
|
||||
const dropdown = ref(null);
|
||||
const dropdownContainer = ref(null);
|
||||
const searchInput = ref(null);
|
||||
const dropdownOpen = ref(false);
|
||||
const searchQuery = ref("");
|
||||
const highlightedIndex = ref(-1);
|
||||
|
||||
const selectedCompany = computed({
|
||||
get: () => props.modelValue || props.companiesStore.selectedCompany,
|
||||
set: (value) => {
|
||||
emit("update:modelValue", value);
|
||||
props.companiesStore.setSelectedCompany(value);
|
||||
},
|
||||
});
|
||||
|
||||
const selectedCompanyName = computed(() => {
|
||||
return selectedCompany.value?.name || "Selectare firma";
|
||||
});
|
||||
|
||||
const selectedCompanyCode = computed(() => {
|
||||
return selectedCompany.value?.fiscal_code
|
||||
? `CUI: ${selectedCompany.value.fiscal_code}`
|
||||
: "";
|
||||
});
|
||||
|
||||
const selectorClass = computed(() => ({
|
||||
'company-selector': true,
|
||||
'company-selector--header': props.variant === 'header'
|
||||
}));
|
||||
|
||||
const filteredCompanies = computed(() => {
|
||||
const companies = props.companiesStore.companies || [];
|
||||
if (!searchQuery.value || searchQuery.value.trim() === "") {
|
||||
return companies;
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase().trim();
|
||||
return companies.filter(
|
||||
(company) =>
|
||||
company.name?.toLowerCase().includes(query) ||
|
||||
company.fiscal_code?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const toggleDropdown = async () => {
|
||||
dropdownOpen.value = !dropdownOpen.value;
|
||||
if (dropdownOpen.value) {
|
||||
searchQuery.value = "";
|
||||
highlightedIndex.value = -1;
|
||||
await nextTick();
|
||||
searchInput.value?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
dropdownOpen.value = false;
|
||||
searchQuery.value = "";
|
||||
};
|
||||
|
||||
const selectCompany = (company) => {
|
||||
selectedCompany.value = company;
|
||||
emit("company-changed", company);
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const scrollToHighlighted = () => {
|
||||
nextTick(() => {
|
||||
const highlightedElement = document.querySelector(
|
||||
".company-item.keyboard-highlighted"
|
||||
);
|
||||
if (highlightedElement) {
|
||||
highlightedElement.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (!dropdownOpen.value || filteredCompanies.value.length === 0) return;
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
highlightedIndex.value =
|
||||
(highlightedIndex.value + 1) % filteredCompanies.value.length;
|
||||
scrollToHighlighted();
|
||||
break;
|
||||
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
if (highlightedIndex.value <= 0) {
|
||||
highlightedIndex.value = filteredCompanies.value.length - 1;
|
||||
} else {
|
||||
highlightedIndex.value--;
|
||||
}
|
||||
scrollToHighlighted();
|
||||
break;
|
||||
|
||||
case "Enter":
|
||||
event.preventDefault();
|
||||
if (
|
||||
highlightedIndex.value >= 0 &&
|
||||
highlightedIndex.value < filteredCompanies.value.length
|
||||
) {
|
||||
selectCompany(filteredCompanies.value[highlightedIndex.value]);
|
||||
}
|
||||
break;
|
||||
|
||||
case "Escape":
|
||||
closeDropdown();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const isHighlighted = (index) => {
|
||||
return index === highlightedIndex.value;
|
||||
};
|
||||
|
||||
const openWithShortcut = async () => {
|
||||
if (dropdownContainer.value) {
|
||||
dropdownContainer.value.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
if (!dropdownOpen.value) {
|
||||
dropdownOpen.value = true;
|
||||
highlightedIndex.value = -1;
|
||||
searchQuery.value = "";
|
||||
await nextTick();
|
||||
searchInput.value?.focus();
|
||||
} else {
|
||||
searchInput.value?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleGlobalKeyDown = (event) => {
|
||||
if (event.altKey && event.key === "q") {
|
||||
event.preventDefault();
|
||||
openWithShortcut();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdown.value && !dropdown.value.contains(event.target)) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
watch(searchQuery, () => {
|
||||
highlightedIndex.value = -1;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
document.addEventListener("keydown", handleGlobalKeyDown);
|
||||
|
||||
// Load companies if not already loaded
|
||||
if (props.companiesStore.companies.length === 0) {
|
||||
props.companiesStore.loadCompanies();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleGlobalKeyDown);
|
||||
});
|
||||
|
||||
return {
|
||||
dropdown,
|
||||
dropdownContainer,
|
||||
searchInput,
|
||||
dropdownOpen,
|
||||
searchQuery,
|
||||
highlightedIndex,
|
||||
selectedCompany,
|
||||
selectedCompanyName,
|
||||
selectedCompanyCode,
|
||||
selectorClass,
|
||||
filteredCompanies,
|
||||
toggleDropdown,
|
||||
closeDropdown,
|
||||
selectCompany,
|
||||
handleKeyDown,
|
||||
isHighlighted,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.company-selector {
|
||||
position: relative;
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
.company-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.company-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
background: var(--color-bg, #fff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: var(--radius-md, 6px);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.company-trigger:hover {
|
||||
border-color: var(--color-primary, #2563eb);
|
||||
background: var(--color-bg-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
.company-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.company-name {
|
||||
display: block;
|
||||
font-size: var(--text-sm, 14px);
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #111827);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.company-code {
|
||||
display: block;
|
||||
font-size: var(--text-xs, 12px);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.pi-chevron-down {
|
||||
transition: transform 0.15s ease;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
font-size: var(--text-xs, 12px);
|
||||
}
|
||||
|
||||
.rotate-180 {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.company-dropdown-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-bg, #fff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: var(--radius-md, 6px);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
max-height: 300px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.panel-open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dropdown-search {
|
||||
padding: var(--space-sm, 8px);
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: var(--space-sm, 8px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
font-size: var(--text-sm, 14px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: var(--space-sm, 8px) var(--space-sm, 8px) var(--space-sm, 8px) var(--space-xl, 32px);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
font-size: var(--text-sm, 14px);
|
||||
background: var(--color-bg, #fff);
|
||||
color: var(--color-text, #111827);
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary, #2563eb);
|
||||
}
|
||||
|
||||
.company-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.company-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
|
||||
}
|
||||
|
||||
.company-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.company-item:hover {
|
||||
background: var(--color-bg-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
.company-item.active {
|
||||
background: var(--color-primary, #2563eb);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.company-item.keyboard-highlighted {
|
||||
background: var(--color-bg-secondary, #f9fafb);
|
||||
outline: 2px solid var(--color-primary, #2563eb);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.company-item.active.keyboard-highlighted {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.company-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.company-main-name {
|
||||
font-size: var(--text-sm, 14px);
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.company-sub-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs, 4px);
|
||||
font-size: var(--text-xs, 12px);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.company-selected-icon {
|
||||
color: inherit;
|
||||
font-size: var(--text-sm, 14px);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
padding: var(--space-xl, 24px);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
font-size: var(--text-sm, 14px);
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.company-selector {
|
||||
max-width: 200px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.company-trigger {
|
||||
min-width: auto;
|
||||
max-width: 200px;
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.company-info {
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.company-name {
|
||||
font-size: var(--text-xs, 12px);
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.company-code {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.company-dropdown-panel {
|
||||
position: fixed;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
top: 60px;
|
||||
width: auto;
|
||||
max-height: 70vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Header variant - transparent background for header integration */
|
||||
.company-selector--header .company-trigger {
|
||||
background: transparent;
|
||||
border-color: var(--color-border, rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
.company-selector--header .company-trigger:hover {
|
||||
background: var(--color-bg-secondary, rgba(0, 0, 0, 0.05));
|
||||
border-color: var(--color-primary, #2563eb);
|
||||
}
|
||||
|
||||
.company-selector--header .company-name {
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.company-selector--header .company-code {
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.company-selector--header .pi-chevron-down {
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* Gradient header variant - white text for dark/gradient headers */
|
||||
.header-container--gradient .company-selector--header .company-trigger {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.header-container--gradient .company-selector--header .company-trigger:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.header-container--gradient .company-selector--header .company-name {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-container--gradient .company-selector--header .company-code {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.header-container--gradient .company-selector--header .pi-chevron-down {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* Dropdown panel keeps default styling (white background) */
|
||||
</style>
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-wrapper">
|
||||
<Card class="login-card">
|
||||
<template #header>
|
||||
<div class="login-header">
|
||||
<i :class="['pi', appIcon, 'text-primary', 'text-6xl']"></i>
|
||||
<h1 class="login-title">{{ appTitle }}</h1>
|
||||
<p class="login-subtitle">{{ appSubtitle }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<form @submit.prevent="handleLogin" class="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username" class="form-label required">Utilizator</label>
|
||||
<InputText
|
||||
id="username"
|
||||
v-model="credentials.username"
|
||||
placeholder="Introduceți numele de utilizator"
|
||||
:class="{ invalid: formErrors.username }"
|
||||
class="w-full"
|
||||
autocomplete="username"
|
||||
@blur="validateField('username')"
|
||||
/>
|
||||
<span v-if="formErrors.username" class="form-error">
|
||||
{{ formErrors.username }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password" class="form-label required">Parolă</label>
|
||||
<Password
|
||||
id="password"
|
||||
v-model="credentials.password"
|
||||
placeholder="Introduceți parola"
|
||||
:class="{ invalid: formErrors.password }"
|
||||
class="w-full"
|
||||
:feedback="false"
|
||||
toggle-mask
|
||||
autocomplete="current-password"
|
||||
@blur="validateField('password')"
|
||||
/>
|
||||
<span v-if="formErrors.password" class="form-error">
|
||||
{{ formErrors.password }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="authStore.error" class="login-error-message">
|
||||
<i class="pi pi-exclamation-triangle"></i>
|
||||
<span>{{ authStore.error }}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
label="Conectare"
|
||||
class="w-full login-button"
|
||||
:loading="authStore.isLoading"
|
||||
:disabled="!isFormValid"
|
||||
/>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="login-footer">
|
||||
<small class="text-color-secondary">
|
||||
ROA2WEB © {{ currentYear }} - Toate drepturile rezervate
|
||||
</small>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
|
||||
// Props for app-specific customization
|
||||
const props = defineProps({
|
||||
appTitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
appSubtitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
appIcon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
redirectPath: {
|
||||
type: String,
|
||||
default: "/",
|
||||
},
|
||||
authStore: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
|
||||
// Form data
|
||||
const credentials = ref({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const formErrors = ref({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
// Computed properties
|
||||
const currentYear = computed(() => new Date().getFullYear());
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return (
|
||||
credentials.value.username.trim() !== "" &&
|
||||
credentials.value.password.trim() !== "" &&
|
||||
!formErrors.value.username &&
|
||||
!formErrors.value.password
|
||||
);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const validateField = (field) => {
|
||||
switch (field) {
|
||||
case "username":
|
||||
formErrors.value.username =
|
||||
credentials.value.username.trim() === ""
|
||||
? "Numele de utilizator este obligatoriu"
|
||||
: "";
|
||||
break;
|
||||
case "password":
|
||||
formErrors.value.password =
|
||||
credentials.value.password.trim() === ""
|
||||
? "Parola este obligatorie"
|
||||
: "";
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
validateField("username");
|
||||
validateField("password");
|
||||
return isFormValid.value;
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await props.authStore.login(credentials.value);
|
||||
|
||||
if (result.success) {
|
||||
router.push(props.redirectPath);
|
||||
} else {
|
||||
toast.add({
|
||||
severity: "error",
|
||||
summary: "Eroare de conectare",
|
||||
detail: result.error || "Date de conectare incorecte",
|
||||
life: 5000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
toast.add({
|
||||
severity: "error",
|
||||
summary: "Eroare",
|
||||
detail: "A apărut o eroare neașteptată",
|
||||
life: 5000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Clear errors when user starts typing
|
||||
const clearErrors = () => {
|
||||
props.authStore.clearError();
|
||||
formErrors.value = {
|
||||
username: "",
|
||||
password: "",
|
||||
};
|
||||
};
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
// Clear any previous errors
|
||||
clearErrors();
|
||||
|
||||
// Focus on username field
|
||||
const usernameInput = document.getElementById("username");
|
||||
if (usernameInput) {
|
||||
usernameInput.focus();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearErrors();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import "../styles/login.css";
|
||||
</style>
|
||||
@@ -0,0 +1,467 @@
|
||||
<template>
|
||||
<div :class="selectorClass" ref="dropdownContainer">
|
||||
<div class="period-dropdown" ref="dropdown">
|
||||
<button
|
||||
class="period-trigger"
|
||||
@click="toggleDropdown"
|
||||
:disabled="!hasSelectedCompany"
|
||||
:aria-expanded="dropdownOpen"
|
||||
aria-label="Selectare perioada contabila"
|
||||
>
|
||||
<div class="period-info">
|
||||
<span class="period-label">Perioada:</span>
|
||||
<span class="period-name">{{ selectedPeriodDisplay }}</span>
|
||||
</div>
|
||||
<i
|
||||
class="pi pi-chevron-down"
|
||||
:class="{ 'rotate-180': dropdownOpen }"
|
||||
></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-show="dropdownOpen"
|
||||
class="period-dropdown-panel"
|
||||
:class="{ 'panel-open': dropdownOpen }"
|
||||
>
|
||||
<div class="period-list">
|
||||
<div
|
||||
v-for="(period, index) in periods"
|
||||
:key="`${period.an}-${period.luna}`"
|
||||
class="period-item"
|
||||
:class="{
|
||||
active: isSelected(period),
|
||||
'keyboard-highlighted': isHighlighted(index),
|
||||
}"
|
||||
@click="selectPeriod(period)"
|
||||
@mouseenter="highlightedIndex = index"
|
||||
>
|
||||
<div class="period-details">
|
||||
{{ period.display_name }}
|
||||
</div>
|
||||
<i v-if="isSelected(period)" class="pi pi-check period-selected-icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="periods.length === 0" class="no-results">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
<span>Nu sunt perioade disponibile</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
||||
|
||||
export default {
|
||||
name: "PeriodSelector",
|
||||
props: {
|
||||
// The accounting period store instance
|
||||
periodStore: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
// The company store instance (to check if company is selected)
|
||||
companiesStore: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
// Variant: 'default' (white background) or 'header' (transparent for dark headers)
|
||||
variant: {
|
||||
type: String,
|
||||
default: "default",
|
||||
validator: (value) => ['default', 'header'].includes(value),
|
||||
},
|
||||
},
|
||||
emits: ["period-changed"],
|
||||
setup(props, { emit }) {
|
||||
const dropdown = ref(null);
|
||||
const dropdownContainer = ref(null);
|
||||
const dropdownOpen = ref(false);
|
||||
const highlightedIndex = ref(-1);
|
||||
|
||||
const hasSelectedCompany = computed(() => {
|
||||
return !!props.companiesStore.selectedCompany;
|
||||
});
|
||||
|
||||
const periods = computed(() => {
|
||||
return props.periodStore.periods || [];
|
||||
});
|
||||
|
||||
const selectedPeriodDisplay = computed(() => {
|
||||
return props.periodStore.selectedPeriod?.display_name || "Selectare perioada";
|
||||
});
|
||||
|
||||
const selectorClass = computed(() => ({
|
||||
'period-selector': true,
|
||||
'period-selector--header': props.variant === 'header'
|
||||
}));
|
||||
|
||||
const isSelected = (period) => {
|
||||
if (!props.periodStore.selectedPeriod) return false;
|
||||
return (
|
||||
period.an === props.periodStore.selectedPeriod.an &&
|
||||
period.luna === props.periodStore.selectedPeriod.luna
|
||||
);
|
||||
};
|
||||
|
||||
const isHighlighted = (index) => {
|
||||
return index === highlightedIndex.value;
|
||||
};
|
||||
|
||||
const toggleDropdown = async () => {
|
||||
if (!hasSelectedCompany.value) return;
|
||||
dropdownOpen.value = !dropdownOpen.value;
|
||||
if (dropdownOpen.value) {
|
||||
highlightedIndex.value = -1;
|
||||
}
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
dropdownOpen.value = false;
|
||||
};
|
||||
|
||||
const selectPeriod = (period) => {
|
||||
props.periodStore.setSelectedPeriod(period);
|
||||
emit("period-changed", period);
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const scrollToHighlighted = () => {
|
||||
nextTick(() => {
|
||||
const highlightedElement = document.querySelector(
|
||||
".period-item.keyboard-highlighted"
|
||||
);
|
||||
if (highlightedElement) {
|
||||
highlightedElement.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (!dropdownOpen.value || periods.value.length === 0) return;
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
highlightedIndex.value =
|
||||
(highlightedIndex.value + 1) % periods.value.length;
|
||||
scrollToHighlighted();
|
||||
break;
|
||||
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
if (highlightedIndex.value <= 0) {
|
||||
highlightedIndex.value = periods.value.length - 1;
|
||||
} else {
|
||||
highlightedIndex.value--;
|
||||
}
|
||||
scrollToHighlighted();
|
||||
break;
|
||||
|
||||
case "Enter":
|
||||
event.preventDefault();
|
||||
if (
|
||||
highlightedIndex.value >= 0 &&
|
||||
highlightedIndex.value < periods.value.length
|
||||
) {
|
||||
selectPeriod(periods.value[highlightedIndex.value]);
|
||||
}
|
||||
break;
|
||||
|
||||
case "Escape":
|
||||
closeDropdown();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdown.value && !dropdown.value.contains(event.target)) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for company changes - load periods and reset
|
||||
watch(
|
||||
() => props.companiesStore.selectedCompany,
|
||||
async (newCompany) => {
|
||||
if (newCompany) {
|
||||
await props.periodStore.loadPeriods(newCompany.id_firma);
|
||||
} else {
|
||||
props.periodStore.reset();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
});
|
||||
|
||||
return {
|
||||
dropdown,
|
||||
dropdownContainer,
|
||||
dropdownOpen,
|
||||
highlightedIndex,
|
||||
hasSelectedCompany,
|
||||
periods,
|
||||
selectedPeriodDisplay,
|
||||
selectorClass,
|
||||
isSelected,
|
||||
isHighlighted,
|
||||
toggleDropdown,
|
||||
closeDropdown,
|
||||
selectPeriod,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.period-selector {
|
||||
position: relative;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.period-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.period-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
background: var(--color-bg, #fff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: var(--radius-md, 6px);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.period-trigger:hover:not(:disabled) {
|
||||
border-color: var(--color-primary, #2563eb);
|
||||
background: var(--color-bg-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
.period-trigger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.period-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.period-label {
|
||||
font-size: var(--text-xs, 12px);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.period-name {
|
||||
font-size: var(--text-sm, 14px);
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #111827);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pi-chevron-down {
|
||||
transition: transform 0.15s ease;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
font-size: var(--text-xs, 12px);
|
||||
}
|
||||
|
||||
.rotate-180 {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.period-dropdown-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-bg, #fff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: var(--radius-md, 6px);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
max-height: 300px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.panel-open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.period-list {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.period-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
|
||||
}
|
||||
|
||||
.period-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.period-item:hover {
|
||||
background: var(--color-bg-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
.period-item.active {
|
||||
background: var(--color-primary, #2563eb);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.period-item.keyboard-highlighted {
|
||||
background: var(--color-bg-secondary, #f9fafb);
|
||||
outline: 2px solid var(--color-primary, #2563eb);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.period-item.active.keyboard-highlighted {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.period-details {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm, 14px);
|
||||
}
|
||||
|
||||
.period-selected-icon {
|
||||
color: inherit;
|
||||
font-size: var(--text-sm, 14px);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
padding: var(--space-xl, 24px);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
font-size: var(--text-sm, 14px);
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.period-selector {
|
||||
max-width: 140px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.period-trigger {
|
||||
min-width: auto;
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.period-info {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
.period-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.period-name {
|
||||
font-size: var(--text-xs, 12px);
|
||||
}
|
||||
|
||||
.period-dropdown-panel {
|
||||
position: fixed;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
top: 60px;
|
||||
width: auto;
|
||||
max-height: 70vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Header variant - transparent background for header integration */
|
||||
.period-selector--header .period-trigger {
|
||||
background: transparent;
|
||||
border-color: var(--color-border, rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
.period-selector--header .period-trigger:hover:not(:disabled) {
|
||||
background: var(--color-bg-secondary, rgba(0, 0, 0, 0.05));
|
||||
border-color: var(--color-primary, #2563eb);
|
||||
}
|
||||
|
||||
.period-selector--header .period-trigger:disabled {
|
||||
border-color: var(--color-border, rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
.period-selector--header .period-label {
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.period-selector--header .period-name {
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.period-selector--header .pi-chevron-down {
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* Gradient header variant - white text for dark/gradient headers */
|
||||
.header-container--gradient .period-selector--header .period-trigger {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.header-container--gradient .period-selector--header .period-trigger:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.header-container--gradient .period-selector--header .period-trigger:disabled {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.header-container--gradient .period-selector--header .period-label {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.header-container--gradient .period-selector--header .period-name {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-container--gradient .period-selector--header .pi-chevron-down {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* Dropdown panel keeps default styling (white background) */
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<header class="header-container" :class="headerClass">
|
||||
<nav class="header-nav">
|
||||
<!-- Left side: Hamburger + Brand -->
|
||||
<div class="header-left">
|
||||
<button
|
||||
class="hamburger-btn"
|
||||
:class="{ active: menuOpen }"
|
||||
@click="$emit('menu-toggle')"
|
||||
aria-label="Toggle navigation menu"
|
||||
>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
</button>
|
||||
<router-link :to="brandLink" class="header-brand">
|
||||
<slot name="brand">
|
||||
<span>{{ title }}</span>
|
||||
</slot>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Right side: Period + Company + User -->
|
||||
<div class="header-actions">
|
||||
<PeriodSelector
|
||||
v-if="showPeriod && selectedCompany"
|
||||
:period-store="periodStore"
|
||||
:companies-store="companiesStore"
|
||||
variant="header"
|
||||
@period-changed="onPeriodChanged"
|
||||
/>
|
||||
<CompanySelector
|
||||
v-if="showCompany"
|
||||
:companies-store="companiesStore"
|
||||
:show-fiscal-code="true"
|
||||
variant="header"
|
||||
@company-changed="onCompanyChanged"
|
||||
/>
|
||||
<slot name="user-menu">
|
||||
<div v-if="showUser && currentUser" class="header-user" @click="$emit('user-menu-toggle')">
|
||||
<i class="pi pi-user"></i>
|
||||
<span class="desktop-only">{{ currentUser?.username || 'User' }}</span>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from "vue";
|
||||
import CompanySelector from "../CompanySelector.vue";
|
||||
import PeriodSelector from "../PeriodSelector.vue";
|
||||
|
||||
export default {
|
||||
name: "AppHeader",
|
||||
components: {
|
||||
CompanySelector,
|
||||
PeriodSelector,
|
||||
},
|
||||
props: {
|
||||
// Header title/brand text
|
||||
title: {
|
||||
type: String,
|
||||
default: "ROA2WEB",
|
||||
},
|
||||
// Router link for brand click
|
||||
brandLink: {
|
||||
type: String,
|
||||
default: "/",
|
||||
},
|
||||
// Additional CSS class for header (e.g., 'header-container--gradient')
|
||||
headerClass: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
// Is hamburger menu open?
|
||||
menuOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Companies store instance (required for selectors)
|
||||
companiesStore: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
// Period store instance (required for period selector)
|
||||
periodStore: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
// Current user object for display
|
||||
currentUser: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
// Show/hide period selector
|
||||
showPeriod: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// Show/hide company selector
|
||||
showCompany: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// Show/hide user info
|
||||
showUser: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ["menu-toggle", "company-changed", "period-changed", "user-menu-toggle"],
|
||||
setup(props, { emit }) {
|
||||
const selectedCompany = computed(() => props.companiesStore.selectedCompany);
|
||||
|
||||
const onCompanyChanged = (company) => {
|
||||
emit("company-changed", company);
|
||||
};
|
||||
|
||||
const onPeriodChanged = (period) => {
|
||||
emit("period-changed", period);
|
||||
};
|
||||
|
||||
return {
|
||||
selectedCompany,
|
||||
onCompanyChanged,
|
||||
onPeriodChanged,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Menu Overlay -->
|
||||
<div
|
||||
class="slide-menu-overlay"
|
||||
:class="{ open: isOpen }"
|
||||
@click="$emit('close')"
|
||||
></div>
|
||||
|
||||
<!-- Slide Menu -->
|
||||
<nav class="slide-menu" :class="{ open: isOpen }">
|
||||
<!-- Dynamic Menu Sections -->
|
||||
<div
|
||||
v-for="section in menuItems"
|
||||
:key="section.title"
|
||||
class="menu-section"
|
||||
>
|
||||
<h3 class="menu-title">{{ section.title }}</h3>
|
||||
<ul class="menu-list">
|
||||
<li
|
||||
v-for="item in section.items"
|
||||
:key="item.to"
|
||||
class="menu-item"
|
||||
>
|
||||
<router-link
|
||||
:to="item.to"
|
||||
class="menu-link"
|
||||
:class="{ active: isRouteActive(item.to) }"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<i :class="['menu-icon', item.icon]"></i>
|
||||
<span>{{ item.label }}</span>
|
||||
<span v-if="item.badge" class="menu-badge">{{ item.badge }}</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Profile Section (at bottom) -->
|
||||
<div class="menu-section menu-profile">
|
||||
<div class="profile-info">
|
||||
<i class="pi pi-user"></i>
|
||||
<span>{{ currentUser?.username || 'Utilizator' }}</span>
|
||||
</div>
|
||||
<ul class="menu-list">
|
||||
<slot name="profile-items"></slot>
|
||||
<li class="menu-item">
|
||||
<a href="#" class="menu-link" @click.prevent="handleLogout">
|
||||
<i class="menu-icon pi pi-sign-out"></i>
|
||||
<span>Deconectare</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
export default {
|
||||
name: "SlideMenu",
|
||||
props: {
|
||||
// Is menu open?
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Menu items configuration
|
||||
// Format: [{ title: 'Section', items: [{ to: '/path', icon: 'pi pi-icon', label: 'Label', badge: null }] }]
|
||||
menuItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
// Current user object
|
||||
currentUser: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ["close", "logout"],
|
||||
setup(props, { emit }) {
|
||||
const route = useRoute();
|
||||
|
||||
const isRouteActive = (path) => {
|
||||
return route.path === path;
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
emit("logout");
|
||||
emit("close");
|
||||
};
|
||||
|
||||
return {
|
||||
isRouteActive,
|
||||
handleLogout,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Shared Accounting Period Store Factory
|
||||
*
|
||||
* Creates a Pinia store for accounting period selection that can be used by any ROA2WEB application.
|
||||
* Each app passes its own apiService and store references.
|
||||
*
|
||||
* Usage:
|
||||
* import { createAccountingPeriodStore } from '@shared/frontend/stores/accountingPeriod';
|
||||
* import { apiService } from '../services/api';
|
||||
* import { useAuthStore } from './auth';
|
||||
* import { useCompanyStore } from './companies';
|
||||
* export const useAccountingPeriodStore = createAccountingPeriodStore(apiService, useAuthStore, useCompanyStore);
|
||||
*/
|
||||
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
/**
|
||||
* Factory function to create an accounting period store
|
||||
* @param {Object} apiService - Axios instance configured for the app's API
|
||||
* @param {Function} useAuthStore - Reference to the auth store function
|
||||
* @param {Function} useCompanyStore - Reference to the company store function
|
||||
* @returns {Function} Pinia store definition
|
||||
*/
|
||||
export function createAccountingPeriodStore(apiService, useAuthStore, useCompanyStore) {
|
||||
return defineStore("accountingPeriod", () => {
|
||||
// State
|
||||
const periods = ref([]);
|
||||
const selectedPeriod = ref(null);
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
// Getters
|
||||
const hasPeriods = computed(() => periods.value.length > 0);
|
||||
const currentPeriod = computed(() => selectedPeriod.value);
|
||||
|
||||
// Computed date range for current period (first/last day of month)
|
||||
const dateRange = computed(() => {
|
||||
if (!selectedPeriod.value) return { dateFrom: null, dateTo: null };
|
||||
|
||||
const { an, luna } = selectedPeriod.value;
|
||||
const firstDay = new Date(an, luna - 1, 1);
|
||||
const lastDay = new Date(an, luna, 0);
|
||||
|
||||
return {
|
||||
dateFrom: firstDay,
|
||||
dateTo: lastDay,
|
||||
};
|
||||
});
|
||||
|
||||
// localStorage helpers
|
||||
const getStorageKey = () => {
|
||||
const authStore = useAuthStore();
|
||||
const companyStore = useCompanyStore();
|
||||
const username = authStore.user?.username;
|
||||
const companyId = companyStore.selectedCompany?.id_firma;
|
||||
if (!username || !companyId) return null;
|
||||
return `selected_period_${username}_${companyId}`;
|
||||
};
|
||||
|
||||
const initializeSelectedPeriod = () => {
|
||||
const key = getStorageKey();
|
||||
if (!key) return null;
|
||||
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch (e) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const persistSelectedPeriod = (period) => {
|
||||
const key = getStorageKey();
|
||||
if (key && period) {
|
||||
localStorage.setItem(key, JSON.stringify(period));
|
||||
}
|
||||
};
|
||||
|
||||
// Actions
|
||||
const loadPeriods = async (companyId) => {
|
||||
if (!companyId) return { success: false };
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await apiService.get("/calendar/periods", {
|
||||
params: { company: companyId },
|
||||
});
|
||||
|
||||
periods.value = response.data.periods || [];
|
||||
|
||||
// Try to restore saved period or use most recent
|
||||
const saved = initializeSelectedPeriod();
|
||||
if (saved) {
|
||||
const exists = periods.value.find(
|
||||
(p) => p.an === saved.an && p.luna === saved.luna
|
||||
);
|
||||
if (exists) {
|
||||
selectedPeriod.value = exists;
|
||||
} else if (response.data.current_period) {
|
||||
setSelectedPeriod(response.data.current_period);
|
||||
}
|
||||
} else if (response.data.current_period) {
|
||||
setSelectedPeriod(response.data.current_period);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.detail || "Failed to load periods";
|
||||
return { success: false, error: error.value };
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setSelectedPeriod = (period) => {
|
||||
selectedPeriod.value = period;
|
||||
persistSelectedPeriod(period);
|
||||
};
|
||||
|
||||
const resetToLatest = () => {
|
||||
if (periods.value.length > 0) {
|
||||
setSelectedPeriod(periods.value[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
periods.value = [];
|
||||
selectedPeriod.value = null;
|
||||
isLoading.value = false;
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
periods,
|
||||
selectedPeriod,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Getters
|
||||
hasPeriods,
|
||||
currentPeriod,
|
||||
dateRange,
|
||||
|
||||
// Actions
|
||||
loadPeriods,
|
||||
setSelectedPeriod,
|
||||
resetToLatest,
|
||||
reset,
|
||||
};
|
||||
});
|
||||
}
|
||||
133
deploy-package-20260223-151231/shared/frontend/stores/auth.js
Normal file
133
deploy-package-20260223-151231/shared/frontend/stores/auth.js
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Shared Auth Store Factory
|
||||
*
|
||||
* Creates a Pinia auth store that can be used by any ROA2WEB application.
|
||||
* Each app passes its own apiService instance configured with the correct baseURL.
|
||||
*
|
||||
* Usage:
|
||||
* import { createAuthStore } from '@shared/frontend/stores/auth';
|
||||
* import { apiService } from '../services/api';
|
||||
* export const useAuthStore = createAuthStore(apiService);
|
||||
*/
|
||||
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
/**
|
||||
* Factory function to create an auth store with the provided API service
|
||||
* @param {Object} apiService - Axios instance configured for the app's API
|
||||
* @returns {Function} Pinia store definition
|
||||
*/
|
||||
export function createAuthStore(apiService) {
|
||||
return defineStore("auth", () => {
|
||||
// State
|
||||
const accessToken = ref(localStorage.getItem("access_token"));
|
||||
const refreshToken = ref(localStorage.getItem("refresh_token"));
|
||||
const user = ref(JSON.parse(localStorage.getItem("user") || "null"));
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
// Getters
|
||||
const isAuthenticated = computed(() => !!accessToken.value);
|
||||
const currentUser = computed(() => user.value);
|
||||
|
||||
// Actions
|
||||
const login = async (credentials) => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await apiService.post("/auth/login", {
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
});
|
||||
const { access_token, refresh_token, user: userData } = response.data;
|
||||
|
||||
accessToken.value = access_token;
|
||||
refreshToken.value = refresh_token;
|
||||
user.value = userData;
|
||||
|
||||
localStorage.setItem("access_token", access_token);
|
||||
localStorage.setItem("refresh_token", refresh_token);
|
||||
localStorage.setItem("user", JSON.stringify(userData));
|
||||
|
||||
apiService.defaults.headers.common["Authorization"] = `Bearer ${access_token}`;
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.detail || "Login failed";
|
||||
return { success: false, error: error.value };
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
accessToken.value = null;
|
||||
refreshToken.value = null;
|
||||
user.value = null;
|
||||
error.value = null;
|
||||
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("user");
|
||||
|
||||
delete apiService.defaults.headers.common["Authorization"];
|
||||
};
|
||||
|
||||
const refreshAccessToken = async () => {
|
||||
if (!refreshToken.value) {
|
||||
logout();
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiService.post("/auth/refresh", {
|
||||
refresh_token: refreshToken.value,
|
||||
});
|
||||
|
||||
const { access_token } = response.data;
|
||||
accessToken.value = access_token;
|
||||
localStorage.setItem("access_token", access_token);
|
||||
apiService.defaults.headers.common["Authorization"] = `Bearer ${access_token}`;
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Token refresh failed:", err);
|
||||
logout();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const initializeAuth = () => {
|
||||
if (accessToken.value) {
|
||||
apiService.defaults.headers.common["Authorization"] = `Bearer ${accessToken.value}`;
|
||||
}
|
||||
};
|
||||
|
||||
const clearError = () => {
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
// Initialize on store creation
|
||||
initializeAuth();
|
||||
|
||||
return {
|
||||
// State
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user,
|
||||
isLoading,
|
||||
error,
|
||||
// Getters
|
||||
isAuthenticated,
|
||||
currentUser,
|
||||
// Actions
|
||||
login,
|
||||
logout,
|
||||
refreshAccessToken,
|
||||
initializeAuth,
|
||||
clearError,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Shared Companies Store Factory
|
||||
*
|
||||
* Creates a Pinia store for company selection that can be used by any ROA2WEB application.
|
||||
* Each app passes its own apiService and auth store instances.
|
||||
*
|
||||
* Usage:
|
||||
* import { createCompaniesStore } from '@shared/frontend/stores/companies';
|
||||
* import { apiService } from '../services/api';
|
||||
* import { useAuthStore } from './auth';
|
||||
* export const useCompanyStore = createCompaniesStore(apiService, useAuthStore);
|
||||
*/
|
||||
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed, watch } from "vue";
|
||||
|
||||
/**
|
||||
* Factory function to create a companies store
|
||||
* @param {Object} apiService - Axios instance configured for the app's API
|
||||
* @param {Function} useAuthStore - Reference to the auth store function
|
||||
* @returns {Function} Pinia store definition
|
||||
*/
|
||||
export function createCompaniesStore(apiService, useAuthStore) {
|
||||
return defineStore("companies", () => {
|
||||
// State
|
||||
const companies = ref([]);
|
||||
const selectedCompany = ref(null);
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
// Initialize from localStorage - per user
|
||||
const initializeSelectedCompany = () => {
|
||||
const authStore = useAuthStore();
|
||||
const username = authStore.user?.username;
|
||||
|
||||
if (!username) {
|
||||
console.log("[Companies] No username available for initialization");
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = `selected_company_${username}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
try {
|
||||
const company = JSON.parse(saved);
|
||||
console.log(`[Companies] Loaded saved company for ${username}:`, company.name);
|
||||
return company;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse saved company", e);
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Watch for auth user changes to restore selected company
|
||||
const authStore = useAuthStore();
|
||||
watch(
|
||||
() => authStore.user,
|
||||
(newUser) => {
|
||||
if (newUser && newUser.username && !selectedCompany.value) {
|
||||
const restoredCompany = initializeSelectedCompany();
|
||||
if (restoredCompany) {
|
||||
selectedCompany.value = restoredCompany;
|
||||
console.log("[Companies] Restored selected company:", restoredCompany.name);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Getters
|
||||
const companyList = computed(() => companies.value);
|
||||
const hasCompanies = computed(() => companies.value.length > 0);
|
||||
const selectedCompanyId = computed(() => selectedCompany.value?.id_firma || null);
|
||||
|
||||
const companyListFormatted = computed(() => {
|
||||
return companies.value.map((company) => ({
|
||||
...company,
|
||||
displayName: company.fiscal_code
|
||||
? `${company.name} (${company.fiscal_code})`
|
||||
: company.name,
|
||||
}));
|
||||
});
|
||||
|
||||
// Actions
|
||||
const loadCompanies = async () => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
console.log("[Companies] Loading companies...");
|
||||
const response = await apiService.get("/companies");
|
||||
companies.value = response.data.companies || [];
|
||||
console.log("[Companies] Loaded", companies.value.length, "companies");
|
||||
|
||||
// Validate saved company is still accessible
|
||||
if (selectedCompany.value) {
|
||||
const exists = companies.value.find(
|
||||
(c) => c.id_firma === selectedCompany.value.id_firma
|
||||
);
|
||||
if (!exists) {
|
||||
console.warn("[Companies] Saved company not accessible, clearing");
|
||||
clearSelectedCompany();
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.detail || "Failed to load companies";
|
||||
console.error("Failed to load companies:", err);
|
||||
return { success: false, error: error.value };
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setSelectedCompany = (company) => {
|
||||
selectedCompany.value = company;
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const username = authStore.user?.username;
|
||||
|
||||
if (!username) {
|
||||
console.warn("[Companies] Cannot save - no username");
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `selected_company_${username}`;
|
||||
if (company) {
|
||||
localStorage.setItem(key, JSON.stringify(company));
|
||||
console.log(`[Companies] Saved company for ${username}:`, company.name);
|
||||
} else {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSelectedCompany = () => {
|
||||
selectedCompany.value = null;
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const username = authStore.user?.username;
|
||||
|
||||
if (username) {
|
||||
const key = `selected_company_${username}`;
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
};
|
||||
|
||||
const getCompanyById = (id_firma) => {
|
||||
return companies.value.find(
|
||||
(company) => company.id_firma === parseInt(id_firma)
|
||||
);
|
||||
};
|
||||
|
||||
const clearError = () => {
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
companies.value = [];
|
||||
selectedCompany.value = null;
|
||||
isLoading.value = false;
|
||||
error.value = null;
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const username = authStore.user?.username;
|
||||
if (username) {
|
||||
const key = `selected_company_${username}`;
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
companies,
|
||||
selectedCompany,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Getters
|
||||
companyList,
|
||||
companyListFormatted,
|
||||
hasCompanies,
|
||||
selectedCompanyId,
|
||||
|
||||
// Actions
|
||||
loadCompanies,
|
||||
setSelectedCompany,
|
||||
clearSelectedCompany,
|
||||
getCompanyById,
|
||||
clearError,
|
||||
reset,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/* Shared Header Styles - ROA2WEB */
|
||||
|
||||
/* Header Container */
|
||||
.header-container {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-header, 100);
|
||||
background: var(--color-bg, #fff);
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
height: var(--header-height, 60px);
|
||||
padding: 0 var(--space-lg, 24px);
|
||||
}
|
||||
|
||||
/* Gradient Header Variant */
|
||||
.header-container--gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.header-container--gradient .header-brand {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-container--gradient .hamburger-line {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
/* Header Navigation */
|
||||
.header-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header Left Section */
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 16px);
|
||||
}
|
||||
|
||||
/* Brand/Logo */
|
||||
.header-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
font-size: var(--text-lg, 18px);
|
||||
font-weight: var(--font-semibold, 600);
|
||||
color: var(--color-primary, #2563eb);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-brand:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Header Actions (right side) */
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 16px);
|
||||
}
|
||||
|
||||
/* Hamburger Button */
|
||||
.hamburger-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
z-index: 10;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.hamburger-btn:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.hamburger-line {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background-color: var(--color-primary, #2563eb);
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
/* Hamburger Animation - X state */
|
||||
.hamburger-btn.active .hamburger-line:nth-child(1) {
|
||||
transform: translateY(9px) rotate(45deg);
|
||||
}
|
||||
|
||||
.hamburger-btn.active .hamburger-line:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.hamburger-btn.active .hamburger-line:nth-child(3) {
|
||||
transform: translateY(-9px) rotate(-45deg);
|
||||
}
|
||||
|
||||
/* Header User Menu */
|
||||
.header-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
padding: var(--space-sm, 8px);
|
||||
border-radius: var(--radius-md, 6px);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.header-user:hover {
|
||||
background-color: var(--color-bg-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
/* Gradient header user menu */
|
||||
.header-container--gradient .header-user {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-container--gradient .header-user:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.header-container {
|
||||
padding: 0 var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.header-brand {
|
||||
font-size: var(--text-base, 16px);
|
||||
}
|
||||
|
||||
/* Hide text-only elements on mobile */
|
||||
.desktop-only {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header-brand span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-brand i {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/* Shared Navigation Styles - ROA2WEB */
|
||||
|
||||
/* Slide-out Menu */
|
||||
.slide-menu {
|
||||
position: fixed;
|
||||
top: var(--header-height, 60px);
|
||||
left: 0;
|
||||
width: var(--sidebar-width, 280px);
|
||||
height: calc(100vh - var(--header-height, 60px));
|
||||
background: var(--color-bg, #fff);
|
||||
border-right: 1px solid var(--color-border, #e5e7eb);
|
||||
box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1));
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
z-index: var(--z-modal, 1000);
|
||||
overflow-y: auto;
|
||||
/* Flex container for profile section at bottom */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.slide-menu.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Menu Overlay */
|
||||
.slide-menu-overlay {
|
||||
position: fixed;
|
||||
top: var(--header-height, 60px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
z-index: var(--z-modal-backdrop, 999);
|
||||
}
|
||||
|
||||
.slide-menu-overlay.open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Menu Sections */
|
||||
.menu-section {
|
||||
padding: var(--space-lg, 24px);
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.menu-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Profile section at bottom */
|
||||
.menu-section.menu-profile {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
font-size: var(--text-sm, 14px);
|
||||
font-weight: var(--font-semibold, 600);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
margin-bottom: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
.menu-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
color: var(--color-text, #111827);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
transition: all 0.15s ease;
|
||||
font-size: var(--text-sm, 14px);
|
||||
}
|
||||
|
||||
.menu-link:hover,
|
||||
.menu-link.active {
|
||||
background-color: var(--color-bg-secondary, #f9fafb);
|
||||
color: var(--color-primary, #2563eb);
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Profile Info */
|
||||
.profile-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
font-weight: var(--font-medium, 500);
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.profile-info i {
|
||||
font-size: 1.25rem;
|
||||
color: var(--color-primary, #2563eb);
|
||||
}
|
||||
|
||||
/* Badge for menu items */
|
||||
.menu-badge {
|
||||
margin-left: auto;
|
||||
background: var(--color-danger, #ef4444);
|
||||
color: white;
|
||||
font-size: var(--text-xs, 12px);
|
||||
font-weight: var(--font-semibold, 600);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-full, 9999px);
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.slide-menu {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.menu-section {
|
||||
padding: var(--space-md, 12px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.slide-menu {
|
||||
width: 100vw;
|
||||
max-width: 320px;
|
||||
}
|
||||
}
|
||||
179
deploy-package-20260223-151231/shared/frontend/styles/login.css
Normal file
179
deploy-package-20260223-151231/shared/frontend/styles/login.css
Normal file
@@ -0,0 +1,179 @@
|
||||
/* Shared Login Page Styles */
|
||||
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary-light) 0%,
|
||||
var(--color-primary) 100%
|
||||
);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-wrapper {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
padding: 2rem 2rem 1rem 2rem;
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
.login-title {
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
color: var(--primary-color);
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: 0;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
padding: 0 2rem 2rem 2rem;
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
.login-button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
background: var(--color-primary-light) !important;
|
||||
color: white !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background: var(--color-primary) !important;
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.login-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
background-color: var(--red-50);
|
||||
color: var(--red-800);
|
||||
border: 1px solid var(--red-200);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
padding: 1rem 2rem;
|
||||
background-color: var(--surface-50);
|
||||
border-top: 1px solid var(--surface-200);
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.login-container {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.login-wrapper {
|
||||
max-width: 100%;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
padding: 0 1rem 1.5rem 1rem;
|
||||
}
|
||||
|
||||
/* Ensure inputs are touch-friendly */
|
||||
.login-container .p-inputtext,
|
||||
.login-container .p-password input {
|
||||
min-height: 44px;
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-container {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
padding: 0 0.5rem 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
padding: 0.75rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation for smooth transitions */
|
||||
.login-card {
|
||||
animation: loginFadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes loginFadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
11
deploy-package-20260223-151231/shared/models/__init__.py
Normal file
11
deploy-package-20260223-151231/shared/models/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Shared Pydantic models for ROA2WEB applications."""
|
||||
|
||||
from .company import Company, CompanyListResponse
|
||||
from .calendar import CalendarPeriod, CalendarPeriodsResponse
|
||||
|
||||
__all__ = [
|
||||
"Company",
|
||||
"CompanyListResponse",
|
||||
"CalendarPeriod",
|
||||
"CalendarPeriodsResponse",
|
||||
]
|
||||
18
deploy-package-20260223-151231/shared/models/calendar.py
Normal file
18
deploy-package-20260223-151231/shared/models/calendar.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Calendar/accounting period models for ROA2WEB applications."""
|
||||
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CalendarPeriod(BaseModel):
|
||||
"""Model for an accounting period."""
|
||||
an: int # Year
|
||||
luna: int # Month (1-12)
|
||||
display_name: str # Format: "Decembrie 2025"
|
||||
|
||||
|
||||
class CalendarPeriodsResponse(BaseModel):
|
||||
"""Response model for calendar periods list."""
|
||||
periods: List[CalendarPeriod]
|
||||
current_period: Optional[CalendarPeriod] = None
|
||||
total_count: int
|
||||
19
deploy-package-20260223-151231/shared/models/company.py
Normal file
19
deploy-package-20260223-151231/shared/models/company.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Company models for ROA2WEB applications."""
|
||||
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Company(BaseModel):
|
||||
"""Model for a company/firma."""
|
||||
id_firma: int
|
||||
name: str
|
||||
schema_name: str
|
||||
fiscal_code: Optional[str] = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class CompanyListResponse(BaseModel):
|
||||
"""Response model for list of companies."""
|
||||
companies: List[Company]
|
||||
total_count: int
|
||||
21
deploy-package-20260223-151231/shared/routes/__init__.py
Normal file
21
deploy-package-20260223-151231/shared/routes/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
Shared Routes for ROA2WEB Applications
|
||||
|
||||
This module provides factory functions for creating common API routers
|
||||
that can be mounted in both the unified monolith backend.
|
||||
|
||||
Usage:
|
||||
from shared.routes import create_companies_router, create_calendar_router
|
||||
|
||||
# In main.py
|
||||
companies_router = create_companies_router(oracle_pool)
|
||||
app.include_router(companies_router, prefix="/api/companies")
|
||||
"""
|
||||
|
||||
from .companies import create_companies_router
|
||||
from .calendar import create_calendar_router
|
||||
|
||||
__all__ = [
|
||||
"create_companies_router",
|
||||
"create_calendar_router",
|
||||
]
|
||||
151
deploy-package-20260223-151231/shared/routes/calendar.py
Normal file
151
deploy-package-20260223-151231/shared/routes/calendar.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
Shared Calendar Router Factory for ROA2WEB Applications
|
||||
|
||||
Creates a FastAPI router for /api/calendar endpoints that can be used
|
||||
by both the unified monolith backend.
|
||||
|
||||
Usage:
|
||||
from shared.routes.calendar import create_calendar_router
|
||||
|
||||
calendar_router = create_calendar_router(oracle_pool, cache_decorator=cached)
|
||||
app.include_router(calendar_router, prefix="/api/calendar")
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Callable, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from auth.dependencies import get_current_user
|
||||
from auth.models import CurrentUser
|
||||
from models.calendar import CalendarPeriod, CalendarPeriodsResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Romanian month names
|
||||
MONTH_NAMES_RO = [
|
||||
"Ianuarie", "Februarie", "Martie", "Aprilie", "Mai", "Iunie",
|
||||
"Iulie", "August", "Septembrie", "Octombrie", "Noiembrie", "Decembrie"
|
||||
]
|
||||
|
||||
|
||||
def create_calendar_router(
|
||||
oracle_pool,
|
||||
cache_decorator: Optional[Callable] = None,
|
||||
tags: Optional[List[str]] = None
|
||||
) -> APIRouter:
|
||||
"""
|
||||
Factory function to create a calendar router.
|
||||
|
||||
Args:
|
||||
oracle_pool: The Oracle connection pool instance
|
||||
cache_decorator: Optional caching decorator (e.g., @cached)
|
||||
tags: OpenAPI tags for the router
|
||||
|
||||
Returns:
|
||||
Configured FastAPI router for calendar endpoints
|
||||
"""
|
||||
router = APIRouter(
|
||||
redirect_slashes=False,
|
||||
tags=tags or ["calendar"]
|
||||
)
|
||||
|
||||
# Helper to get schema for company
|
||||
async def _get_schema_for_company(company_id: int, server_id: Optional[str] = None) -> Optional[str]:
|
||||
"""Get Oracle schema for company ID.
|
||||
|
||||
Args:
|
||||
company_id: The company ID to get schema for
|
||||
server_id: The Oracle server ID (for multi-server mode)
|
||||
"""
|
||||
async with oracle_pool.get_connection(server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT SCHEMA FROM CONTAFIN_ORACLE.V_NOM_FIRME
|
||||
WHERE ID_FIRMA = :company_id
|
||||
""", {'company_id': company_id})
|
||||
result = cursor.fetchone()
|
||||
return result[0] if result else None
|
||||
|
||||
# Apply cache to schema lookup if decorator provided
|
||||
# Include server_id in cache key for multi-server mode
|
||||
if cache_decorator:
|
||||
_get_schema_for_company = cache_decorator(
|
||||
cache_type='schema',
|
||||
key_params=['company_id', 'server_id']
|
||||
)(_get_schema_for_company)
|
||||
|
||||
# Helper to get periods - can be cached
|
||||
async def _get_available_periods(company_id: int, server_id: Optional[str] = None) -> CalendarPeriodsResponse:
|
||||
"""Get available accounting periods for a company.
|
||||
|
||||
Args:
|
||||
company_id: The company ID to get periods for
|
||||
server_id: The Oracle server ID (for multi-server mode)
|
||||
"""
|
||||
schema = await _get_schema_for_company(company_id, server_id)
|
||||
if not schema:
|
||||
logger.warning(f"Schema not found for company {company_id}")
|
||||
return CalendarPeriodsResponse(periods=[], current_period=None, total_count=0)
|
||||
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(f"""
|
||||
SELECT ANUL, LUNA
|
||||
FROM {schema}.CALENDAR
|
||||
ORDER BY ANUL DESC, LUNA DESC
|
||||
""")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
periods = []
|
||||
for row in rows:
|
||||
an, luna = row[0], row[1]
|
||||
month_name = MONTH_NAMES_RO[luna - 1]
|
||||
periods.append(CalendarPeriod(
|
||||
an=an,
|
||||
luna=luna,
|
||||
display_name=f"{month_name} {an}"
|
||||
))
|
||||
|
||||
current_period = periods[0] if periods else None
|
||||
|
||||
logger.info(f"Loaded {len(periods)} periods for company {company_id}")
|
||||
|
||||
return CalendarPeriodsResponse(
|
||||
periods=periods,
|
||||
current_period=current_period,
|
||||
total_count=len(periods)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching periods for company {company_id}: {e}")
|
||||
return CalendarPeriodsResponse(periods=[], current_period=None, total_count=0)
|
||||
|
||||
# Apply cache decorator if provided
|
||||
# Include server_id in cache key for multi-server mode
|
||||
if cache_decorator:
|
||||
_get_available_periods = cache_decorator(
|
||||
cache_type='calendar_periods',
|
||||
key_params=['company_id', 'server_id']
|
||||
)(_get_available_periods)
|
||||
|
||||
@router.get("/periods", response_model=CalendarPeriodsResponse)
|
||||
async def get_calendar_periods(
|
||||
request: Request,
|
||||
company: int = Query(..., description="Company ID"),
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> CalendarPeriodsResponse:
|
||||
"""
|
||||
Get available accounting periods for a company.
|
||||
Returns periods ordered by year DESC, month DESC with Romanian month names.
|
||||
"""
|
||||
# Validate company access
|
||||
if str(company) not in current_user.companies:
|
||||
raise HTTPException(403, f"Nu aveți acces la firma {company}")
|
||||
|
||||
# Get server_id from request state (injected by auth middleware from JWT)
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
return await _get_available_periods(company, server_id)
|
||||
|
||||
return router
|
||||
185
deploy-package-20260223-151231/shared/routes/companies.py
Normal file
185
deploy-package-20260223-151231/shared/routes/companies.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Shared Companies Router Factory for ROA2WEB Applications
|
||||
|
||||
Creates a FastAPI router for /api/companies endpoints that can be used
|
||||
by both the unified monolith backend.
|
||||
|
||||
Usage:
|
||||
from shared.routes.companies import create_companies_router
|
||||
|
||||
companies_router = create_companies_router(oracle_pool, cache_decorator=cached)
|
||||
app.include_router(companies_router, prefix="/api/companies")
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Callable, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from auth.dependencies import get_current_user
|
||||
from auth.models import CurrentUser
|
||||
from models.company import Company, CompanyListResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_companies_router(
|
||||
oracle_pool,
|
||||
cache_decorator: Optional[Callable] = None,
|
||||
tags: Optional[List[str]] = None
|
||||
) -> APIRouter:
|
||||
"""
|
||||
Factory function to create a companies router.
|
||||
|
||||
Args:
|
||||
oracle_pool: The Oracle connection pool instance
|
||||
cache_decorator: Optional caching decorator (e.g., @cached)
|
||||
tags: OpenAPI tags for the router
|
||||
|
||||
Returns:
|
||||
Configured FastAPI router for company endpoints
|
||||
"""
|
||||
router = APIRouter(
|
||||
redirect_slashes=False,
|
||||
tags=tags or ["companies"]
|
||||
)
|
||||
|
||||
# Helper function to get companies - can be cached
|
||||
async def _get_user_companies_data(username: str, server_id: Optional[str] = None) -> List[Company]:
|
||||
"""
|
||||
Get list of companies for a user from Oracle.
|
||||
|
||||
Args:
|
||||
username: The username to get companies for
|
||||
server_id: The Oracle server ID (for multi-server mode)
|
||||
"""
|
||||
companies = []
|
||||
|
||||
async with oracle_pool.get_connection(server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
try:
|
||||
# Get user ID
|
||||
cursor.execute("""
|
||||
SELECT ID_UTIL, UTILIZATOR
|
||||
FROM UTILIZATORI
|
||||
WHERE UPPER(UTILIZATOR) = :username
|
||||
""", {'username': username.upper()})
|
||||
|
||||
user_row = cursor.fetchone()
|
||||
if not user_row:
|
||||
logger.warning(f"User {username} not found in UTILIZATORI")
|
||||
return []
|
||||
|
||||
user_id = user_row[0]
|
||||
|
||||
# Get companies for user (program 2 = data entry/reports)
|
||||
cursor.execute("""
|
||||
SELECT A.ID_FIRMA, A.FIRMA, A.SCHEMA, A.COD_FISCAL
|
||||
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})
|
||||
|
||||
for row in cursor.fetchall():
|
||||
companies.append(Company(
|
||||
id_firma=row[0],
|
||||
name=row[1],
|
||||
schema_name=row[2],
|
||||
fiscal_code=row[3],
|
||||
is_active=True
|
||||
))
|
||||
|
||||
logger.info(f"Found {len(companies)} companies for user {username}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching companies: {e}")
|
||||
|
||||
return companies
|
||||
|
||||
# Apply cache decorator if provided
|
||||
# Include server_id in cache key for multi-server mode
|
||||
if cache_decorator:
|
||||
_get_user_companies_data = cache_decorator(
|
||||
cache_type='companies',
|
||||
key_params=['username', 'server_id']
|
||||
)(_get_user_companies_data)
|
||||
|
||||
@router.get("", response_model=CompanyListResponse)
|
||||
@router.get("/", response_model=CompanyListResponse)
|
||||
async def get_user_companies(
|
||||
request: Request,
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""Get list of companies the user has access to."""
|
||||
try:
|
||||
# Get server_id from request state (injected by auth middleware from JWT)
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
companies = await _get_user_companies_data(current_user.username, server_id)
|
||||
|
||||
return CompanyListResponse(
|
||||
companies=companies,
|
||||
total_count=len(companies)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_user_companies: {e}")
|
||||
raise HTTPException(500, f"Eroare la obținerea listei de firme: {str(e)}")
|
||||
|
||||
@router.get("/{company_id}", response_model=Company)
|
||||
async def get_company_details(
|
||||
company_id: str,
|
||||
request: Request,
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""Get details of a specific company."""
|
||||
# Validate access
|
||||
if company_id not in current_user.companies:
|
||||
raise HTTPException(403, f"Nu aveți acces la firma {company_id}")
|
||||
|
||||
try:
|
||||
# Get server_id from request state (injected by auth middleware from JWT)
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
async with oracle_pool.get_connection(server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT ID_FIRMA, FIRMA, SCHEMA, COD_FISCAL
|
||||
FROM V_NOM_FIRME
|
||||
WHERE ID_FIRMA = :company_id
|
||||
""", {'company_id': int(company_id)})
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(404, f"Firma {company_id} nu a fost găsită")
|
||||
|
||||
return Company(
|
||||
id_firma=row[0],
|
||||
name=row[1],
|
||||
schema_name=row[2],
|
||||
fiscal_code=row[3] or "",
|
||||
is_active=True
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Eroare la obținerea detaliilor firmei: {str(e)}")
|
||||
|
||||
@router.get("/{company_id}/validate")
|
||||
async def validate_company_access(
|
||||
company_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""Validate if user has access to a company."""
|
||||
has_access = company_id in current_user.companies
|
||||
|
||||
return {
|
||||
"company_id": company_id,
|
||||
"has_access": has_access,
|
||||
"user": current_user.username,
|
||||
"message": "Acces validat" if has_access else "Acces refuzat"
|
||||
}
|
||||
|
||||
return router
|
||||
192
deploy-package-20260223-151231/shared/routes/system.py
Normal file
192
deploy-package-20260223-151231/shared/routes/system.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
System routes for server monitoring and logs.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from collections import deque
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from shared.auth.dependencies import get_current_user, CurrentUser
|
||||
|
||||
|
||||
class AuthModeResponse(BaseModel):
|
||||
"""Response for auth mode endpoint."""
|
||||
mode: str # "single-server" or "multi-server"
|
||||
supports_email_login: bool # True if email-based login is available
|
||||
|
||||
|
||||
class LogEntry(BaseModel):
|
||||
"""Single log entry."""
|
||||
line: str
|
||||
level: Optional[str] = None
|
||||
|
||||
|
||||
class LogsResponse(BaseModel):
|
||||
"""Response with log entries."""
|
||||
file: str
|
||||
lines: list[str]
|
||||
total_lines: int
|
||||
showing: int
|
||||
logs_path: Optional[str] = None
|
||||
file_exists: bool = True
|
||||
file_size_kb: Optional[float] = None
|
||||
|
||||
|
||||
def create_system_router() -> APIRouter:
|
||||
"""
|
||||
Create system router for logs and monitoring.
|
||||
"""
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/auth-mode", response_model=AuthModeResponse)
|
||||
async def get_auth_mode():
|
||||
"""
|
||||
Get the authentication mode configuration.
|
||||
|
||||
This is a PUBLIC endpoint (no auth required) that tells the frontend
|
||||
whether to use the email-based multi-server login flow or the classic
|
||||
username/password single-server flow.
|
||||
|
||||
Returns:
|
||||
- mode: "single-server" for legacy config, "multi-server" for ORACLE_SERVERS
|
||||
- supports_email_login: True only in multi-server mode with email cache
|
||||
"""
|
||||
from backend.config import settings
|
||||
|
||||
servers = settings.get_oracle_servers()
|
||||
|
||||
# Multi-server mode: ANY servers configured via ORACLE_SERVERS
|
||||
# Shows server dropdown even with 1 server (explicit server selection)
|
||||
if servers and len(servers) >= 1:
|
||||
return AuthModeResponse(
|
||||
mode="multi-server",
|
||||
supports_email_login=len(servers) > 1 # Email lookup only for 2+ servers
|
||||
)
|
||||
|
||||
# Single-server mode: legacy config (no ORACLE_SERVERS, uses env vars)
|
||||
return AuthModeResponse(
|
||||
mode="single-server",
|
||||
supports_email_login=False
|
||||
)
|
||||
|
||||
def get_logs_path() -> Path:
|
||||
"""Get logs directory path based on environment."""
|
||||
# Windows production: C:\inetpub\wwwroot\roa2web\logs
|
||||
# Development: backend/logs or ./logs
|
||||
if os.name == 'nt': # Windows
|
||||
prod_path = Path(r"C:\inetpub\wwwroot\roa2web\logs")
|
||||
if prod_path.exists():
|
||||
return prod_path
|
||||
|
||||
# Development fallback
|
||||
dev_paths = [
|
||||
Path(__file__).parent.parent.parent / "backend" / "logs",
|
||||
Path(__file__).parent.parent.parent / "logs",
|
||||
Path("./logs"),
|
||||
]
|
||||
for path in dev_paths:
|
||||
if path.exists():
|
||||
return path
|
||||
|
||||
return Path("./logs")
|
||||
|
||||
@router.get("/logs", response_model=LogsResponse)
|
||||
async def get_logs(
|
||||
file: str = Query(default="backend-stderr", description="Log file: backend-stderr or backend-stdout"),
|
||||
lines: int = Query(default=100, ge=10, le=1000, description="Number of lines to return"),
|
||||
filter: Optional[str] = Query(default=None, description="Filter text (case-insensitive)"),
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get server log entries.
|
||||
|
||||
Args:
|
||||
file: Log file name (backend-stderr or backend-stdout)
|
||||
lines: Number of lines to return (10-1000)
|
||||
filter: Optional filter text
|
||||
|
||||
Returns:
|
||||
LogsResponse with log lines
|
||||
"""
|
||||
# Validate file name to prevent path traversal
|
||||
allowed_files = ["backend-stderr", "backend-stdout"]
|
||||
if file not in allowed_files:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid file. Allowed: {allowed_files}")
|
||||
|
||||
logs_path = get_logs_path()
|
||||
log_file = logs_path / f"{file}.log"
|
||||
logs_path_str = str(logs_path.resolve())
|
||||
|
||||
if not log_file.exists():
|
||||
return LogsResponse(
|
||||
file=file,
|
||||
lines=[f"Log file not found: {log_file}"],
|
||||
total_lines=0,
|
||||
showing=0,
|
||||
logs_path=logs_path_str,
|
||||
file_exists=False,
|
||||
file_size_kb=0
|
||||
)
|
||||
|
||||
try:
|
||||
# Get file size
|
||||
file_size_kb = round(log_file.stat().st_size / 1024, 2)
|
||||
|
||||
# Read file and get last N lines efficiently
|
||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as f:
|
||||
# Use deque for efficient tail operation
|
||||
all_lines = deque(f, maxlen=lines * 2 if filter else lines)
|
||||
|
||||
# Apply filter if provided
|
||||
if filter:
|
||||
filter_lower = filter.lower()
|
||||
filtered_lines = [line.rstrip() for line in all_lines if filter_lower in line.lower()]
|
||||
result_lines = list(filtered_lines)[-lines:]
|
||||
else:
|
||||
result_lines = [line.rstrip() for line in all_lines][-lines:]
|
||||
|
||||
return LogsResponse(
|
||||
file=file,
|
||||
lines=result_lines,
|
||||
total_lines=len(result_lines),
|
||||
showing=len(result_lines),
|
||||
logs_path=logs_path_str,
|
||||
file_exists=True,
|
||||
file_size_kb=file_size_kb
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error reading logs: {str(e)}")
|
||||
|
||||
@router.get("/logs/available")
|
||||
async def get_available_logs(
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get list of available log files.
|
||||
"""
|
||||
logs_path = get_logs_path()
|
||||
|
||||
if not logs_path.exists():
|
||||
return {"logs_path": str(logs_path), "files": [], "exists": False}
|
||||
|
||||
log_files = []
|
||||
for f in logs_path.glob("*.log"):
|
||||
stat = f.stat()
|
||||
log_files.append({
|
||||
"name": f.stem,
|
||||
"size_kb": round(stat.st_size / 1024, 1),
|
||||
"modified": stat.st_mtime
|
||||
})
|
||||
|
||||
return {
|
||||
"logs_path": str(logs_path),
|
||||
"files": sorted(log_files, key=lambda x: x["name"]),
|
||||
"exists": True
|
||||
}
|
||||
|
||||
return router
|
||||
39
deploy-package-20260223-151231/shared/utils/config.py
Normal file
39
deploy-package-20260223-151231/shared/utils/config.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
Configurări comune pentru toate aplicațiile ROA2WEB
|
||||
"""
|
||||
import os
|
||||
from typing import List, Dict, Any
|
||||
from pydantic import BaseSettings
|
||||
|
||||
class SharedConfig(BaseSettings):
|
||||
"""Configurări partajate între microservicii"""
|
||||
|
||||
# Database
|
||||
oracle_user: str = os.getenv('ORACLE_USER', '')
|
||||
oracle_password: str = os.getenv('ORACLE_PASSWORD', '')
|
||||
oracle_dsn: str = os.getenv('ORACLE_DSN', '')
|
||||
|
||||
# Database Pool
|
||||
db_min_connections: int = int(os.getenv('DB_MIN_CONNECTIONS', 2))
|
||||
db_max_connections: int = int(os.getenv('DB_MAX_CONNECTIONS', 10))
|
||||
db_connection_increment: int = int(os.getenv('DB_CONNECTION_INCREMENT', 1))
|
||||
|
||||
# JWT Authentication
|
||||
jwt_secret_key: str = os.getenv('JWT_SECRET_KEY', 'your-super-secret-jwt-key-change-in-production')
|
||||
jwt_algorithm: str = os.getenv('JWT_ALGORITHM', 'HS256')
|
||||
access_token_expire_minutes: int = int(os.getenv('ACCESS_TOKEN_EXPIRE_MINUTES', 30))
|
||||
refresh_token_expire_days: int = int(os.getenv('REFRESH_TOKEN_EXPIRE_DAYS', 7))
|
||||
|
||||
# Authentication Settings
|
||||
auth_cache_ttl_minutes: int = int(os.getenv('AUTH_CACHE_TTL_MINUTES', 15))
|
||||
rate_limit_max_requests: int = int(os.getenv('RATE_LIMIT_MAX_REQUESTS', 5))
|
||||
rate_limit_time_window: int = int(os.getenv('RATE_LIMIT_TIME_WINDOW', 300))
|
||||
|
||||
# Logging
|
||||
log_level: str = os.getenv('LOG_LEVEL', 'INFO')
|
||||
|
||||
class Config:
|
||||
env_file = '.env'
|
||||
|
||||
# Instance globală
|
||||
shared_config = SharedConfig()
|
||||
27
deploy-package-20260223-151231/shared/utils/exceptions.py
Normal file
27
deploy-package-20260223-151231/shared/utils/exceptions.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Exception handlers comune pentru ROA2WEB
|
||||
"""
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
class ROAException(Exception):
|
||||
"""Exception de bază pentru aplicațiile ROA"""
|
||||
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
|
||||
self.message = message
|
||||
self.details = details or {}
|
||||
super().__init__(self.message)
|
||||
|
||||
class DatabaseException(ROAException):
|
||||
"""Excepții legate de baza de date"""
|
||||
pass
|
||||
|
||||
class AuthenticationException(ROAException):
|
||||
"""Excepții legate de autentificare"""
|
||||
pass
|
||||
|
||||
class AuthorizationException(ROAException):
|
||||
"""Excepții legate de autorizare"""
|
||||
pass
|
||||
|
||||
class ValidationException(ROAException):
|
||||
"""Excepții legate de validare date"""
|
||||
pass
|
||||
Reference in New Issue
Block a user