Initial commit: PESCO NCR system
Complete Non-Conformance Report system replacing the PowerApps/SharePoint prototype: FastAPI + SQLAlchemy 2 (async) + Alembic + MySQL 8 backend, React 18 + Vite + TypeScript + MUI frontend, Entra ID auth (MSAL / JWKS, group-gated), Microsoft Graph delegated Mail.Send notifications (OBO), six-stage workflow state machine with server-side enforcement, atomic NCR-YYYY-NNNN numbering, attachments with camera capture, immutable field-level audit trail, admin reopen, reports + CSV export, WeasyPrint PDF traveler, Power BI reporting views + read-only DB user, documented VISUAL ERP job-lookup stub, pytest suite (26 tests), docker-compose deployment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
67
backend/app/services/audit.py
Normal file
67
backend/app/services/audit.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Audit trail helpers. Audit rows are append-only: the application exposes
|
||||
no endpoint that updates or deletes them."""
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import AuditLog, Ncr
|
||||
|
||||
|
||||
def _fmt(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return str(value)
|
||||
|
||||
|
||||
def audit_event(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
action: str,
|
||||
ncr_id: int | None = None,
|
||||
field_name: str | None = None,
|
||||
old_value: Any = None,
|
||||
new_value: Any = None,
|
||||
detail: str | None = None,
|
||||
) -> None:
|
||||
db.add(
|
||||
AuditLog(
|
||||
ncr_id=ncr_id,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
field_name=field_name,
|
||||
old_value=_fmt(old_value),
|
||||
new_value=_fmt(new_value),
|
||||
detail=detail,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def apply_field_updates(
|
||||
db: AsyncSession,
|
||||
ncr: Ncr,
|
||||
user_id: int,
|
||||
updates: dict[str, Any],
|
||||
action: str = "update",
|
||||
) -> dict[str, tuple[Any, Any]]:
|
||||
"""Set attributes on the NCR, writing one audit row per actually-changed
|
||||
field. Returns {field: (old, new)} for the fields that changed."""
|
||||
changes: dict[str, tuple[Any, Any]] = {}
|
||||
for field, new_value in updates.items():
|
||||
old_value = getattr(ncr, field)
|
||||
if old_value == new_value:
|
||||
continue
|
||||
setattr(ncr, field, new_value)
|
||||
changes[field] = (old_value, new_value)
|
||||
audit_event(
|
||||
db,
|
||||
ncr_id=ncr.id,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
field_name=field,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
)
|
||||
return changes
|
||||
Reference in New Issue
Block a user