feat: Version 1.10.0 - Refactorizacion, optimizacion UI y mejoras de seguridad
- Extraccion de helpers en backend: audit_helpers.py, helpers.py - Modularizacion de schemas en archivos individuales por dominio - Reduccion de audit.py en 953 lineas (74% del archivo) - Reduccion de tickets.py en 655 lineas (60% del archivo) - Expansion de auth.py con recuperacion de contrasenia y tokens - Nuevos modulos: core/email.py, core/cache.py - Reorganizacion de scripts a backend/scripts/ - Frontend: refactorizacion de audit page con array-driven components - Frontend: correccion de 11 errores ortograficos en tickets page - Frontend: proxy Docker corregido en vite.config.js - Frontend: nuevas rutas forgot-password, reset-password, organization, profile - Nuevas utilidades TS: colorUtils.ts, dateFormats.ts - 5 nuevos archivos de tests unitarios en backend/tests/unit/ - Eliminacion de 3 scripts temporales de prueba - Documentacion tecnica: CAMBIOS_v1.10.0.md, OPTIMIZACIONES_RENDIMIENTO.md
This commit is contained in:
@@ -13,9 +13,7 @@ from app.models.tenant import Tenant
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Define OAuth2 scheme here or import from auth if needed.
|
||||
# Defining here creates a separate instance which is fine as they share config.
|
||||
# Ideally auth.py should import from here, but modifying auth.py is risky now.
|
||||
# Esquema OAuth2 centralizado — auth.py importa desde aquí
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
|
||||
|
||||
async def get_current_user(
|
||||
|
||||
@@ -1,15 +1,65 @@
|
||||
"""Schemas package initialization."""
|
||||
|
||||
from .auth import (
|
||||
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
|
||||
TwoFactorStatusResponse, TwoFactorSetupResponse,
|
||||
TwoFactorEnableRequest, TwoFactorEnableResponse, TwoFactorDisableRequest,
|
||||
ChangePasswordRequest, ForgotPasswordRequest, ResetPasswordRequest,
|
||||
)
|
||||
from .tenant import TenantBase, TenantCreate, TenantUpdate, TenantResponse
|
||||
from .user import UserCreate, UserUpdate, UserResponse
|
||||
from .category import CategoryCreate, CategoryUpdate, CategoryResponse
|
||||
from .system import SystemCreate, SystemUpdate, SystemResponse
|
||||
from .ticket import (
|
||||
TicketCreate,
|
||||
TicketUpdate,
|
||||
TicketResponse,
|
||||
TicketCloseRequest,
|
||||
CommentCreate,
|
||||
CommentResponse,
|
||||
)
|
||||
from .client_profile import (
|
||||
ClientProfileCreate,
|
||||
ClientProfileUpdate,
|
||||
ClientProfileUpdate,
|
||||
ClientProfileResponse,
|
||||
ClientProfileSummary
|
||||
ClientProfileSummary,
|
||||
)
|
||||
from .audit import * # noqa: F401,F403
|
||||
from .sla import * # noqa: F401,F403
|
||||
|
||||
__all__ = [
|
||||
# Auth
|
||||
"LoginRequest",
|
||||
"LoginResponse",
|
||||
"RefreshTokenRequest",
|
||||
"TokenResponse",
|
||||
# Tenant
|
||||
"TenantBase",
|
||||
"TenantCreate",
|
||||
"TenantUpdate",
|
||||
"TenantResponse",
|
||||
# User
|
||||
"UserCreate",
|
||||
"UserUpdate",
|
||||
"UserResponse",
|
||||
# Category
|
||||
"CategoryCreate",
|
||||
"CategoryUpdate",
|
||||
"CategoryResponse",
|
||||
# System
|
||||
"SystemCreate",
|
||||
"SystemUpdate",
|
||||
"SystemResponse",
|
||||
# Ticket
|
||||
"TicketCreate",
|
||||
"TicketUpdate",
|
||||
"TicketResponse",
|
||||
"TicketCloseRequest",
|
||||
"CommentCreate",
|
||||
"CommentResponse",
|
||||
# Client Profile
|
||||
"ClientProfileCreate",
|
||||
"ClientProfileUpdate",
|
||||
"ClientProfileResponse",
|
||||
"ClientProfileSummary"
|
||||
"ClientProfileResponse",
|
||||
"ClientProfileSummary",
|
||||
]
|
||||
96
backend/app/api/schemas/auth.py
Normal file
96
backend/app/api/schemas/auth.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Auth Schemas - ServiceManagerWeb
|
||||
|
||||
Pydantic schemas para autenticación y autorización.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Schema para solicitud de login."""
|
||||
email: EmailStr
|
||||
password: str
|
||||
tenant_slug: str
|
||||
totp_code: Optional[str] = None
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""Schema de respuesta al login exitoso."""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
user: dict
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""Schema para renovar access token usando refresh token."""
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Schema de respuesta al renovar token."""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 2FA / TOTP Schemas
|
||||
# ============================================================
|
||||
|
||||
class TwoFactorStatusResponse(BaseModel):
|
||||
"""Estado actual de 2FA del usuario autenticado."""
|
||||
enabled: bool
|
||||
|
||||
|
||||
class TwoFactorSetupResponse(BaseModel):
|
||||
"""QR URI y clave manual devueltos al iniciar el setup de 2FA."""
|
||||
secret: str
|
||||
qr_uri: str
|
||||
|
||||
|
||||
class TwoFactorEnableRequest(BaseModel):
|
||||
"""Código TOTP para confirmar y activar 2FA."""
|
||||
totp_code: str
|
||||
|
||||
|
||||
class TwoFactorEnableResponse(BaseModel):
|
||||
"""Resultado al habilitar 2FA: incluye los códigos de respaldo."""
|
||||
enabled: bool
|
||||
backup_codes: List[str]
|
||||
|
||||
|
||||
class TwoFactorDisableRequest(BaseModel):
|
||||
"""Deshabilitar 2FA verificando con TOTP o código de respaldo."""
|
||||
totp_code: Optional[str] = None
|
||||
backup_code: Optional[str] = None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Cambio de contraseña
|
||||
# ============================================================
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
"""Schema para cambio de contraseña del usuario autenticado."""
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
model_config = {"json_schema_extra": {"example": {"current_password": "old_pass", "new_password": "new_secure_pass"}}}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Recuperación de contraseña
|
||||
# ============================================================
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
"""Solicitar enlace de reseteo de contraseña por email."""
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
"""Aplicar nueva contraseña usando token de reseteo."""
|
||||
token: str
|
||||
new_password: str
|
||||
48
backend/app/api/schemas/category.py
Normal file
48
backend/app/api/schemas/category.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Category Schemas - ServiceManagerWeb
|
||||
|
||||
Pydantic schemas para gestión de categorías de tickets.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class CategoryCreate(BaseModel):
|
||||
"""Schema para crear categoría. No incluye tenant_id (se asigna automáticamente)."""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sla_response_hours: int = 24
|
||||
sla_resolution_hours: int = 72
|
||||
auto_assign_to: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
"""Schema para actualizar categoría."""
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sla_response_hours: Optional[int] = None
|
||||
sla_resolution_hours: Optional[int] = None
|
||||
auto_assign_to: Optional[uuid.UUID] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class CategoryResponse(BaseModel):
|
||||
"""Schema de respuesta con todos los campos públicos de la categoría."""
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sla_response_hours: int
|
||||
sla_resolution_hours: int
|
||||
auto_assign_to: Optional[uuid.UUID] = None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
36
backend/app/api/schemas/system.py
Normal file
36
backend/app/api/schemas/system.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
System Schemas - ServiceManagerWeb
|
||||
|
||||
Pydantic schemas para gestión de sistemas afectados en tickets.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class SystemCreate(BaseModel):
|
||||
"""Schema para crear sistema. No incluye tenant_id (se asigna automáticamente)."""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class SystemUpdate(BaseModel):
|
||||
"""Schema para actualizar sistema."""
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class SystemResponse(BaseModel):
|
||||
"""Schema de respuesta con todos los campos públicos del sistema."""
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
43
backend/app/api/schemas/tenant.py
Normal file
43
backend/app/api/schemas/tenant.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Tenant Schemas - ServiceManagerWeb
|
||||
|
||||
Pydantic schemas para gestión de tenants (organizaciones cliente).
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||
from typing import Optional
|
||||
import uuid
|
||||
|
||||
from app.models.tenant import TenantStatus
|
||||
|
||||
|
||||
class TenantBase(BaseModel):
|
||||
"""Campos base compartidos entre Create y Response."""
|
||||
name: str
|
||||
slug: str
|
||||
domain: Optional[str] = None
|
||||
contact_email: Optional[EmailStr] = None
|
||||
contact_phone: Optional[str] = None
|
||||
|
||||
|
||||
class TenantCreate(TenantBase):
|
||||
"""Schema para crear un nuevo tenant."""
|
||||
pass
|
||||
|
||||
|
||||
class TenantUpdate(BaseModel):
|
||||
"""Schema para actualizar un tenant existente."""
|
||||
name: Optional[str] = None
|
||||
slug: Optional[str] = None
|
||||
domain: Optional[str] = None
|
||||
contact_email: Optional[EmailStr] = None
|
||||
contact_phone: Optional[str] = None
|
||||
status: Optional[TenantStatus] = None
|
||||
|
||||
|
||||
class TenantResponse(TenantBase):
|
||||
"""Schema de respuesta con todos los campos públicos del tenant."""
|
||||
id: uuid.UUID
|
||||
status: TenantStatus
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
75
backend/app/api/schemas/ticket.py
Normal file
75
backend/app/api/schemas/ticket.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Ticket Schemas - ServiceManagerWeb
|
||||
|
||||
Pydantic schemas para gestión de tickets y comentarios.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TicketCreate(BaseModel):
|
||||
"""Schema para crear un ticket."""
|
||||
subject: str
|
||||
description: str
|
||||
category_id: Optional[str] = None
|
||||
affected_system_id: Optional[str] = None
|
||||
priority: str = "MEDIUM"
|
||||
|
||||
|
||||
class TicketUpdate(BaseModel):
|
||||
"""Schema para actualizar un ticket."""
|
||||
subject: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
assigned_to: Optional[str] = None
|
||||
|
||||
|
||||
class TicketResponse(BaseModel):
|
||||
"""Schema de respuesta con todos los campos públicos del ticket."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
ticket_number: str
|
||||
subject: str
|
||||
title: str
|
||||
description: str
|
||||
status: str
|
||||
priority: str
|
||||
category_id: Optional[str] = None
|
||||
affected_system_id: Optional[str] = None
|
||||
created_by: str
|
||||
assigned_to: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
sla_response_due: Optional[datetime] = None
|
||||
sla_resolution_due: Optional[datetime] = None
|
||||
first_response_at: Optional[datetime] = None
|
||||
resolved_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class TicketCloseRequest(BaseModel):
|
||||
"""Schema para cerrar un ticket con resolución opcional."""
|
||||
resolution: Optional[str] = None
|
||||
|
||||
|
||||
class CommentCreate(BaseModel):
|
||||
"""Schema para crear un comentario en un ticket."""
|
||||
content: str
|
||||
is_internal: bool = False
|
||||
|
||||
|
||||
class CommentResponse(BaseModel):
|
||||
"""Schema de respuesta de comentario."""
|
||||
id: str
|
||||
ticket_id: str
|
||||
author_id: str
|
||||
author_name: str
|
||||
content: str
|
||||
is_internal: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
59
backend/app/api/schemas/user.py
Normal file
59
backend/app/api/schemas/user.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
User Schemas - ServiceManagerWeb
|
||||
|
||||
Pydantic schemas para gestión de usuarios.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from app.models.user import UserRole
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""Schema para crear usuario. No incluye tenant_id (se asigna automáticamente)."""
|
||||
email: EmailStr
|
||||
first_name: str
|
||||
last_name: str
|
||||
role: UserRole
|
||||
password: str
|
||||
language: str = "es"
|
||||
timezone: str = "UTC"
|
||||
notifications_email: bool = True
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Schema para actualizar usuario."""
|
||||
email: Optional[EmailStr] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
role: Optional[UserRole] = None
|
||||
is_active: Optional[bool] = None
|
||||
password: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
timezone: Optional[str] = None
|
||||
notifications_email: Optional[bool] = None
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""Schema de respuesta con todos los campos públicos del usuario."""
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
email: EmailStr
|
||||
first_name: str
|
||||
last_name: str
|
||||
avatar_url: Optional[str] = None
|
||||
role: UserRole
|
||||
is_active: bool
|
||||
email_verified: bool
|
||||
last_login: Optional[datetime] = None
|
||||
language: str
|
||||
timezone: str
|
||||
notifications_email: bool
|
||||
totp_enabled: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
221
backend/app/api/v1/audit_helpers.py
Normal file
221
backend/app/api/v1/audit_helpers.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""Helper functions for audit endpoints"""
|
||||
from sqlalchemy import select, func, and_, or_, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional, Dict, List
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
def audit_log_to_dict(log: AuditLog) -> dict:
|
||||
"""Convierte AuditLog a diccionario de respuesta"""
|
||||
log_dict = {
|
||||
"id": log.id,
|
||||
"tenant_id": log.tenant_id,
|
||||
"user_id": log.user_id,
|
||||
"action": log.action,
|
||||
"resource_type": log.resource_type,
|
||||
"resource_id": log.resource_id,
|
||||
"ip_address": str(log.ip_address) if log.ip_address else None,
|
||||
"user_agent": log.user_agent,
|
||||
"correlation_id": log.correlation_id,
|
||||
"old_values": log.old_values,
|
||||
"new_values": log.new_values,
|
||||
"metadata": log.extra_metadata,
|
||||
"created_at": log.created_at,
|
||||
"action_display": log.action_display,
|
||||
"user_email": None,
|
||||
"user_name": None
|
||||
}
|
||||
|
||||
if log.user:
|
||||
log_dict["user_email"] = log.user.email
|
||||
log_dict["user_name"] = log.user.full_name
|
||||
log_dict["user_role"] = log.user.role.value if hasattr(log.user.role, 'value') else str(log.user.role)
|
||||
|
||||
return log_dict
|
||||
|
||||
|
||||
def apply_tenant_filter(query, current_user: User, current_tenant: Tenant, all_tenants: bool = False, specific_tenant_id: Optional[uuid.UUID] = None):
|
||||
"""Aplica filtro de tenant según permisos del usuario"""
|
||||
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
|
||||
|
||||
if all_tenants and can_see_all_tenants:
|
||||
return query # No filtrar por tenant
|
||||
elif specific_tenant_id and can_see_all_tenants:
|
||||
return query.where(AuditLog.tenant_id == specific_tenant_id)
|
||||
else:
|
||||
return query.where(AuditLog.tenant_id == current_tenant.id)
|
||||
|
||||
|
||||
async def get_count_stat(db: AsyncSession, tenant_id: Optional[uuid.UUID] = None,
|
||||
date_from: Optional[datetime] = None, action_filter=None) -> int:
|
||||
"""Obtiene estadística de conteo con filtros opcionales"""
|
||||
query = select(func.count()).select_from(AuditLog)
|
||||
|
||||
if tenant_id:
|
||||
query = query.where(AuditLog.tenant_id == tenant_id)
|
||||
if date_from:
|
||||
query = query.where(AuditLog.created_at >= date_from)
|
||||
if action_filter is not None:
|
||||
query = query.where(action_filter)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def get_top_items(db: AsyncSession, field, tenant_id: Optional[uuid.UUID] = None,
|
||||
limit: int = 5, join_user: bool = False) -> Dict[str, int]:
|
||||
"""Obtiene top items por campo con conteo"""
|
||||
if join_user:
|
||||
query = select(User.email, func.count(AuditLog.id).label('count')).join(User, AuditLog.user_id == User.id)
|
||||
else:
|
||||
query = select(field, func.count(AuditLog.id).label('count'))
|
||||
|
||||
if tenant_id:
|
||||
query = query.where(AuditLog.tenant_id == tenant_id)
|
||||
|
||||
if not join_user:
|
||||
query = query.group_by(field)
|
||||
else:
|
||||
query = query.group_by(User.email)
|
||||
|
||||
query = query.order_by(desc('count')).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return {row[0]: row[1] for row in result}
|
||||
|
||||
|
||||
def detect_mass_deletions(logs: List[AuditLog], now: datetime) -> List[dict]:
|
||||
"""Detecta eliminaciones masivas de logs de auditoría"""
|
||||
deletion_groups = {}
|
||||
|
||||
for log in logs:
|
||||
if not log.user:
|
||||
continue
|
||||
|
||||
key = f"{log.user.email}_{log.created_at.date()}"
|
||||
if key not in deletion_groups:
|
||||
deletion_groups[key] = {
|
||||
'user': log.user.email, 'date': log.created_at.date(),
|
||||
'count': 0, 'logs': [], 'first_seen': log.created_at, 'last_seen': log.created_at
|
||||
}
|
||||
|
||||
deletion_groups[key]['count'] += 1
|
||||
deletion_groups[key]['logs'].append(log)
|
||||
deletion_groups[key]['first_seen'] = min(deletion_groups[key]['first_seen'], log.created_at)
|
||||
deletion_groups[key]['last_seen'] = max(deletion_groups[key]['last_seen'], log.created_at)
|
||||
|
||||
incidents = []
|
||||
for key, group in deletion_groups.items():
|
||||
if group['count'] >= 3:
|
||||
severity = "critical" if group['count'] >= 10 else "high" if group['count'] >= 5 else "medium"
|
||||
status = "active" if (now - group['last_seen']).days <= 1 else "resolved"
|
||||
|
||||
incidents.append({
|
||||
"id": f"mass_del_{key.replace('_', '-')}",
|
||||
"title": f"Eliminaciones masivas - {group['user']}",
|
||||
"description": f"{group['user']} eliminó {group['count']} elementos el {group['date']}",
|
||||
"severity": severity,
|
||||
"status": status,
|
||||
"incident_type": "mass_deletion",
|
||||
"affected_user": group['user'],
|
||||
"source_ip": group['logs'][0].ip_address,
|
||||
"evidence": [f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}" for log in group['logs'][:5]],
|
||||
"metadata": {
|
||||
"total_deletions": group['count'],
|
||||
"resource_types": list(set(log.resource_type for log in group['logs'])),
|
||||
"time_span_minutes": int((group['last_seen'] - group['first_seen']).total_seconds() / 60)
|
||||
},
|
||||
"created_at": group['first_seen'],
|
||||
"updated_at": group['last_seen']
|
||||
})
|
||||
|
||||
return incidents
|
||||
|
||||
|
||||
def detect_brute_force(logs: List[AuditLog], now: datetime) -> List[dict]:
|
||||
"""Detecta ataques de fuerza bruta de logs de login fallido"""
|
||||
ip_groups = {}
|
||||
|
||||
for log in logs:
|
||||
if not log.ip_address:
|
||||
continue
|
||||
|
||||
ip = str(log.ip_address)
|
||||
if ip not in ip_groups:
|
||||
ip_groups[ip] = {'count': 0, 'logs': [], 'first_seen': log.created_at, 'last_seen': log.created_at, 'users': set()}
|
||||
|
||||
ip_groups[ip]['count'] += 1
|
||||
ip_groups[ip]['logs'].append(log)
|
||||
ip_groups[ip]['first_seen'] = min(ip_groups[ip]['first_seen'], log.created_at)
|
||||
ip_groups[ip]['last_seen'] = max(ip_groups[ip]['last_seen'], log.created_at)
|
||||
if log.user and log.user.email:
|
||||
ip_groups[ip]['users'].add(log.user.email)
|
||||
|
||||
incidents = []
|
||||
for ip, group in ip_groups.items():
|
||||
if group['count'] >= 5:
|
||||
severity = "critical" if group['count'] >= 20 else "high" if group['count'] >= 10 else "medium"
|
||||
status = "active" if (now - group['last_seen']).total_seconds() <= 86400 else "investigating"
|
||||
|
||||
incidents.append({
|
||||
"id": f"brute_force_{ip.replace('.', '-')}",
|
||||
"title": f"Posible ataque de fuerza bruta desde {ip}",
|
||||
"description": f"Se detectaron {group['count']} intentos fallidos de login desde la IP {ip}",
|
||||
"severity": severity,
|
||||
"status": status,
|
||||
"incident_type": "brute_force_attack",
|
||||
"affected_user": ', '.join(list(group['users'])[:3]) if group['users'] else None,
|
||||
"source_ip": ip,
|
||||
"evidence": [f"Login fallido - {log.user.email if log.user else 'Unknown'} - {log.created_at.strftime('%H:%M:%S')}" for log in group['logs'][:5]],
|
||||
"metadata": {
|
||||
"total_attempts": group['count'],
|
||||
"targeted_users": list(group['users']),
|
||||
"time_span_hours": int((group['last_seen'] - group['first_seen']).total_seconds() / 3600)
|
||||
},
|
||||
"created_at": group['first_seen'],
|
||||
"updated_at": group['last_seen']
|
||||
})
|
||||
|
||||
return incidents
|
||||
|
||||
|
||||
def detect_privilege_escalation(logs: List[AuditLog]) -> List[dict]:
|
||||
"""Detecta escaladas de privilegios"""
|
||||
role_hierarchy = {'CLIENT_USER': 1, 'CLIENT_ADMIN': 2, 'AGENT': 3, 'SUPPORT_MANAGER': 4, 'ADMIN': 5}
|
||||
incidents = []
|
||||
|
||||
for log in logs:
|
||||
if not log.user or not log.new_values or 'role' not in log.new_values:
|
||||
continue
|
||||
|
||||
old_role = log.old_values.get('role') if log.old_values else 'Unknown'
|
||||
new_role = log.new_values.get('role')
|
||||
old_level = role_hierarchy.get(old_role, 0)
|
||||
new_level = role_hierarchy.get(new_role, 0)
|
||||
|
||||
if new_level > old_level:
|
||||
incidents.append({
|
||||
"id": f"priv_esc_{log.id}",
|
||||
"title": f"Escalada de privilegios - {log.user.email}",
|
||||
"description": f"Usuario {log.user.email} cambió de rol {old_role} a {new_role}",
|
||||
"severity": "high" if new_role in ['ADMIN', 'SUPPORT_MANAGER'] else "medium",
|
||||
"status": "investigating",
|
||||
"incident_type": "privilege_escalation",
|
||||
"affected_user": log.user.email,
|
||||
"source_ip": log.ip_address,
|
||||
"evidence": [f"Cambio de rol: {old_role} → {new_role} - {log.created_at.strftime('%Y-%m-%d %H:%M')}"],
|
||||
"metadata": {
|
||||
"old_role": old_role,
|
||||
"new_role": new_role,
|
||||
"correlation_id": str(log.correlation_id) if log.correlation_id else None
|
||||
},
|
||||
"created_at": log.created_at,
|
||||
"updated_at": log.created_at
|
||||
})
|
||||
|
||||
return incidents
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,10 @@ Endpoints para autenticación y autorización
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status, Depends
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
import structlog
|
||||
|
||||
@@ -19,47 +18,18 @@ from app.core.config import get_settings
|
||||
from app.models.user import User
|
||||
from app.models.tenant import Tenant
|
||||
from app.services.audit_service import AuditService
|
||||
from app.api.deps import oauth2_scheme, get_current_user
|
||||
from app.api.schemas.auth import (
|
||||
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
|
||||
TwoFactorStatusResponse, TwoFactorSetupResponse,
|
||||
TwoFactorEnableRequest, TwoFactorEnableResponse, TwoFactorDisableRequest,
|
||||
ChangePasswordRequest, ForgotPasswordRequest, ResetPasswordRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = structlog.get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
# OAuth2 scheme
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
|
||||
|
||||
|
||||
# ===================================
|
||||
# PYDANTIC SCHEMAS
|
||||
# ===================================
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Schema for login request."""
|
||||
email: EmailStr
|
||||
password: str
|
||||
tenant_slug: str
|
||||
totp_code: Optional[str] = None
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""Schema for login response."""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
user: dict
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""Schema for refresh token request."""
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Schema for token response."""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
# ===================================
|
||||
# ENDPOINTS
|
||||
@@ -132,7 +102,22 @@ async def login(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Usuario inactivo"
|
||||
)
|
||||
|
||||
|
||||
# 4. Verificar 2FA si está habilitado
|
||||
if user.totp_enabled:
|
||||
if not login_data.totp_code:
|
||||
# Indicar al frontend que debe pedir el código TOTP
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Se requiere autenticación de dos factores (2FA). Ingresa tu código."
|
||||
)
|
||||
if not security.verify_totp(user.totp_secret, login_data.totp_code):
|
||||
logger.warning("Login failed - invalid 2FA code", email=login_data.email)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Código 2FA inválido o expirado"
|
||||
)
|
||||
|
||||
# Create tokens
|
||||
token_data = {
|
||||
"sub": str(user.id),
|
||||
@@ -355,4 +340,348 @@ async def get_current_user(
|
||||
# DEPENDENCIES
|
||||
# ===================================
|
||||
# Dependencies are imported from app.api.deps to avoid duplication
|
||||
# Use get_current_user and get_current_active_superuser from deps.py
|
||||
# Use get_current_user and get_current_active_superuser from deps.py
|
||||
|
||||
|
||||
# ===================================
|
||||
# 2FA / TOTP ENDPOINTS
|
||||
# ===================================
|
||||
|
||||
@router.get("/2fa/status", response_model=TwoFactorStatusResponse)
|
||||
async def get_2fa_status(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Consultar si el 2FA está habilitado para el usuario actual.
|
||||
|
||||
Returns:
|
||||
Estado de 2FA del usuario autenticado.
|
||||
"""
|
||||
return TwoFactorStatusResponse(enabled=bool(current_user.totp_enabled))
|
||||
|
||||
|
||||
@router.post("/2fa/setup", response_model=TwoFactorSetupResponse)
|
||||
async def setup_2fa(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Iniciar configuración de 2FA: genera un nuevo TOTP secret y QR URI.
|
||||
|
||||
El secret se guarda en BD pero 2FA NO se activa todavía.
|
||||
Se necesita llamar a /2fa/enable con un código válido para activarlo.
|
||||
|
||||
Returns:
|
||||
Secret y QR URI para escanear con la app autenticadora.
|
||||
"""
|
||||
new_secret = security.generate_totp_secret()
|
||||
qr_uri = security.generate_totp_uri(new_secret, current_user.email)
|
||||
|
||||
# Guardar el secret (sin habilitar aún)
|
||||
current_user.totp_secret = new_secret
|
||||
await db.commit()
|
||||
|
||||
logger.info("2FA setup initiated", user_id=str(current_user.id))
|
||||
|
||||
return TwoFactorSetupResponse(secret=new_secret, qr_uri=qr_uri)
|
||||
|
||||
|
||||
@router.post("/2fa/enable", response_model=TwoFactorEnableResponse)
|
||||
async def enable_2fa(
|
||||
data: TwoFactorEnableRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Activar 2FA verificando que el usuario escaneó correctamente el QR.
|
||||
|
||||
Requiere que /2fa/setup haya sido llamado previamente.
|
||||
|
||||
Args:
|
||||
data: Código TOTP generado por la app autenticadora.
|
||||
|
||||
Returns:
|
||||
Confirmación y lista de códigos de respaldo.
|
||||
"""
|
||||
if not current_user.totp_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Primero inicia el proceso de configuración con /2fa/setup"
|
||||
)
|
||||
|
||||
if not security.verify_totp(current_user.totp_secret, data.totp_code):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Código TOTP inválido. Verifica la hora de tu dispositivo e intenta de nuevo."
|
||||
)
|
||||
|
||||
# Activar 2FA y generar códigos de respaldo
|
||||
backup_codes = security.generate_backup_codes()
|
||||
current_user.totp_enabled = True
|
||||
current_user.backup_codes = backup_codes
|
||||
await db.commit()
|
||||
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
action="user.2fa_enabled",
|
||||
resource_type="user",
|
||||
resource_id=current_user.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info("2FA enabled", user_id=str(current_user.id))
|
||||
|
||||
return TwoFactorEnableResponse(enabled=True, backup_codes=backup_codes)
|
||||
|
||||
|
||||
@router.post("/2fa/disable")
|
||||
async def disable_2fa(
|
||||
data: TwoFactorDisableRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Deshabilitar 2FA verificando con código TOTP o código de respaldo.
|
||||
|
||||
Args:
|
||||
data: totp_code o backup_code para verificar identidad.
|
||||
|
||||
Returns:
|
||||
Mensaje de confirmación.
|
||||
"""
|
||||
if not current_user.totp_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="El 2FA no está habilitado en esta cuenta"
|
||||
)
|
||||
|
||||
# Verificar con TOTP o código de respaldo
|
||||
verified = False
|
||||
|
||||
if data.totp_code:
|
||||
verified = security.verify_totp(current_user.totp_secret, data.totp_code)
|
||||
elif data.backup_code and current_user.backup_codes:
|
||||
if data.backup_code in current_user.backup_codes:
|
||||
verified = True
|
||||
# Invalidar el código de respaldo usado
|
||||
current_user.backup_codes = [
|
||||
c for c in current_user.backup_codes if c != data.backup_code
|
||||
]
|
||||
|
||||
if not verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido."
|
||||
)
|
||||
|
||||
# Deshabilitar 2FA
|
||||
current_user.totp_enabled = False
|
||||
current_user.totp_secret = None
|
||||
current_user.backup_codes = None
|
||||
await db.commit()
|
||||
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
action="user.2fa_disabled",
|
||||
resource_type="user",
|
||||
resource_id=current_user.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info("2FA disabled", user_id=str(current_user.id))
|
||||
|
||||
return {"message": "Autenticación de dos factores deshabilitada correctamente"}
|
||||
|
||||
|
||||
@router.post("/change-password", status_code=status.HTTP_200_OK)
|
||||
async def change_password(
|
||||
data: ChangePasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Cambiar la contraseña del usuario autenticado.
|
||||
|
||||
Verifica la contraseña actual antes de actualizar.
|
||||
Requiere autenticación activa.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
# Validar longitud mínima
|
||||
if len(data.new_password) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="La nueva contraseña debe tener al menos 8 caracteres"
|
||||
)
|
||||
|
||||
# Verificar que la contraseña actual sea correcta
|
||||
if not security.verify_password(data.current_password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="La contraseña actual es incorrecta"
|
||||
)
|
||||
|
||||
# No permitir que la nueva sea igual a la actual
|
||||
if security.verify_password(data.new_password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="La nueva contraseña no puede ser igual a la actual"
|
||||
)
|
||||
|
||||
current_user.password_hash = security.hash_password(data.new_password)
|
||||
current_user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
action="user.password_changed",
|
||||
resource_type="user",
|
||||
resource_id=current_user.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info("Password changed", user_id=str(current_user.id))
|
||||
return {"message": "Contraseña actualizada correctamente"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Recuperación de contraseña (forgot / reset)
|
||||
# ============================================================
|
||||
|
||||
_RESET_TOKEN_TTL = 1800 # 30 minutos en segundos
|
||||
_RESET_KEY_PREFIX = "pwd_reset:"
|
||||
|
||||
|
||||
@router.post("/forgot-password", status_code=status.HTTP_200_OK)
|
||||
async def forgot_password(
|
||||
data: ForgotPasswordRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Solicitar reseteo de contraseña.
|
||||
|
||||
Siempre retorna 200 aunque el email no exista, para no revelar
|
||||
si una dirección está registrada en el sistema.
|
||||
"""
|
||||
import secrets
|
||||
from redis.asyncio import from_url as redis_from_url
|
||||
from app.core.email import send_email, build_password_reset_email
|
||||
|
||||
# Buscar usuario activo con ese email
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.email == data.email,
|
||||
User.is_active == True, # noqa: E712
|
||||
).limit(1)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
# Respuesta idéntica — no revelar existencia
|
||||
logger.info("Forgot password: email not found", email=data.email)
|
||||
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
|
||||
|
||||
# Generar token seguro
|
||||
token = secrets.token_urlsafe(32)
|
||||
redis_key = f"{_RESET_KEY_PREFIX}{token}"
|
||||
|
||||
# Guardar en Redis con TTL de 30 min
|
||||
redis = redis_from_url(settings.REDIS_URL, decode_responses=True)
|
||||
try:
|
||||
await redis.setex(redis_key, _RESET_TOKEN_TTL, str(user.id))
|
||||
finally:
|
||||
await redis.aclose()
|
||||
|
||||
# Construir URL y enviar email
|
||||
reset_url = f"{settings.CLIENT_FRONTEND_URL}/reset-password?token={token}"
|
||||
user_name = f"{user.first_name} {user.last_name}".strip() or user.email
|
||||
html, text = build_password_reset_email(reset_url, user_name)
|
||||
|
||||
await send_email(
|
||||
to_email=user.email,
|
||||
subject="Restablece tu contraseña — ServiceManager",
|
||||
html_content=html,
|
||||
text_content=text,
|
||||
)
|
||||
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
action="user.password_reset_requested",
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
new_values={"email": user.email},
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info("Password reset email sent", user_id=str(user.id))
|
||||
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
|
||||
|
||||
|
||||
@router.post("/reset-password", status_code=status.HTTP_200_OK)
|
||||
async def reset_password(
|
||||
data: ResetPasswordRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Aplicar nueva contraseña usando el token recibido por email.
|
||||
|
||||
El token es de un solo uso: se elimina de Redis al usarse.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from redis.asyncio import from_url as redis_from_url
|
||||
import uuid
|
||||
|
||||
if len(data.new_password) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="La contraseña debe tener al menos 8 caracteres"
|
||||
)
|
||||
|
||||
redis_key = f"{_RESET_KEY_PREFIX}{data.token}"
|
||||
redis = redis_from_url(settings.REDIS_URL, decode_responses=True)
|
||||
|
||||
try:
|
||||
user_id_str = await redis.get(redis_key)
|
||||
if not user_id_str:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="El enlace de reseteo es inválido o ya expiró. Solicita uno nuevo."
|
||||
)
|
||||
|
||||
# Eliminar token inmediatamente (un solo uso)
|
||||
await redis.delete(redis_key)
|
||||
finally:
|
||||
await redis.aclose()
|
||||
|
||||
# Buscar y actualizar usuario
|
||||
user = await db.get(User, uuid.UUID(user_id_str))
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Usuario no encontrado o inactivo"
|
||||
)
|
||||
|
||||
user.password_hash = security.hash_password(data.new_password)
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
action="user.password_reset_completed",
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info("Password reset completed", user_id=str(user.id))
|
||||
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."}
|
||||
@@ -1,58 +1,20 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.cache import cache, cache_key
|
||||
from app.models.category import Category
|
||||
from app.models.user import User
|
||||
from app.api import deps
|
||||
from app.services.audit_service import AuditService
|
||||
from app.services.audit_service import AuditService
|
||||
from app.api.schemas.category import CategoryCreate, CategoryUpdate, CategoryResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ===================================
|
||||
# PYDANTIC SCHEMAS
|
||||
# ===================================
|
||||
|
||||
class CategoryCreate(BaseModel):
|
||||
"""Schema para crear categoría - NO incluye tenant_id (se asigna automáticamente)"""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sla_response_hours: int = 24
|
||||
sla_resolution_hours: int = 72
|
||||
auto_assign_to: Optional[uuid.UUID] = None
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
"""Schema para actualizar categoría"""
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sla_response_hours: Optional[int] = None
|
||||
sla_resolution_hours: Optional[int] = None
|
||||
auto_assign_to: Optional[uuid.UUID] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class CategoryResponse(BaseModel):
|
||||
"""Schema de respuesta - incluye todos los campos"""
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID # ✅ AÑADIDO
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sla_response_hours: int
|
||||
sla_resolution_hours: int
|
||||
auto_assign_to: Optional[uuid.UUID] = None
|
||||
is_active: bool
|
||||
created_at: datetime # ✅ AÑADIDO
|
||||
updated_at: datetime # ✅ AÑADIDO
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# ENDPOINTS
|
||||
@@ -69,14 +31,41 @@ async def read_categories(
|
||||
Listar categorías del tenant del usuario actual.
|
||||
|
||||
✅ Implementa multi-tenancy: solo muestra categorías del tenant del usuario.
|
||||
✅ Optimizado con caché Redis (TTL: 10 minutos)
|
||||
"""
|
||||
# ✅ CORREGIDO: Filtrar por tenant_id
|
||||
# Intentar obtener del caché
|
||||
cache_key_str = cache_key("categories", "tenant", str(current_user.tenant_id), f"skip-{skip}", f"limit-{limit}")
|
||||
cached_categories = await cache.get(cache_key_str)
|
||||
|
||||
if cached_categories is not None:
|
||||
return [CategoryResponse(**cat) for cat in cached_categories]
|
||||
|
||||
# Si no está en caché, consultar BD
|
||||
query = select(Category).where(
|
||||
Category.tenant_id == current_user.tenant_id
|
||||
).offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
categories = result.scalars().all()
|
||||
|
||||
# Guardar en caché (10 minutos)
|
||||
categories_dict = [
|
||||
{
|
||||
"id": str(cat.id),
|
||||
"name": cat.name,
|
||||
"description": cat.description,
|
||||
"sla_response_hours": cat.sla_response_hours,
|
||||
"sla_resolution_hours": cat.sla_resolution_hours,
|
||||
"is_active": cat.is_active,
|
||||
"tenant_id": str(cat.tenant_id),
|
||||
"created_at": cat.created_at.isoformat(),
|
||||
"updated_at": cat.updated_at.isoformat()
|
||||
}
|
||||
for cat in categories
|
||||
]
|
||||
await cache.set(cache_key_str, categories_dict, ttl=600)
|
||||
|
||||
return categories
|
||||
|
||||
|
||||
@router.post("/", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
@@ -100,6 +89,9 @@ async def create_category(
|
||||
await db.commit()
|
||||
await db.refresh(db_category)
|
||||
|
||||
# Invalidar caché de categorías para este tenant
|
||||
await cache.delete_pattern(f"categories:tenant:{current_user.tenant_id}:*")
|
||||
|
||||
# Registrar creación en auditoría
|
||||
try:
|
||||
await AuditService.log(
|
||||
@@ -191,6 +183,9 @@ async def update_category(
|
||||
await db.commit()
|
||||
await db.refresh(db_category)
|
||||
|
||||
# Invalidar caché de categorías para este tenant
|
||||
await cache.delete_pattern(f"categories:tenant:{current_user.tenant_id}:*")
|
||||
|
||||
# Registrar actualización en auditoría
|
||||
try:
|
||||
new_values = {
|
||||
@@ -250,6 +245,9 @@ async def delete_category(
|
||||
db_category.is_active = False
|
||||
await db.commit()
|
||||
|
||||
# Invalidar caché de categorías para este tenant
|
||||
await cache.delete_pattern(f"categories:tenant:{current_user.tenant_id}:*")
|
||||
|
||||
# Registrar eliminación en auditoría
|
||||
try:
|
||||
await AuditService.log(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
@@ -9,37 +8,11 @@ import uuid
|
||||
from app.core.database import get_db
|
||||
from app.models.system import System
|
||||
from app.models.user import User
|
||||
from app.api import deps
|
||||
from app.api import deps
|
||||
from app.api.schemas.system import SystemCreate, SystemUpdate, SystemResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ===================================
|
||||
# PYDANTIC SCHEMAS
|
||||
# ===================================
|
||||
|
||||
class SystemCreate(BaseModel):
|
||||
"""Schema para crear sistema - NO incluye tenant_id (se asigna automáticamente)"""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
class SystemUpdate(BaseModel):
|
||||
"""Schema para actualizar sistema"""
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class SystemResponse(BaseModel):
|
||||
"""Schema de respuesta - incluye todos los campos"""
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID # ✅ AÑADIDO
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
is_active: bool
|
||||
created_at: datetime # ✅ AÑADIDO
|
||||
updated_at: datetime # ✅ AÑADIDO
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# ENDPOINTS
|
||||
|
||||
@@ -1,40 +1,16 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.tenant import Tenant, TenantStatus
|
||||
from app.api import deps
|
||||
from app.api import deps
|
||||
from app.api.schemas.tenant import TenantBase, TenantCreate, TenantUpdate, TenantResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class TenantBase(BaseModel):
|
||||
name: str
|
||||
slug: str
|
||||
domain: Optional[str] = None
|
||||
contact_email: Optional[EmailStr] = None
|
||||
contact_phone: Optional[str] = None
|
||||
|
||||
class TenantCreate(TenantBase):
|
||||
pass
|
||||
|
||||
class TenantUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
slug: Optional[str] = None
|
||||
domain: Optional[str] = None
|
||||
contact_email: Optional[EmailStr] = None
|
||||
contact_phone: Optional[str] = None
|
||||
status: Optional[TenantStatus] = None
|
||||
|
||||
class TenantResponse(TenantBase):
|
||||
id: uuid.UUID
|
||||
status: TenantStatus
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@router.get("/", response_model=List[TenantResponse])
|
||||
async def read_tenants(
|
||||
skip: int = 0,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
@@ -10,58 +9,11 @@ from app.core.database import get_db
|
||||
from app.core.security import security
|
||||
from app.models.user import User, UserRole
|
||||
from app.services.audit_service import AuditService
|
||||
from app.api import deps
|
||||
from app.api import deps
|
||||
from app.api.schemas.user import UserCreate, UserUpdate, UserResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ===================================
|
||||
# PYDANTIC SCHEMAS
|
||||
# ===================================
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""Schema para crear usuario - NO incluye tenant_id (se asigna automáticamente)"""
|
||||
email: EmailStr
|
||||
first_name: str
|
||||
last_name: str
|
||||
role: UserRole
|
||||
password: str
|
||||
language: str = "es"
|
||||
timezone: str = "UTC"
|
||||
notifications_email: bool = True
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Schema para actualizar usuario"""
|
||||
email: Optional[EmailStr] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
role: Optional[UserRole] = None
|
||||
is_active: Optional[bool] = None
|
||||
password: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
timezone: Optional[str] = None
|
||||
notifications_email: Optional[bool] = None
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""Schema de respuesta - incluye todos los campos públicos"""
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
email: EmailStr
|
||||
first_name: str
|
||||
last_name: str
|
||||
avatar_url: Optional[str] = None
|
||||
role: UserRole
|
||||
is_active: bool
|
||||
email_verified: bool
|
||||
last_login: Optional[datetime] = None
|
||||
language: str
|
||||
timezone: str
|
||||
notifications_email: bool
|
||||
totp_enabled: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# ENDPOINTS
|
||||
|
||||
114
backend/app/api/v1/helpers.py
Normal file
114
backend/app/api/v1/helpers.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Helper functions for API endpoints
|
||||
"""
|
||||
import uuid
|
||||
from typing import Any, Type
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Query
|
||||
from datetime import datetime, timedelta
|
||||
from app.models.user import User
|
||||
from app.models.ticket import Ticket
|
||||
from app.models.category import Category
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
|
||||
def validate_uuid_param(value: str, param_name: str = "ID") -> uuid.UUID:
|
||||
"""Valida y convierte string a UUID"""
|
||||
try:
|
||||
return uuid.UUID(value)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid {param_name} format"
|
||||
)
|
||||
|
||||
|
||||
def apply_client_permissions(query: Query, model: Type, current_user: User) -> Query:
|
||||
"""Aplica filtros de tenant y permisos de cliente"""
|
||||
query = query.where(model.tenant_id == current_user.tenant_id)
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||
query = query.where(model.created_by == current_user.id)
|
||||
return query
|
||||
|
||||
|
||||
def apply_enum_filter(query: Query, model_field: Any, filter_value: str,
|
||||
enum_class: Type, filter_name: str) -> Query:
|
||||
"""Aplica filtro de enum genérico"""
|
||||
if filter_value:
|
||||
try:
|
||||
enum_val = enum_class[filter_value.upper()]
|
||||
return query.where(model_field == enum_val)
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid {filter_name}: {filter_value}"
|
||||
)
|
||||
return query
|
||||
|
||||
|
||||
async def safe_audit_log(db: AsyncSession, **kwargs):
|
||||
"""Registra en auditoría sin fallar la operación principal"""
|
||||
try:
|
||||
await AuditService.log(db=db, **kwargs)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass # Silent fail para audit logs
|
||||
|
||||
|
||||
async def generate_next_ticket_number(db: AsyncSession, tenant_id: uuid.UUID) -> str:
|
||||
"""Genera el siguiente número de ticket único para el tenant"""
|
||||
result = await db.execute(
|
||||
select(Ticket.ticket_number)
|
||||
.where(Ticket.tenant_id == tenant_id)
|
||||
.order_by(Ticket.ticket_number.desc())
|
||||
.limit(1)
|
||||
)
|
||||
last_ticket_number = result.scalar_one_or_none()
|
||||
|
||||
if last_ticket_number:
|
||||
last_number = int(last_ticket_number.split('-')[1])
|
||||
next_number = last_number + 1
|
||||
else:
|
||||
next_number = 1
|
||||
|
||||
return f"TK-{next_number:06d}"
|
||||
|
||||
|
||||
def calculate_sla_deadlines(category: Category = None) -> tuple[datetime, datetime]:
|
||||
"""Calcula SLA response y resolution deadlines"""
|
||||
if not category:
|
||||
return None, None
|
||||
|
||||
now = datetime.utcnow()
|
||||
sla_response_due = now + timedelta(hours=category.sla_response_hours)
|
||||
sla_resolution_due = now + timedelta(hours=category.sla_resolution_hours)
|
||||
return sla_response_due, sla_resolution_due
|
||||
|
||||
|
||||
def ticket_to_dict(ticket: Ticket) -> dict:
|
||||
"""Convierte un modelo Ticket a diccionario de respuesta"""
|
||||
return {
|
||||
"id": str(ticket.id),
|
||||
"ticket_number": ticket.ticket_number,
|
||||
"subject": ticket.subject,
|
||||
"title": ticket.subject,
|
||||
"description": ticket.description,
|
||||
"status": ticket.status.value,
|
||||
"priority": ticket.priority.value,
|
||||
"category_id": str(ticket.category_id) if ticket.category_id else None,
|
||||
"category_name": ticket.category.name if ticket.category else None,
|
||||
"affected_system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None,
|
||||
"affected_system_name": ticket.affected_system.name if ticket.affected_system else None,
|
||||
"created_by": str(ticket.created_by),
|
||||
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
|
||||
"assigned_to_name": f"{ticket.assigned_to_user.first_name} {ticket.assigned_to_user.last_name}" if ticket.assigned_to_user else None,
|
||||
"created_at": ticket.created_at,
|
||||
"updated_at": ticket.updated_at,
|
||||
"sla_response_due": ticket.sla_response_due,
|
||||
"sla_resolution_due": ticket.sla_resolution_due,
|
||||
"first_response_at": ticket.first_response_at,
|
||||
"resolved_at": ticket.resolved_at,
|
||||
"tenant_id": str(ticket.tenant_id)
|
||||
}
|
||||
308
backend/app/core/cache.py
Normal file
308
backend/app/core/cache.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
Redis Caching Service - ServiceManagerWeb
|
||||
|
||||
Servicio centralizado para manejo de caché con Redis.
|
||||
"""
|
||||
|
||||
from redis import asyncio as aioredis
|
||||
from typing import Optional, Any, Union
|
||||
import json
|
||||
import structlog
|
||||
from functools import wraps
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class CacheService:
|
||||
"""
|
||||
Servicio de caché usando Redis.
|
||||
|
||||
Proporciona métodos para get/set/delete de datos con serialización JSON.
|
||||
Usa un singleton pattern para compartir la conexión Redis.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_redis = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
async def connect(self):
|
||||
"""Conectar a Redis si aún no está conectado."""
|
||||
if self._redis is None:
|
||||
try:
|
||||
self._redis = await aioredis.from_url(
|
||||
settings.REDIS_URL,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5
|
||||
)
|
||||
logger.info("Redis cache connected", url=settings.REDIS_URL)
|
||||
except Exception as e:
|
||||
logger.error("Failed to connect to Redis", error=str(e))
|
||||
self._redis = None
|
||||
|
||||
async def disconnect(self):
|
||||
"""Cerrar conexión Redis."""
|
||||
if self._redis:
|
||||
await self._redis.close()
|
||||
self._redis = None
|
||||
logger.info("Redis cache disconnected")
|
||||
|
||||
async def get(self, key: str) -> Optional[Any]:
|
||||
"""
|
||||
Obtener valor del cache.
|
||||
|
||||
Args:
|
||||
key: Clave del cache
|
||||
|
||||
Returns:
|
||||
Valor deserializado o None si no existe
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
logger.warning("Redis not available, skipping cache get", key=key)
|
||||
return None
|
||||
|
||||
try:
|
||||
value = await self._redis.get(key)
|
||||
if value:
|
||||
logger.debug("Cache hit", key=key)
|
||||
return json.loads(value)
|
||||
logger.debug("Cache miss", key=key)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Cache get error", key=key, error=str(e))
|
||||
return None
|
||||
|
||||
async def set(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
ttl: int = 300
|
||||
) -> bool:
|
||||
"""
|
||||
Guardar valor en cache.
|
||||
|
||||
Args:
|
||||
key: Clave del cache
|
||||
value: Valor a guardar (será serializado a JSON)
|
||||
ttl: Tiempo de vida en segundos (default: 5 minutos)
|
||||
|
||||
Returns:
|
||||
True si se guardó exitosamente
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
logger.warning("Redis not available, skipping cache set", key=key)
|
||||
return False
|
||||
|
||||
try:
|
||||
serialized = json.dumps(value, default=str)
|
||||
await self._redis.setex(key, ttl, serialized)
|
||||
logger.debug("Cache set", key=key, ttl=ttl)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Cache set error", key=key, error=str(e))
|
||||
return False
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
"""
|
||||
Eliminar clave del cache.
|
||||
|
||||
Args:
|
||||
key: Clave a eliminar
|
||||
|
||||
Returns:
|
||||
True si se eliminó exitosamente
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
logger.warning("Redis not available, skipping cache delete", key=key)
|
||||
return False
|
||||
|
||||
try:
|
||||
await self._redis.delete(key)
|
||||
logger.debug("Cache delete", key=key)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Cache delete error", key=key, error=str(e))
|
||||
return False
|
||||
|
||||
async def delete_pattern(self, pattern: str) -> int:
|
||||
"""
|
||||
Eliminar todas las claves que coincidan con el patrón.
|
||||
|
||||
Args:
|
||||
pattern: Patrón de búsqueda (ej: "tickets:tenant:*")
|
||||
|
||||
Returns:
|
||||
Número de claves eliminadas
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
logger.warning("Redis not available, skipping pattern delete", pattern=pattern)
|
||||
return 0
|
||||
|
||||
try:
|
||||
keys = []
|
||||
async for key in self._redis.scan_iter(pattern):
|
||||
keys.append(key)
|
||||
|
||||
if keys:
|
||||
deleted = await self._redis.delete(*keys)
|
||||
logger.info("Cache pattern delete", pattern=pattern, deleted=deleted)
|
||||
return deleted
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error("Cache pattern delete error", pattern=pattern, error=str(e))
|
||||
return 0
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
"""
|
||||
Verificar si una clave existe en cache.
|
||||
|
||||
Args:
|
||||
key: Clave a verificar
|
||||
|
||||
Returns:
|
||||
True si existe
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
return await self._redis.exists(key) > 0
|
||||
except Exception as e:
|
||||
logger.error("Cache exists error", key=key, error=str(e))
|
||||
return False
|
||||
|
||||
async def incr(self, key: str, amount: int = 1) -> Optional[int]:
|
||||
"""
|
||||
Incrementar un contador en cache.
|
||||
|
||||
Args:
|
||||
key: Clave del contador
|
||||
amount: Cantidad a incrementar
|
||||
|
||||
Returns:
|
||||
Nuevo valor del contador
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return await self._redis.incrby(key, amount)
|
||||
except Exception as e:
|
||||
logger.error("Cache incr error", key=key, error=str(e))
|
||||
return None
|
||||
|
||||
async def expire(self, key: str, ttl: int) -> bool:
|
||||
"""
|
||||
Establecer tiempo de expiración a una clave existente.
|
||||
|
||||
Args:
|
||||
key: Clave a expirar
|
||||
ttl: Tiempo de vida en segundos
|
||||
|
||||
Returns:
|
||||
True si se estableció exitosamente
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
return await self._redis.expire(key, ttl)
|
||||
except Exception as e:
|
||||
logger.error("Cache expire error", key=key, error=str(e))
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
cache = CacheService()
|
||||
|
||||
|
||||
def cache_key(*parts: str) -> str:
|
||||
"""
|
||||
Helper para construir claves de cache consistentes.
|
||||
|
||||
Args:
|
||||
*parts: Partes de la clave a unir
|
||||
|
||||
Returns:
|
||||
Clave formateada
|
||||
|
||||
Example:
|
||||
cache_key("tickets", "tenant", tenant_id) -> "tickets:tenant:123"
|
||||
"""
|
||||
return ":".join(str(part) for part in parts)
|
||||
|
||||
|
||||
def cached(
|
||||
key_prefix: str,
|
||||
ttl: int = 300,
|
||||
key_builder: Optional[callable] = None
|
||||
):
|
||||
"""
|
||||
Decorator para cachear resultados de funciones async.
|
||||
|
||||
Args:
|
||||
key_prefix: Prefijo para la clave de cache
|
||||
ttl: Tiempo de vida en segundos
|
||||
key_builder: Función opcional para construir la clave
|
||||
|
||||
Example:
|
||||
@cached("categories", ttl=600)
|
||||
async def get_categories(tenant_id: str):
|
||||
return await db.query(Category).all()
|
||||
"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Construir clave de cache
|
||||
if key_builder:
|
||||
key = key_builder(*args, **kwargs)
|
||||
else:
|
||||
# Default: usar nombre de función y args
|
||||
key_parts = [key_prefix, func.__name__]
|
||||
key_parts.extend(str(arg) for arg in args)
|
||||
key_parts.extend(f"{k}={v}" for k, v in sorted(kwargs.items()))
|
||||
key = cache_key(*key_parts)
|
||||
|
||||
# Intentar obtener del cache
|
||||
cached_value = await cache.get(key)
|
||||
if cached_value is not None:
|
||||
return cached_value
|
||||
|
||||
# Si no está en cache, ejecutar función
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# Guardar en cache
|
||||
await cache.set(key, result, ttl=ttl)
|
||||
|
||||
return result
|
||||
return wrapper
|
||||
return decorator
|
||||
@@ -27,7 +27,7 @@ class Settings(BaseSettings):
|
||||
DEBUG: bool = Field(default=False)
|
||||
SECRET_KEY: str = Field(...)
|
||||
API_VERSION: str = Field(default="v1")
|
||||
APP_VERSION: str = Field(default="1.6.0")
|
||||
APP_VERSION: str = Field(default="1.9.0")
|
||||
|
||||
# ===================================
|
||||
# DATABASE
|
||||
|
||||
@@ -19,10 +19,11 @@ settings = get_settings()
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_size=20, # Increased for better concurrency
|
||||
max_overflow=30, # Increased for peak loads
|
||||
pool_pre_ping=True, # Verify connections before use
|
||||
pool_recycle=3600, # Recycle connections after 1 hour
|
||||
pool_timeout=30, # Wait up to 30s for connection from pool
|
||||
)
|
||||
|
||||
# Create session factory
|
||||
|
||||
179
backend/app/core/email.py
Normal file
179
backend/app/core/email.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Email Utility - ServiceManagerWeb
|
||||
|
||||
Envío directo de emails desde el backend para flujos críticos
|
||||
(reseteo de contraseña, verificación) sin depender de Celery.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Optional
|
||||
import structlog
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _send_smtp_sync(
|
||||
to_email: str,
|
||||
subject: str,
|
||||
html_content: str,
|
||||
text_content: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Enviar email de forma síncrona vía SMTP.
|
||||
Llamar desde asyncio.to_thread para no bloquear el event loop.
|
||||
"""
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = f"{settings.DEFAULT_FROM_NAME} <{settings.DEFAULT_FROM_EMAIL}>"
|
||||
msg["To"] = to_email
|
||||
|
||||
if text_content:
|
||||
msg.attach(MIMEText(text_content, "plain", "utf-8"))
|
||||
msg.attach(MIMEText(html_content, "html", "utf-8"))
|
||||
|
||||
if settings.SMTP_USE_SSL:
|
||||
context = ssl.create_default_context()
|
||||
with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT, context=context) as server:
|
||||
if settings.SMTP_USER and settings.SMTP_PASSWORD:
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
server.sendmail(settings.DEFAULT_FROM_EMAIL, to_email, msg.as_string())
|
||||
else:
|
||||
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
|
||||
if settings.SMTP_USE_TLS:
|
||||
server.starttls()
|
||||
if settings.SMTP_USER and settings.SMTP_PASSWORD:
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
server.sendmail(settings.DEFAULT_FROM_EMAIL, to_email, msg.as_string())
|
||||
|
||||
|
||||
async def send_email(
|
||||
to_email: str,
|
||||
subject: str,
|
||||
html_content: str,
|
||||
text_content: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Enviar email de forma asíncrona.
|
||||
|
||||
Retorna True si el envío fue exitoso, False con log de error si falló.
|
||||
Se diseña para no propagar excepciones (fail-silent) en flujos de UI.
|
||||
"""
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
_send_smtp_sync,
|
||||
to_email,
|
||||
subject,
|
||||
html_content,
|
||||
text_content,
|
||||
)
|
||||
logger.info("Email sent", to=to_email, subject=subject)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("Email send failed", to=to_email, subject=subject, error=str(exc))
|
||||
return False
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Plantillas HTML inline
|
||||
# ============================================================
|
||||
|
||||
def build_password_reset_email(reset_url: str, user_name: str) -> tuple[str, str]:
|
||||
"""
|
||||
Construir HTML y texto plano para email de reseteo de contraseña.
|
||||
|
||||
Returns:
|
||||
(html_content, text_content)
|
||||
"""
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Restablecer contraseña</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f4f6f8;font-family:Arial,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f6f8;padding:40px 0;">
|
||||
<tr><td align="center">
|
||||
<table width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.08);">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background:#1d4ed8;padding:32px 40px;text-align:center;">
|
||||
<span style="color:#ffffff;font-size:22px;font-weight:700;letter-spacing:-.5px;">ServiceManager</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="padding:40px;">
|
||||
<h2 style="margin:0 0 16px;font-size:20px;color:#111827;">Restablece tu contraseña</h2>
|
||||
<p style="margin:0 0 12px;font-size:15px;color:#374151;line-height:1.6;">
|
||||
Hola <strong>{user_name}</strong>,
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:15px;color:#374151;line-height:1.6;">
|
||||
Recibimos una solicitud para restablecer la contraseña de tu cuenta.
|
||||
Haz clic en el botón de abajo para crear una nueva contraseña.
|
||||
Este enlace es válido por <strong>30 minutos</strong>.
|
||||
</p>
|
||||
|
||||
<table cellpadding="0" cellspacing="0" style="margin:0 auto 32px;">
|
||||
<tr>
|
||||
<td style="background:#1d4ed8;border-radius:6px;">
|
||||
<a href="{reset_url}"
|
||||
style="display:inline-block;padding:14px 32px;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;border-radius:6px;">
|
||||
Restablecer contraseña
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0 0 8px;font-size:13px;color:#6b7280;">
|
||||
Si no puedes hacer clic en el botón, copia y pega este enlace en tu navegador:
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:12px;color:#2563eb;word-break:break-all;">
|
||||
<a href="{reset_url}" style="color:#2563eb;">{reset_url}</a>
|
||||
</p>
|
||||
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
|
||||
|
||||
<p style="margin:0;font-size:13px;color:#9ca3af;line-height:1.6;">
|
||||
Si no solicitaste restablecer tu contraseña, puedes ignorar este mensaje.
|
||||
Tu contraseña no se modificará.<br>
|
||||
Por seguridad, este enlace expira en 30 minutos y solo puede usarse una vez.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding:20px 40px;background:#f9fafb;text-align:center;">
|
||||
<p style="margin:0;font-size:12px;color:#9ca3af;">
|
||||
© 2026 Aduanasoft — Acceso exclusivo autorizado
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
text = (
|
||||
f"Hola {user_name},\n\n"
|
||||
"Recibimos una solicitud para restablecer la contraseña de tu cuenta.\n\n"
|
||||
f"Haz clic en el siguiente enlace (válido por 30 minutos):\n{reset_url}\n\n"
|
||||
"Si no solicitaste este cambio, ignora este mensaje.\n\n"
|
||||
"— ServiceManager"
|
||||
)
|
||||
|
||||
return html, text
|
||||
@@ -30,6 +30,7 @@ from app.core.logging import setup_logging
|
||||
from app.api.v1.router import api_router
|
||||
from app.middleware.tenant import TenantMiddleware
|
||||
from app.middleware.correlation_id import CorrelationIDMiddleware
|
||||
from app.core.cache import cache
|
||||
|
||||
settings = get_settings()
|
||||
setup_logging()
|
||||
@@ -42,6 +43,10 @@ async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
logger.info("Iniciando ServiceManagerWeb Backend", version=settings.API_VERSION)
|
||||
|
||||
# Conectar a Redis cache
|
||||
await cache.connect()
|
||||
logger.info("Caché Redis conectado")
|
||||
|
||||
if settings.ENVIRONMENT == "development":
|
||||
await create_tables()
|
||||
logger.info("Tablas de base de datos verificadas")
|
||||
@@ -50,6 +55,8 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Shutdown
|
||||
logger.info("Cerrando ServiceManagerWeb Backend")
|
||||
await cache.disconnect()
|
||||
logger.info("Caché Redis desconectado")
|
||||
|
||||
|
||||
# Crear aplicación FastAPI
|
||||
|
||||
@@ -4,28 +4,41 @@ Tenant Middleware - ServiceManagerWeb
|
||||
Middleware para manejo de multi-tenancy
|
||||
"""
|
||||
|
||||
from fastapi import Request, HTTPException, status
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response, JSONResponse
|
||||
from sqlalchemy import select
|
||||
import structlog
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.core.config import get_settings
|
||||
from app.models.tenant import Tenant, TenantStatus
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class TenantMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware para extraer y validar información del tenant.
|
||||
|
||||
Extrae el tenant_id del header X-Tenant-ID y lo almacena
|
||||
en el estado de la request para uso posterior.
|
||||
|
||||
Extrae el tenant_id del header X-Tenant-ID o el slug del header
|
||||
X-Tenant-Slug, valida que exista en la base de datos y que esté
|
||||
activo, y almacena el objeto Tenant en request.state.tenant.
|
||||
"""
|
||||
|
||||
|
||||
# Rutas que no requieren tenant
|
||||
EXCLUDED_PATHS = {
|
||||
"/health",
|
||||
"/",
|
||||
"/api/v1/auth/login",
|
||||
"/v1/auth/login",
|
||||
"/api/v1/auth/refresh",
|
||||
"/v1/auth/refresh",
|
||||
"/api/v1/auth/forgot-password",
|
||||
"/v1/auth/forgot-password",
|
||||
"/api/v1/auth/reset-password",
|
||||
"/v1/auth/reset-password",
|
||||
"/docs",
|
||||
"/api/v1/docs",
|
||||
"/v1/docs",
|
||||
@@ -34,46 +47,99 @@ class TenantMiddleware(BaseHTTPMiddleware):
|
||||
"/v1/openapi.json",
|
||||
"/redoc",
|
||||
"/api/v1/redoc",
|
||||
"/v1/redoc"
|
||||
"/v1/redoc",
|
||||
}
|
||||
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
"""Process request and add tenant information."""
|
||||
|
||||
# Skip tenant validation for excluded paths
|
||||
"""Valida el tenant en cada request y lo almacena en request.state."""
|
||||
|
||||
# Inicializar state con valores por defecto
|
||||
request.state.tenant = None
|
||||
request.state.tenant_id = None
|
||||
request.state.tenant_slug = None
|
||||
|
||||
# Saltar validación en rutas excluidas
|
||||
if request.url.path in self.EXCLUDED_PATHS or request.url.path.startswith("/docs"):
|
||||
return await call_next(request)
|
||||
|
||||
# Extract tenant from header
|
||||
|
||||
# Extraer headers de tenant
|
||||
tenant_id = request.headers.get("X-Tenant-ID")
|
||||
tenant_slug = request.headers.get("X-Tenant-Slug")
|
||||
|
||||
# For now, we'll be more permissive in development
|
||||
# In production, tenant should be strictly required
|
||||
|
||||
# Si no hay headers de tenant
|
||||
if not tenant_id and not tenant_slug:
|
||||
if settings.ENVIRONMENT == "production":
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": "Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"}
|
||||
)
|
||||
# En desarrollo, continuar sin tenant con advertencia
|
||||
logger.warning(
|
||||
"Request without tenant information",
|
||||
path=request.url.path,
|
||||
method=request.method
|
||||
method=request.method,
|
||||
)
|
||||
# For now, continue without tenant for development
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_400_BAD_REQUEST,
|
||||
# detail="Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"
|
||||
# )
|
||||
|
||||
# Store tenant info in request state
|
||||
request.state.tenant_id = tenant_id
|
||||
request.state.tenant_slug = tenant_slug
|
||||
|
||||
# TODO: Validate tenant exists and is active
|
||||
# This would involve a database query which we'll implement later
|
||||
|
||||
logger.debug(
|
||||
"Tenant middleware processed",
|
||||
tenant_id=tenant_id,
|
||||
tenant_slug=tenant_slug,
|
||||
path=request.url.path
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
# Validar tenant contra la base de datos
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
if tenant_id:
|
||||
result = await session.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_id)
|
||||
)
|
||||
else:
|
||||
result = await session.execute(
|
||||
select(Tenant).where(Tenant.slug == tenant_slug)
|
||||
)
|
||||
tenant = result.scalars().first()
|
||||
|
||||
if tenant is None:
|
||||
logger.warning(
|
||||
"Tenant not found",
|
||||
tenant_id=tenant_id,
|
||||
tenant_slug=tenant_slug,
|
||||
path=request.url.path,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"detail": "Tenant not found"}
|
||||
)
|
||||
|
||||
if tenant.status != TenantStatus.ACTIVE:
|
||||
logger.warning(
|
||||
"Tenant is not active",
|
||||
tenant_id=str(tenant.id),
|
||||
tenant_slug=tenant.slug,
|
||||
status=tenant.status,
|
||||
path=request.url.path,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": f"Tenant is {tenant.status.value}"}
|
||||
)
|
||||
|
||||
# Almacenar tenant validado en el state
|
||||
request.state.tenant = tenant
|
||||
request.state.tenant_id = str(tenant.id)
|
||||
request.state.tenant_slug = tenant.slug
|
||||
|
||||
logger.debug(
|
||||
"Tenant validated",
|
||||
tenant_id=str(tenant.id),
|
||||
tenant_slug=tenant.slug,
|
||||
path=request.url.path,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Error validating tenant",
|
||||
error=str(exc),
|
||||
path=request.url.path,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"detail": "Service temporarily unavailable"}
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
Reference in New Issue
Block a user