From f90e20469a2be88de1056dabcc8eb927229a14cf Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Tue, 10 Feb 2026 14:07:20 -0600 Subject: [PATCH] feat: Implement a comprehensive audit log system with a dedicated dashboard page and backend API. --- .../api/v1/modules/a76/audit_log/events.py | 116 ++++++ .../v1/modules/a76/audit_log/middleware.py | 22 ++ .../api/v1/modules/a76/audit_log/models.py | 56 +++ .../api/v1/modules/a76/audit_log/router.py | 93 +++++ .../api/v1/modules/a76/audit_log/schemas.py | 52 +++ .../v1/modules/a76/audit_log/services/core.py | 148 ++++++++ .../modules/a76/audit_log/services/service.py | 162 +++++++++ backend/api/v1/modules/a76/router.py | 6 +- backend/core/context.py | 10 + backend/main.py | 77 +++- .../src/lib/api/dashboard/a76/audit_log.ts | 73 ++++ .../dashboard/a76/reports/reports-invoices.ts | 5 - .../src/lib/components/sidebar/modules.ts | 7 + .../routes/dashboard/bitacora/+page.svelte | 343 ++++++++++++++++++ 14 files changed, 1163 insertions(+), 7 deletions(-) create mode 100644 backend/api/v1/modules/a76/audit_log/events.py create mode 100644 backend/api/v1/modules/a76/audit_log/middleware.py create mode 100644 backend/api/v1/modules/a76/audit_log/models.py create mode 100644 backend/api/v1/modules/a76/audit_log/router.py create mode 100644 backend/api/v1/modules/a76/audit_log/schemas.py create mode 100644 backend/api/v1/modules/a76/audit_log/services/core.py create mode 100644 backend/api/v1/modules/a76/audit_log/services/service.py create mode 100644 backend/core/context.py create mode 100644 frontend/src/lib/api/dashboard/a76/audit_log.ts create mode 100644 frontend/src/routes/dashboard/bitacora/+page.svelte diff --git a/backend/api/v1/modules/a76/audit_log/events.py b/backend/api/v1/modules/a76/audit_log/events.py new file mode 100644 index 00000000..478ef95c --- /dev/null +++ b/backend/api/v1/modules/a76/audit_log/events.py @@ -0,0 +1,116 @@ +""" +Audit Log Events +""" +from sqlalchemy import event, inspect +from sqlalchemy.orm import Session +from .services.service import AuditService +from core.context import get_user_context + +def register_audit_listeners(models_to_audit): + """ + Register SQLAlchemy listeners for given models + """ + for model in models_to_audit: + event.listen(model, "after_insert", after_insert_listener) + 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" + except: + pass + return "System" + +def after_insert_listener(mapper, connection, target): + """ + Listener for INSERT operations + """ + 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) + + # Create a session bound to the connection + session = Session(bind=connection) + try: + AuditService.log_crud_operation( + db=session, + table_name=table_name, + operation_type="CREATE", + record_data=record_data, + username=username, + record_id=str(getattr(target, "id", "")), + company_id=company_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(): + changes[attr.key] = hist.added[0] if hist.added else None + old_values[attr.key] = hist.deleted[0] if hist.deleted else None + new_values[attr.key] = hist.added[0] if hist.added else None + + if not changes: + return + + record_data = {c.name: getattr(target, c.name) for c in mapper.columns} + username = _get_current_username() + + session = Session(bind=connection) + try: + AuditService.log_crud_operation( + db=session, + table_name=table_name, + operation_type="UPDATE", + record_data=record_data, + username=username, + record_id=str(getattr(target, "id", "")), + old_values=old_values, + new_values=new_values + ) + except Exception as e: + print(f"Error logging update: {e}") + finally: + session.close() + +def after_delete_listener(mapper, connection, target): + """ + Listener for DELETE operations + """ + table_name = target.__tablename__ + record_data = {c.name: getattr(target, c.name) for c in mapper.columns} + username = _get_current_username() + + session = Session(bind=connection) + try: + AuditService.log_crud_operation( + db=session, + table_name=table_name, + operation_type="DELETE", + record_data=record_data, + username=username, + record_id=str(getattr(target, "id", "")) + ) + except Exception as e: + print(f"Error logging delete: {e}") + finally: + session.close() diff --git a/backend/api/v1/modules/a76/audit_log/middleware.py b/backend/api/v1/modules/a76/audit_log/middleware.py new file mode 100644 index 00000000..fe2c111a --- /dev/null +++ b/backend/api/v1/modules/a76/audit_log/middleware.py @@ -0,0 +1,22 @@ +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response +from core.security import verify_token +from core.context import set_user_context + +class UserContextMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next) -> Response: + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + token = auth_header.split(" ")[1] + try: + # verify_token might raise exception if invalid, we catch it to not block request + # but we won't have user context + user_info = verify_token(token) + set_user_context(user_info) + except Exception: + # Log error or ignore + pass + + response = await call_next(request) + return response diff --git a/backend/api/v1/modules/a76/audit_log/models.py b/backend/api/v1/modules/a76/audit_log/models.py new file mode 100644 index 00000000..c47900f5 --- /dev/null +++ b/backend/api/v1/modules/a76/audit_log/models.py @@ -0,0 +1,56 @@ +""" +Audit Log Models +""" + +from sqlalchemy import Column, Integer, String, Date, Time, DateTime, Text, Index, func +from sqlalchemy.dialects.postgresql import JSONB, ARRAY +from core.database import Base + +class AuditLog(Base): + __tablename__ = "audit_logs" + + # Primary Key + spec_id = Column(Integer, primary_key=True, autoincrement=True) + + # Legacy Display Columns (English names as requested) + reference = Column(String(100), nullable=False, index=True) # Legacy: Referencia + procedure = Column(String(100), nullable=False, index=True) # Legacy: Procedimiento + movement = Column(String(255), nullable=False) # Legacy: Movimiento + username = Column(String(100), nullable=False, index=True) # Legacy: Usuario + date = Column(Date, nullable=False, index=True) # Legacy: Fecha + time = Column(Time, nullable=False) # Legacy: Hora + + # 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) + record_id = Column(String(255), nullable=True, index=True) + operation_type = Column(String(20), nullable=True, index=True) # CREATE, UPDATE, DELETE, LOGIN + + # Data Changes + old_values = Column(JSONB, nullable=True) + new_values = Column(JSONB, nullable=True) + changed_fields = Column(ARRAY(String), nullable=True) + + # Request Context + ip_address = Column(String(45), nullable=True) + user_agent = Column(Text, nullable=True) + 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'), + ) diff --git a/backend/api/v1/modules/a76/audit_log/router.py b/backend/api/v1/modules/a76/audit_log/router.py new file mode 100644 index 00000000..6bd03187 --- /dev/null +++ b/backend/api/v1/modules/a76/audit_log/router.py @@ -0,0 +1,93 @@ +""" +Audit Log Router +""" +from typing import List, Optional +from datetime import date +from fastapi import APIRouter, Depends, Query, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy import or_, desc, distinct + +from core.database import get_core_db +from core.security import get_current_user # Assuming this exists +from .models import AuditLog +from .schemas import AuditLogListResponse, AuditLogResponse, AuditLogDetailResponse + +router = APIRouter() + +@router.get("/bitacora", response_model=AuditLogListResponse) +async def get_bitacora( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=100), + search: Optional[str] = None, + username: Optional[str] = None, + procedure: Optional[str] = None, + reference: Optional[str] = None, + date_from: Optional[date] = None, + date_to: Optional[date] = None, + db: Session = Depends(get_core_db) +): + """ + Get legacy audit log (Bitácora) + """ + query = db.query(AuditLog) + + # Filters + if date_from: + query = query.filter(AuditLog.date >= date_from) + if date_to: + query = query.filter(AuditLog.date <= date_to) + + if username: + query = query.filter(AuditLog.username.ilike(f"%{username}%")) + if procedure: + # Exact match for dropdown filter usually better, but let's allow partial if manual + # Legacy UI sends exact strings usually + query = query.filter(AuditLog.procedure == procedure) + if reference: + query = query.filter(AuditLog.reference.ilike(f"%{reference}%")) + + if search: + # General search across main columns + search_filter = or_( + AuditLog.reference.ilike(f"%{search}%"), + AuditLog.procedure.ilike(f"%{search}%"), + AuditLog.movement.ilike(f"%{search}%"), + AuditLog.username.ilike(f"%{search}%") + ) + query = query.filter(search_filter) + + total = query.count() + + # Sort by ID desc (newest first) -> Legacy usually shows newest first or spec_id desc + logs = query.order_by(desc(AuditLog.spec_id))\ + .offset((page - 1) * page_size)\ + .limit(page_size)\ + .all() + + return { + "data": logs, + "total": total, + "page": page, + "page_size": page_size + } + +@router.get("/bitacora/procedimientos", response_model=List[str]) +async def get_procedures(db: Session = Depends(get_core_db)): + """ + Get distinct list of procedures for filters + """ + results = db.query(distinct(AuditLog.procedure))\ + .order_by(AuditLog.procedure)\ + .all() + # verify if result is tuple + return [r[0] for r in results if r[0]] + +@router.get("/bitacora/{spec_id}/detalle", response_model=AuditLogDetailResponse) +async def get_audit_detail(spec_id: int, db: Session = Depends(get_core_db)): + """ + Get full detail of a log entry + """ + log = db.query(AuditLog).filter(AuditLog.spec_id == spec_id).first() + if not log: + raise HTTPException(status_code=404, detail="Log entry not found") + return log diff --git a/backend/api/v1/modules/a76/audit_log/schemas.py b/backend/api/v1/modules/a76/audit_log/schemas.py new file mode 100644 index 00000000..bd803974 --- /dev/null +++ b/backend/api/v1/modules/a76/audit_log/schemas.py @@ -0,0 +1,52 @@ +""" +Audit Log Schemas +""" + +from typing import Optional, List, Any, Dict +from datetime import date as date_type, time as time_type, datetime +from pydantic import BaseModel, Field + +# --- Response Schemas --- + +class AuditLogResponse(BaseModel): + """ + Standard response showing the Legacy columns + """ + spec_id: int + reference: str + procedure: str + movement: str + username: str + date: date_type + time: time_type + + # Modern extras + timestamp: datetime + system: str + operation_type: Optional[str] = None + table_name: Optional[str] = None + record_id: Optional[str] = None + + class Config: + from_attributes = True + +class AuditLogDetailResponse(AuditLogResponse): + """ + Detailed response including changed values + """ + old_values: Optional[Dict[str, Any]] = None + new_values: Optional[Dict[str, Any]] = None + changed_fields: Optional[List[str]] = None + ip_address: Optional[str] = None + execution_time_ms: Optional[int] = None + +# --- List Response --- + +class AuditLogListResponse(BaseModel): + """ + Paginated response + """ + data: List[AuditLogResponse] + total: int + page: int + page_size: int diff --git a/backend/api/v1/modules/a76/audit_log/services/core.py b/backend/api/v1/modules/a76/audit_log/services/core.py new file mode 100644 index 00000000..45c1d1cc --- /dev/null +++ b/backend/api/v1/modules/a76/audit_log/services/core.py @@ -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 + } diff --git a/backend/api/v1/modules/a76/audit_log/services/service.py b/backend/api/v1/modules/a76/audit_log/services/service.py new file mode 100644 index 00000000..57ad48f4 --- /dev/null +++ b/backend/api/v1/modules/a76/audit_log/services/service.py @@ -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 + ) diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 01c8c57d..714415b8 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -145,4 +145,8 @@ router.include_router( aviso_consolidado_export_router, prefix="/a76/reports/exportacion/aviso_consolidado", tags=["a76 / reports"] -) \ No newline at end of file +) + +# Registrar router de bitácora +from .audit_log.router import router as audit_log_router +router.include_router(audit_log_router, prefix="/a76/audit-log", tags=["Audit Log"]) \ No newline at end of file diff --git a/backend/core/context.py b/backend/core/context.py new file mode 100644 index 00000000..d8cef923 --- /dev/null +++ b/backend/core/context.py @@ -0,0 +1,10 @@ +from contextvars import ContextVar +from typing import Optional, Dict, Any + +_user_context: ContextVar[Optional[Dict[str, Any]]] = ContextVar("user_context", default=None) + +def get_user_context() -> Optional[Dict[str, Any]]: + return _user_context.get() + +def set_user_context(user: Dict[str, Any]) -> None: + _user_context.set(user) diff --git a/backend/main.py b/backend/main.py index b7ed6b69..95c9fe25 100644 --- a/backend/main.py +++ b/backend/main.py @@ -108,11 +108,86 @@ if settings.DEBUG: app.add_middleware(LicenseValidationMiddleware) app.add_middleware(TenantMiddleware) +# Middleware de Contexto de Usuario (Audit Log) +from api.v1.modules.a76.audit_log.middleware import UserContextMiddleware +app.add_middleware(UserContextMiddleware) + +# Importar modelos para Audit Log +from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceSalesDetails +from api.v1.modules.a76.audit_log.events import register_audit_listeners + +# Core Modules +from api.v1.modules.a76.clients_and_providers.models import ClientProvider +from api.v1.modules.a76.customs_brokers.models import CustomsBroker +from api.v1.modules.a76.parts.models import Part +from api.v1.modules.a76.items.models import Item +from api.v1.modules.a76.general_catalogs.company.models import Company + +# Reference Data +from api.v1.modules.public.reference_data.countries.models import Country +from api.v1.modules.public.reference_data.currency_types.models import CurrencyType +from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection +from api.v1.modules.public.reference_data.customs_warehouses.models import CustomsWarehouse +from api.v1.modules.public.reference_data.incoterms.models import Incoterm +from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType +from api.v1.modules.public.reference_data.material_types.models import MaterialType +from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod +from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode +from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento +from api.v1.modules.public.reference_data.sectors.models import Sector +from api.v1.modules.public.reference_data.states.models import State +from api.v1.modules.public.reference_data.transport_modes.models import TransportMode +from api.v1.modules.public.reference_data.transport_types.models import TransportType +from api.v1.modules.public.reference_data.valuation_methods.models import ValuationMethod +from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure +from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate +from api.v1.modules.a76.general_catalogs.identifiers.models import Identifier + +# Registrar Listeners de Auditoría +@app.on_event("startup") +def register_audit(): + register_audit_listeners([ + # Core Transactions + Pedimentos, + InvoiceHeader, + InvoiceSalesDetails, + Item, + + # Sidebar Core Modules + ClientProvider, + CustomsBroker, + Part, + Company, + + # Reference Data + Country, + CurrencyType, + CustomsSection, + CustomsWarehouse, + Incoterm, + InvoiceType, + MaterialType, + PaymentMethod, + PedimentoCode, + RegimenPedimento, + Sector, + State, + TransportMode, + TransportType, + ValuationMethod, + UnitOfMeasure, + ExchangeRate, + Identifier + ]) + + # Crear directorio de uploads si no existe y montar archivos estáticos -uploads_dir = Path("/app/uploads") +uploads_dir = Path("uploads").resolve() uploads_dir.mkdir(parents=True, exist_ok=True) app.mount("/api/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads") + # Registrar routers app.include_router(api_v1_router, prefix="/api/v1") diff --git a/frontend/src/lib/api/dashboard/a76/audit_log.ts b/frontend/src/lib/api/dashboard/a76/audit_log.ts new file mode 100644 index 00000000..0997e54c --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/audit_log.ts @@ -0,0 +1,73 @@ +import { api } from '$lib/api'; + +const BASE_PATH = '/v1/a76/audit-log'; + +export interface AuditLog { + spec_id: number; + reference: string; + procedure: string; // "Procedimiento" + movement: string; // "Movimiento" + username: string; + date: string; // "YYYY-MM-DD" + time: string; // "HH:MM:SS" + timestamp: string; // ISO + system: string; + operation_type?: string; + table_name?: string; + old_values?: any; + new_values?: any; +} + +export interface AuditLogResponse { + data: AuditLog[]; + total: number; + page: number; + page_size: number; +} + +export interface AuditLogParams { + page?: number; + page_size?: number; + search?: string; + username?: string; + procedure?: string; + reference?: string; + date_from?: string; + date_to?: string; +} + +export const AuditLogAPI = { + getLogs: async (params: AuditLogParams = {}): Promise => { + const query = new URLSearchParams(); + if (params.page) query.append('page', params.page.toString()); + if (params.page_size) query.append('page_size', params.page_size.toString()); + if (params.search) query.append('search', params.search); + if (params.username) query.append('username', params.username); + if (params.procedure) query.append('procedure', params.procedure); + if (params.reference) query.append('reference', params.reference); + if (params.date_from) query.append('date_from', params.date_from); + if (params.date_to) query.append('date_to', params.date_to); + + const response = await api.get(`${BASE_PATH}/bitacora?${query.toString()}`); + if (response.error || !response.data) { + throw new Error(response.error || 'Failed to fetch audit logs'); + } + return response.data; + }, + + getProcedures: async (): Promise => { + const response = await api.get(`${BASE_PATH}/bitacora/procedimientos`); + if (response.error || !response.data) { + throw new Error(response.error || 'Failed to fetch procedures'); + } + return response.data; + }, + + getDetail: async (specId: number): Promise => { + const response = await api.get(`${BASE_PATH}/bitacora/${specId}/detalle`); + if (response.error || !response.data) { + throw new Error(response.error || 'Failed to fetch audit log detail'); + } + return response.data; + } +}; diff --git a/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts b/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts index 7041c3a6..313e31a7 100644 --- a/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts @@ -1,5 +1,4 @@ -const BASE_URL = import.meta.env.VITE_API_URL || ''; const BASE_URL = import.meta.env.VITE_API_URL || ''; export const invoicesReportsApi = { @@ -15,7 +14,6 @@ export const invoicesReportsApi = { const token = localStorage.getItem('access_token'); const response = await fetch(endpoint, { - method: 'POST', method: 'POST', headers: { 'Authorization': `Bearer ${token}`, @@ -25,14 +23,11 @@ export const invoicesReportsApi = { if (!response.ok) throw new Error('Error al iniciar la generación'); return await response.json(); - return await response.json(); }, getTaskStatus: async (taskId: string) => { const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/tasks/${taskId}`; - const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/tasks/${taskId}`; - const token = localStorage.getItem('access_token'); const response = await fetch(endpoint, { method: 'GET', diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index a0dd99a8..61dea03e 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -77,6 +77,13 @@ export function getSidebarData(): SidebarData { icon: LayoutDashboard, items: [], }, + { + title: "Bitácora", + url: "/dashboard/bitacora", + icon: Shield, + items: [], + }, + { title: m["sidebar.reference_data.title"](), url: "/dashboard", diff --git a/frontend/src/routes/dashboard/bitacora/+page.svelte b/frontend/src/routes/dashboard/bitacora/+page.svelte new file mode 100644 index 00000000..ef465f05 --- /dev/null +++ b/frontend/src/routes/dashboard/bitacora/+page.svelte @@ -0,0 +1,343 @@ + + +
+
+
+

Bitácora de Movimientos

+

Auditoría detallada de operaciones del sistema

+
+
+ +
+
+ + +
+ + + Filtros + + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+
+
+ + + + +
+
+ Registros + Total: {total} registros encontrados +
+
+
+ + {#if error} +
+ Error al cargar datos: {error} +
+ {:else} +
+ + + + ID + Referencia + Procedimiento + Movimiento + Usuario + Fecha + Hora + + + + {#if loading && logs.length === 0} + + Cargando... + + {:else if logs.length === 0} + + No se encontraron registros + + {:else} + {#each logs as log} + + {log.spec_id} + {log.reference} + {log.procedure} + {log.movement} + {log.username} + {formatDate(log.date)} + {formatTime(log.time)} + + {/each} + {/if} + + + + +
+ {#if loading && logs.length > 0} +
+ + Cargando más registros... +
+ {:else if !hasMore && logs.length > 0} + Fin de la bitácora + {/if} +
+
+ {/if} +
+
+