feat: enhance audit log functionality with tenant_id handling and optional seed data in initialization script

This commit is contained in:
2026-02-18 12:58:11 -06:00
parent bbd0b7ad90
commit d4b52b32ed
8 changed files with 341 additions and 63 deletions

View File

@@ -1,11 +1,14 @@
"""
Audit Log Events
"""
from sqlalchemy import event, inspect
from sqlalchemy.orm import Session
from .services.service import AuditService
from .utils.serialization import serialize_for_json
from core.context import get_user_context
def register_audit_listeners(models_to_audit):
"""
Register SQLAlchemy listeners for given models
@@ -15,16 +18,23 @@ def register_audit_listeners(models_to_audit):
event.listen(model, "after_update", after_update_listener)
event.listen(model, "after_delete", after_delete_listener)
def _get_current_username():
try:
context = get_user_context()
if context:
# Token usually has 'preferred_username' or 'name' or 'sub'
return context.get("preferred_username") or context.get("email") or context.get("sub") or "System"
return (
context.get("preferred_username")
or context.get("email")
or context.get("sub")
or "System"
)
except:
pass
return "System"
def after_insert_listener(mapper, connection, target):
"""
Listener for INSERT operations
@@ -33,7 +43,8 @@ def after_insert_listener(mapper, connection, target):
record_data = {c.name: getattr(target, c.name) for c in mapper.columns}
username = _get_current_username()
company_id = getattr(target, "company_id", None)
tenant_id = getattr(target, "tenant_id", None)
# Create a session bound to the connection
session = Session(bind=connection)
try:
@@ -44,24 +55,26 @@ def after_insert_listener(mapper, connection, target):
record_data=record_data,
username=username,
record_id=str(getattr(target, "id", "")),
company_id=company_id
company_id=company_id,
tenant_id=tenant_id,
)
except Exception as e:
print(f"Error logging insert: {e}")
finally:
session.close()
def after_update_listener(mapper, connection, target):
"""
Listener for UPDATE operations
"""
table_name = target.__tablename__
state = inspect(target)
changes = {}
old_values = {}
new_values = {}
for attr in state.attrs:
hist = attr.history
if hist.has_changes():
@@ -74,6 +87,8 @@ def after_update_listener(mapper, connection, target):
record_data = {c.name: getattr(target, c.name) for c in mapper.columns}
username = _get_current_username()
company_id = getattr(target, "company_id", None)
tenant_id = getattr(target, "tenant_id", None)
session = Session(bind=connection)
try:
@@ -84,13 +99,16 @@ def after_update_listener(mapper, connection, target):
record_data=record_data,
username=username,
record_id=str(getattr(target, "id", "")),
old_values=old_values,
new_values=new_values
old_values=serialize_for_json(old_values),
new_values=serialize_for_json(new_values),
company_id=company_id,
tenant_id=tenant_id,
)
except Exception as e:
print(f"Error logging update: {e}")
print(f"Error logging update: {e}")
finally:
session.close()
session.close()
def after_delete_listener(mapper, connection, target):
"""
@@ -99,7 +117,9 @@ def after_delete_listener(mapper, connection, target):
table_name = target.__tablename__
record_data = {c.name: getattr(target, c.name) for c in mapper.columns}
username = _get_current_username()
company_id = getattr(target, "company_id", None)
tenant_id = getattr(target, "tenant_id", None)
session = Session(bind=connection)
try:
AuditService.log_crud_operation(
@@ -108,9 +128,11 @@ def after_delete_listener(mapper, connection, target):
operation_type="DELETE",
record_data=record_data,
username=username,
record_id=str(getattr(target, "id", ""))
record_id=str(getattr(target, "id", "")),
company_id=company_id,
tenant_id=tenant_id,
)
except Exception as e:
print(f"Error logging delete: {e}")
print(f"Error logging delete: {e}")
finally:
session.close()
session.close()

View File

@@ -4,10 +4,18 @@ Audit Log Models
from sqlalchemy import Column, Integer, String, Date, Time, DateTime, Text, Index, func
from sqlalchemy.dialects.postgresql import JSONB, ARRAY
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class AuditLog(Base):
class AuditLog(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "audit_logs"
__table_args__ = (
Index('idx_audit_username_date', 'username', 'date'),
Index('idx_audit_procedure_date', 'procedure', 'date'),
Index('idx_audit_system_timestamp', 'system', 'timestamp'),
Index('idx_audit_table_record', 'table_name', 'record_id'),
{"schema": "a76"} # Use the a76 schema for audit logs
)
# Primary Key
spec_id = Column(Integer, primary_key=True, autoincrement=True)
@@ -23,8 +31,6 @@ class AuditLog(Base):
# Technical Columns
timestamp = Column(DateTime(timezone=True), nullable=False, index=True) # Combined for queries
system = Column(String(20), nullable=False, index=True, default="SCAF")
company_id = Column(Integer, nullable=True, index=True)
tenant_id = Column(Integer, nullable=True, index=True)
# Traceability
table_name = Column(String(100), nullable=True, index=True)
@@ -42,15 +48,4 @@ class AuditLog(Base):
endpoint = Column(String(500), nullable=True)
request_method = Column(String(10), nullable=True)
session_id = Column(String(50), nullable=True, index=True)
execution_time_ms = Column(Integer, nullable=True)
# Metadata
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Composite Indexes for common filters
__table_args__ = (
Index('idx_audit_username_date', 'username', 'date'),
Index('idx_audit_procedure_date', 'procedure', 'date'),
Index('idx_audit_system_timestamp', 'system', 'timestamp'),
Index('idx_audit_table_record', 'table_name', 'record_id'),
)
execution_time_ms = Column(Integer, nullable=True)

View File

@@ -86,7 +86,8 @@ class AuditService:
# Context
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
company_id: Optional[int] = None
company_id: Optional[int] = None,
tenant_id: Optional[int] = None
):
"""
High-level wrapper to log CRUD operations automatically mapping to Legacy format
@@ -149,7 +150,8 @@ class AuditService:
changed_fields=changed_fields,
ip_address=ip_address,
user_agent=user_agent,
company_id=company_id
company_id=company_id,
tenant_id=tenant_id
)
@staticmethod

