Files
plantillas-proyectos/backend/api/v1/modules/a76/audit_log/services/service.py

262 lines
8.4 KiB
Python

"""
Audit Log Service
"""
import logging
from datetime import datetime, timedelta, date, time
from decimal import Decimal
import uuid
import pytz
from typing import Optional, List, Dict, Any
from sqlalchemy.orm import Session
from ..models import AuditLog
from .core import AuditMapper, ReferenceGenerator
logger = logging.getLogger(__name__)
def _make_json_safe(obj: Any) -> Any:
"""Recursively convert non-JSON-serializable types to serializable equivalents."""
if obj is None:
return None
if isinstance(obj, dict):
return {k: _make_json_safe(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_make_json_safe(v) for v in obj]
if isinstance(obj, datetime):
return obj.isoformat()
if isinstance(obj, date):
return obj.isoformat()
if isinstance(obj, time):
return obj.isoformat()
if isinstance(obj, Decimal):
return float(obj)
if isinstance(obj, uuid.UUID):
return str(obj)
if isinstance(obj, bytes):
return obj.decode("utf-8", errors="replace")
if hasattr(obj, "__dict__"):
d = dict(obj.__dict__)
d.pop("_sa_instance_state", None)
return {k: _make_json_safe(v) for k, v in d.items()}
if not isinstance(obj, (int, float, str, bool)):
return str(obj)
return obj
class AuditService:
@staticmethod
def create_audit_log(
db: Session,
reference: str,
procedure: str,
movement: str,
username: str,
system: str = "SCAF",
# Extra context
table_name: Optional[str] = None,
record_id: Optional[str] = None,
operation_type: Optional[str] = None,
old_values: Optional[Dict] = None,
new_values: Optional[Dict] = None,
changed_fields: Optional[List[str]] = None,
# HTTP Context
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
endpoint: Optional[str] = None,
request_method: Optional[str] = None,
session_id: Optional[str] = None,
company_id: Optional[int] = None,
tenant_id: Optional[int] = None,
) -> AuditLog:
"""
Low-level creation of an Audit Log entry
"""
# Timezone handling: Use UTC for consistency across regions.
# Frontend will convert to user's local time.
now = datetime.now(pytz.UTC)
log = AuditLog(
reference=reference,
procedure=procedure,
movement=movement,
username=username,
date=now.date(),
time=now.time(),
timestamp=now,
system=system,
table_name=table_name,
record_id=record_id,
operation_type=operation_type,
old_values=_make_json_safe(old_values),
new_values=_make_json_safe(new_values),
changed_fields=changed_fields,
ip_address=ip_address,
user_agent=user_agent,
endpoint=endpoint,
request_method=request_method,
session_id=session_id,
company_id=company_id,
tenant_id=tenant_id
)
db.add(log)
db.flush()
return log
@staticmethod
def log_crud_operation(
db: Session,
table_name: str,
operation_type: str,
record_data: Dict[str, Any],
username: str,
record_id: Optional[str] = None,
old_values: Optional[Dict] = None,
new_values: Optional[Dict] = None,
# Context
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
company_id: Optional[int] = None,
tenant_id: Optional[int] = None
):
"""
High-level wrapper to log CRUD operations automatically mapping to Legacy format
"""
# 1. Map to Legacy Base Format
legacy_data = AuditMapper.map_to_legacy_format(
table_name=table_name,
record_id=record_id,
operation_type=operation_type,
username=username
)
# 2. Refine Reference based on specific table logic
reference = legacy_data["reference"]
if table_name == "invoice_header":
generated_ref = ReferenceGenerator.generate_invoice_reference(record_data)
# Use generated ref only if meaningful, else keep default
if generated_ref != "FUSE0-000-00":
reference = generated_ref
elif table_name == "pedimentos":
reference = ReferenceGenerator.generate_pedimento_reference(record_data)
elif table_name == "clients_and_providers":
reference = record_data.get("rfc") or reference
elif table_name == "parts":
reference = record_data.get("part_number") or reference
elif table_name in {"companies", "company"}:
reference = record_data.get("rfc") or reference
elif table_name == "classes":
reference = record_data.get("class_code") or reference
# 3. Detect Changed Fields (for Update)
changed_fields = None
if operation_type == "UPDATE" and old_values and new_values:
changed_fields = [
k for k in new_values.keys()
if old_values.get(k) != new_values.get(k)
]
# 4. Create Log
return AuditService.create_audit_log(
db=db,
reference=reference,
procedure=legacy_data["procedure"],
movement=legacy_data["movement"],
username=username,
system=legacy_data["system"],
table_name=table_name,
record_id=record_id,
operation_type=operation_type,
old_values=old_values,
new_values=new_values,
changed_fields=changed_fields,
ip_address=ip_address,
user_agent=user_agent,
company_id=company_id,
tenant_id=tenant_id
)
@staticmethod
def log_login(
db: Session,
username: str,
ip_address: str = None,
user_agent: str = None,
company_id: Optional[int] = None,
tenant_id: Optional[int] = None,
):
"""
``audit_logs`` exige ``tenant_id`` y ``company_id``. El login vía Hub no
define compañía activa; sin ambos argumentos no se inserta fila (antes fallaba NOT NULL).
"""
if company_id is None or tenant_id is None:
return None
try:
now = datetime.now(pytz.UTC)
five_seconds_ago = now - timedelta(seconds=5)
existing = db.query(AuditLog).filter(
AuditLog.username == username,
AuditLog.operation_type == "LOGIN",
AuditLog.company_id == company_id,
AuditLog.tenant_id == tenant_id,
AuditLog.timestamp >= five_seconds_ago,
).first()
if existing:
return existing
except Exception as e:
logger.warning("Login audit debounce query failed: %s", e)
return AuditService.create_audit_log(
db=db,
reference="LOGIN",
procedure="SYSTEM SCAF",
movement="SYSTEM LOGIN",
username=username,
operation_type="LOGIN",
ip_address=ip_address,
user_agent=user_agent,
company_id=company_id,
tenant_id=tenant_id,
)
@staticmethod
def log_logout(
db: Session,
username: str,
ip_address: str = None,
user_agent: str = None,
company_id: Optional[int] = None,
tenant_id: Optional[int] = None,
):
"""Misma condición que ``log_login``: sin alcance compañía/tenant no se escribe."""
if company_id is None or tenant_id is None:
return None
try:
AuditService.create_audit_log(
db=db,
reference="LOGOUT",
procedure="SYSTEM AUTH",
movement="SYSTEM LOGOUT",
username=username,
operation_type="LOGOUT",
ip_address=ip_address,
user_agent=user_agent,
company_id=company_id,
tenant_id=tenant_id,
)
except Exception as e:
logger.warning("Logout audit insert failed: %s", e)