""" Audit Log Model - ServiceManagerWeb Modelo para bitácora de auditoría y compliance. Registra todas las acciones importantes del sistema. """ from sqlalchemy import String, Text, DateTime, ForeignKey, Index, JSON from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.dialects.postgresql import INET, JSONB from typing import Optional, Dict, Any, TYPE_CHECKING import uuid from datetime import datetime, timezone from app.core.database import Base, GUID if TYPE_CHECKING: from app.models.tenant import Tenant from app.models.user import User class AuditLog(Base): """ Bitácora de auditoría para tracking completo de acciones. Registra: - Quién hizo la acción (user_id) - Qué hizo (action) - Sobre qué recurso (resource_type + resource_id) - Cuándo lo hizo (created_at) - Desde dónde (ip_address, user_agent) - Qué cambió (old_values, new_values) """ __tablename__ = "audit_logs" # Multi-tenancy: cada registro pertenece a un tenant específico tenant_id: Mapped[uuid.UUID] = mapped_column( GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True ) # Usuario que ejecutó la acción (NULL = acción del sistema) user_id: Mapped[Optional[uuid.UUID]] = mapped_column( GUID(), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True ) # Acción realizada en formato "recurso.verbo" # Ejemplos: "user.login", "ticket.create", "ticket.assign" action: Mapped[str] = mapped_column( String(100), nullable=False, index=True ) # Tipo de recurso afectado (user, ticket, comment, category, etc.) resource_type: Mapped[str] = mapped_column( String(50), nullable=False, index=True ) # ID del recurso afectado resource_id: Mapped[Optional[uuid.UUID]] = mapped_column( GUID(), nullable=True ) # Contexto de la request: IP y navegador del usuario ip_address: Mapped[Optional[str]] = mapped_column( String(45).with_variant(INET, "postgresql"), nullable=True, ) user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # Correlation ID para rastrear todas las requests relacionadas # en una misma operación o sesión correlation_id: Mapped[Optional[uuid.UUID]] = mapped_column( GUID(), nullable=True, index=True ) # Estado del recurso antes del cambio (para auditoría de cambios) old_values: Mapped[Optional[Dict[str, Any]]] = mapped_column( JSON().with_variant(JSONB, "postgresql"), nullable=True ) # Estado del recurso después del cambio (para auditoría de cambios) new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column( JSON().with_variant(JSONB, "postgresql"), nullable=True ) # Metadata adicional con cualquier información relevante del contexto # Nota: 'metadata' está reservado en SQLAlchemy, se usa 'extra_metadata' # como nombre del atributo Python, pero la columna en BD se llama 'metadata' extra_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column( 'metadata', JSON().with_variant(JSONB, "postgresql"), nullable=True ) # Timestamp de creación con timezone # CORRECCIÓN: default=lambda: datetime.now(timezone.utc) genera un # datetime aware en UTC, compatible con DateTime(timezone=True). # El default anterior (datetime.utcnow) generaba datetimes naive, # causando que los filtros de fecha fallaran silenciosamente porque # SQLAlchemy no podía comparar aware vs naive correctamente. created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False, index=True ) # Relaciones con otros modelos tenant: Mapped["Tenant"] = relationship("Tenant", foreign_keys=[tenant_id]) user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id]) # Índices compuestos para optimizar las queries más frecuentes __table_args__ = ( # Filtrar logs por tenant y tipo de acción (uso más común) Index('idx_audit_logs_tenant_action', 'tenant_id', 'action'), # Buscar el historial de un recurso específico Index('idx_audit_logs_resource', 'resource_type', 'resource_id'), # Ver la actividad de un usuario ordenada por fecha Index('idx_audit_logs_user_created', 'user_id', 'created_at'), ) # Los audit logs son inmutables: nunca se actualizan, solo se crean # Por eso se excluye updated_at del mapper __mapper_args__ = { "exclude_properties": ["updated_at"] } def __repr__(self) -> str: return ( f"" ) @property def action_display(self) -> str: """ Formato legible de la acción para mostrar en la interfaz. Convierte el formato interno "recurso.verbo" a texto descriptivo. Ejemplo: "ticket.create" → "creó ticket" """ parts = self.action.split('.') if len(parts) == 2: resource, verb = parts verb_map = { 'create': 'creó', 'update': 'actualizó', 'delete': 'eliminó', 'login': 'inició sesión', 'logout': 'cerró sesión', 'login_failed': 'intentó iniciar sesión', 'assign': 'asignó', 'close': 'cerró', 'reopen': 'reabrió' } return f"{verb_map.get(verb, verb)} {resource}" return self.action