View File

@@ -0,0 +1,7 @@
"""
Audit Log Utilities
"""
from .serialization import serialize_for_json
__all__ = ["serialize_for_json"]

View File

@@ -0,0 +1,44 @@
"""
Serialization utilities for audit logs
"""
from datetime import date, datetime, time
from decimal import Decimal
from uuid import UUID
from typing import Any, Dict
def serialize_value(value: Any) -> Any:
"""
Convert a Python value to a JSON-serializable type
"""
if value is None:
return None
elif isinstance(value, (date, datetime)):
return value.isoformat()
elif isinstance(value, time):
return value.isoformat()
elif isinstance(value, Decimal):
return float(value)
elif isinstance(value, UUID):
return str(value)
elif isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
elif isinstance(value, (list, tuple)):
return [serialize_value(item) for item in value]
elif isinstance(value, dict):
return {key: serialize_value(val) for key, val in value.items()}
else:
# For any other type, try to return as-is (str, int, float, bool, None)
# If it fails JSON serialization later, at least we tried
return value
def serialize_for_json(data: Dict[str, Any]) -> Dict[str, Any]:
"""
Recursively serialize a dictionary for JSON storage
"""
if not data:
return data
return {key: serialize_value(value) for key, value in data.items()}

View File

@@ -6,14 +6,7 @@ SQLAlchemy v2 - Annex 24 Compliance
from typing import Optional, TYPE_CHECKING
from core.database import Base
from decimal import Decimal
from sqlalchemy import (
Boolean,
String,
Integer,
Numeric,
SmallInteger,
ForeignKey
)
from sqlalchemy import Boolean, String, Integer, Numeric, SmallInteger, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
@@ -21,7 +14,7 @@ from core.database import Base
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
if TYPE_CHECKING:
if TYPE_CHECKING:
from .line_financials.models import LineFinancial
from .line_quantities.models import LineQuantity
from .line_customs.models import LineCustom
@@ -41,17 +34,20 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin):
Unified item header table for all import/export operations
Consolidates headers from both SCAF and SCAII systems
"""
__tablename__ = "item_lines"
__table_args__ = {
"schema": "a76",
}
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
invoice_id: Mapped[int] = mapped_column(ForeignKey("a76.invoice_header.id")) # CONSECUTIVO
invoice_id: Mapped[int] = mapped_column(
ForeignKey("a76.invoice_header.id")
) # CONSECUTIVO
line_number: Mapped[int] = mapped_column(Integer) # LINEAIMPO/LINEAEXPO/LINEA
# Part identification
# Part identification
part_number_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("a76.parts.id")
) # NUMPARTE
@@ -170,30 +166,28 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin):
pallet2: Mapped[Optional[int]] = mapped_column(SmallInteger) # PALLET2
# Wildcard field
wildcard_field: Mapped[Optional[str]] = mapped_column(String(100)) # CAMPOCOMODIN
wildcard_field: Mapped[Optional[str]] = mapped_column(String(100)) # CAMPOCOMODIN
# Item references
reference_number: Mapped[Optional[str]] = mapped_column(
String(20)) # NUMREFERENCIA
order: Mapped[Optional[str]] = mapped_column(
String(50)) # ORDENCOMPRA / ORDENVENTA
reference_number: Mapped[Optional[str]] = mapped_column(String(20)) # NUMREFERENCIA
order: Mapped[Optional[str]] = mapped_column(String(50)) # ORDENCOMPRA / ORDENVENTA
guide_number: Mapped[Optional[str]] = mapped_column(
String(50)) # NUMEROGUIA/NUMERODEGUIA
String(50)
) # NUMEROGUIA/NUMERODEGUIA
# Dates
depreciation_date: Mapped[Optional[int]] = mapped_column(
Integer) # FECHADEPRECIACION
Integer
) # FECHADEPRECIACION
# Administrative fields
rectification: Mapped[Optional[bool]] = mapped_column(
Boolean) # RECTIFICACION
rectification: Mapped[Optional[bool]] = mapped_column(Boolean) # RECTIFICACION
warehouse: Mapped[Optional[str]] = mapped_column(String(30)) # BODEGA
location: Mapped[Optional[str]] = mapped_column(
String(200)) # LOCALIZACION
location: Mapped[Optional[str]] = mapped_column(String(200)) # LOCALIZACION
invoice: Mapped["InvoiceHeader"] = relationship("InvoiceHeader")
# Relationships
# Relationships
financial: Mapped[Optional["LineFinancial"]] = relationship(
back_populates="line", cascade="all, delete-orphan", uselist=False
)
@@ -220,7 +214,7 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin):
viewonly=True,
)
fa_data: Mapped[Optional["FaLineItem"]] = relationship(
"FaLineItem",
"api.v1.modules.a24.fa.fa_item_lines.models.FaLineItem",
back_populates="master_info",
cascade="all, delete-orphan",
uselist=False,
@@ -231,6 +225,7 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin):
viewonly=True,
)
# ============================================================================
# SUPPORTING TABLES
# ============================================================================
@@ -241,6 +236,7 @@ class PackingList(Base, TenantScopedMixin, TimestampMixin):
Packing list items
From: SPartidasPackingList
"""
__tablename__ = "packing_lists"
__table_args__ = {
"schema": "a76",
@@ -249,7 +245,8 @@ class PackingList(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
item_line_id: Mapped[int] = mapped_column(Integer) # LINEA
packing_list_number: Mapped[Optional[str]] = mapped_column(
String(100)) # NUMPACKINGLIST
String(100)
) # NUMPACKINGLIST
class CTMReceipt(Base, TenantScopedMixin, TimestampMixin):
@@ -257,6 +254,7 @@ class CTMReceipt(Base, TenantScopedMixin, TimestampMixin):
CTM Receipt lines (temporary manufacturing)
From: SPartidasReciboCTM
"""
__tablename__ = "ctm_receipts"
__table_args__ = {
"schema": "a76",
@@ -264,11 +262,11 @@ class CTMReceipt(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
receipt_line: Mapped[int] = mapped_column(
ForeignKey("a76.item_lines.id")) # LINEARECIBO
ForeignKey("a76.item_lines.id")
) # LINEARECIBO
option: Mapped[Optional[str]] = mapped_column(String(3)) # OPCION
exit_invoice: Mapped[Optional[str]] = mapped_column(
String(19)) # FACTURASALIDA
exit_invoice: Mapped[Optional[str]] = mapped_column(String(19)) # FACTURASALIDA
class SubassemblyEntry(Base, TenantScopedMixin, TimestampMixin):
@@ -276,6 +274,7 @@ class SubassemblyEntry(Base, TenantScopedMixin, TimestampMixin):
Subassembly/Submanufacturing Entry lines
From: SPartidasEntradaSM
"""
__tablename__ = "subassembly_entries"
__table_args__ = {
"schema": "a76",
@@ -283,10 +282,10 @@ class SubassemblyEntry(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
remission_line: Mapped[int] = mapped_column(Integer) # LINEAREMISION
exit_invoice: Mapped[Optional[str]] = mapped_column(
String(15)) # FACTURASALIDA
exit_invoice: Mapped[Optional[str]] = mapped_column(String(15)) # FACTURASALIDA
exit_line: Mapped[Optional[int]] = mapped_column(Integer) # LINEASALIDA
# ============================================================================
# INDEXES AND CONSTRAINTS
# ============================================================================