update: added assets manager and extra nvs settings on cloudflash
This commit is contained in:
@@ -201,13 +201,18 @@ async def patch_lifecycle_entry(
|
||||
return _doc_to_inventory_item(doc_ref.get())
|
||||
|
||||
|
||||
@router.post("/devices/{sn}/lifecycle", response_model=DeviceInventoryItem, status_code=201)
|
||||
@router.post("/devices/{sn}/lifecycle", response_model=DeviceInventoryItem, status_code=200)
|
||||
async def create_lifecycle_entry(
|
||||
sn: str,
|
||||
body: LifecycleEntryCreate,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
):
|
||||
"""Create a lifecycle history entry for a step that has no entry yet (on-the-fly)."""
|
||||
"""Upsert a lifecycle history entry for the given status_id.
|
||||
|
||||
If an entry for this status already exists it is overwritten in-place;
|
||||
otherwise a new entry is appended. This prevents duplicate entries when
|
||||
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())
|
||||
@@ -215,14 +220,25 @@ async def create_lifecycle_entry(
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
doc_ref = docs[0].reference
|
||||
data = docs[0].to_dict() or {}
|
||||
history = data.get("lifecycle_history") or []
|
||||
history = list(data.get("lifecycle_history") or [])
|
||||
|
||||
new_entry = {
|
||||
"status_id": body.status_id,
|
||||
"date": body.date or datetime.now(timezone.utc).isoformat(),
|
||||
"note": body.note,
|
||||
"set_by": user.email,
|
||||
}
|
||||
history.append(new_entry)
|
||||
|
||||
# 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:
|
||||
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())
|
||||
@@ -313,6 +329,91 @@ async def delete_device(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/devices/{sn}/email/manufactured", status_code=204)
|
||||
async def send_manufactured_email(
|
||||
sn: str,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
):
|
||||
"""Send the 'device manufactured' notification to the assigned customer's email."""
|
||||
db = get_firestore()
|
||||
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
|
||||
if not docs:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
data = docs[0].to_dict() or {}
|
||||
customer_id = data.get("customer_id")
|
||||
if not customer_id:
|
||||
raise HTTPException(status_code=400, detail="No customer assigned to this device")
|
||||
customer_doc = db.collection("crm_customers").document(customer_id).get()
|
||||
if not customer_doc.exists:
|
||||
raise HTTPException(status_code=404, detail="Assigned customer not found")
|
||||
cdata = customer_doc.to_dict() or {}
|
||||
email = cdata.get("email")
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Customer has no email address")
|
||||
name_parts = [cdata.get("name") or "", cdata.get("surname") or ""]
|
||||
customer_name = " ".join(p for p in name_parts if p).strip() or None
|
||||
hw_family = data.get("hw_family") or data.get("hw_type") or ""
|
||||
from utils.emails.device_mfged_mail import send_device_manufactured_email
|
||||
send_device_manufactured_email(
|
||||
customer_email=email,
|
||||
serial_number=sn,
|
||||
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},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/devices/{sn}/email/assigned", status_code=204)
|
||||
async def send_assigned_email(
|
||||
sn: str,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
):
|
||||
"""Send the 'device assigned / app instructions' email to the assigned user(s)."""
|
||||
db = get_firestore()
|
||||
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
|
||||
if not docs:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
data = docs[0].to_dict() or {}
|
||||
user_list = data.get("user_list") or []
|
||||
if not user_list:
|
||||
raise HTTPException(status_code=400, detail="No users assigned to this device")
|
||||
hw_family = data.get("hw_family") or data.get("hw_type") or ""
|
||||
device_name = hw_family.replace("_", " ").title()
|
||||
from utils.emails.device_assigned_mail import send_device_assigned_email
|
||||
errors = []
|
||||
for uid in user_list:
|
||||
try:
|
||||
user_doc = db.collection("users").document(uid).get()
|
||||
if not user_doc.exists:
|
||||
continue
|
||||
udata = user_doc.to_dict() or {}
|
||||
email = udata.get("email")
|
||||
if not email:
|
||||
continue
|
||||
display_name = udata.get("display_name") or udata.get("name") or None
|
||||
send_device_assigned_email(
|
||||
user_email=email,
|
||||
serial_number=sn,
|
||||
device_name=device_name,
|
||||
user_name=display_name,
|
||||
)
|
||||
except Exception as exc:
|
||||
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)},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/devices", status_code=200)
|
||||
async def delete_unprovisioned(
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
|
||||
@@ -347,6 +448,56 @@ def redirect_firmware(
|
||||
# Upload once per hw_type after each PlatformIO build that changes the layout.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/flash-assets")
|
||||
def list_flash_assets(
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||
):
|
||||
"""Return asset status for all known board types (and any discovered bespoke UIDs).
|
||||
|
||||
Checks the filesystem directly — no database involved.
|
||||
Each entry contains: hw_type, bootloader (exists, size, uploaded_at), partitions (same), note.
|
||||
"""
|
||||
return {"assets": service.list_flash_assets()}
|
||||
|
||||
|
||||
@router.delete("/flash-assets/{hw_type}/{asset}", status_code=204)
|
||||
async def delete_flash_asset(
|
||||
hw_type: str,
|
||||
asset: str,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
|
||||
):
|
||||
"""Delete a single flash asset file (bootloader.bin or partitions.bin)."""
|
||||
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))}")
|
||||
try:
|
||||
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},
|
||||
)
|
||||
|
||||
|
||||
class FlashAssetNoteBody(BaseModel):
|
||||
note: str
|
||||
|
||||
|
||||
@router.put("/flash-assets/{hw_type}/note", status_code=204)
|
||||
async def set_flash_asset_note(
|
||||
hw_type: str,
|
||||
body: FlashAssetNoteBody,
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
):
|
||||
"""Save (or overwrite) the note for a hw_type's flash asset set.
|
||||
|
||||
The note is stored as note.txt next to the binary files.
|
||||
Pass an empty string to clear the note.
|
||||
"""
|
||||
service.set_flash_asset_note(hw_type, body.note)
|
||||
|
||||
|
||||
@router.post("/flash-assets/{hw_type}/{asset}", status_code=204)
|
||||
async def upload_flash_asset(
|
||||
hw_type: str,
|
||||
|
||||
@@ -168,16 +168,23 @@ def update_device_status(sn: str, data: DeviceStatusUpdate, set_by: str | None =
|
||||
doc_data = docs[0].to_dict() or {}
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
history = doc_data.get("lifecycle_history") or []
|
||||
history = list(doc_data.get("lifecycle_history") or [])
|
||||
|
||||
# Append new lifecycle entry
|
||||
# Upsert lifecycle entry — overwrite existing entry for this status if present
|
||||
new_entry = {
|
||||
"status_id": data.status.value,
|
||||
"date": now,
|
||||
"note": data.note if data.note else None,
|
||||
"set_by": set_by,
|
||||
}
|
||||
history.append(new_entry)
|
||||
existing_idx = next(
|
||||
(i for i, e in enumerate(history) if e.get("status_id") == data.status.value),
|
||||
None,
|
||||
)
|
||||
if existing_idx is not None:
|
||||
history[existing_idx] = new_entry
|
||||
else:
|
||||
history.append(new_entry)
|
||||
|
||||
update = {
|
||||
"mfg_status": data.status.value,
|
||||
@@ -379,11 +386,68 @@ def delete_unprovisioned_devices() -> list[str]:
|
||||
return deleted
|
||||
|
||||
|
||||
KNOWN_HW_TYPES = ["vesper", "vesper_plus", "vesper_pro", "agnus", "agnus_mini", "chronos", "chronos_pro"]
|
||||
FLASH_ASSET_FILES = ["bootloader.bin", "partitions.bin"]
|
||||
|
||||
|
||||
def _flash_asset_path(hw_type: str, asset: str) -> Path:
|
||||
"""Return path to a flash asset (bootloader.bin or partitions.bin) for a given hw_type."""
|
||||
return Path(settings.flash_assets_storage_path) / hw_type / asset
|
||||
|
||||
|
||||
def _flash_asset_info(hw_type: str) -> dict:
|
||||
"""Build the asset info dict for a single hw_type by inspecting the filesystem."""
|
||||
base = Path(settings.flash_assets_storage_path) / hw_type
|
||||
note_path = base / "note.txt"
|
||||
note = note_path.read_text(encoding="utf-8").strip() if note_path.exists() else ""
|
||||
|
||||
files = {}
|
||||
for fname in FLASH_ASSET_FILES:
|
||||
p = base / fname
|
||||
if p.exists():
|
||||
stat = p.stat()
|
||||
files[fname] = {
|
||||
"exists": True,
|
||||
"size_bytes": stat.st_size,
|
||||
"uploaded_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||
}
|
||||
else:
|
||||
files[fname] = {"exists": False, "size_bytes": None, "uploaded_at": None}
|
||||
|
||||
return {
|
||||
"hw_type": hw_type,
|
||||
"bootloader": files["bootloader.bin"],
|
||||
"partitions": files["partitions.bin"],
|
||||
"note": note,
|
||||
}
|
||||
|
||||
|
||||
def list_flash_assets() -> list:
|
||||
"""Return asset status for all known board types plus any discovered bespoke directories."""
|
||||
base = Path(settings.flash_assets_storage_path)
|
||||
results = []
|
||||
|
||||
# Always include all known hw types, even if no files uploaded yet
|
||||
seen = set(KNOWN_HW_TYPES)
|
||||
for hw_type in KNOWN_HW_TYPES:
|
||||
results.append(_flash_asset_info(hw_type))
|
||||
|
||||
# Discover bespoke directories (anything in storage/flash_assets/ not in known list)
|
||||
if base.exists():
|
||||
for entry in sorted(base.iterdir()):
|
||||
if entry.is_dir() and entry.name not in seen:
|
||||
seen.add(entry.name)
|
||||
info = _flash_asset_info(entry.name)
|
||||
info["is_bespoke"] = True
|
||||
results.append(info)
|
||||
|
||||
# Mark known types
|
||||
for r in results:
|
||||
r.setdefault("is_bespoke", False)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def save_flash_asset(hw_type: str, asset: str, data: bytes) -> Path:
|
||||
"""Persist a flash asset binary. asset must be 'bootloader.bin' or 'partitions.bin'."""
|
||||
if asset not in ("bootloader.bin", "partitions.bin"):
|
||||
@@ -394,6 +458,25 @@ def save_flash_asset(hw_type: str, asset: str, data: bytes) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def delete_flash_asset(hw_type: str, asset: str) -> None:
|
||||
"""Delete a flash asset file. Raises NotFoundError if not present."""
|
||||
path = _flash_asset_path(hw_type, asset)
|
||||
if not path.exists():
|
||||
raise NotFoundError(f"Flash asset '{asset}' for '{hw_type}' not found")
|
||||
path.unlink()
|
||||
|
||||
|
||||
def set_flash_asset_note(hw_type: str, note: str) -> None:
|
||||
"""Write (or clear) the note for a hw_type's flash asset directory."""
|
||||
base = Path(settings.flash_assets_storage_path) / hw_type
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
note_path = base / "note.txt"
|
||||
if note.strip():
|
||||
note_path.write_text(note.strip(), encoding="utf-8")
|
||||
elif note_path.exists():
|
||||
note_path.unlink()
|
||||
|
||||
|
||||
def get_flash_asset(hw_type: str, asset: str) -> bytes:
|
||||
"""Load a flash asset binary. Raises NotFoundError if not uploaded yet."""
|
||||
path = _flash_asset_path(hw_type, asset)
|
||||
|
||||
Reference in New Issue
Block a user