Files
maziggy edf244c469 Add local profiles — import OrcaSlicer presets without Bambu Cloud (#310)
Users who use OrcaSlicer without Bambu Cloud can now import slicer
presets directly into Bambuddy. Supports .orca_filament, .bbscfg,
.bbsflmt, .zip, and .json exports with automatic inheritance resolution
via OrcaSlicer's GitHub base profiles (cached with 7-day TTL).
2026-02-09 17:00:02 +01:00

201 lines
6.6 KiB
Python

"""API routes for local slicer presets (imported from OrcaSlicer, etc.)."""
import json
import logging
from fastapi import APIRouter, Depends, HTTPException, UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.local_preset import LocalPreset
from backend.app.models.user import User
from backend.app.schemas.local_preset import (
ImportResponse,
LocalPresetCreate,
LocalPresetDetail,
LocalPresetResponse,
LocalPresetsResponse,
LocalPresetUpdate,
)
from backend.app.services.orca_profiles import (
extract_core_fields,
get_cache_status,
import_orca_file,
reclassify_presets,
refresh_base_cache,
resolve_preset,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/local-presets", tags=["Local Presets"])
@router.get("/", response_model=LocalPresetsResponse)
async def list_local_presets(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
db: AsyncSession = Depends(get_db),
):
"""List all local presets grouped by type."""
result = await db.execute(select(LocalPreset).order_by(LocalPreset.name))
presets = result.scalars().all()
grouped = LocalPresetsResponse()
for p in presets:
resp = LocalPresetResponse.model_validate(p)
if p.preset_type == "filament":
grouped.filament.append(resp)
elif p.preset_type == "printer":
grouped.printer.append(resp)
elif p.preset_type == "process":
grouped.process.append(resp)
return grouped
@router.get("/{preset_id}", response_model=LocalPresetDetail)
async def get_local_preset(
preset_id: int,
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
db: AsyncSession = Depends(get_db),
):
"""Get full detail for a local preset including the setting JSON."""
result = await db.execute(select(LocalPreset).where(LocalPreset.id == preset_id))
preset = result.scalar_one_or_none()
if not preset:
raise HTTPException(404, "Local preset not found")
data = LocalPresetResponse.model_validate(preset).model_dump()
try:
data["setting"] = json.loads(preset.setting)
except Exception:
data["setting"] = {}
return LocalPresetDetail(**data)
@router.post("/import", response_model=ImportResponse)
async def import_presets(
file: UploadFile,
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
db: AsyncSession = Depends(get_db),
):
"""Import presets from an OrcaSlicer export file (.json, .orca_filament, .bbscfg, .bbsflmt, .zip)."""
if not file.filename:
raise HTTPException(400, "No filename provided")
content = await file.read()
if not content:
raise HTTPException(400, "Empty file")
result = await import_orca_file(file.filename, content, db)
return ImportResponse(**result)
@router.post("/", response_model=LocalPresetResponse)
async def create_local_preset(
data: LocalPresetCreate,
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
db: AsyncSession = Depends(get_db),
):
"""Manually create a local preset."""
if data.preset_type not in ("filament", "printer", "process"):
raise HTTPException(400, "preset_type must be filament, printer, or process")
# Extract core fields
core = extract_core_fields(data.setting)
preset = LocalPreset(
name=data.name,
preset_type=data.preset_type,
source="manual",
setting=json.dumps(data.setting),
**core,
)
db.add(preset)
await db.flush()
await db.refresh(preset)
return LocalPresetResponse.model_validate(preset)
@router.put("/{preset_id}", response_model=LocalPresetResponse)
async def update_local_preset(
preset_id: int,
data: LocalPresetUpdate,
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
db: AsyncSession = Depends(get_db),
):
"""Update a local preset's name or settings."""
result = await db.execute(select(LocalPreset).where(LocalPreset.id == preset_id))
preset = result.scalar_one_or_none()
if not preset:
raise HTTPException(404, "Local preset not found")
if data.name is not None:
preset.name = data.name
if data.setting is not None:
# Re-resolve and extract core fields
resolved = await resolve_preset(data.setting, preset.preset_type, db)
core = extract_core_fields(resolved)
preset.setting = json.dumps(resolved)
preset.filament_type = core.get("filament_type")
preset.filament_vendor = core.get("filament_vendor")
preset.nozzle_temp_min = core.get("nozzle_temp_min")
preset.nozzle_temp_max = core.get("nozzle_temp_max")
preset.pressure_advance = core.get("pressure_advance")
preset.default_filament_colour = core.get("default_filament_colour")
preset.filament_cost = core.get("filament_cost")
preset.filament_density = core.get("filament_density")
preset.compatible_printers = core.get("compatible_printers")
await db.flush()
await db.refresh(preset)
return LocalPresetResponse.model_validate(preset)
@router.delete("/{preset_id}")
async def delete_local_preset(
preset_id: int,
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
db: AsyncSession = Depends(get_db),
):
"""Delete a local preset."""
result = await db.execute(select(LocalPreset).where(LocalPreset.id == preset_id))
preset = result.scalar_one_or_none()
if not preset:
raise HTTPException(404, "Local preset not found")
await db.delete(preset)
return {"success": True}
@router.get("/base-cache/status")
async def base_cache_status(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
db: AsyncSession = Depends(get_db),
):
"""Get the status of the OrcaSlicer base profile cache."""
return await get_cache_status(db)
@router.post("/base-cache/refresh")
async def refresh_cache(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
db: AsyncSession = Depends(get_db),
):
"""Force refresh all cached base profiles from GitHub."""
return await refresh_base_cache(db)
@router.post("/reclassify")
async def reclassify(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
db: AsyncSession = Depends(get_db),
):
"""Re-evaluate preset types for all local presets using the improved heuristic."""
return await reclassify_presets(db)