Compare commits
5 Commits
9a213e93f8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ed5012a95 | |||
| 72f7e00990 | |||
| 9df80dd4e1 | |||
| 1022b7e5f1 | |||
| 024ba88470 |
@@ -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 = []
|
||||
|
||||
@@ -33,6 +33,8 @@ class Settings(BaseSettings):
|
||||
built_melodies_storage_path: str = "./storage/built_melodies"
|
||||
firmware_storage_path: str = "./storage/firmware"
|
||||
flash_assets_storage_path: str = "./storage/flash_assets"
|
||||
melody_binaries_storage_path: str = "./storage/melody_binaries"
|
||||
melody_download_base_url: str = "http://melodies.bellsystems.net/download"
|
||||
|
||||
# Email (Resend)
|
||||
resend_api_key: str = "re_placeholder_change_me"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, Query, HTTPException, Response
|
||||
from fastapi.responses import FileResponse
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from auth.models import TokenPayload
|
||||
@@ -13,6 +14,22 @@ from shared.audit import log_action
|
||||
router = APIRouter(prefix="/api/melodies", tags=["melodies"])
|
||||
|
||||
|
||||
@router.get("/download/{pid}")
|
||||
async def download_binary_by_pid(pid: str):
|
||||
"""Download a melody's .bsm binary by PID over plain HTTP.
|
||||
|
||||
No auth — ESP32 devices call this directly and can't afford a TLS client's
|
||||
memory footprint, so this is served from a plain-HTTP-only host
|
||||
(melodies.bellsystems.net) rather than the HTTPS console domain.
|
||||
"""
|
||||
path = await service.get_binary_path_by_pid(pid)
|
||||
return FileResponse(
|
||||
path=str(path),
|
||||
media_type="application/octet-stream",
|
||||
filename=f"{pid}.bsm",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=MelodyListResponse)
|
||||
async def list_melodies(
|
||||
search: Optional[str] = Query(None),
|
||||
@@ -134,15 +151,21 @@ async def upload_file(
|
||||
|
||||
if file_type == "binary":
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
url = service.upload_file_for_melody(
|
||||
melody_id=melody_id,
|
||||
melody_uid=melody.uid,
|
||||
melody_pid=melody.pid,
|
||||
file_bytes=contents,
|
||||
filename=file.filename,
|
||||
content_type=content_type,
|
||||
)
|
||||
if not melody.pid:
|
||||
raise HTTPException(status_code=400, detail="Melody must have a PID before uploading a binary")
|
||||
url = service.save_binary_for_melody(
|
||||
pid=melody.pid,
|
||||
file_bytes=contents,
|
||||
)
|
||||
else:
|
||||
url = service.upload_file_for_melody(
|
||||
melody_id=melody_id,
|
||||
melody_uid=melody.uid,
|
||||
melody_pid=melody.pid,
|
||||
file_bytes=contents,
|
||||
filename=file.filename,
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
# Update the melody document with the file URL
|
||||
if file_type == "preview":
|
||||
@@ -169,7 +192,7 @@ async def delete_file(
|
||||
raise HTTPException(status_code=400, detail="file_type must be 'binary' or 'preview'")
|
||||
|
||||
melody = await service.get_melody(melody_id)
|
||||
service.delete_file(melody_id, file_type, melody.uid)
|
||||
await service.delete_file(melody_id, file_type, melody.uid, melody.pid)
|
||||
|
||||
|
||||
@router.get("/{melody_id}/files")
|
||||
@@ -179,7 +202,7 @@ async def get_files(
|
||||
):
|
||||
"""Get storage file URLs for a melody."""
|
||||
melody = await service.get_melody(melody_id)
|
||||
return service.get_storage_files(melody_id, melody.uid)
|
||||
return service.get_storage_files(melody_id, melody.uid, melody.pid)
|
||||
|
||||
|
||||
@router.patch("/{melody_id}/set-outdated", response_model=MelodyInDB)
|
||||
@@ -206,7 +229,7 @@ async def download_binary_file(
|
||||
):
|
||||
"""Download current melody binary with a PID-based filename."""
|
||||
melody = await service.get_melody(melody_id)
|
||||
file_bytes, content_type = service.get_binary_file_bytes(melody_id, melody.uid)
|
||||
file_bytes, content_type = service.get_binary_file_bytes(melody_id, melody.pid)
|
||||
filename = f"{(melody.pid or 'binary')}.bsm"
|
||||
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
|
||||
return Response(content=file_bytes, media_type=content_type, headers=headers)
|
||||
|
||||
@@ -2,15 +2,39 @@ import json
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from shared.firebase import get_db as get_firestore, get_bucket
|
||||
from shared.exceptions import NotFoundError
|
||||
from melodies.models import MelodyCreate, MelodyUpdate, MelodyInDB
|
||||
from melodies import database as melody_db
|
||||
from config import settings
|
||||
|
||||
COLLECTION = "melodies"
|
||||
logger = logging.getLogger("melodies.service")
|
||||
|
||||
# Local disk storage for melody .bsm binaries — served over plain HTTP for ESP32 compatibility.
|
||||
# The audio preview file still goes to Firebase Storage (only ever fetched by the browser admin UI).
|
||||
BINARY_STORAGE_DIR = Path(settings.melody_binaries_storage_path)
|
||||
|
||||
|
||||
def _ensure_binary_storage_dir():
|
||||
BINARY_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _binary_file_path(pid: str) -> Path:
|
||||
"""Path to the local .bsm file for a given archetype PID.
|
||||
|
||||
Keyed on pid, not melody uid: pid identifies the underlying archetype binary
|
||||
(raw note sequence). Multiple melodies can legitimately share one pid — each
|
||||
melody remaps those notes to actual bells via its own noteAssignments/speed/
|
||||
duration settings — so several melodies writing/reading the same {pid}.bsm
|
||||
file is expected, not a collision.
|
||||
"""
|
||||
return BINARY_STORAGE_DIR / f"{pid}.bsm"
|
||||
|
||||
|
||||
def _parse_localized_string(value: str) -> dict:
|
||||
"""Parse a JSON-encoded localized string into a dict. Returns {} on failure."""
|
||||
@@ -232,12 +256,50 @@ async def delete_melody(melody_id: str) -> None:
|
||||
doc_ref.delete()
|
||||
|
||||
# Delete storage files
|
||||
_delete_storage_files(melody_id, row["data"].get("uid"))
|
||||
await _delete_storage_files(melody_id, row["data"].get("uid"), row["data"].get("pid"))
|
||||
|
||||
# Delete from SQLite
|
||||
await melody_db.delete_melody(melody_id)
|
||||
|
||||
|
||||
def save_binary_for_melody(pid: str, file_bytes: bytes) -> str:
|
||||
"""Save an archetype binary to local disk under its pid, replacing any previous
|
||||
file for that pid. Multiple melodies can share one pid (they remap the same
|
||||
underlying note sequence via their own noteAssignments/speed/duration), so
|
||||
writing the same {pid}.bsm from a different melody is expected — not a collision.
|
||||
|
||||
Returns the plain-HTTP download URL devices use (served from melody_download_base_url,
|
||||
not Firebase — ESP32 devices can't afford the RAM for a TLS client).
|
||||
"""
|
||||
_ensure_binary_storage_dir()
|
||||
path = _binary_file_path(pid)
|
||||
path.write_bytes(file_bytes)
|
||||
return f"{settings.melody_download_base_url}/{pid}"
|
||||
|
||||
|
||||
async def get_binary_path_by_pid(pid: str) -> Path:
|
||||
"""Resolve a pid to its local .bsm file path. Used by the unauthenticated
|
||||
device-facing download route."""
|
||||
path = _binary_file_path(pid)
|
||||
if path.exists():
|
||||
return path
|
||||
raise NotFoundError("Binary file")
|
||||
|
||||
|
||||
def delete_local_binary(pid: str) -> None:
|
||||
"""Delete the local .bsm binary file for a pid, if present.
|
||||
|
||||
Only call this when no other melody still references this pid — since the
|
||||
file may be shared by multiple melodies, deleting one melody shouldn't
|
||||
reflexively delete a binary others still use.
|
||||
"""
|
||||
if not pid:
|
||||
return
|
||||
path = _binary_file_path(pid)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
|
||||
def upload_file(melody_id: str, file_bytes: bytes, filename: str, content_type: str) -> str:
|
||||
"""Upload a file to Firebase Storage under melodies/{melody_id}/."""
|
||||
bucket = get_bucket()
|
||||
@@ -334,32 +396,32 @@ def upload_file_for_melody(melody_id: str, melody_uid: str | None, melody_pid: s
|
||||
return blob.public_url
|
||||
|
||||
|
||||
def get_binary_file_bytes(melody_id: str, melody_uid: str | None = None) -> tuple[bytes, str]:
|
||||
"""Fetch current binary bytes for a melody from Firebase Storage."""
|
||||
bucket = get_bucket()
|
||||
if not bucket:
|
||||
raise RuntimeError("Firebase Storage not initialized")
|
||||
|
||||
prefixes = _storage_prefixes(melody_id, melody_uid)
|
||||
blobs = [b for b in _list_blobs_for_prefixes(bucket, prefixes) if _is_binary_blob_name(b.name)]
|
||||
if not blobs:
|
||||
raise NotFoundError("Binary file")
|
||||
|
||||
# Prefer explicit binary.* naming, then newest.
|
||||
blobs.sort(
|
||||
key=lambda b: (
|
||||
0 if "binary" in b.name.rsplit("/", 1)[-1].lower() else 1,
|
||||
-(int(b.time_created.timestamp()) if getattr(b, "time_created", None) else 0),
|
||||
)
|
||||
async def _pid_used_by_other_melody(pid: str, exclude_melody_id: str) -> bool:
|
||||
"""Check whether any melody other than exclude_melody_id still references this pid."""
|
||||
if not pid:
|
||||
return False
|
||||
rows = await melody_db.list_melodies()
|
||||
return any(
|
||||
row["id"] != exclude_melody_id and row["data"].get("pid") == pid
|
||||
for row in rows
|
||||
)
|
||||
chosen = blobs[0]
|
||||
data = chosen.download_as_bytes()
|
||||
content_type = chosen.content_type or "application/octet-stream"
|
||||
return data, content_type
|
||||
|
||||
|
||||
def delete_file(melody_id: str, file_type: str, melody_uid: str | None = None) -> None:
|
||||
def get_binary_file_bytes(melody_id: str, melody_pid: str | None = None) -> tuple[bytes, str]:
|
||||
"""Fetch current binary bytes for a melody from local disk storage."""
|
||||
path = _binary_file_path(melody_pid) if melody_pid else None
|
||||
if not path or not path.exists():
|
||||
raise NotFoundError("Binary file")
|
||||
return path.read_bytes(), "application/octet-stream"
|
||||
|
||||
|
||||
async def delete_file(melody_id: str, file_type: str, melody_uid: str | None = None, melody_pid: str | None = None) -> None:
|
||||
"""Delete a specific file from storage. file_type is 'binary' or 'preview'."""
|
||||
if file_type == "binary":
|
||||
if melody_pid and not await _pid_used_by_other_melody(melody_pid, melody_id):
|
||||
delete_local_binary(melody_pid)
|
||||
return
|
||||
|
||||
bucket = get_bucket()
|
||||
if not bucket:
|
||||
return
|
||||
@@ -368,14 +430,19 @@ def delete_file(melody_id: str, file_type: str, melody_uid: str | None = None) -
|
||||
blobs = _list_blobs_for_prefixes(bucket, prefixes)
|
||||
|
||||
for blob in blobs:
|
||||
if file_type == "binary" and "binary" in blob.name:
|
||||
blob.delete()
|
||||
elif file_type == "preview" and "preview" in blob.name:
|
||||
if file_type == "preview" and "preview" in blob.name:
|
||||
blob.delete()
|
||||
|
||||
|
||||
def _delete_storage_files(melody_id: str, melody_uid: str | None = None) -> None:
|
||||
"""Delete all storage files for a melody."""
|
||||
async def _delete_storage_files(melody_id: str, melody_uid: str | None = None, melody_pid: str | None = None) -> None:
|
||||
"""Delete all storage files for a melody (local binary + Firebase preview).
|
||||
|
||||
The local binary is only deleted if no other melody still references the same
|
||||
pid — multiple melodies can share one archetype binary by design.
|
||||
"""
|
||||
if melody_pid and not await _pid_used_by_other_melody(melody_pid, melody_id):
|
||||
delete_local_binary(melody_pid)
|
||||
|
||||
bucket = get_bucket()
|
||||
if not bucket:
|
||||
return
|
||||
@@ -383,24 +450,29 @@ def _delete_storage_files(melody_id: str, melody_uid: str | None = None) -> None
|
||||
prefixes = _storage_prefixes(melody_id, melody_uid)
|
||||
blobs = _list_blobs_for_prefixes(bucket, prefixes)
|
||||
for blob in blobs:
|
||||
if _is_binary_blob_name(blob.name):
|
||||
continue # legacy Firebase binaries, if any, are no longer authoritative
|
||||
blob.delete()
|
||||
|
||||
|
||||
def get_storage_files(melody_id: str, melody_uid: str | None = None) -> dict:
|
||||
"""List storage files for a melody, returning URLs."""
|
||||
def get_storage_files(melody_id: str, melody_uid: str | None = None, melody_pid: str | None = None) -> dict:
|
||||
"""List storage files for a melody, returning URLs. Binary comes from local disk,
|
||||
preview still comes from Firebase Storage."""
|
||||
result = {"binary_url": None, "preview_url": None}
|
||||
|
||||
if melody_pid and _binary_file_path(melody_pid).exists():
|
||||
result["binary_url"] = f"{settings.melody_download_base_url}/{melody_pid}"
|
||||
|
||||
bucket = get_bucket()
|
||||
if not bucket:
|
||||
return {"binary_url": None, "preview_url": None}
|
||||
return result
|
||||
|
||||
prefixes = _storage_prefixes(melody_id, melody_uid)
|
||||
blobs = _list_blobs_for_prefixes(bucket, prefixes)
|
||||
|
||||
result = {"binary_url": None, "preview_url": None}
|
||||
for blob in blobs:
|
||||
blob.make_public()
|
||||
if _is_binary_blob_name(blob.name):
|
||||
result["binary_url"] = blob.public_url
|
||||
elif "preview" in blob.name:
|
||||
if "preview" in blob.name:
|
||||
blob.make_public()
|
||||
result["preview_url"] = blob.public_url
|
||||
|
||||
return result
|
||||
|
||||
262
backend/scripts/migrate_melody_binaries_to_local.py
Normal file
262
backend/scripts/migrate_melody_binaries_to_local.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""
|
||||
One-time migration: move every melody's .bsm binary off Firebase Storage and
|
||||
onto local disk, updating melody.url to point at our own plain-HTTP download
|
||||
endpoint instead of the Firebase public URL.
|
||||
|
||||
Background: ESP32 devices can't afford the RAM for a TLS client, so melody
|
||||
binaries are now served from ./storage/melody_binaries via
|
||||
GET /api/melodies/download/{pid} (exposed publicly as
|
||||
melodies.bellsystems.net/download/{pid}) instead of Firebase Storage's
|
||||
HTTPS-only URLs. This script backfills existing melodies created before that
|
||||
change — melodies created/edited after the change already use the new path
|
||||
automatically (via "Select Archetype" / "Build on the Fly").
|
||||
|
||||
What this script does, for every melody whose url points at Firebase Storage:
|
||||
1. Downloads the .bsm bytes from the Firebase Storage URL
|
||||
2. Writes them to ./storage/melody_binaries/{pid}.bsm
|
||||
3. Updates melody.url -> http://melodies.bellsystems.net/download/{pid}
|
||||
4. Updates both SQLite (melody_drafts) and Firestore (if published)
|
||||
|
||||
Storage is keyed on pid, not melody uid: pid identifies the underlying
|
||||
archetype binary, and multiple melodies legitimately share one pid (each
|
||||
remaps the same note sequence to different bells/speed/duration). The script
|
||||
downloads each distinct pid's binary only once (from whichever melody it
|
||||
encounters first) and reuses it for every other melody sharing that pid.
|
||||
|
||||
CAVEAT: some existing melodies share a pid despite pointing at *different*
|
||||
Firebase source files (observed in practice — e.g. voice-count variants like
|
||||
1N_/2N_/3N_/4N_-prefixed filenames all filed under one pid). This script does
|
||||
NOT attempt to detect or fix that — it flags it in the output (see "WARNING:
|
||||
also seen with a different source file") so you can review and re-assign the
|
||||
correct archetype per melody afterward using the in-app playback button. This
|
||||
is a pre-existing PID data issue, not something safe to silently resolve here.
|
||||
|
||||
Melodies with no pid are skipped and reported, since the new URL scheme
|
||||
requires one (pid is the public lookup key for the download route).
|
||||
|
||||
Run from the backend/ directory (or scripts/ — it searches upward for .env):
|
||||
|
||||
docker exec -it bellsystems-backend python scripts/migrate_melody_binaries_to_local.py --dry-run
|
||||
docker exec -it bellsystems-backend python scripts/migrate_melody_binaries_to_local.py
|
||||
|
||||
Requires: firebase-admin, requests (both already in the backend image).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
# Melody names may contain Greek/non-ASCII text — force UTF-8 stdout so this
|
||||
# doesn't crash on Windows consoles defaulting to cp1252.
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# .env loader — searches upward from script location for a .env file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_env() -> dict:
|
||||
search = Path(__file__).resolve().parent
|
||||
for _ in range(4):
|
||||
env_file = search / ".env"
|
||||
if env_file.exists():
|
||||
result = {}
|
||||
for line in env_file.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, val = line.partition("=")
|
||||
result[key.strip()] = val.strip().strip('"').strip("'")
|
||||
print(f"[INFO] Loaded config from {env_file}")
|
||||
return result
|
||||
search = search.parent
|
||||
print("[WARN] No .env file found — relying on environment variables")
|
||||
return {}
|
||||
|
||||
|
||||
_env = _load_env()
|
||||
|
||||
|
||||
def _cfg(key: str, default: str = "") -> str:
|
||||
return _env.get(key) or os.environ.get(key) or default
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Firebase (only needed to read Firestore for published melodies)
|
||||
# ---------------------------------------------------------------------------
|
||||
try:
|
||||
import firebase_admin
|
||||
from firebase_admin import credentials, firestore
|
||||
|
||||
_fb_app = None
|
||||
|
||||
def _init_firebase(sa_path: str, bucket_name: str):
|
||||
global _fb_app
|
||||
if _fb_app is not None:
|
||||
return
|
||||
cred = credentials.Certificate(sa_path)
|
||||
_fb_app = firebase_admin.initialize_app(cred, {"storageBucket": bucket_name})
|
||||
|
||||
def get_firestore(sa_path: str, bucket_name: str):
|
||||
_init_firebase(sa_path, bucket_name)
|
||||
return firestore.client()
|
||||
|
||||
FIREBASE_AVAILABLE = True
|
||||
except Exception as _fb_err:
|
||||
print(f"[WARN] Firebase unavailable: {_fb_err}")
|
||||
FIREBASE_AVAILABLE = False
|
||||
|
||||
def get_firestore(sa_path: str, bucket_name: str):
|
||||
return None
|
||||
|
||||
|
||||
def _is_firebase_url(url: str) -> bool:
|
||||
return bool(url) and ("firebasestorage" in url or "storage.googleapis.com" in url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main migration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run(dry_run: bool = False, db_path: str = "", storage_dir: str = "", base_url: str = ""):
|
||||
label = "[DRY-RUN]" if dry_run else "[LIVE]"
|
||||
db_path = db_path or _cfg("SQLITE_DB_PATH", "./data/database.db")
|
||||
storage_dir = Path(storage_dir or _cfg("MELODY_BINARIES_STORAGE_PATH", "./storage/melody_binaries"))
|
||||
base_url = base_url or _cfg("MELODY_DOWNLOAD_BASE_URL", "http://melodies.bellsystems.net/download")
|
||||
sa_path = _cfg("FIREBASE_SERVICE_ACCOUNT_PATH", "./firebase-service-account.json")
|
||||
bucket_name = _cfg("FIREBASE_STORAGE_BUCKET")
|
||||
|
||||
print(f"\n{label} Database: {db_path}")
|
||||
print(f"{label} Local binary storage: {storage_dir}")
|
||||
print(f"{label} New base URL: {base_url}\n")
|
||||
|
||||
firestore_db = get_firestore(sa_path, bucket_name) if FIREBASE_AVAILABLE and bucket_name else None
|
||||
|
||||
con = sqlite3.connect(db_path)
|
||||
con.row_factory = sqlite3.Row
|
||||
|
||||
rows = con.execute("SELECT * FROM melody_drafts").fetchall()
|
||||
candidates = []
|
||||
for row in rows:
|
||||
data = json.loads(row["data"])
|
||||
if _is_firebase_url(data.get("url", "")):
|
||||
candidates.append((dict(row), data))
|
||||
|
||||
if not candidates:
|
||||
print("No melodies with a Firebase Storage url found. Nothing to do.")
|
||||
con.close()
|
||||
return
|
||||
|
||||
print(f"Found {len(candidates)} melody(ies) with a Firebase Storage binary url:\n")
|
||||
|
||||
if not dry_run:
|
||||
storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
migrated = 0
|
||||
skipped_no_pid = 0
|
||||
failed = 0
|
||||
downloaded_pids: dict[str, bool] = {} # pid -> whether the local file is ready to use
|
||||
pid_source_urls: dict[str, str] = {} # pid -> first-seen Firebase source url, to detect mismatches
|
||||
mismatch_warnings = []
|
||||
|
||||
for row, data in candidates:
|
||||
melody_id = row["id"]
|
||||
pid = data.get("pid")
|
||||
old_url = data.get("url")
|
||||
name = (data.get("information") or {}).get("name", "")
|
||||
|
||||
label_id = f"[{melody_id[:8]}]"
|
||||
|
||||
if not pid:
|
||||
print(f" {label_id} SKIPPED: no pid set — the new download route requires one (name: {name})")
|
||||
skipped_no_pid += 1
|
||||
continue
|
||||
|
||||
print(f" {label_id} {name!r} pid={pid}")
|
||||
print(f" {old_url}")
|
||||
|
||||
first_seen_url = pid_source_urls.get(pid)
|
||||
if first_seen_url and first_seen_url != old_url:
|
||||
warning = (f"pid '{pid}': melody {label_id} points at a DIFFERENT Firebase file "
|
||||
f"than the one already saved for this pid — only the first one encountered "
|
||||
f"is kept as {pid}.bsm. Verify with the playback button after migrating.")
|
||||
print(f" WARNING: also seen with a different source file! {warning}")
|
||||
mismatch_warnings.append(warning)
|
||||
else:
|
||||
pid_source_urls[pid] = old_url
|
||||
|
||||
dest = storage_dir / f"{pid}.bsm"
|
||||
new_url = f"{base_url}/{pid}"
|
||||
|
||||
if dry_run:
|
||||
if pid in downloaded_pids:
|
||||
print(f" -> pid already handled by another melody in this run, would reuse {dest}")
|
||||
else:
|
||||
print(f" -> would download and save to {dest}")
|
||||
print(f" -> would set url = {new_url}")
|
||||
downloaded_pids[pid] = True
|
||||
continue
|
||||
|
||||
if pid not in downloaded_pids:
|
||||
try:
|
||||
resp = requests.get(old_url, timeout=30)
|
||||
resp.raise_for_status()
|
||||
except Exception as e:
|
||||
print(f" ERROR downloading binary: {e}")
|
||||
failed += 1
|
||||
continue
|
||||
dest.write_bytes(resp.content)
|
||||
print(f" saved {len(resp.content)} bytes -> {dest}")
|
||||
downloaded_pids[pid] = True
|
||||
else:
|
||||
print(f" reusing already-downloaded {dest} (shared pid)")
|
||||
|
||||
print(f" url -> {new_url}")
|
||||
|
||||
data["url"] = new_url
|
||||
con.execute(
|
||||
"UPDATE melody_drafts SET data=? WHERE id=?",
|
||||
(json.dumps(data), melody_id),
|
||||
)
|
||||
con.commit()
|
||||
|
||||
if row["status"] == "published":
|
||||
if firestore_db:
|
||||
try:
|
||||
firestore_db.collection("melodies").document(melody_id).update({"url": new_url})
|
||||
print(f" Firestore updated")
|
||||
except Exception as e:
|
||||
print(f" ERROR updating Firestore: {e}")
|
||||
else:
|
||||
print(f" WARNING: melody is published but Firestore unavailable!")
|
||||
|
||||
migrated += 1
|
||||
|
||||
con.close()
|
||||
print(f"\n{'-'*60}")
|
||||
print(f"{label} Done. Migrated: {migrated}, skipped (no pid): {skipped_no_pid}, failed: {failed}")
|
||||
if mismatch_warnings:
|
||||
print(f"\n{len(mismatch_warnings)} pid(s) had melodies pointing at different source files "
|
||||
f"— please review these with the playback button:")
|
||||
for w in mismatch_warnings:
|
||||
print(f" - {w}")
|
||||
if dry_run:
|
||||
print("\nThis was a dry run. No changes were made. Run without --dry-run to apply.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Migrate melody .bsm binaries from Firebase Storage to local disk"
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true", help="Preview changes without writing anything")
|
||||
parser.add_argument("--db", default="", help="Override SQLite database path (default: read from .env)")
|
||||
parser.add_argument("--storage-dir", default="", help="Override local binary storage dir (default: read from .env)")
|
||||
parser.add_argument("--base-url", default="", help="Override download base URL (default: read from .env)")
|
||||
args = parser.parse_args()
|
||||
run(dry_run=args.dry_run, db_path=args.db, storage_dir=args.storage_dir, base_url=args.base_url)
|
||||
@@ -9,6 +9,7 @@ services:
|
||||
- ./data/built_melodies:/app/storage/built_melodies
|
||||
- ./data/firmware:/app/storage/firmware
|
||||
- ./data/flash_assets:/app/storage/flash_assets
|
||||
- ./data/melody_binaries:/app/storage/melody_binaries
|
||||
- ./data/firebase-service-account.json:/app/firebase-service-account.json:ro
|
||||
ports:
|
||||
- "8000:8000"
|
||||
@@ -21,12 +22,6 @@ services:
|
||||
frontend:
|
||||
build: ./frontend
|
||||
container_name: bellsystems-frontend
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
ports:
|
||||
- "5173:5174"
|
||||
- "8001:5174"
|
||||
networks:
|
||||
- internal
|
||||
|
||||
|
||||
119
docs/melody-binary-serving.md
Normal file
119
docs/melody-binary-serving.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Melody Binary Serving (Plain HTTP)
|
||||
|
||||
## Why this exists
|
||||
|
||||
ESP32 devices download `.bsm` melody files directly, using a URL supplied through the
|
||||
Android app. The download previously pointed at a Firebase Storage public URL (HTTPS
|
||||
only). ESP32's TLS client needs 40KB+ of RAM to open an HTTPS connection, which these
|
||||
devices can't reliably spare. To fix this, melody `.bsm` binaries are now stored and
|
||||
served by our own backend over plain HTTP, instead of Firebase Storage.
|
||||
|
||||
This does **not** affect the audio preview file (`information.previewURL`) — that's
|
||||
only ever fetched by the browser admin UI, which is already HTTPS, so it stays on
|
||||
Firebase Storage unchanged.
|
||||
|
||||
## PID vs UID — why storage is keyed on pid, not uid
|
||||
|
||||
- **`pid`** ("Playback ID") identifies the underlying **archetype binary** — the raw
|
||||
note sequence (A, B, C...) baked into a `.bsm` file. Multiple melodies can
|
||||
legitimately share one `pid`: each melody remaps those notes to actual bells via its
|
||||
own `noteAssignments`, speed, and duration settings. One binary can back many
|
||||
different "remix" melodies. **Duplicate PIDs across melodies are correct and
|
||||
expected, not a bug.**
|
||||
- **`uid`** is meant to be each melody's own unique database identity. It is *not*
|
||||
currently populated anywhere in `MelodyForm.jsx` — this is a pre-existing, separate
|
||||
bug, deferred as its own task. Because of this, local binary storage is keyed on
|
||||
`pid`, not `uid`.
|
||||
|
||||
## What changed
|
||||
|
||||
- **Storage**: `.bsm` binaries are written to `./data/melody_binaries/{pid}.bsm` on
|
||||
the host (mounted into the backend container at `/app/storage/melody_binaries`,
|
||||
same pattern as `./data/firmware` and `./data/built_melodies`). Because storage is
|
||||
keyed on `pid`, multiple melodies sharing a `pid` share one file — saving from any
|
||||
of them overwrites the same file, which is expected.
|
||||
- **Upload path**: `SelectArchetypeModal` / `BuildOnTheFlyModal` still call
|
||||
`POST /api/melodies/{melodyId}/upload/binary` exactly as before. The backend
|
||||
(`backend/melodies/service.py::save_binary_for_melody`) now writes the bytes to
|
||||
local disk instead of uploading to Firebase Storage, and returns a URL like
|
||||
`http://melodies.bellsystems.net/download/{pid}`. The melody must have a `pid` set
|
||||
before uploading a binary (the endpoint returns 400 if not).
|
||||
- **Stored URL**: the melody's `url` field (Firestore + SQLite) now holds that
|
||||
plain-HTTP URL instead of a Firebase public URL. No schema change — `url` was
|
||||
already a plain string field.
|
||||
- **Download route**: `GET /api/melodies/download/{pid}` (`backend/melodies/router.py`)
|
||||
is unauthenticated (devices have no login token) and resolves `pid` directly to
|
||||
`{pid}.bsm` on disk, same pattern as the existing firmware download route
|
||||
(`backend/firmware/router.py::download_firmware`).
|
||||
- **Deletion is share-aware**: deleting a melody or its binary
|
||||
(`service.delete_file` / `service._delete_storage_files`) only removes the local
|
||||
`.bsm` file if no *other* melody still references the same `pid`
|
||||
(`service._pid_used_by_other_melody`). This prevents one melody's deletion from
|
||||
breaking playback for other melodies sharing its archetype binary.
|
||||
|
||||
## Migrating existing melodies
|
||||
|
||||
Melodies created before this change still have a Firebase Storage URL in their `url`
|
||||
field — deploying this code does not touch existing data. They keep working exactly
|
||||
as before (still HTTPS to Firebase) until migrated.
|
||||
|
||||
Two ways to move a melody to the new plain-HTTP URL:
|
||||
|
||||
1. **Per-melody, via the UI**: open the melody and re-run "Select Archetype" or
|
||||
"Build on the Fly" — this naturally re-uploads through the same endpoint, which now
|
||||
writes to local disk and updates `url`.
|
||||
2. **Bulk, via script**: `backend/scripts/migrate_melody_binaries_to_local.py`
|
||||
downloads each melody's current Firebase binary and writes it to local disk under
|
||||
its `pid`, then updates `url` (SQLite + Firestore if published). Run with
|
||||
`--dry-run` first:
|
||||
|
||||
```
|
||||
docker exec -it bellsystems-backend python scripts/migrate_melody_binaries_to_local.py --dry-run
|
||||
docker exec -it bellsystems-backend python scripts/migrate_melody_binaries_to_local.py
|
||||
```
|
||||
|
||||
**Known caveat**: some existing melodies share a `pid` despite their Firebase URLs
|
||||
pointing at *different* source files (observed on real data — e.g. voice-count
|
||||
variant filenames like `1N_`/`2N_`/`3N_`/`4N_` all filed under one `pid`). The
|
||||
script downloads only the first melody's file for each `pid` and reuses it for
|
||||
every other melody sharing that `pid` — it does not attempt to detect which file is
|
||||
"correct". It prints a warning list of every `pid` where this happened; use each
|
||||
melody's playback button afterward to verify the right archetype plays, and
|
||||
manually re-assign the correct one via "Select Archetype" if it's wrong.
|
||||
|
||||
## Infrastructure (outside this repo)
|
||||
|
||||
The plain-HTTP requirement is handled by keeping `melodies.bellsystems.net` on a
|
||||
**separate** hostname from `console.bellsystems.net`, so the console's NPM proxy host
|
||||
can stay HTTPS-only with no per-path exceptions.
|
||||
|
||||
Required, one-time setup outside this repository:
|
||||
|
||||
1. **DNS**: add an A/CNAME record for `melodies.bellsystems.net` pointing at the same
|
||||
host as `console.bellsystems.net`.
|
||||
2. **NPM (Nginx Proxy Manager) proxy host**: create a new proxy host for
|
||||
`melodies.bellsystems.net` forwarding to the `nginx` container's exposed port
|
||||
(`90` per `docker-compose.yml`). **Do not force SSL / do not enable the "Force
|
||||
SSL" redirect** on this proxy host — it must remain reachable over plain HTTP.
|
||||
|
||||
The in-repo `nginx/nginx.conf` already has a dedicated `server_name
|
||||
melodies.bellsystems.net` block that maps `/download/{pid}` to the backend's
|
||||
`/api/melodies/download/{pid}` route, so no further nginx changes are needed once
|
||||
the NPM proxy host exists.
|
||||
|
||||
## Verification
|
||||
|
||||
1. Build/select an archetype for a melody with a `pid` set → confirm a file appears
|
||||
at `./data/melody_binaries/{pid}.bsm` and the melody's `url` becomes
|
||||
`http://melodies.bellsystems.net/download/{pid}`.
|
||||
2. `curl http://localhost:8000/api/melodies/download/{pid}` (direct to backend,
|
||||
bypassing nginx) → returns the `.bsm` bytes, no auth header needed.
|
||||
3. `curl http://localhost:90/api/melodies/download/{pid}` (through nginx on the
|
||||
console server block) → same result.
|
||||
4. Once NPM is configured, `curl http://melodies.bellsystems.net/download/{pid}`
|
||||
→ same result, over plain HTTP.
|
||||
5. Delete a melody whose `pid` is *not* shared with any other melody → confirm the
|
||||
local `.bsm` file is removed. Delete one of two melodies sharing a `pid` → confirm
|
||||
the file survives until the last one is deleted.
|
||||
6. Upload a preview audio file → confirm it still lands in Firebase Storage and
|
||||
`previewURL` still populates (regression check).
|
||||
@@ -1,4 +1,5 @@
|
||||
FROM node:20-alpine
|
||||
# Stage 1: build
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -6,5 +7,12 @@ COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
# Stage 2: serve with nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.prod.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
17
frontend/nginx.prod.conf
Normal file
17
frontend/nginx.prod.conf
Normal file
@@ -0,0 +1,17 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback — all unknown routes serve index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|svg|ico|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ http {
|
||||
|
||||
resolver 127.0.0.11 valid=5s;
|
||||
set $backend_upstream http://backend:8000;
|
||||
set $frontend_upstream http://frontend:5174;
|
||||
set $frontend_upstream http://frontend:80;
|
||||
|
||||
location /ota/ {
|
||||
root /srv;
|
||||
@@ -54,4 +54,25 @@ http {
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
|
||||
# Plain-HTTP-only host for ESP32 melody downloads. Kept separate from the
|
||||
# console.bellsystems.net server block above so console can stay HTTPS-only
|
||||
# in NPM with no path-based TLS exceptions. ESP32 devices can't afford the
|
||||
# RAM for a TLS client, so this host must never be forced to HTTPS upstream.
|
||||
server {
|
||||
listen 80;
|
||||
server_name melodies.bellsystems.net;
|
||||
|
||||
resolver 127.0.0.11 valid=5s;
|
||||
set $backend_upstream http://backend:8000;
|
||||
|
||||
location /download/ {
|
||||
rewrite ^/download/(.*)$ /api/melodies/download/$1 break;
|
||||
proxy_pass $backend_upstream$uri;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
42
strategies/PROMPT.txt
Normal file
42
strategies/PROMPT.txt
Normal file
@@ -0,0 +1,42 @@
|
||||
frontend\src\pages\bellcloud\devices\notes
|
||||
frontend\src\pages\bellcloud\devices\DeviceForm.jsx
|
||||
frontend\src\pages\bellcloud\mqtt
|
||||
|
||||
|
||||
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
|
||||
|
||||
|
||||
Read CLAUDE.md and DESIGN.md and use your /frontend-design for this task !
|
||||
|
||||
Build PAGE at PAGEURL
|
||||
|
||||
REFERENCE (logic only — do not copy styling):
|
||||
- Archive: ARCHIVEPAGEURL
|
||||
- Use this ONLY to understand: API endpoints, data fields, user actions
|
||||
|
||||
DESIGN REFERENCE:
|
||||
- Style guide: frontend/src/pages/dev/StyleGuide.jsx
|
||||
- Match components, spacing, and patterns exactly as shown there
|
||||
- Use only components from frontend/src/components/ui/
|
||||
- Use only CSS token variables — no hardcoded values
|
||||
|
||||
When done, replace the <ComingSoon /> for this route in frontend/src/router/index.jsx
|
||||
|
||||
|
||||
STITCH REFERENCE:
|
||||
- frontend/src/_archive/stitch-references/PageName.html
|
||||
- Use this as visual/layout inspiration for this specific page
|
||||
- Translate the layout and visual structure into React using our design system
|
||||
- Do not copy Stitch's inline styles — use our tokens instead
|
||||
|
||||
|
||||
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
|
||||
|
||||
|
||||
Apply the same glass effect as the DataTable and Card components to [component/page name].
|
||||
|
||||
Replace any solid background: var(--color-bg-*) with rgba(28, 32, 38, 0.30) (or 0.40 for elevated/nested surfaces)
|
||||
Add backdrop-filter: var(--blur-modal) and -webkit-backdrop-filter: var(--blur-modal)
|
||||
Replace any solid hover/active/selected background colors with their semi-transparent equivalents (rgba(49, 53, 60, 0.50))
|
||||
Keep borders and shadows unchanged
|
||||
|
||||
76
strategies/todo-features.txt
Normal file
76
strategies/todo-features.txt
Normal file
@@ -0,0 +1,76 @@
|
||||
TODO FEATURES AND FIXES
|
||||
|
||||
|
||||
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
|
||||
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
|
||||
|
||||
|
||||
// CONSOLE:
|
||||
|
||||
- Add an option to view all melodies for a user, delete them (one by one or flush), and edit them / reorder them.
|
||||
|
||||
- Automate the adding of a device on the cloud after it has been sold. Not necessarily claim it, but add it on the cloud with Subscription settings in place.
|
||||
|
||||
- Device Subscription Start (subscrStart) value on Firestore should be timestamp not string.
|
||||
|
||||
- Automate the whole 'device settings' process with a step-menu modal.
|
||||
|
||||
- On the Firmware Release Upload modal, do not close when clicking outside. Only with CLOSE button or ESC key.
|
||||
|
||||
- Implement a system to allow only approved users, to register an account on our app or automate the user creation from the console, and in turn:
|
||||
- Automate (properly) the device assignment to the users.
|
||||
|
||||
- When editing a location allow to show the map even withour coordinates, and add them when I first CLICK on the map.
|
||||
|
||||
- On the Quotations page, add "Add Legacy" option.
|
||||
|
||||
- Add Uptime Indicator per device
|
||||
|
||||
- Add "Retired" option for Melodies
|
||||
|
||||
- On the MAIL modal add option to "Seach for Customer" on the email field. When searching and selecting a customer, allow us to choose WHICH email, if they have multiple.
|
||||
|
||||
## Customer/FinanceTab:
|
||||
|
||||
- The overall Financial Status should be smaller, more compact and differentiated than the active order statuses.
|
||||
|
||||
- When an order is complete in terms of finance, make it really obvious (maybe make the whole payment container green)
|
||||
|
||||
- Do not allow negative numbers in Amount fields.
|
||||
|
||||
|
||||
## Device Inventory Page:
|
||||
|
||||
- Add a button somewhere to "Send APP Link via mail"
|
||||
|
||||
|
||||
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
|
||||
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
|
||||
|
||||
|
||||
// FIRMWARE:
|
||||
|
||||
- When Resetting Settings, via the S1 Key, if on AP Mode, switch back to STA Mode.
|
||||
|
||||
- On the /{deviceip}/settings page, add basic auth with pass: "bsvspr8998"
|
||||
|
||||
- Add AutoDST switch (figure out the system behind it)
|
||||
|
||||
- Add
|
||||
|
||||
|
||||
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
|
||||
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
|
||||
|
||||
|
||||
// FLUTTER APP:
|
||||
|
||||
- Add a post to firestore each time the app LOGS IN, or Loads the Playback Menu
|
||||
|
||||
- Add a option/system to allow the Console to REFRESH the melodies list with the new Titles/Descriptions/Info
|
||||
|
||||
- Add option to DELETE melodies
|
||||
|
||||
- Add option in the Settings to Test Fire a bell
|
||||
|
||||
|
||||
Reference in New Issue
Block a user