diff --git a/backend/audit/router.py b/backend/audit/router.py index ad91a79..d4882e4 100644 --- a/backend/audit/router.py +++ b/backend/audit/router.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from database.postgres import get_pg_session from shared.orm import AuditLog -from auth.dependencies import require_admin_or_above +from auth.dependencies import require_sysadmin from auth.models import TokenPayload router = APIRouter(prefix="/api/audit-log", tags=["audit-log"]) @@ -26,7 +26,7 @@ async def list_audit_log( to_date: Optional[datetime] = Query(None), limit: int = Query(_DEFAULT_LIMIT, ge=1, le=_MAX_LIMIT), offset: int = Query(0, ge=0), - _user: TokenPayload = Depends(require_admin_or_above), + _user: TokenPayload = Depends(require_sysadmin), db: AsyncSession = Depends(get_pg_session), ): filters = [] diff --git a/backend/manufacturing/router.py b/backend/manufacturing/router.py index 4c7dfaf..835f375 100644 --- a/backend/manufacturing/router.py +++ b/backend/manufacturing/router.py @@ -3,6 +3,7 @@ from fastapi.responses import Response from fastapi.responses import RedirectResponse from typing import Optional from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession from auth.models import TokenPayload from auth.dependencies import require_permission @@ -13,9 +14,10 @@ from manufacturing.models import ( ManufacturingStats, ) from manufacturing import service -from manufacturing import audit +from shared.audit import log_action from shared.exceptions import NotFoundError from shared.firebase import get_db as get_firestore +from database.postgres import get_pg_session class LifecycleEntryPatch(BaseModel): @@ -43,26 +45,21 @@ def get_stats( return service.get_stats() -@router.get("/audit-log") -async def get_audit_log( - limit: int = Query(20, ge=1, le=100), - _user: TokenPayload = Depends(require_permission("manufacturing", "view")), -): - entries = await audit.get_recent(limit=limit) - return {"entries": entries} - @router.post("/batch", response_model=BatchResponse, status_code=201) async def create_batch( body: BatchCreate, user: TokenPayload = Depends(require_permission("manufacturing", "add")), + db: AsyncSession = Depends(get_pg_session), ): result = service.create_batch(body) - await audit.log_action( - admin_user=user.email, - action="batch_created", - detail={ - "batch_id": result.batch_id, + await log_action( + db, user.sub, user.email, + action="CREATE", + entity_type="device_batch", + entity_id=result.batch_id, + entity_label=f"Batch {result.batch_id} ({result.board_type}, qty {len(result.serial_numbers)})", + meta={ "board_type": result.board_type, "board_version": result.board_version, "quantity": len(result.serial_numbers), @@ -137,6 +134,7 @@ async def update_status( sn: str, body: DeviceStatusUpdate, user: TokenPayload = Depends(require_permission("manufacturing", "edit")), + db: AsyncSession = Depends(get_pg_session), ): # Guard: claimed requires at least one user in user_list # (allow if explicitly force_claimed=true, which the mfg UI sets after adding a user manually) @@ -167,11 +165,13 @@ async def update_status( ) result = service.update_device_status(sn, body, set_by=user.email) - await audit.log_action( - admin_user=user.email, - action="status_updated", - serial_number=sn, - detail={"status": body.status.value, "note": body.note}, + await log_action( + db, user.sub, user.email, + action="STATUS_CHANGE", + entity_type="device", + entity_id=sn, + entity_label=sn, + meta={"status": body.status.value, "note": body.note}, ) return result @@ -181,10 +181,11 @@ async def patch_lifecycle_entry( sn: str, body: LifecycleEntryPatch, user: TokenPayload = Depends(require_permission("manufacturing", "edit")), + db: AsyncSession = Depends(get_pg_session), ): """Edit the date and/or note of a lifecycle history entry by index.""" - db = get_firestore() - docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream()) + fs = get_firestore() + docs = list(fs.collection("devices").where("serial_number", "==", sn).limit(1).stream()) if not docs: raise HTTPException(status_code=404, detail="Device not found") doc_ref = docs[0].reference @@ -198,7 +199,16 @@ async def patch_lifecycle_entry( history[body.index]["note"] = body.note doc_ref.update({"lifecycle_history": history}) from manufacturing.service import _doc_to_inventory_item - return _doc_to_inventory_item(doc_ref.get()) + result = _doc_to_inventory_item(doc_ref.get()) + await log_action( + db, user.sub, user.email, + action="UPDATE", + entity_type="device", + entity_id=sn, + entity_label=sn, + meta={"lifecycle_index": body.index, "date": body.date, "note": body.note}, + ) + return result @router.post("/devices/{sn}/lifecycle", response_model=DeviceInventoryItem, status_code=200) @@ -206,6 +216,7 @@ async def create_lifecycle_entry( sn: str, body: LifecycleEntryCreate, user: TokenPayload = Depends(require_permission("manufacturing", "edit")), + db: AsyncSession = Depends(get_pg_session), ): """Upsert a lifecycle history entry for the given status_id. @@ -214,8 +225,8 @@ async def create_lifecycle_entry( a status is visited more than once (max one entry per status). """ from datetime import datetime, timezone - db = get_firestore() - docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream()) + fs = get_firestore() + docs = list(fs.collection("devices").where("serial_number", "==", sn).limit(1).stream()) if not docs: raise HTTPException(status_code=404, detail="Device not found") doc_ref = docs[0].reference @@ -229,19 +240,28 @@ async def create_lifecycle_entry( "set_by": user.email, } - # Overwrite existing entry for this status if present, else append existing_idx = next( (i for i, e in enumerate(history) if e.get("status_id") == body.status_id), None, ) - if existing_idx is not None: + is_update = existing_idx is not None + if is_update: history[existing_idx] = new_entry else: history.append(new_entry) doc_ref.update({"lifecycle_history": history}) from manufacturing.service import _doc_to_inventory_item - return _doc_to_inventory_item(doc_ref.get()) + result = _doc_to_inventory_item(doc_ref.get()) + await log_action( + db, user.sub, user.email, + action="UPDATE" if is_update else "CREATE", + entity_type="device", + entity_id=sn, + entity_label=sn, + meta={"lifecycle_status": body.status_id, "date": new_entry["date"], "note": body.note}, + ) + return result @router.delete("/devices/{sn}/lifecycle/{index}", response_model=DeviceInventoryItem) @@ -249,10 +269,11 @@ async def delete_lifecycle_entry( sn: str, index: int, user: TokenPayload = Depends(require_permission("manufacturing", "edit")), + db: AsyncSession = Depends(get_pg_session), ): """Delete a lifecycle history entry by index. Cannot delete the entry for the current status.""" - db = get_firestore() - docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream()) + fs = get_firestore() + docs = list(fs.collection("devices").where("serial_number", "==", sn).limit(1).stream()) if not docs: raise HTTPException(status_code=404, detail="Device not found") doc_ref = docs[0].reference @@ -261,12 +282,22 @@ async def delete_lifecycle_entry( if index < 0 or index >= len(history): raise HTTPException(status_code=400, detail="Invalid lifecycle entry index") current_status = data.get("mfg_status", "") - if history[index].get("status_id") == current_status: + deleted_entry = history[index] + if deleted_entry.get("status_id") == current_status: raise HTTPException(status_code=400, detail="Cannot delete the entry for the current status. Change the status first.") history.pop(index) doc_ref.update({"lifecycle_history": history}) from manufacturing.service import _doc_to_inventory_item - return _doc_to_inventory_item(doc_ref.get()) + result = _doc_to_inventory_item(doc_ref.get()) + await log_action( + db, user.sub, user.email, + action="DELETE", + entity_type="device", + entity_id=sn, + entity_label=sn, + meta={"lifecycle_status": deleted_entry.get("status_id"), "index": index}, + ) + return result @router.get("/devices/{sn}/nvs.bin") @@ -276,12 +307,16 @@ async def download_nvs( hw_revision_override: Optional[str] = Query(None, description="Override hw_revision written to NVS (for bespoke firmware)"), nvs_schema: Optional[str] = Query(None, description="NVS schema to use: 'legacy' or 'new' (default)"), user: TokenPayload = Depends(require_permission("manufacturing", "view")), + db: AsyncSession = Depends(get_pg_session), ): binary = service.get_nvs_binary(sn, hw_type_override=hw_type_override, hw_revision_override=hw_revision_override, legacy=(nvs_schema == "legacy")) - await audit.log_action( - admin_user=user.email, - action="device_flashed", - serial_number=sn, + await log_action( + db, user.sub, user.email, + action="COMMAND", + entity_type="device", + entity_id=sn, + entity_label=sn, + meta={"command": "nvs_flash", "hw_type_override": hw_type_override, "nvs_schema": nvs_schema or "new"}, ) return Response( content=binary, @@ -295,16 +330,19 @@ async def assign_device( sn: str, body: DeviceAssign, user: TokenPayload = Depends(require_permission("manufacturing", "edit")), + db: AsyncSession = Depends(get_pg_session), ): try: result = service.assign_device(sn, body) except NotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) - await audit.log_action( - admin_user=user.email, - action="device_assigned", - serial_number=sn, - detail={"customer_id": body.customer_id}, + await log_action( + db, user.sub, user.email, + action="UPDATE", + entity_type="device", + entity_id=sn, + entity_label=sn, + meta={"customer_id": body.customer_id}, ) return result @@ -314,6 +352,7 @@ async def delete_device( sn: str, force: bool = Query(False, description="Required to delete sold/claimed devices"), user: TokenPayload = Depends(require_permission("manufacturing", "delete")), + db: AsyncSession = Depends(get_pg_session), ): """Delete a device. Sold/claimed devices require force=true.""" try: @@ -322,11 +361,13 @@ async def delete_device( raise HTTPException(status_code=404, detail="Device not found") except PermissionError as e: raise HTTPException(status_code=403, detail=str(e)) - await audit.log_action( - admin_user=user.email, - action="device_deleted", - serial_number=sn, - detail={"force": force}, + await log_action( + db, user.sub, user.email, + action="DELETE", + entity_type="device", + entity_id=sn, + entity_label=sn, + meta={"force": force}, ) @@ -334,6 +375,7 @@ async def delete_device( async def send_manufactured_email( sn: str, user: TokenPayload = Depends(require_permission("manufacturing", "edit")), + db: AsyncSession = Depends(get_pg_session), ): """Send the 'device manufactured' notification to the assigned customer's email.""" db = get_firestore() @@ -361,11 +403,13 @@ async def send_manufactured_email( device_name=hw_family.replace("_", " ").title(), customer_name=customer_name, ) - await audit.log_action( - admin_user=user.email, - action="email_manufactured_sent", - serial_number=sn, - detail={"recipient": email}, + await log_action( + db, user.sub, user.email, + action="COMMAND", + entity_type="device", + entity_id=sn, + entity_label=sn, + meta={"command": "email_manufactured", "recipient": email}, ) @@ -373,6 +417,7 @@ async def send_manufactured_email( async def send_assigned_email( sn: str, user: TokenPayload = Depends(require_permission("manufacturing", "edit")), + db: AsyncSession = Depends(get_pg_session), ): """Send the 'device assigned / app instructions' email to the assigned user(s).""" db = get_firestore() @@ -407,24 +452,30 @@ async def send_assigned_email( errors.append(str(exc)) if errors: raise HTTPException(status_code=500, detail=f"Some emails failed: {'; '.join(errors)}") - await audit.log_action( - admin_user=user.email, - action="email_assigned_sent", - serial_number=sn, - detail={"user_count": len(user_list)}, + await log_action( + db, user.sub, user.email, + action="COMMAND", + entity_type="device", + entity_id=sn, + entity_label=sn, + meta={"command": "email_assigned", "user_count": len(user_list)}, ) @router.delete("/devices", status_code=200) async def delete_unprovisioned( user: TokenPayload = Depends(require_permission("manufacturing", "delete")), + db: AsyncSession = Depends(get_pg_session), ): """Delete all devices with status 'manufactured' (never provisioned).""" deleted = service.delete_unprovisioned_devices() - await audit.log_action( - admin_user=user.email, - action="bulk_delete_unprovisioned", - detail={"count": len(deleted), "serial_numbers": deleted}, + await log_action( + db, user.sub, user.email, + action="DELETE", + entity_type="device_batch", + entity_id="bulk_unprovisioned", + entity_label=f"Bulk delete unprovisioned ({len(deleted)} devices)", + meta={"count": len(deleted), "serial_numbers": deleted}, ) return {"deleted": deleted, "count": len(deleted)} @@ -466,6 +517,7 @@ async def delete_flash_asset( hw_type: str, asset: str, user: TokenPayload = Depends(require_permission("manufacturing", "delete")), + db: AsyncSession = Depends(get_pg_session), ): """Delete a single flash asset file (bootloader.bin or partitions.bin).""" if asset not in VALID_FLASH_ASSETS: @@ -474,10 +526,13 @@ async def delete_flash_asset( service.delete_flash_asset(hw_type, asset) except NotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) - await audit.log_action( - admin_user=user.email, - action="flash_asset_deleted", - detail={"hw_type": hw_type, "asset": asset}, + await log_action( + db, user.sub, user.email, + action="DELETE", + entity_type="firmware", + entity_id=f"{hw_type}/{asset}", + entity_label=f"{hw_type} / {asset}", + meta={"hw_type": hw_type, "asset": asset}, ) @@ -489,7 +544,8 @@ class FlashAssetNoteBody(BaseModel): async def set_flash_asset_note( hw_type: str, body: FlashAssetNoteBody, - _user: TokenPayload = Depends(require_permission("manufacturing", "edit")), + user: TokenPayload = Depends(require_permission("manufacturing", "edit")), + db: AsyncSession = Depends(get_pg_session), ): """Save (or overwrite) the note for a hw_type's flash asset set. @@ -497,6 +553,14 @@ async def set_flash_asset_note( Pass an empty string to clear the note. """ service.set_flash_asset_note(hw_type, body.note) + await log_action( + db, user.sub, user.email, + action="UPDATE", + entity_type="firmware", + entity_id=hw_type, + entity_label=hw_type, + meta={"note": body.note}, + ) @router.post("/flash-assets/{hw_type}/{asset}", status_code=204) @@ -504,7 +568,8 @@ async def upload_flash_asset( hw_type: str, asset: str, file: UploadFile = File(...), - _user: TokenPayload = Depends(require_permission("manufacturing", "add")), + user: TokenPayload = Depends(require_permission("manufacturing", "add")), + db: AsyncSession = Depends(get_pg_session), ): """Upload a bootloader.bin or partitions.bin for a given hw_type. @@ -512,13 +577,20 @@ async def upload_flash_asset( and .pio/build/{env}/partitions.bin). Upload them once per hw_type after each PlatformIO build that changes the partition layout. """ - # hw_type can be a standard board type OR a bespoke UID (any non-empty slug) if not hw_type or len(hw_type) > 128: raise HTTPException(status_code=400, detail="Invalid hw_type/bespoke UID.") if asset not in VALID_FLASH_ASSETS: raise HTTPException(status_code=400, detail=f"Invalid asset. Must be one of: {', '.join(sorted(VALID_FLASH_ASSETS))}") data = await file.read() service.save_flash_asset(hw_type, asset, data) + await log_action( + db, user.sub, user.email, + action="CREATE", + entity_type="firmware", + entity_id=f"{hw_type}/{asset}", + entity_label=f"{hw_type} / {asset}", + meta={"hw_type": hw_type, "asset": asset, "size_bytes": len(data)}, + ) @router.get("/devices/{sn}/bootloader.bin") diff --git a/frontend/src/pages/settings/LogViewerPage.jsx b/frontend/src/pages/settings/LogViewerPage.jsx index f24d45f..946dbf6 100644 --- a/frontend/src/pages/settings/LogViewerPage.jsx +++ b/frontend/src/pages/settings/LogViewerPage.jsx @@ -32,17 +32,18 @@ const ACTION_META = { } const ENTITY_LABELS = { - customer: 'Customer', - order: 'Order', - device: 'Device', - melody: 'Melody', - product: 'Product', - staff: 'Staff', - ticket: 'Ticket', - note: 'Note', - quotation: 'Quotation', - firmware: 'Firmware', - archetype: 'Archetype', + customer: 'Customer', + order: 'Order', + device: 'Device', + device_batch: 'Device Batch', + melody: 'Melody', + product: 'Product', + staff: 'Staff', + ticket: 'Ticket', + note: 'Note', + quotation: 'Quotation', + firmware: 'Firmware', + archetype: 'Archetype', } const ACTION_OPTIONS = [ @@ -246,7 +247,7 @@ function LogRow({ entry, isExpanded, onToggle }) { {/* Action badge */} -