Compare commits

...

2 Commits

Author SHA1 Message Date
1022b7e5f1 fix: remove duplicate port mapping 8001:5174 from frontend service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:22:14 +03:00
024ba88470 fix: route manufacturing audit logs to shared Postgres audit log
- Manufacturing router now uses shared/audit.log_action (Postgres) instead
  of the separate manufacturing/audit.py (SQLite mfg_audit_log), so all
  manufacturing events appear in the Log Viewer
- Added log_action calls to 5 previously unlogged endpoints: lifecycle
  patch, lifecycle create, lifecycle delete, flash asset upload, flash
  asset note
- Removed the now-redundant /manufacturing/audit-log endpoint
- Log Viewer restricted to sysadmin only: backend uses require_sysadmin
  (was require_admin_or_above), frontend adds role guard on the page
- Fixed Action badge column clipping: table-layout auto + whiteSpace nowrap
  so the column sizes to fit the widest badge (Status Change)
- Added device_batch entity type to Log Viewer entity labels and filters

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:21:39 +03:00
4 changed files with 173 additions and 86 deletions

View File

@@ -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 = []

View File

@@ -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")

View File

@@ -26,7 +26,6 @@ services:
- /app/node_modules
ports:
- "5173:5174"
- "8001:5174"
networks:
- internal

View File

@@ -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 }) {
</td>
{/* Action badge */}
<td className="log-cell">
<td className="log-cell" style={{ whiteSpace: 'nowrap' }}>
<StatusBadge variant={variant}>{label}</StatusBadge>
</td>
@@ -492,9 +493,7 @@ const INITIAL_FILTERS = {
offset: 0,
}
export default function LogViewerPage() {
const { user } = useAuth()
function LogViewerContent() {
const [entries, setEntries] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -592,7 +591,7 @@ export default function LogViewerPage() {
.log-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
table-layout: auto;
}
.log-table thead th {
padding: var(--space-2) var(--space-4);
@@ -668,7 +667,7 @@ export default function LogViewerPage() {
<tr>
<th style={{ width: 148 }}>Timestamp</th>
<th style={{ width: 160 }}>Staff</th>
<th style={{ width: 110 }}>Action</th>
<th style={{ whiteSpace: 'nowrap' }}>Action</th>
<th>Entity</th>
<th style={{ width: 120 }}>ID</th>
<th style={{ width: 40 }} />
@@ -752,3 +751,20 @@ export default function LogViewerPage() {
</>
)
}
export default function LogViewerPage() {
const { hasRole } = useAuth()
if (!hasRole('sysadmin')) {
return (
<div className="page-wrapper">
<PageHeader title="Log Viewer" subtitle="Staff actions and system events across the console" />
<div style={{ padding: 'var(--space-12)', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)' }}>
Access restricted to system administrators.
</div>
</div>
)
}
return <LogViewerContent />
}