""" 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 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 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 (ej: "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_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 requests relacionadas correlation_id: Mapped[Optional[uuid.UUID]] = mapped_column( GUID(), nullable=True, index=True ) # Valores antes del cambio (JSON) old_values: Mapped[Optional[Dict[str, Any]]] = mapped_column( JSON().with_variant(JSONB, "postgresql"), nullable=True ) # Valores despu├®s del cambio (JSON) new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column( JSON().with_variant(JSONB, "postgresql"), nullable=True ) # Metadata adicional (cualquier info relevante) # Nota: 'metadata' est├í reservado en SQLAlchemy, usamos 'extra_metadata' extra_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column( 'metadata', # Nombre real de la columna en BD JSON().with_variant(JSONB, "postgresql"), nullable=True ) # Timestamp created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.utcnow, nullable=False, index=True ) # Relaciones tenant: Mapped["Tenant"] = relationship("Tenant", foreign_keys=[tenant_id]) user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id]) # ├ìndices compuestos para queries comunes __table_args__ = ( Index('idx_audit_logs_tenant_action', 'tenant_id', 'action'), Index('idx_audit_logs_resource', 'resource_type', 'resource_id'), Index('idx_audit_logs_user_created', 'user_id', 'created_at'), ) # Configuraci├│n del mapper: excluir updated_at porque audit logs son inmutables __mapper_args__ = { "exclude_properties": ["updated_at"] } def __repr__(self) -> str: return f"" @property def action_display(self) -> str: """Formato amigable de la acci├│n.""" 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', 'assign': 'asign├│', 'close': 'cerr├│', 'reopen': 'reabri├│' } return f"{verb_map.get(verb, verb)} {resource}" return self.action