Files
service_manager/backend/app/models/audit.py
icamarillo bd21207aae v1.15.1 - modulo de reportes implementado
- Nuevo módulo de reportes: backend/app/api/v1/endpoints/reports.py
- Schemas de reportes: backend/app/api/schemas/reports.py
- Frontend: frontend-internal/src/routes/reports/
- Mejoras al módulo de auditoría (audit.py, audit_helpers.py)
- Modelo de auditoría actualizado
- Sidebar actualizado con enlace a reportes
2026-02-26 08:51:46 -07:00

174 lines
5.8 KiB
Python

"""
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"<AuditLog("
f"action='{self.action}', "
f"resource='{self.resource_type}:{self.resource_id}'"
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