feat: Implement a comprehensive audit log system with a dedicated dashboard page and backend API.
This commit is contained in:
148
backend/api/v1/modules/a76/audit_log/services/core.py
Normal file
148
backend/api/v1/modules/a76/audit_log/services/core.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
Audit Log Core Logic: Reference Generation and Mapping
|
||||
"""
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
class ReferenceGenerator:
|
||||
"""
|
||||
Generates legacy-style references (e.g., FUSE0-040-10)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def generate_invoice_reference(invoice_data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Format: {SYSTEM}-{CUSTOMS}-{YEAR}
|
||||
Example: FUSE0-040-10
|
||||
"""
|
||||
# Default values
|
||||
system = "FUSE0"
|
||||
customs = "000"
|
||||
year = "00"
|
||||
|
||||
# Try to extract system (invoice_type usually holds this key)
|
||||
if invoice_data.get("invoice_type"):
|
||||
system = str(invoice_data["invoice_type"])
|
||||
|
||||
# Try to extract customs (need to look into nested compliance_mx if available, or just use default)
|
||||
# Since this receives a dictionary from the mapper, we might not have deep nested relations resolved
|
||||
# We'll try to do our best with available data
|
||||
|
||||
# Try to get year from invoice_date
|
||||
if invoice_data.get("invoice_date"):
|
||||
try:
|
||||
# invoice_date can be a date object or string
|
||||
d = invoice_data["invoice_date"]
|
||||
if hasattr(d, "year"):
|
||||
y = d.year
|
||||
else:
|
||||
# Assume string YYYY-MM-DD
|
||||
y = int(str(d)[:4])
|
||||
year = str(y)[-2:]
|
||||
except:
|
||||
pass
|
||||
|
||||
return f"{system}-{customs}-{year}"
|
||||
|
||||
@staticmethod
|
||||
def generate_invoice_item_reference(invoice_ref: str, item_data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Format: {INVOICE_REF}-{ITEM_PART}
|
||||
Example: FUSE0-040-10-FUS035
|
||||
"""
|
||||
part_number = item_data.get("part_number", "ITEM")
|
||||
return f"{invoice_ref}-{part_number}"
|
||||
|
||||
@staticmethod
|
||||
def generate_pedimento_reference(pedimento_data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Format: {LICENSE}-{CUSTOMS}{YEAR}{NUMBER}
|
||||
Example: 0756C-040010315
|
||||
"""
|
||||
license = str(pedimento_data.get("license", "0000")).strip()
|
||||
customs = str(pedimento_data.get("customs_office", "000")).zfill(3)
|
||||
year = str(pedimento_data.get("year", "00")).zfill(2)
|
||||
number = str(pedimento_data.get("pedimento_number", "0000000")).zfill(7)
|
||||
|
||||
return f"{license}-{customs}{year}{number}"
|
||||
|
||||
|
||||
class AuditMapper:
|
||||
"""
|
||||
Maps table names and operations to English Procedures and Movements
|
||||
"""
|
||||
|
||||
# Map table names to Legacy Procedures (English)
|
||||
TABLE_TO_PROCEDURE = {
|
||||
# Invoices
|
||||
"invoice_header": "IMPORT INVOICE BROWSE", # BROWSEOFACIMP
|
||||
"invoice_sales_details": "IMPORT INVOICE UPDATE", # UPDATEOFACIMP
|
||||
|
||||
# Exports would be similar but we start with general
|
||||
|
||||
# Pedimentos
|
||||
"pedimentos": "PEDIMENTO BROWSE", # BROWSEPEDIMEN
|
||||
|
||||
# System
|
||||
"users": "SYSTEM SCAF",
|
||||
"sessions": "SYSTEM SCAF",
|
||||
|
||||
# General fallbacks
|
||||
"clients_and_providers": "CATALOGS",
|
||||
"items": "CATALOGS",
|
||||
}
|
||||
|
||||
# Map (Table, Operation) to Legacy Movements (English)
|
||||
OPERATION_TO_MOVEMENT = {
|
||||
("invoice_header", "CREATE"): "ADD IMPORT_INVOICE",
|
||||
("invoice_header", "UPDATE"): "EDIT IMPORT_INVOICE",
|
||||
("invoice_header", "DELETE"): "DELETE IMPORT_INVOICE",
|
||||
|
||||
("invoice_sales_details", "CREATE"): "ADD IMPORT_INVOICE_ITEM",
|
||||
("invoice_sales_details", "UPDATE"): "EDIT IMPORT_INVOICE_ITEM",
|
||||
("invoice_sales_details", "DELETE"): "DELETE IMPORT_INVOICE_ITEM",
|
||||
|
||||
("pedimentos", "CREATE"): "ADD PEDIMENTO",
|
||||
("pedimentos", "UPDATE"): "EDIT PEDIMENTO",
|
||||
("pedimentos", "DELETE"): "DELETE PEDIMENTO",
|
||||
|
||||
("auth", "LOGIN"): "SYSTEM LOGIN",
|
||||
("auth", "LOGOUT"): "SYSTEM LOGOUT",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def map_to_legacy_format(
|
||||
table_name: str,
|
||||
record_id: str,
|
||||
operation_type: str,
|
||||
username: str,
|
||||
system: str = "SCAF"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Returns dictionary with keys: reference, procedure, movement, username, system
|
||||
"""
|
||||
# Determine Procedure
|
||||
procedure = AuditMapper.TABLE_TO_PROCEDURE.get(
|
||||
table_name,
|
||||
table_name.upper().replace("_", " ") # Fallback
|
||||
)
|
||||
|
||||
# Determine Movement
|
||||
movement_key = (table_name, operation_type)
|
||||
movement = AuditMapper.OPERATION_TO_MOVEMENT.get(
|
||||
movement_key,
|
||||
f"{operation_type} {table_name.upper()}"
|
||||
)
|
||||
|
||||
# Determine Reference Base
|
||||
if operation_type in ["LOGIN", "LOGOUT"]:
|
||||
reference = operation_type
|
||||
else:
|
||||
reference = record_id or "NO-REF"
|
||||
|
||||
return {
|
||||
"reference": reference,
|
||||
"procedure": procedure,
|
||||
"movement": movement,
|
||||
"username": username,
|
||||
"system": system
|
||||
}
|
||||
162
backend/api/v1/modules/a76/audit_log/services/service.py
Normal file
162
backend/api/v1/modules/a76/audit_log/services/service.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Audit Log Service
|
||||
"""
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from ..models import AuditLog
|
||||
from .core import AuditMapper, ReferenceGenerator
|
||||
from core.security import verify_token # keep if needed or simpler just remove if unused
|
||||
# We don't need security import here anymore as context is passed explicitly or handled by events
|
||||
|
||||
|
||||
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 set to Mexico City as requested implicitly by legacy format example
|
||||
tz = pytz.timezone('America/Mexico_City')
|
||||
now = datetime.now(tz)
|
||||
|
||||
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=old_values,
|
||||
new_values=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.commit()
|
||||
db.refresh(log)
|
||||
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
|
||||
):
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def log_login(db: Session, username: str, ip_address: str = None):
|
||||
return AuditService.create_audit_log(
|
||||
db=db,
|
||||
reference="LOGIN",
|
||||
procedure="SYSTEM SCAF",
|
||||
movement="SYSTEM LOGIN",
|
||||
username=username,
|
||||
operation_type="LOGIN",
|
||||
ip_address=ip_address
|
||||
)
|
||||
Reference in New Issue
Block a user