262 lines
8.4 KiB
Python
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 == "companies":
|
|
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)
|