mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 01:57:41 +02:00
Merge branch '0.1.8b' into feature/updated_plate_view
This commit is contained in:
@@ -7,8 +7,11 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import and_, func, 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.ams_history import AMSSensorHistory
|
||||
from backend.app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/ams-history", tags=["ams-history"])
|
||||
|
||||
@@ -38,6 +41,7 @@ async def get_ams_history(
|
||||
ams_id: int,
|
||||
hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
|
||||
):
|
||||
"""Get AMS sensor history for a specific printer and AMS unit."""
|
||||
since = datetime.now() - timedelta(hours=hours)
|
||||
@@ -101,6 +105,7 @@ async def delete_old_history(
|
||||
printer_id: int,
|
||||
days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
|
||||
):
|
||||
"""Delete old AMS history data for a printer."""
|
||||
cutoff = datetime.now() - timedelta(days=days)
|
||||
|
||||
@@ -4,9 +4,11 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import generate_api_key
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled, generate_api_key
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.api_key import (
|
||||
APIKeyCreate,
|
||||
APIKeyCreateResponse,
|
||||
@@ -20,7 +22,10 @@ router = APIRouter(prefix="/api-keys", tags=["api-keys"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[APIKeyResponse])
|
||||
async def list_api_keys(db: AsyncSession = Depends(get_db)):
|
||||
async def list_api_keys(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
|
||||
):
|
||||
"""List all API keys (without full key values)."""
|
||||
result = await db.execute(select(APIKey).order_by(APIKey.created_at.desc()))
|
||||
return list(result.scalars().all())
|
||||
@@ -30,6 +35,7 @@ async def list_api_keys(db: AsyncSession = Depends(get_db)):
|
||||
async def create_api_key(
|
||||
data: APIKeyCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_CREATE),
|
||||
):
|
||||
"""Create a new API key.
|
||||
|
||||
@@ -74,6 +80,7 @@ async def create_api_key(
|
||||
async def get_api_key(
|
||||
key_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
|
||||
):
|
||||
"""Get an API key by ID."""
|
||||
result = await db.execute(select(APIKey).where(APIKey.id == key_id))
|
||||
@@ -90,6 +97,7 @@ async def update_api_key(
|
||||
key_id: int,
|
||||
data: APIKeyUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_UPDATE),
|
||||
):
|
||||
"""Update an API key."""
|
||||
result = await db.execute(select(APIKey).where(APIKey.id == key_id))
|
||||
@@ -124,6 +132,7 @@ async def update_api_key(
|
||||
async def delete_api_key(
|
||||
key_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_DELETE),
|
||||
):
|
||||
"""Delete (revoke) an API key."""
|
||||
result = await db.execute(select(APIKey).where(APIKey.id == key_id))
|
||||
|
||||
@@ -8,7 +8,10 @@ from fastapi.responses import FileResponse, Response
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import require_auth_if_enabled, require_ownership_permission
|
||||
from backend.app.core.auth import (
|
||||
RequirePermissionIfAuthEnabled,
|
||||
require_ownership_permission,
|
||||
)
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
@@ -118,6 +121,7 @@ async def list_archives(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""List archived prints."""
|
||||
service = ArchiveService(db)
|
||||
@@ -148,6 +152,7 @@ async def search_archives(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Full-text search across archives.
|
||||
|
||||
@@ -229,7 +234,10 @@ async def search_archives(
|
||||
|
||||
|
||||
@router.post("/search/rebuild-index")
|
||||
async def rebuild_search_index(db: AsyncSession = Depends(get_db)):
|
||||
async def rebuild_search_index(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
|
||||
):
|
||||
"""Rebuild the full-text search index from existing archives.
|
||||
|
||||
Use this if search results seem incomplete or incorrect.
|
||||
@@ -267,6 +275,7 @@ async def analyze_failures(
|
||||
printer_id: int | None = None,
|
||||
project_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Analyze failure patterns across prints.
|
||||
|
||||
@@ -291,6 +300,7 @@ async def analyze_failures(
|
||||
async def compare_archives(
|
||||
archive_ids: str = Query(..., description="Comma-separated archive IDs (2-5)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Compare multiple archives side by side.
|
||||
|
||||
@@ -331,6 +341,7 @@ async def export_archives(
|
||||
date_to: str | None = Query(None, description="End date (ISO format)"),
|
||||
search: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Export archives to CSV or Excel format.
|
||||
|
||||
@@ -393,6 +404,7 @@ async def export_stats(
|
||||
printer_id: int | None = None,
|
||||
project_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
|
||||
):
|
||||
"""Export statistics summary to CSV or Excel format."""
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -421,7 +433,10 @@ async def export_stats(
|
||||
|
||||
|
||||
@router.get("/stats", response_model=ArchiveStats)
|
||||
async def get_archive_stats(db: AsyncSession = Depends(get_db)):
|
||||
async def get_archive_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
|
||||
):
|
||||
"""Get statistics across all archives."""
|
||||
# Total counts
|
||||
total_result = await db.execute(select(func.count(PrintArchive.id)))
|
||||
@@ -451,7 +466,10 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
|
||||
total_seconds += print_time_seconds
|
||||
total_time = total_seconds / 3600 # Convert to hours
|
||||
|
||||
filament_result = await db.execute(select(func.sum(PrintArchive.filament_used_grams)))
|
||||
# Multiply filament by quantity to account for multiple items printed
|
||||
filament_result = await db.execute(
|
||||
select(func.sum(PrintArchive.filament_used_grams * func.coalesce(PrintArchive.quantity, 1)))
|
||||
)
|
||||
total_filament = filament_result.scalar() or 0
|
||||
|
||||
cost_result = await db.execute(select(func.sum(PrintArchive.cost)))
|
||||
@@ -571,7 +589,10 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/tags")
|
||||
async def get_all_tags(db: AsyncSession = Depends(get_db)):
|
||||
async def get_all_tags(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""List all unique tags with usage counts.
|
||||
|
||||
Returns a list of tags sorted by count (descending), then by name.
|
||||
@@ -601,6 +622,7 @@ async def rename_tag(
|
||||
tag_name: str,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
|
||||
):
|
||||
"""Rename a tag across all archives.
|
||||
|
||||
@@ -643,7 +665,11 @@ async def rename_tag(
|
||||
|
||||
|
||||
@router.delete("/tags/{tag_name}")
|
||||
async def delete_tag(tag_name: str, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_tag(
|
||||
tag_name: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
|
||||
):
|
||||
"""Delete a tag from all archives.
|
||||
|
||||
Returns the count of affected archives.
|
||||
@@ -668,7 +694,11 @@ async def delete_tag(tag_name: str, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/{archive_id}", response_model=ArchiveResponse)
|
||||
async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_archive(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Get a specific archive."""
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
@@ -691,6 +721,7 @@ async def find_similar_archives(
|
||||
archive_id: int,
|
||||
limit: int = 10,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Find archives with similar settings for comparison.
|
||||
|
||||
@@ -759,6 +790,7 @@ async def update_archive(
|
||||
async def toggle_favorite(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
|
||||
):
|
||||
"""Toggle favorite status for an archive."""
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
||||
@@ -773,7 +805,11 @@ async def toggle_favorite(
|
||||
|
||||
|
||||
@router.post("/{archive_id}/rescan", response_model=ArchiveResponse)
|
||||
async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def rescan_archive(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
|
||||
):
|
||||
"""Rescan the 3MF file and update metadata."""
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
from backend.app.services.archive import ThreeMFParser
|
||||
@@ -832,7 +868,10 @@ async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/recalculate-costs")
|
||||
async def recalculate_all_costs(db: AsyncSession = Depends(get_db)):
|
||||
async def recalculate_all_costs(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
|
||||
):
|
||||
"""Recalculate costs for all archives based on filament usage and prices."""
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
|
||||
@@ -862,7 +901,10 @@ async def recalculate_all_costs(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/rescan-all")
|
||||
async def rescan_all_archives(db: AsyncSession = Depends(get_db)):
|
||||
async def rescan_all_archives(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
|
||||
):
|
||||
"""Rescan all archives and update their metadata."""
|
||||
from backend.app.services.archive import ThreeMFParser
|
||||
|
||||
@@ -909,7 +951,11 @@ async def rescan_all_archives(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/{archive_id}/duplicates")
|
||||
async def get_archive_duplicates(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_archive_duplicates(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Get duplicates for a specific archive."""
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
@@ -927,7 +973,10 @@ async def get_archive_duplicates(archive_id: int, db: AsyncSession = Depends(get
|
||||
|
||||
|
||||
@router.post("/backfill-hashes")
|
||||
async def backfill_content_hashes(db: AsyncSession = Depends(get_db)):
|
||||
async def backfill_content_hashes(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
|
||||
):
|
||||
"""Compute and store content hashes for all archives missing them."""
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash.is_(None)))
|
||||
archives = list(result.scalars().all())
|
||||
@@ -988,6 +1037,7 @@ async def download_archive(
|
||||
archive_id: int,
|
||||
inline: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Download the 3MF file."""
|
||||
service = ArchiveService(db)
|
||||
@@ -1015,6 +1065,7 @@ async def download_archive_with_filename(
|
||||
archive_id: int,
|
||||
filename: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Download the 3MF file with filename in URL (for Bambu Studio protocol)."""
|
||||
service = ArchiveService(db)
|
||||
@@ -1034,8 +1085,14 @@ async def download_archive_with_filename(
|
||||
|
||||
|
||||
@router.get("/{archive_id}/thumbnail")
|
||||
async def get_thumbnail(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Get the thumbnail image."""
|
||||
async def get_thumbnail(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get the thumbnail image.
|
||||
|
||||
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
|
||||
"""
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
if not archive or not archive.thumbnail_path:
|
||||
@@ -1059,8 +1116,14 @@ async def get_thumbnail(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/{archive_id}/timelapse")
|
||||
async def get_timelapse(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Get the timelapse video."""
|
||||
async def get_timelapse(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get the timelapse video.
|
||||
|
||||
Note: Unauthenticated - loaded via <video> tags which can't send auth headers.
|
||||
"""
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
if not archive or not archive.timelapse_path:
|
||||
@@ -1088,6 +1151,7 @@ async def get_timelapse(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def scan_timelapse(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
|
||||
):
|
||||
"""Scan printer for timelapse matching this archive and attach it."""
|
||||
from backend.app.models.printer import Printer
|
||||
@@ -1314,6 +1378,7 @@ async def select_timelapse(
|
||||
archive_id: int,
|
||||
filename: str = Query(..., description="Timelapse filename to attach"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
|
||||
):
|
||||
"""Manually select a timelapse from the printer to attach."""
|
||||
from backend.app.models.printer import Printer
|
||||
@@ -1398,6 +1463,7 @@ async def upload_timelapse(
|
||||
archive_id: int,
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
|
||||
):
|
||||
"""Manually upload a timelapse video to an archive."""
|
||||
service = ArchiveService(db)
|
||||
@@ -1418,7 +1484,11 @@ async def upload_timelapse(
|
||||
|
||||
|
||||
@router.get("/{archive_id}/timelapse/info")
|
||||
async def get_timelapse_info(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_timelapse_info(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Get timelapse video metadata for editor."""
|
||||
from backend.app.schemas.timelapse import TimelapseInfoResponse
|
||||
from backend.app.services.timelapse_processor import TimelapseProcessor
|
||||
@@ -1447,6 +1517,7 @@ async def get_timelapse_thumbnails(
|
||||
count: int = Query(10, ge=1, le=30),
|
||||
width: int = Query(160, ge=80, le=320),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Generate timeline thumbnail frames for visual scrubbing."""
|
||||
import base64
|
||||
@@ -1486,6 +1557,7 @@ async def process_timelapse(
|
||||
output_filename: str = Form(None),
|
||||
audio: UploadFile = File(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
|
||||
):
|
||||
"""Process timelapse with trim, speed, and optional audio overlay."""
|
||||
import shutil
|
||||
@@ -1589,6 +1661,7 @@ async def upload_photo(
|
||||
archive_id: int,
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
|
||||
):
|
||||
"""Upload a photo of the printed result."""
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
||||
@@ -1633,7 +1706,10 @@ async def get_photo(
|
||||
filename: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a specific photo."""
|
||||
"""Get a specific photo.
|
||||
|
||||
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
|
||||
"""
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
||||
archive = result.scalar_one_or_none()
|
||||
if not archive:
|
||||
@@ -1663,6 +1739,7 @@ async def delete_photo(
|
||||
archive_id: int,
|
||||
filename: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
|
||||
):
|
||||
"""Delete a photo."""
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
||||
@@ -1700,7 +1777,10 @@ async def get_qrcode(
|
||||
size: int = 200,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Generate a QR code that links to this archive."""
|
||||
"""Generate a QR code that links to this archive.
|
||||
|
||||
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
|
||||
"""
|
||||
try:
|
||||
import qrcode
|
||||
from PIL import Image as PILImage
|
||||
@@ -1748,7 +1828,11 @@ async def get_qrcode(
|
||||
|
||||
|
||||
@router.get("/{archive_id}/capabilities")
|
||||
async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_archive_capabilities(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Check what viewing capabilities are available for this 3MF file."""
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
@@ -1965,7 +2049,11 @@ async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(g
|
||||
|
||||
|
||||
@router.get("/{archive_id}/gcode")
|
||||
async def get_gcode(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_gcode(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Extract and return G-code from the 3MF file."""
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
@@ -1998,11 +2086,16 @@ async def get_gcode(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/{archive_id}/plate-preview")
|
||||
async def get_plate_preview(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_plate_preview(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get the plate preview image from the 3MF file.
|
||||
|
||||
Returns the slicer-generated plate thumbnail which shows the model
|
||||
with correct colors and positioning.
|
||||
|
||||
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
|
||||
"""
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
@@ -2065,7 +2158,7 @@ async def upload_archive(
|
||||
file: UploadFile = File(...),
|
||||
printer_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = Depends(require_auth_if_enabled),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
|
||||
):
|
||||
"""Manually upload a 3MF file to archive."""
|
||||
if not file.filename or not file.filename.endswith(".3mf"):
|
||||
@@ -2100,7 +2193,7 @@ async def upload_archives_bulk(
|
||||
files: list[UploadFile] = File(...),
|
||||
printer_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = Depends(require_auth_if_enabled),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
|
||||
):
|
||||
"""Bulk upload multiple 3MF files to archive."""
|
||||
results = []
|
||||
@@ -2154,6 +2247,7 @@ async def upload_archives_bulk(
|
||||
async def get_archive_plates(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Get available plates from a multi-plate 3MF archive.
|
||||
|
||||
@@ -2399,7 +2493,10 @@ async def get_plate_thumbnail(
|
||||
plate_index: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get the thumbnail image for a specific plate."""
|
||||
"""Get the thumbnail image for a specific plate.
|
||||
|
||||
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
|
||||
"""
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
if not archive:
|
||||
@@ -2426,6 +2523,7 @@ async def get_filament_requirements(
|
||||
archive_id: int,
|
||||
plate_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Get filament requirements from the archived 3MF file.
|
||||
|
||||
@@ -2721,7 +2819,11 @@ async def reprint_archive(
|
||||
|
||||
|
||||
@router.get("/{archive_id}/project-page")
|
||||
async def get_project_page(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_project_page(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Get the project page data from the 3MF file."""
|
||||
from backend.app.schemas.archive import ProjectPageResponse
|
||||
from backend.app.services.archive import ProjectPageParser
|
||||
@@ -2746,6 +2848,7 @@ async def update_project_page(
|
||||
archive_id: int,
|
||||
update_data: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
|
||||
):
|
||||
"""Update project page metadata in the 3MF file."""
|
||||
from backend.app.services.archive import ProjectPageParser
|
||||
@@ -2776,7 +2879,10 @@ async def get_project_image(
|
||||
image_path: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get an image from the 3MF project page."""
|
||||
"""Get an image from the 3MF project page.
|
||||
|
||||
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
|
||||
"""
|
||||
from backend.app.services.archive import ProjectPageParser
|
||||
|
||||
service = ArchiveService(db)
|
||||
@@ -2812,6 +2918,7 @@ async def upload_source_3mf(
|
||||
archive_id: int,
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
|
||||
):
|
||||
"""Upload the original source 3MF project file for an archive."""
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
||||
@@ -2858,6 +2965,7 @@ async def upload_source_3mf(
|
||||
async def download_source_3mf(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Download the source 3MF project file."""
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
||||
@@ -2887,6 +2995,7 @@ async def download_source_3mf_for_slicer(
|
||||
archive_id: int,
|
||||
filename: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Download source 3MF with filename in URL (for Bambu Studio compatibility)."""
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
||||
@@ -2913,6 +3022,7 @@ async def upload_source_3mf_by_name(
|
||||
file: UploadFile = File(...),
|
||||
print_name: str = Query(None, description="Match archive by print name"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
|
||||
):
|
||||
"""Upload source 3MF and match to archive by print name.
|
||||
|
||||
@@ -2999,6 +3109,7 @@ async def upload_source_3mf_by_name(
|
||||
async def delete_source_3mf(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
|
||||
):
|
||||
"""Delete the source 3MF project file from an archive."""
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
||||
@@ -3031,6 +3142,7 @@ async def upload_f3d(
|
||||
archive_id: int,
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
|
||||
):
|
||||
"""Upload a Fusion 360 design file for an archive."""
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
||||
@@ -3077,6 +3189,7 @@ async def upload_f3d(
|
||||
async def download_f3d(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Download the Fusion 360 design file."""
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
||||
@@ -3105,6 +3218,7 @@ async def download_f3d(
|
||||
async def delete_f3d(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
|
||||
):
|
||||
"""Delete the Fusion 360 design file from an archive."""
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
||||
|
||||
@@ -9,8 +9,11 @@ from fastapi.responses import Response, StreamingResponse
|
||||
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.printer import Printer
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.camera import (
|
||||
capture_camera_frame,
|
||||
generate_chamber_image_stream,
|
||||
@@ -353,6 +356,8 @@ async def camera_stream(
|
||||
This endpoint returns a multipart MJPEG stream that can be used directly
|
||||
in an <img> tag or video player.
|
||||
|
||||
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
|
||||
|
||||
Uses external camera if configured, otherwise uses built-in camera:
|
||||
- External: MJPEG, RTSP, or HTTP snapshot
|
||||
- A1/P1: Chamber image protocol (port 6000)
|
||||
@@ -476,7 +481,10 @@ async def camera_stream(
|
||||
|
||||
|
||||
@router.api_route("/{printer_id}/camera/stop", methods=["GET", "POST"])
|
||||
async def stop_camera_stream(printer_id: int):
|
||||
async def stop_camera_stream(
|
||||
printer_id: int,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Stop all active camera streams for a printer.
|
||||
|
||||
This can be called by the frontend when the camera window is closed.
|
||||
@@ -527,6 +535,8 @@ async def camera_snapshot(
|
||||
"""Capture a single frame from the printer camera.
|
||||
|
||||
Returns a JPEG image.
|
||||
|
||||
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
@@ -574,6 +584,7 @@ async def camera_snapshot(
|
||||
async def test_camera(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Test camera connection for a printer.
|
||||
|
||||
@@ -591,7 +602,10 @@ async def test_camera(
|
||||
|
||||
|
||||
@router.get("/{printer_id}/camera/status")
|
||||
async def camera_status(printer_id: int):
|
||||
async def camera_status(
|
||||
printer_id: int,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Get the status of an active camera stream.
|
||||
|
||||
Returns whether a stream is active and when the last frame was received.
|
||||
@@ -658,6 +672,7 @@ async def test_external_camera(
|
||||
url: str,
|
||||
camera_type: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Test external camera connection.
|
||||
|
||||
@@ -684,6 +699,7 @@ async def check_plate_empty(
|
||||
use_external: bool = False,
|
||||
include_debug_image: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Check if the build plate is empty using camera vision.
|
||||
|
||||
@@ -791,6 +807,7 @@ async def calibrate_plate_detection(
|
||||
label: str | None = None,
|
||||
use_external: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Calibrate plate detection by capturing a reference image of the empty plate.
|
||||
|
||||
@@ -854,6 +871,7 @@ async def delete_plate_calibration(
|
||||
printer_id: int,
|
||||
plate_type: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Delete the plate detection calibration for a printer and plate type.
|
||||
|
||||
@@ -894,6 +912,7 @@ async def get_plate_detection_status(
|
||||
printer_id: int,
|
||||
plate_type: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Check plate detection status for a printer and plate type.
|
||||
|
||||
@@ -937,6 +956,7 @@ async def get_plate_detection_status(
|
||||
async def get_plate_references(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Get all calibration references for a printer with metadata.
|
||||
|
||||
@@ -971,7 +991,10 @@ async def get_reference_thumbnail(
|
||||
index: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get thumbnail image for a calibration reference."""
|
||||
"""Get thumbnail image for a calibration reference.
|
||||
|
||||
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
|
||||
"""
|
||||
from fastapi.responses import Response
|
||||
|
||||
from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
|
||||
@@ -997,6 +1020,7 @@ async def update_reference_label(
|
||||
index: int,
|
||||
label: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Update the label for a calibration reference."""
|
||||
from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
|
||||
@@ -1021,6 +1045,7 @@ async def delete_reference(
|
||||
printer_id: int,
|
||||
index: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Delete a specific calibration reference."""
|
||||
from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
|
||||
|
||||
@@ -13,8 +13,11 @@ from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
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.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.cloud import (
|
||||
CloudAuthStatus,
|
||||
CloudDevice,
|
||||
@@ -74,7 +77,10 @@ async def clear_token(db: AsyncSession) -> None:
|
||||
|
||||
|
||||
@router.get("/status", response_model=CloudAuthStatus)
|
||||
async def get_auth_status(db: AsyncSession = Depends(get_db)):
|
||||
async def get_auth_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
|
||||
):
|
||||
"""Get current cloud authentication status."""
|
||||
token, email = await get_stored_token(db)
|
||||
cloud = get_cloud_service()
|
||||
@@ -89,12 +95,20 @@ async def get_auth_status(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/login", response_model=CloudLoginResponse)
|
||||
async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
async def login(
|
||||
request: CloudLoginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
|
||||
):
|
||||
"""
|
||||
Initiate login to Bambu Cloud.
|
||||
|
||||
This will typically trigger a verification code to be sent to the user's email.
|
||||
After receiving the code, call /cloud/verify to complete the login.
|
||||
This will trigger either:
|
||||
- Email verification: A code is sent to the user's email
|
||||
- TOTP verification: User enters code from their authenticator app
|
||||
|
||||
After receiving/generating the code, call /cloud/verify to complete the login.
|
||||
For TOTP, include the tfa_key from this response in the verify request.
|
||||
"""
|
||||
cloud = get_cloud_service()
|
||||
|
||||
@@ -112,6 +126,8 @@ async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
success=result.get("success", False),
|
||||
needs_verification=result.get("needs_verification", False),
|
||||
message=result.get("message", "Unknown error"),
|
||||
verification_type=result.get("verification_type"),
|
||||
tfa_key=result.get("tfa_key"),
|
||||
)
|
||||
except BambuCloudAuthError as e:
|
||||
raise HTTPException(status_code=401, detail=str(e))
|
||||
@@ -120,17 +136,30 @@ async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/verify", response_model=CloudLoginResponse)
|
||||
async def verify_code(request: CloudVerifyRequest, db: AsyncSession = Depends(get_db)):
|
||||
async def verify_code(
|
||||
request: CloudVerifyRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
|
||||
):
|
||||
"""
|
||||
Complete login with verification code.
|
||||
Complete login with verification code (email or TOTP).
|
||||
|
||||
After calling /cloud/login, the user will receive an email with a 6-digit code.
|
||||
Submit that code here to complete authentication.
|
||||
For email verification:
|
||||
- After calling /cloud/login, the user receives an email with a 6-digit code
|
||||
- Submit the code with email address
|
||||
|
||||
For TOTP verification:
|
||||
- The user enters the 6-digit code from their authenticator app
|
||||
- Include the tfa_key from the /cloud/login response
|
||||
"""
|
||||
cloud = get_cloud_service()
|
||||
|
||||
try:
|
||||
result = await cloud.verify_code(request.email, request.code)
|
||||
# Use TOTP verification if tfa_key is provided
|
||||
if request.tfa_key:
|
||||
result = await cloud.verify_totp(request.tfa_key, request.code)
|
||||
else:
|
||||
result = await cloud.verify_code(request.email, request.code)
|
||||
|
||||
if result.get("success") and cloud.access_token:
|
||||
await store_token(db, cloud.access_token, request.email)
|
||||
@@ -147,7 +176,11 @@ async def verify_code(request: CloudVerifyRequest, db: AsyncSession = Depends(ge
|
||||
|
||||
|
||||
@router.post("/token", response_model=CloudAuthStatus)
|
||||
async def set_token(request: CloudTokenRequest, db: AsyncSession = Depends(get_db)):
|
||||
async def set_token(
|
||||
request: CloudTokenRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
|
||||
):
|
||||
"""
|
||||
Set access token directly.
|
||||
|
||||
@@ -167,7 +200,10 @@ async def set_token(request: CloudTokenRequest, db: AsyncSession = Depends(get_d
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(db: AsyncSession = Depends(get_db)):
|
||||
async def logout(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
|
||||
):
|
||||
"""Log out of Bambu Cloud."""
|
||||
cloud = get_cloud_service()
|
||||
cloud.logout()
|
||||
@@ -179,6 +215,7 @@ async def logout(db: AsyncSession = Depends(get_db)):
|
||||
async def get_slicer_settings(
|
||||
version: str = "02.04.00.70",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
):
|
||||
"""
|
||||
Get all slicer settings (filament, printer, process presets).
|
||||
@@ -235,7 +272,11 @@ async def get_slicer_settings(
|
||||
|
||||
|
||||
@router.get("/settings/{setting_id}")
|
||||
async def get_setting_detail(setting_id: str, db: AsyncSession = Depends(get_db)):
|
||||
async def get_setting_detail(
|
||||
setting_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
):
|
||||
"""
|
||||
Get detailed information for a specific setting/preset.
|
||||
|
||||
@@ -296,7 +337,11 @@ def _filament_id_to_setting_id(filament_id: str) -> str:
|
||||
|
||||
|
||||
@router.post("/filament-info")
|
||||
async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession = Depends(get_db)):
|
||||
async def get_filament_info(
|
||||
setting_ids: list[str] = Body(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
|
||||
):
|
||||
"""
|
||||
Get filament preset info (name and K value) for multiple setting IDs.
|
||||
|
||||
@@ -370,7 +415,10 @@ async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession
|
||||
|
||||
|
||||
@router.get("/devices", response_model=list[CloudDevice])
|
||||
async def get_devices(db: AsyncSession = Depends(get_db)):
|
||||
async def get_devices(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
|
||||
):
|
||||
"""
|
||||
Get list of bound printer devices.
|
||||
|
||||
@@ -408,7 +456,10 @@ async def get_devices(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/firmware-updates", response_model=FirmwareUpdatesResponse)
|
||||
async def get_firmware_updates(db: AsyncSession = Depends(get_db)):
|
||||
async def get_firmware_updates(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
|
||||
):
|
||||
"""
|
||||
Check for firmware updates for all bound devices.
|
||||
|
||||
@@ -484,7 +535,11 @@ async def get_firmware_updates(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/settings")
|
||||
async def create_setting(request: SlicerSettingCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_setting(
|
||||
request: SlicerSettingCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""
|
||||
Create a new slicer preset/setting.
|
||||
|
||||
@@ -524,6 +579,7 @@ async def update_setting(
|
||||
setting_id: str,
|
||||
request: SlicerSettingUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""
|
||||
Update an existing slicer preset/setting.
|
||||
@@ -555,7 +611,11 @@ async def update_setting(
|
||||
|
||||
|
||||
@router.delete("/settings/{setting_id}", response_model=SlicerSettingDeleteResponse)
|
||||
async def delete_setting(setting_id: str, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_setting(
|
||||
setting_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""
|
||||
Delete a slicer preset/setting.
|
||||
|
||||
@@ -620,7 +680,10 @@ def _load_fields(preset_type: str) -> dict:
|
||||
|
||||
|
||||
@router.get("/fields/{preset_type}")
|
||||
async def get_preset_fields(preset_type: Literal["filament", "print", "process", "printer"]):
|
||||
async def get_preset_fields(
|
||||
preset_type: Literal["filament", "print", "process", "printer"],
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
):
|
||||
"""
|
||||
Get field definitions for a preset type.
|
||||
|
||||
@@ -639,7 +702,9 @@ async def get_preset_fields(preset_type: Literal["filament", "print", "process",
|
||||
|
||||
|
||||
@router.get("/fields")
|
||||
async def get_all_preset_fields():
|
||||
async def get_all_preset_fields(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
):
|
||||
"""
|
||||
Get all field definitions for all preset types.
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ import logging
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.discovery import (
|
||||
discovery_service,
|
||||
is_running_in_docker,
|
||||
@@ -60,7 +63,9 @@ class DiscoveredPrinterResponse(BaseModel):
|
||||
|
||||
|
||||
@router.get("/info", response_model=DiscoveryInfo)
|
||||
async def get_discovery_info():
|
||||
async def get_discovery_info(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
||||
):
|
||||
"""Get discovery environment info (Docker detection, etc.)."""
|
||||
return DiscoveryInfo(
|
||||
is_docker=is_running_in_docker(),
|
||||
@@ -70,13 +75,18 @@ async def get_discovery_info():
|
||||
|
||||
|
||||
@router.get("/status", response_model=DiscoveryStatus)
|
||||
async def get_discovery_status():
|
||||
async def get_discovery_status(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
||||
):
|
||||
"""Get the current SSDP discovery status."""
|
||||
return DiscoveryStatus(running=discovery_service.is_running)
|
||||
|
||||
|
||||
@router.post("/start", response_model=DiscoveryStatus)
|
||||
async def start_discovery(duration: float = 10.0):
|
||||
async def start_discovery(
|
||||
duration: float = 10.0,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
||||
):
|
||||
"""Start SSDP printer discovery.
|
||||
|
||||
Args:
|
||||
@@ -87,14 +97,18 @@ async def start_discovery(duration: float = 10.0):
|
||||
|
||||
|
||||
@router.post("/stop", response_model=DiscoveryStatus)
|
||||
async def stop_discovery():
|
||||
async def stop_discovery(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
||||
):
|
||||
"""Stop SSDP printer discovery."""
|
||||
await discovery_service.stop()
|
||||
return DiscoveryStatus(running=discovery_service.is_running)
|
||||
|
||||
|
||||
@router.get("/printers", response_model=list[DiscoveredPrinterResponse])
|
||||
async def get_discovered_printers():
|
||||
async def get_discovered_printers(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
||||
):
|
||||
"""Get list of discovered printers (from both SSDP and subnet scan)."""
|
||||
# Combine results from both discovery methods
|
||||
printers = {}
|
||||
@@ -124,7 +138,10 @@ async def get_discovered_printers():
|
||||
|
||||
|
||||
@router.post("/scan", response_model=SubnetScanStatus)
|
||||
async def start_subnet_scan(request: SubnetScanRequest):
|
||||
async def start_subnet_scan(
|
||||
request: SubnetScanRequest,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
||||
):
|
||||
"""Start a subnet scan for Bambu printers.
|
||||
|
||||
Use this when running in Docker where SSDP multicast doesn't work.
|
||||
@@ -147,7 +164,9 @@ async def start_subnet_scan(request: SubnetScanRequest):
|
||||
|
||||
|
||||
@router.get("/scan/status", response_model=SubnetScanStatus)
|
||||
async def get_scan_status():
|
||||
async def get_scan_status(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
||||
):
|
||||
"""Get the current subnet scan status."""
|
||||
scanned, total = subnet_scanner.progress
|
||||
return SubnetScanStatus(
|
||||
@@ -158,7 +177,9 @@ async def get_scan_status():
|
||||
|
||||
|
||||
@router.post("/scan/stop", response_model=SubnetScanStatus)
|
||||
async def stop_subnet_scan():
|
||||
async def stop_subnet_scan(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
||||
):
|
||||
"""Stop the current subnet scan."""
|
||||
subnet_scanner.stop()
|
||||
scanned, total = subnet_scanner.progress
|
||||
|
||||
@@ -9,9 +9,12 @@ from fastapi.responses import FileResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.config import settings as app_settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.external_link import ExternalLink
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.external_link import (
|
||||
ExternalLinkCreate,
|
||||
ExternalLinkReorder,
|
||||
@@ -29,7 +32,10 @@ router = APIRouter(prefix="/external-links", tags=["external-links"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[ExternalLinkResponse])
|
||||
async def list_external_links(db: AsyncSession = Depends(get_db)):
|
||||
async def list_external_links(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
|
||||
):
|
||||
"""List all external links ordered by sort_order."""
|
||||
result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
|
||||
links = result.scalars().all()
|
||||
@@ -40,6 +46,7 @@ async def list_external_links(db: AsyncSession = Depends(get_db)):
|
||||
async def create_external_link(
|
||||
link_data: ExternalLinkCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_CREATE),
|
||||
):
|
||||
"""Create a new external link."""
|
||||
# Get the highest sort_order to place new link at end
|
||||
@@ -67,6 +74,7 @@ async def create_external_link(
|
||||
async def get_external_link(
|
||||
link_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
|
||||
):
|
||||
"""Get a specific external link."""
|
||||
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
|
||||
@@ -83,6 +91,7 @@ async def update_external_link(
|
||||
link_id: int,
|
||||
update_data: ExternalLinkUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
|
||||
):
|
||||
"""Update an external link."""
|
||||
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
|
||||
@@ -108,6 +117,7 @@ async def update_external_link(
|
||||
async def delete_external_link(
|
||||
link_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_DELETE),
|
||||
):
|
||||
"""Delete an external link."""
|
||||
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
|
||||
@@ -129,6 +139,7 @@ async def delete_external_link(
|
||||
async def reorder_external_links(
|
||||
reorder_data: ExternalLinkReorder,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
|
||||
):
|
||||
"""Update the sort order of external links."""
|
||||
# Update sort_order for each link based on position in the list
|
||||
@@ -154,6 +165,7 @@ async def upload_icon(
|
||||
link_id: int,
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
|
||||
):
|
||||
"""Upload a custom icon for an external link."""
|
||||
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
|
||||
@@ -202,6 +214,7 @@ async def upload_icon(
|
||||
async def delete_icon(
|
||||
link_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
|
||||
):
|
||||
"""Delete the custom icon for an external link."""
|
||||
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
|
||||
@@ -227,7 +240,10 @@ async def get_icon(
|
||||
link_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get the custom icon for an external link."""
|
||||
"""Get the custom icon for an external link.
|
||||
|
||||
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
|
||||
"""
|
||||
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
|
||||
link = result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
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.filament import Filament
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.filament import (
|
||||
FilamentCostCalculation,
|
||||
FilamentCreate,
|
||||
@@ -15,7 +18,10 @@ router = APIRouter(prefix="/filaments", tags=["filaments"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[FilamentResponse])
|
||||
async def list_filaments(db: AsyncSession = Depends(get_db)):
|
||||
async def list_filaments(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
|
||||
):
|
||||
"""List all filaments."""
|
||||
result = await db.execute(select(Filament).order_by(Filament.type, Filament.name))
|
||||
return list(result.scalars().all())
|
||||
@@ -25,6 +31,7 @@ async def list_filaments(db: AsyncSession = Depends(get_db)):
|
||||
async def create_filament(
|
||||
filament_data: FilamentCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_CREATE),
|
||||
):
|
||||
"""Create a new filament entry."""
|
||||
filament = Filament(**filament_data.model_dump())
|
||||
@@ -35,7 +42,11 @@ async def create_filament(
|
||||
|
||||
|
||||
@router.get("/{filament_id}", response_model=FilamentResponse)
|
||||
async def get_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_filament(
|
||||
filament_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
|
||||
):
|
||||
"""Get a specific filament."""
|
||||
result = await db.execute(select(Filament).where(Filament.id == filament_id))
|
||||
filament = result.scalar_one_or_none()
|
||||
@@ -49,6 +60,7 @@ async def update_filament(
|
||||
filament_id: int,
|
||||
filament_data: FilamentUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
|
||||
):
|
||||
"""Update a filament."""
|
||||
result = await db.execute(select(Filament).where(Filament.id == filament_id))
|
||||
@@ -65,7 +77,11 @@ async def update_filament(
|
||||
|
||||
|
||||
@router.delete("/{filament_id}")
|
||||
async def delete_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_filament(
|
||||
filament_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_DELETE),
|
||||
):
|
||||
"""Delete a filament."""
|
||||
result = await db.execute(select(Filament).where(Filament.id == filament_id))
|
||||
filament = result.scalar_one_or_none()
|
||||
@@ -82,6 +98,7 @@ async def calculate_cost(
|
||||
filament_id: int,
|
||||
weight_grams: float,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
|
||||
):
|
||||
"""Calculate the cost for a given weight of filament."""
|
||||
result = await db.execute(select(Filament).where(Filament.id == filament_id))
|
||||
@@ -104,6 +121,7 @@ async def calculate_cost(
|
||||
async def get_filaments_by_type(
|
||||
filament_type: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
|
||||
):
|
||||
"""Get all filaments of a specific type."""
|
||||
result = await db.execute(select(Filament).where(Filament.type.ilike(f"%{filament_type}%")).order_by(Filament.name))
|
||||
@@ -111,7 +129,10 @@ async def get_filaments_by_type(
|
||||
|
||||
|
||||
@router.post("/seed-defaults")
|
||||
async def seed_default_filaments(db: AsyncSession = Depends(get_db)):
|
||||
async def seed_default_filaments(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_CREATE),
|
||||
):
|
||||
"""Seed the database with common filament types."""
|
||||
defaults = [
|
||||
{
|
||||
|
||||
@@ -12,8 +12,11 @@ from pydantic import BaseModel, Field
|
||||
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.printer import Printer
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.firmware_check import get_firmware_service
|
||||
from backend.app.services.firmware_update import (
|
||||
FirmwareUploadStatus,
|
||||
@@ -59,6 +62,7 @@ class LatestFirmwareInfo(BaseModel):
|
||||
@router.get("/updates", response_model=FirmwareUpdatesResponse)
|
||||
async def check_firmware_updates(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
|
||||
):
|
||||
"""
|
||||
Check for firmware updates for all connected printers.
|
||||
@@ -112,6 +116,7 @@ async def check_firmware_updates(
|
||||
async def check_printer_firmware(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
|
||||
):
|
||||
"""
|
||||
Check for firmware update for a specific printer.
|
||||
@@ -148,7 +153,9 @@ async def check_printer_firmware(
|
||||
|
||||
|
||||
@router.get("/latest", response_model=list[LatestFirmwareInfo])
|
||||
async def get_all_latest_firmware():
|
||||
async def get_all_latest_firmware(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
|
||||
):
|
||||
"""
|
||||
Get the latest firmware versions for all Bambu Lab printer models.
|
||||
|
||||
@@ -211,6 +218,7 @@ class FirmwareUploadStartResponse(BaseModel):
|
||||
async def prepare_firmware_upload(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
|
||||
):
|
||||
"""
|
||||
Check prerequisites for uploading firmware to a printer.
|
||||
@@ -232,6 +240,7 @@ async def prepare_firmware_upload(
|
||||
async def start_firmware_upload(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_UPDATE),
|
||||
):
|
||||
"""
|
||||
Start uploading firmware to a printer's SD card.
|
||||
@@ -279,7 +288,10 @@ async def start_firmware_upload(
|
||||
|
||||
|
||||
@router.get("/updates/{printer_id}/upload/status", response_model=FirmwareUploadStatusResponse)
|
||||
async def get_firmware_upload_status(printer_id: int):
|
||||
async def get_firmware_upload_status(
|
||||
printer_id: int,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
|
||||
):
|
||||
"""
|
||||
Get the current status of a firmware upload operation.
|
||||
|
||||
|
||||
@@ -6,8 +6,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import delete, desc, 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.github_backup import GitHubBackupConfig, GitHubBackupLog
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.github_backup import (
|
||||
GitHubBackupConfigCreate,
|
||||
GitHubBackupConfigResponse,
|
||||
@@ -48,7 +51,10 @@ def _config_to_response(config: GitHubBackupConfig) -> dict:
|
||||
|
||||
|
||||
@router.get("/config", response_model=GitHubBackupConfigResponse | None)
|
||||
async def get_config(db: AsyncSession = Depends(get_db)):
|
||||
async def get_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""Get the current GitHub backup configuration."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
@@ -63,6 +69,7 @@ async def get_config(db: AsyncSession = Depends(get_db)):
|
||||
async def save_config(
|
||||
config_data: GitHubBackupConfigCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""Create or update GitHub backup configuration.
|
||||
|
||||
@@ -121,6 +128,7 @@ async def save_config(
|
||||
async def update_config(
|
||||
update_data: GitHubBackupConfigUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""Partially update GitHub backup configuration."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
@@ -153,7 +161,10 @@ async def update_config(
|
||||
|
||||
|
||||
@router.delete("/config")
|
||||
async def delete_config(db: AsyncSession = Depends(get_db)):
|
||||
async def delete_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""Delete the GitHub backup configuration and all logs."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
@@ -173,6 +184,7 @@ async def delete_config(db: AsyncSession = Depends(get_db)):
|
||||
async def test_connection(
|
||||
repo_url: str = Query(..., description="GitHub repository URL"),
|
||||
token: str = Query(..., description="Personal Access Token"),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""Test GitHub connection with provided credentials."""
|
||||
result = await github_backup_service.test_connection(repo_url, token)
|
||||
@@ -180,7 +192,10 @@ async def test_connection(
|
||||
|
||||
|
||||
@router.post("/test-stored", response_model=GitHubTestConnectionResponse)
|
||||
async def test_stored_connection(db: AsyncSession = Depends(get_db)):
|
||||
async def test_stored_connection(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""Test GitHub connection using stored configuration."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
@@ -196,7 +211,10 @@ async def test_stored_connection(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/run", response_model=GitHubBackupTriggerResponse)
|
||||
async def trigger_backup(db: AsyncSession = Depends(get_db)):
|
||||
async def trigger_backup(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""Manually trigger a backup."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
@@ -213,7 +231,10 @@ async def trigger_backup(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/status", response_model=GitHubBackupStatus)
|
||||
async def get_status(db: AsyncSession = Depends(get_db)):
|
||||
async def get_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""Get current backup status."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
@@ -245,6 +266,7 @@ async def get_logs(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""Get backup logs."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
@@ -282,6 +304,7 @@ async def get_logs(
|
||||
async def clear_logs(
|
||||
keep_last: int = Query(default=10, ge=0, le=100, description="Number of recent logs to keep"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""Clear backup logs, optionally keeping the most recent entries."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
|
||||
@@ -7,9 +7,12 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
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.kprofile_note import KProfileNote as KProfileNoteModel
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.kprofile import (
|
||||
KProfile,
|
||||
KProfileCreate,
|
||||
@@ -30,6 +33,7 @@ async def get_kprofiles(
|
||||
printer_id: int,
|
||||
nozzle_diameter: str = "0.4",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
|
||||
):
|
||||
"""Get K-profiles from a printer.
|
||||
|
||||
@@ -78,6 +82,7 @@ async def set_kprofile(
|
||||
printer_id: int,
|
||||
profile: KProfileCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
|
||||
):
|
||||
"""Create or update a K-profile on the printer.
|
||||
|
||||
@@ -178,6 +183,7 @@ async def set_kprofiles_batch(
|
||||
printer_id: int,
|
||||
profiles: list[KProfileCreate],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
|
||||
):
|
||||
"""Create multiple K-profiles in a single command (for dual-nozzle).
|
||||
|
||||
@@ -236,6 +242,7 @@ async def delete_kprofile(
|
||||
printer_id: int,
|
||||
profile: KProfileDelete,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
|
||||
):
|
||||
"""Delete a K-profile from the printer.
|
||||
|
||||
@@ -278,6 +285,7 @@ async def delete_kprofile(
|
||||
async def get_kprofile_notes(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
|
||||
):
|
||||
"""Get all K-profile notes for a printer.
|
||||
|
||||
@@ -305,6 +313,7 @@ async def set_kprofile_note(
|
||||
printer_id: int,
|
||||
note_data: KProfileNote,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
|
||||
):
|
||||
"""Set or update a note for a K-profile.
|
||||
|
||||
@@ -353,6 +362,7 @@ async def delete_kprofile_note(
|
||||
printer_id: int,
|
||||
setting_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
|
||||
):
|
||||
"""Delete a note for a K-profile.
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from backend.app.core.auth import (
|
||||
require_auth_if_enabled,
|
||||
require_ownership_permission,
|
||||
require_permission_if_auth_enabled,
|
||||
)
|
||||
@@ -237,7 +236,11 @@ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", "
|
||||
|
||||
@router.get("/folders", response_model=list[FolderTreeItem])
|
||||
@router.get("/folders/", response_model=list[FolderTreeItem])
|
||||
async def list_folders(response: Response, db: AsyncSession = Depends(get_db)):
|
||||
async def list_folders(
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""Get all folders as a tree structure."""
|
||||
# Prevent browser caching of folder list
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
@@ -289,7 +292,11 @@ async def list_folders(response: Response, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/folders/by-project/{project_id}", response_model=list[FolderResponse])
|
||||
async def get_folders_by_project(project_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_folders_by_project(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""Get all folders linked to a specific project."""
|
||||
result = await db.execute(
|
||||
select(LibraryFolder, Project.name)
|
||||
@@ -326,7 +333,11 @@ async def get_folders_by_project(project_id: int, db: AsyncSession = Depends(get
|
||||
|
||||
|
||||
@router.get("/folders/by-archive/{archive_id}", response_model=list[FolderResponse])
|
||||
async def get_folders_by_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_folders_by_archive(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""Get all folders linked to a specific archive."""
|
||||
result = await db.execute(
|
||||
select(LibraryFolder, PrintArchive.print_name)
|
||||
@@ -364,7 +375,11 @@ async def get_folders_by_archive(archive_id: int, db: AsyncSession = Depends(get
|
||||
|
||||
@router.post("/folders", response_model=FolderResponse)
|
||||
@router.post("/folders/", response_model=FolderResponse)
|
||||
async def create_folder(data: FolderCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_folder(
|
||||
data: FolderCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
|
||||
):
|
||||
"""Create a new folder."""
|
||||
# Verify parent exists if specified
|
||||
if data.parent_id is not None:
|
||||
@@ -415,7 +430,11 @@ async def create_folder(data: FolderCreate, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/folders/{folder_id}", response_model=FolderResponse)
|
||||
async def get_folder(folder_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_folder(
|
||||
folder_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""Get a folder by ID."""
|
||||
result = await db.execute(
|
||||
select(LibraryFolder, Project.name, PrintArchive.print_name)
|
||||
@@ -449,8 +468,17 @@ async def get_folder(folder_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.put("/folders/{folder_id}", response_model=FolderResponse)
|
||||
async def update_folder(folder_id: int, data: FolderUpdate, db: AsyncSession = Depends(get_db)):
|
||||
"""Update a folder."""
|
||||
async def update_folder(
|
||||
folder_id: int,
|
||||
data: FolderUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
|
||||
):
|
||||
"""Update a folder.
|
||||
|
||||
Note: Folders require library:update_all permission since they don't have
|
||||
ownership tracking.
|
||||
"""
|
||||
result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
|
||||
folder = result.scalar_one_or_none()
|
||||
|
||||
@@ -595,6 +623,7 @@ async def list_files(
|
||||
folder_id: int | None = None,
|
||||
include_root: bool = True,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""List files, optionally filtered by folder.
|
||||
|
||||
@@ -669,7 +698,7 @@ async def upload_file(
|
||||
folder_id: int | None = None,
|
||||
generate_stl_thumbnails: bool = Query(default=True),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = Depends(require_auth_if_enabled),
|
||||
current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
|
||||
):
|
||||
"""Upload a file to the library."""
|
||||
try:
|
||||
@@ -805,7 +834,7 @@ async def extract_zip_file(
|
||||
create_folder_from_zip: bool = Query(default=False),
|
||||
generate_stl_thumbnails: bool = Query(default=True),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = Depends(require_auth_if_enabled),
|
||||
current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
|
||||
):
|
||||
"""Upload and extract a ZIP file to the library.
|
||||
|
||||
@@ -1064,9 +1093,13 @@ async def extract_zip_file(
|
||||
async def batch_generate_stl_thumbnails(
|
||||
request: BatchThumbnailRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
|
||||
):
|
||||
"""Generate thumbnails for STL files in batch.
|
||||
|
||||
Note: Requires library:update_all permission since this is a batch operation
|
||||
that may affect files owned by different users.
|
||||
|
||||
Can generate thumbnails for:
|
||||
- Specific file IDs (file_ids)
|
||||
- All STL files in a folder (folder_id)
|
||||
@@ -1188,6 +1221,7 @@ def is_sliced_file(filename: str) -> bool:
|
||||
async def add_files_to_queue(
|
||||
request: AddToQueueRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)),
|
||||
):
|
||||
"""Add library files to the print queue.
|
||||
|
||||
@@ -1266,6 +1300,7 @@ async def add_files_to_queue(
|
||||
async def get_library_file_plates(
|
||||
file_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""Get available plates from a multi-plate 3MF library file.
|
||||
|
||||
@@ -1537,6 +1572,7 @@ async def get_library_file_filament_requirements(
|
||||
file_id: int,
|
||||
plate_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""Get filament requirements from a library file.
|
||||
|
||||
@@ -1659,6 +1695,7 @@ async def print_library_file(
|
||||
printer_id: int,
|
||||
body: FilePrintRequest | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.PRINTERS_CONTROL)),
|
||||
):
|
||||
"""Print a library file directly.
|
||||
|
||||
@@ -1846,7 +1883,11 @@ async def print_library_file(
|
||||
|
||||
|
||||
@router.get("/files/{file_id}", response_model=FileResponseSchema)
|
||||
async def get_file(file_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_file(
|
||||
file_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""Get a file by ID with full details."""
|
||||
result = await db.execute(
|
||||
select(LibraryFile).options(selectinload(LibraryFile.created_by)).where(LibraryFile.id == file_id)
|
||||
@@ -2021,7 +2062,11 @@ async def delete_file(
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/download")
|
||||
async def download_file(file_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def download_file(
|
||||
file_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""Download a file."""
|
||||
result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
|
||||
file = result.scalar_one_or_none()
|
||||
@@ -2068,7 +2113,11 @@ async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/gcode")
|
||||
async def get_gcode(file_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_gcode(
|
||||
file_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""Get gcode for a file (for preview)."""
|
||||
result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
|
||||
file = result.scalar_one_or_none()
|
||||
@@ -2106,8 +2155,22 @@ async def get_gcode(file_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/files/move")
|
||||
async def move_files(data: FileMoveRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Move multiple files to a folder."""
|
||||
async def move_files(
|
||||
data: FileMoveRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_ownership_permission(
|
||||
Permission.LIBRARY_UPDATE_ALL,
|
||||
Permission.LIBRARY_UPDATE_OWN,
|
||||
)
|
||||
),
|
||||
):
|
||||
"""Move multiple files to a folder.
|
||||
|
||||
Files not owned by the user are skipped (unless user has *_all permission).
|
||||
"""
|
||||
user, can_modify_all = auth_result
|
||||
|
||||
# Verify folder exists if specified
|
||||
if data.folder_id is not None:
|
||||
folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.folder_id))
|
||||
@@ -2116,14 +2179,19 @@ async def move_files(data: FileMoveRequest, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
# Update files
|
||||
moved = 0
|
||||
skipped = 0
|
||||
for file_id in data.file_ids:
|
||||
result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
|
||||
file = result.scalar_one_or_none()
|
||||
if file:
|
||||
# Ownership check
|
||||
if not can_modify_all and file.created_by_id != user.id:
|
||||
skipped += 1
|
||||
continue
|
||||
file.folder_id = data.folder_id
|
||||
moved += 1
|
||||
|
||||
return {"status": "success", "moved": moved}
|
||||
return {"status": "success", "moved": moved, "skipped": skipped}
|
||||
|
||||
|
||||
@router.post("/bulk-delete", response_model=BulkDeleteResponse)
|
||||
@@ -2193,7 +2261,10 @@ async def bulk_delete(
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
||||
async def get_library_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
|
||||
):
|
||||
"""Get library statistics."""
|
||||
# Total files
|
||||
total_files_result = await db.execute(select(func.count(LibraryFile.id)))
|
||||
|
||||
@@ -8,9 +8,12 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
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.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.maintenance import (
|
||||
MaintenanceHistoryResponse,
|
||||
MaintenanceStatus,
|
||||
@@ -114,7 +117,10 @@ async def ensure_default_types(db: AsyncSession) -> None:
|
||||
|
||||
|
||||
@router.get("/types", response_model=list[MaintenanceTypeResponse])
|
||||
async def get_maintenance_types(db: AsyncSession = Depends(get_db)):
|
||||
async def get_maintenance_types(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
|
||||
):
|
||||
"""Get all maintenance types."""
|
||||
await ensure_default_types(db)
|
||||
result = await db.execute(select(MaintenanceType).order_by(MaintenanceType.is_system.desc(), MaintenanceType.name))
|
||||
@@ -125,6 +131,7 @@ async def get_maintenance_types(db: AsyncSession = Depends(get_db)):
|
||||
async def create_maintenance_type(
|
||||
data: MaintenanceTypeCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
|
||||
):
|
||||
"""Create a custom maintenance type."""
|
||||
new_type = MaintenanceType(
|
||||
@@ -146,6 +153,7 @@ async def update_maintenance_type(
|
||||
type_id: int,
|
||||
data: MaintenanceTypeUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
|
||||
):
|
||||
"""Update a maintenance type."""
|
||||
result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
|
||||
@@ -166,6 +174,7 @@ async def update_maintenance_type(
|
||||
async def delete_maintenance_type(
|
||||
type_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
|
||||
):
|
||||
"""Delete a custom maintenance type."""
|
||||
result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
|
||||
@@ -331,13 +340,17 @@ async def _get_printer_maintenance_internal(
|
||||
async def get_printer_maintenance(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
|
||||
):
|
||||
"""Get maintenance overview for a specific printer."""
|
||||
return await _get_printer_maintenance_internal(printer_id, db, commit=True)
|
||||
|
||||
|
||||
@router.get("/overview", response_model=list[PrinterMaintenanceOverview])
|
||||
async def get_all_maintenance_overview(db: AsyncSession = Depends(get_db)):
|
||||
async def get_all_maintenance_overview(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
|
||||
):
|
||||
"""Get maintenance overview for all active printers."""
|
||||
await ensure_default_types(db)
|
||||
|
||||
@@ -361,6 +374,7 @@ async def update_printer_maintenance(
|
||||
item_id: int,
|
||||
data: PrinterMaintenanceUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
|
||||
):
|
||||
"""Update a printer maintenance item (e.g., custom interval, enabled)."""
|
||||
result = await db.execute(
|
||||
@@ -386,6 +400,7 @@ async def assign_maintenance_type(
|
||||
printer_id: int,
|
||||
type_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
|
||||
):
|
||||
"""Assign a maintenance type to a specific printer (for custom types)."""
|
||||
# Verify printer exists
|
||||
@@ -438,6 +453,7 @@ async def assign_maintenance_type(
|
||||
async def remove_maintenance_item(
|
||||
item_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
|
||||
):
|
||||
"""Remove a maintenance item (unassign a custom type from a printer)."""
|
||||
result = await db.execute(
|
||||
@@ -464,6 +480,7 @@ async def perform_maintenance(
|
||||
item_id: int,
|
||||
data: PerformMaintenanceRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
|
||||
):
|
||||
"""Mark maintenance as performed (reset the counter)."""
|
||||
result = await db.execute(
|
||||
@@ -541,6 +558,7 @@ async def perform_maintenance(
|
||||
async def get_maintenance_history(
|
||||
item_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
|
||||
):
|
||||
"""Get maintenance history for a specific item."""
|
||||
result = await db.execute(
|
||||
@@ -552,7 +570,10 @@ async def get_maintenance_history(
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def get_maintenance_summary(db: AsyncSession = Depends(get_db)):
|
||||
async def get_maintenance_summary(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
|
||||
):
|
||||
"""Get a summary of maintenance status across all printers."""
|
||||
await ensure_default_types(db)
|
||||
|
||||
@@ -589,6 +610,7 @@ async def set_printer_hours(
|
||||
printer_id: int,
|
||||
total_hours: float,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
|
||||
):
|
||||
"""Set the total print hours for a printer (adjusts offset to match).
|
||||
|
||||
|
||||
@@ -362,11 +362,13 @@ async def get_metrics(
|
||||
)
|
||||
lines.append(f"bambuddy_printer_prints_total{labels} {count}")
|
||||
|
||||
# Total filament used
|
||||
# Total filament used (multiply by quantity to account for multiple items printed)
|
||||
lines.append("")
|
||||
lines.append("# HELP bambuddy_filament_used_grams Total filament used in grams")
|
||||
lines.append("# TYPE bambuddy_filament_used_grams counter")
|
||||
result = await db.execute(select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)))
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(PrintArchive.filament_used_grams * func.coalesce(PrintArchive.quantity, 1)), 0))
|
||||
)
|
||||
total_filament = result.scalar() or 0
|
||||
lines.append(f"bambuddy_filament_used_grams {total_filament:.1f}")
|
||||
|
||||
|
||||
@@ -4,8 +4,11 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
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.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.notification_template import (
|
||||
EVENT_VARIABLES,
|
||||
SAMPLE_DATA,
|
||||
@@ -45,14 +48,19 @@ EVENT_NAMES = {
|
||||
|
||||
@router.get("", response_model=list[NotificationTemplateResponse])
|
||||
@router.get("/", response_model=list[NotificationTemplateResponse])
|
||||
async def get_templates(db: AsyncSession = Depends(get_db)):
|
||||
async def get_templates(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
|
||||
):
|
||||
"""Get all notification templates."""
|
||||
result = await db.execute(select(NotificationTemplate).order_by(NotificationTemplate.id))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/variables", response_model=list[EventVariablesResponse])
|
||||
async def get_variables():
|
||||
async def get_variables(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
|
||||
):
|
||||
"""Get available variables for each event type."""
|
||||
return [
|
||||
EventVariablesResponse(
|
||||
@@ -65,7 +73,11 @@ async def get_variables():
|
||||
|
||||
|
||||
@router.get("/{template_id}", response_model=NotificationTemplateResponse)
|
||||
async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_template(
|
||||
template_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
|
||||
):
|
||||
"""Get a single notification template."""
|
||||
result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
|
||||
template = result.scalar_one_or_none()
|
||||
@@ -79,6 +91,7 @@ async def update_template(
|
||||
template_id: int,
|
||||
update: NotificationTemplateUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_UPDATE),
|
||||
):
|
||||
"""Update a notification template."""
|
||||
result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
|
||||
@@ -101,7 +114,11 @@ async def update_template(
|
||||
|
||||
|
||||
@router.post("/{template_id}/reset", response_model=NotificationTemplateResponse)
|
||||
async def reset_template(template_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def reset_template(
|
||||
template_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_UPDATE),
|
||||
):
|
||||
"""Reset a notification template to its default values."""
|
||||
result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
|
||||
template = result.scalar_one_or_none()
|
||||
@@ -129,7 +146,10 @@ async def reset_template(template_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/preview", response_model=TemplatePreviewResponse)
|
||||
async def preview_template(request: TemplatePreviewRequest):
|
||||
async def preview_template(
|
||||
request: TemplatePreviewRequest,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
|
||||
):
|
||||
"""Preview a template with sample data."""
|
||||
sample = SAMPLE_DATA.get(request.event_type, {})
|
||||
|
||||
|
||||
@@ -8,8 +8,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import delete, desc, func, 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.notification import NotificationLog, NotificationProvider
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.notification import (
|
||||
NotificationLogResponse,
|
||||
NotificationLogStats,
|
||||
@@ -86,7 +89,10 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
|
||||
|
||||
|
||||
@router.get("/", response_model=list[NotificationProviderResponse])
|
||||
async def list_notification_providers(db: AsyncSession = Depends(get_db)):
|
||||
async def list_notification_providers(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
|
||||
):
|
||||
"""List all notification providers."""
|
||||
result = await db.execute(select(NotificationProvider).order_by(NotificationProvider.created_at.desc()))
|
||||
providers = result.scalars().all()
|
||||
@@ -98,6 +104,7 @@ async def list_notification_providers(db: AsyncSession = Depends(get_db)):
|
||||
async def create_notification_provider(
|
||||
provider_data: NotificationProviderCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
|
||||
):
|
||||
"""Create a new notification provider."""
|
||||
provider = NotificationProvider(
|
||||
@@ -153,6 +160,7 @@ async def create_notification_provider(
|
||||
async def test_notification_config(
|
||||
test_request: NotificationTestRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
|
||||
):
|
||||
"""Test notification configuration before saving."""
|
||||
success, message = await notification_service.send_test_notification(
|
||||
@@ -163,7 +171,10 @@ async def test_notification_config(
|
||||
|
||||
|
||||
@router.post("/test-all")
|
||||
async def test_all_notification_providers(db: AsyncSession = Depends(get_db)):
|
||||
async def test_all_notification_providers(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
|
||||
):
|
||||
"""Send a test notification to all enabled providers."""
|
||||
result = await db.execute(select(NotificationProvider).where(NotificationProvider.enabled.is_(True)))
|
||||
providers = result.scalars().all()
|
||||
@@ -222,6 +233,7 @@ async def get_notification_logs(
|
||||
success: bool | None = Query(default=None),
|
||||
days: int | None = Query(default=7, ge=1, le=90, description="Filter logs from the last N days"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
|
||||
):
|
||||
"""Get notification logs with optional filters."""
|
||||
query = select(NotificationLog).order_by(desc(NotificationLog.created_at))
|
||||
@@ -278,6 +290,7 @@ async def get_notification_logs(
|
||||
async def get_notification_log_stats(
|
||||
days: int = Query(default=7, ge=1, le=90, description="Statistics for the last N days"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
|
||||
):
|
||||
"""Get notification log statistics."""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
@@ -323,6 +336,7 @@ async def get_notification_log_stats(
|
||||
async def clear_notification_logs(
|
||||
older_than_days: int = Query(default=30, ge=1, description="Delete logs older than N days"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
|
||||
):
|
||||
"""Clear old notification logs."""
|
||||
cutoff = datetime.utcnow() - timedelta(days=older_than_days)
|
||||
@@ -345,6 +359,7 @@ async def clear_notification_logs(
|
||||
async def get_notification_provider(
|
||||
provider_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
|
||||
):
|
||||
"""Get a specific notification provider."""
|
||||
result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
|
||||
@@ -361,6 +376,7 @@ async def update_notification_provider(
|
||||
provider_id: int,
|
||||
update_data: NotificationProviderUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
|
||||
):
|
||||
"""Update a notification provider."""
|
||||
result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
|
||||
@@ -392,6 +408,7 @@ async def update_notification_provider(
|
||||
async def delete_notification_provider(
|
||||
provider_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
|
||||
):
|
||||
"""Delete a notification provider."""
|
||||
result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
|
||||
@@ -413,6 +430,7 @@ async def delete_notification_provider(
|
||||
async def test_notification_provider(
|
||||
provider_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
|
||||
):
|
||||
"""Send a test notification using an existing provider."""
|
||||
result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
|
||||
|
||||
@@ -8,8 +8,11 @@ from pydantic import BaseModel
|
||||
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.pending_upload import PendingUpload
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.archive import ArchiveService
|
||||
|
||||
router = APIRouter(prefix="/pending-uploads", tags=["pending-uploads"])
|
||||
@@ -41,7 +44,10 @@ class PendingUploadResponse(BaseModel):
|
||||
|
||||
|
||||
@router.get("/", response_model=list[PendingUploadResponse])
|
||||
async def list_pending_uploads(db: AsyncSession = Depends(get_db)):
|
||||
async def list_pending_uploads(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
|
||||
):
|
||||
"""List all pending uploads."""
|
||||
result = await db.execute(
|
||||
select(PendingUpload).where(PendingUpload.status == "pending").order_by(PendingUpload.uploaded_at.desc())
|
||||
@@ -51,7 +57,10 @@ async def list_pending_uploads(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/count")
|
||||
async def get_pending_count(db: AsyncSession = Depends(get_db)):
|
||||
async def get_pending_count(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
|
||||
):
|
||||
"""Get count of pending uploads."""
|
||||
result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
|
||||
count = len(result.scalars().all())
|
||||
@@ -64,7 +73,10 @@ async def get_pending_count(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/archive-all")
|
||||
async def archive_all_pending(db: AsyncSession = Depends(get_db)):
|
||||
async def archive_all_pending(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
|
||||
):
|
||||
"""Archive all pending uploads."""
|
||||
result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
|
||||
pending_uploads = result.scalars().all()
|
||||
@@ -117,7 +129,10 @@ async def archive_all_pending(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.delete("/discard-all")
|
||||
async def discard_all_pending(db: AsyncSession = Depends(get_db)):
|
||||
async def discard_all_pending(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
|
||||
):
|
||||
"""Discard all pending uploads."""
|
||||
result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
|
||||
pending_uploads = result.scalars().all()
|
||||
@@ -144,6 +159,7 @@ async def discard_all_pending(db: AsyncSession = Depends(get_db)):
|
||||
async def get_pending_upload(
|
||||
upload_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
|
||||
):
|
||||
"""Get a specific pending upload."""
|
||||
result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
|
||||
@@ -160,6 +176,7 @@ async def archive_pending_upload(
|
||||
upload_id: int,
|
||||
request: ArchiveRequest = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
|
||||
):
|
||||
"""Archive a pending upload."""
|
||||
result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
|
||||
@@ -227,6 +244,7 @@ async def archive_pending_upload(
|
||||
async def discard_pending_upload(
|
||||
upload_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
|
||||
):
|
||||
"""Discard a pending upload without archiving."""
|
||||
result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
|
||||
|
||||
@@ -12,7 +12,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from backend.app.core.auth import require_auth_if_enabled, require_ownership_permission
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_ownership_permission
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
@@ -121,6 +121,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
|
||||
"id": item.id,
|
||||
"printer_id": item.printer_id,
|
||||
"target_model": item.target_model,
|
||||
"target_location": item.target_location,
|
||||
"required_filament_types": required_filament_types_parsed,
|
||||
"waiting_reason": item.waiting_reason,
|
||||
"archive_id": item.archive_id,
|
||||
@@ -172,6 +173,7 @@ async def list_queue(
|
||||
printer_id: int | None = Query(None, description="Filter by printer (-1 for unassigned)"),
|
||||
status: str | None = Query(None, description="Filter by status"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
|
||||
):
|
||||
"""List all queue items, optionally filtered by printer or status."""
|
||||
query = (
|
||||
@@ -203,7 +205,7 @@ async def list_queue(
|
||||
async def add_to_queue(
|
||||
data: PrintQueueItemCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = Depends(require_auth_if_enabled),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
|
||||
):
|
||||
"""Add an item to the print queue."""
|
||||
# Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E")
|
||||
@@ -289,6 +291,7 @@ async def add_to_queue(
|
||||
item = PrintQueueItem(
|
||||
printer_id=data.printer_id,
|
||||
target_model=target_model_norm,
|
||||
target_location=data.target_location,
|
||||
required_filament_types=required_filament_types,
|
||||
archive_id=data.archive_id,
|
||||
library_file_id=data.library_file_id,
|
||||
@@ -423,7 +426,11 @@ async def bulk_update_queue_items(
|
||||
|
||||
|
||||
@router.get("/{item_id}", response_model=PrintQueueItemResponse)
|
||||
async def get_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_queue_item(
|
||||
item_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
|
||||
):
|
||||
"""Get a specific queue item."""
|
||||
result = await db.execute(
|
||||
select(PrintQueueItem)
|
||||
@@ -551,6 +558,7 @@ async def delete_queue_item(
|
||||
async def reorder_queue(
|
||||
data: PrintQueueReorder,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
|
||||
):
|
||||
"""Bulk update positions for queue items."""
|
||||
for reorder_item in data.items:
|
||||
@@ -603,6 +611,7 @@ async def cancel_queue_item(
|
||||
async def stop_queue_item(
|
||||
item_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
|
||||
):
|
||||
"""Stop an actively printing queue item."""
|
||||
import asyncio
|
||||
@@ -673,6 +682,7 @@ async def stop_queue_item(
|
||||
async def start_queue_item(
|
||||
item_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
|
||||
):
|
||||
"""Manually start a staged (manual_start) queue item.
|
||||
|
||||
|
||||
@@ -672,10 +672,11 @@ async def get_printer_cover(
|
||||
# Extract thumbnail from 3MF (which is a ZIP file)
|
||||
try:
|
||||
zf = zipfile.ZipFile(temp_path, "r")
|
||||
except zipfile.BadZipFile as e:
|
||||
raise HTTPException(500, f"Downloaded file is not a valid 3MF/ZIP: {e}")
|
||||
except zipfile.BadZipFile:
|
||||
raise HTTPException(500, "Downloaded file is not a valid 3MF/ZIP archive")
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to open 3MF file: {e}")
|
||||
logger.error(f"Failed to open 3MF file: {e}", exc_info=True)
|
||||
raise HTTPException(500, "Failed to open 3MF file. Check server logs for details.")
|
||||
|
||||
try:
|
||||
# Try common thumbnail paths in 3MF files
|
||||
@@ -1676,6 +1677,7 @@ async def reset_ams_slot(
|
||||
ams_id: int,
|
||||
tray_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
|
||||
):
|
||||
"""Reset an AMS slot to empty/unconfigured state.
|
||||
|
||||
@@ -1718,6 +1720,7 @@ async def reset_ams_slot(
|
||||
async def debug_simulate_print_complete(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
|
||||
):
|
||||
"""DEBUG: Simulate print completion to test freeze behavior.
|
||||
|
||||
|
||||
@@ -14,13 +14,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from backend.app.api.routes.library import get_library_dir
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.library import LibraryFile, LibraryFolder
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
from backend.app.models.project import Project
|
||||
from backend.app.models.project_bom import ProjectBOMItem
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.project import (
|
||||
ArchivePreview,
|
||||
BatchAddArchives,
|
||||
@@ -154,6 +157,7 @@ async def compute_project_stats(
|
||||
async def list_projects(
|
||||
status: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
||||
):
|
||||
"""List all projects with basic stats."""
|
||||
query = select(Project)
|
||||
@@ -258,6 +262,7 @@ async def list_projects(
|
||||
async def create_project(
|
||||
data: ProjectCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
|
||||
):
|
||||
"""Create a new project."""
|
||||
# Verify parent exists if specified
|
||||
@@ -319,6 +324,7 @@ async def create_project(
|
||||
@router.get("/templates", response_model=list[ProjectListResponse])
|
||||
async def list_templates(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
||||
):
|
||||
"""List all project templates."""
|
||||
result = await db.execute(select(Project).where(Project.is_template.is_(True)).order_by(Project.name))
|
||||
@@ -356,6 +362,7 @@ async def create_project_from_template(
|
||||
template_id: int,
|
||||
name: str = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
|
||||
):
|
||||
"""Create a new project from a template."""
|
||||
result = await db.execute(select(Project).where(Project.id == template_id))
|
||||
@@ -470,6 +477,7 @@ async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectCh
|
||||
async def get_project(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
||||
):
|
||||
"""Get a project by ID with detailed stats."""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
@@ -519,6 +527,7 @@ async def update_project(
|
||||
project_id: int,
|
||||
data: ProjectUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
|
||||
):
|
||||
"""Update a project."""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
@@ -609,6 +618,7 @@ async def update_project(
|
||||
async def delete_project(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_DELETE),
|
||||
):
|
||||
"""Delete a project. Archives and queue items will have project_id set to NULL."""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
@@ -628,6 +638,7 @@ async def list_project_archives(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
||||
):
|
||||
"""List archives in a project."""
|
||||
# Verify project exists
|
||||
@@ -657,6 +668,7 @@ async def list_project_archives(
|
||||
async def list_project_queue(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
||||
):
|
||||
"""List queue items in a project."""
|
||||
# Verify project exists
|
||||
@@ -677,6 +689,7 @@ async def add_archives_to_project(
|
||||
project_id: int,
|
||||
data: BatchAddArchives,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
|
||||
):
|
||||
"""Batch add archives to a project."""
|
||||
# Verify project exists
|
||||
@@ -701,6 +714,7 @@ async def add_queue_items_to_project(
|
||||
project_id: int,
|
||||
data: BatchAddQueueItems,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
|
||||
):
|
||||
"""Batch add queue items to a project."""
|
||||
# Verify project exists
|
||||
@@ -725,6 +739,7 @@ async def remove_archives_from_project(
|
||||
project_id: int,
|
||||
data: BatchAddArchives,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
|
||||
):
|
||||
"""Remove archives from a project (sets project_id to NULL)."""
|
||||
updated = 0
|
||||
@@ -811,6 +826,7 @@ async def upload_attachment(
|
||||
project_id: int,
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
|
||||
):
|
||||
"""Upload an attachment to a project."""
|
||||
logger.info(f"=== UPLOAD START: {file.filename} for project {project_id} ===")
|
||||
@@ -888,6 +904,7 @@ async def download_attachment(
|
||||
project_id: int,
|
||||
filename: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
||||
):
|
||||
"""Download an attachment from a project."""
|
||||
# Verify project exists
|
||||
@@ -919,6 +936,7 @@ async def delete_attachment(
|
||||
project_id: int,
|
||||
filename: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
|
||||
):
|
||||
"""Delete an attachment from a project."""
|
||||
# Verify project exists
|
||||
@@ -962,6 +980,7 @@ async def delete_attachment(
|
||||
async def list_bom_items(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
||||
):
|
||||
"""List all BOM items for a project."""
|
||||
# Verify project exists
|
||||
@@ -1013,6 +1032,7 @@ async def create_bom_item(
|
||||
project_id: int,
|
||||
data: BOMItemCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
|
||||
):
|
||||
"""Add a BOM item to a project."""
|
||||
# Verify project exists
|
||||
@@ -1072,6 +1092,7 @@ async def update_bom_item(
|
||||
item_id: int,
|
||||
data: BOMItemUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
|
||||
):
|
||||
"""Update a BOM item."""
|
||||
result = await db.execute(
|
||||
@@ -1135,6 +1156,7 @@ async def delete_bom_item(
|
||||
project_id: int,
|
||||
item_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
|
||||
):
|
||||
"""Delete a BOM item."""
|
||||
result = await db.execute(
|
||||
@@ -1157,6 +1179,7 @@ async def delete_bom_item(
|
||||
async def create_template_from_project(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
|
||||
):
|
||||
"""Create a template from an existing project."""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
@@ -1238,6 +1261,7 @@ async def get_project_timeline(
|
||||
project_id: int,
|
||||
limit: int = 50,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
||||
):
|
||||
"""Get timeline of events for a project."""
|
||||
# Verify project exists
|
||||
@@ -1338,6 +1362,7 @@ async def export_project(
|
||||
project_id: int,
|
||||
format: str = "zip", # "zip" (with files) or "json" (metadata only)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
||||
):
|
||||
"""Export a project. Use format=zip (default) for full export with files, or format=json for metadata only."""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
@@ -1458,6 +1483,7 @@ async def export_project(
|
||||
async def import_project(
|
||||
data: ProjectImport,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
|
||||
):
|
||||
"""Import a project with optional BOM items and linked folders."""
|
||||
# Create the project
|
||||
@@ -1551,6 +1577,7 @@ async def import_project(
|
||||
async def import_project_file(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
|
||||
):
|
||||
"""Import a project from a ZIP or JSON file."""
|
||||
if not file.filename:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import io
|
||||
import logging
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -8,11 +9,16 @@ from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.config import settings as app_settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
# Default settings
|
||||
@@ -39,7 +45,10 @@ async def set_setting(db: AsyncSession, key: str, value: str) -> None:
|
||||
|
||||
@router.get("", response_model=AppSettings)
|
||||
@router.get("/", response_model=AppSettings)
|
||||
async def get_settings(db: AsyncSession = Depends(get_db)):
|
||||
async def get_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
):
|
||||
"""Get all application settings."""
|
||||
settings_dict = DEFAULT_SETTINGS.model_dump()
|
||||
|
||||
@@ -96,6 +105,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
|
||||
async def update_settings(
|
||||
settings_update: AppSettingsUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Update application settings."""
|
||||
update_data = settings_update.model_dump(exclude_unset=True)
|
||||
@@ -153,13 +163,17 @@ async def update_settings(
|
||||
async def patch_settings(
|
||||
settings_update: AppSettingsUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Partially update application settings (same as PUT, for REST compatibility)."""
|
||||
return await update_settings(settings_update, db)
|
||||
return await update_settings(settings_update, db, _)
|
||||
|
||||
|
||||
@router.post("/reset", response_model=AppSettings)
|
||||
async def reset_settings(db: AsyncSession = Depends(get_db)):
|
||||
async def reset_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Reset all settings to defaults."""
|
||||
# Delete all settings
|
||||
result = await db.execute(select(Settings))
|
||||
@@ -185,7 +199,10 @@ async def check_ffmpeg():
|
||||
|
||||
|
||||
@router.get("/spoolman")
|
||||
async def get_spoolman_settings(db: AsyncSession = Depends(get_db)):
|
||||
async def get_spoolman_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
):
|
||||
"""Get Spoolman integration settings."""
|
||||
spoolman_enabled = await get_setting(db, "spoolman_enabled") or "false"
|
||||
spoolman_url = await get_setting(db, "spoolman_url") or ""
|
||||
@@ -202,6 +219,7 @@ async def get_spoolman_settings(db: AsyncSession = Depends(get_db)):
|
||||
async def update_spoolman_settings(
|
||||
settings: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Update Spoolman integration settings."""
|
||||
if "spoolman_enabled" in settings:
|
||||
@@ -219,7 +237,10 @@ async def update_spoolman_settings(
|
||||
|
||||
|
||||
@router.get("/backup")
|
||||
async def create_backup(db: AsyncSession = Depends(get_db)):
|
||||
async def create_backup(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
|
||||
):
|
||||
"""Create a complete backup (database + all files) as a ZIP.
|
||||
|
||||
This is a simplified backup that includes the entire SQLite database
|
||||
@@ -232,47 +253,61 @@ async def create_backup(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
from backend.app.core.database import engine
|
||||
|
||||
base_dir = app_settings.base_dir
|
||||
db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", ""))
|
||||
try:
|
||||
base_dir = app_settings.base_dir
|
||||
db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", ""))
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# 1. Checkpoint WAL to ensure all data is in main db file
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)"))
|
||||
# 1. Checkpoint WAL to ensure all data is in main db file
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)"))
|
||||
|
||||
# 2. Copy database file
|
||||
shutil.copy2(db_path, temp_path / "bambuddy.db")
|
||||
# 2. Copy database file
|
||||
shutil.copy2(db_path, temp_path / "bambuddy.db")
|
||||
|
||||
# 3. Copy data directories (if they exist)
|
||||
dirs_to_backup = [
|
||||
("archive", base_dir / "archive"),
|
||||
("virtual_printer", base_dir / "virtual_printer"),
|
||||
("plate_calibration", app_settings.plate_calibration_dir),
|
||||
("icons", base_dir / "icons"),
|
||||
("projects", base_dir / "projects"),
|
||||
]
|
||||
# 3. Copy data directories (if they exist)
|
||||
dirs_to_backup = [
|
||||
("archive", base_dir / "archive"),
|
||||
("virtual_printer", base_dir / "virtual_printer"),
|
||||
("plate_calibration", app_settings.plate_calibration_dir),
|
||||
("icons", base_dir / "icons"),
|
||||
("projects", base_dir / "projects"),
|
||||
]
|
||||
|
||||
for name, src_dir in dirs_to_backup:
|
||||
if src_dir.exists() and any(src_dir.iterdir()):
|
||||
shutil.copytree(src_dir, temp_path / name)
|
||||
for name, src_dir in dirs_to_backup:
|
||||
if src_dir.exists() and any(src_dir.iterdir()):
|
||||
try:
|
||||
shutil.copytree(src_dir, temp_path / name)
|
||||
except shutil.Error as e:
|
||||
# Some files may have restricted permissions (e.g., SSL keys)
|
||||
# Log the error but continue with partial backup
|
||||
logger.warning(f"Some files in {name} could not be copied: {e}")
|
||||
except PermissionError as e:
|
||||
logger.warning(f"Permission denied copying {name}: {e}")
|
||||
|
||||
# 4. Create ZIP
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for file_path in temp_path.rglob("*"):
|
||||
if file_path.is_file():
|
||||
arcname = file_path.relative_to(temp_path)
|
||||
zf.write(file_path, arcname)
|
||||
# 4. Create ZIP
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for file_path in temp_path.rglob("*"):
|
||||
if file_path.is_file():
|
||||
arcname = file_path.relative_to(temp_path)
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
zip_buffer.seek(0)
|
||||
filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
|
||||
zip_buffer.seek(0)
|
||||
filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
|
||||
|
||||
return StreamingResponse(
|
||||
zip_buffer,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
return StreamingResponse(
|
||||
zip_buffer,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Backup failed: {e}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"success": False, "message": "Backup failed. Check server logs for details."},
|
||||
)
|
||||
|
||||
|
||||
@@ -280,6 +315,7 @@ async def create_backup(db: AsyncSession = Depends(get_db)):
|
||||
async def restore_backup(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_RESTORE),
|
||||
):
|
||||
"""Restore from a complete backup ZIP.
|
||||
|
||||
@@ -292,6 +328,7 @@ async def restore_backup(
|
||||
from fastapi import HTTPException
|
||||
|
||||
from backend.app.core.database import close_all_connections
|
||||
from backend.app.services.virtual_printer import virtual_printer_manager
|
||||
|
||||
base_dir = app_settings.base_dir
|
||||
db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", ""))
|
||||
@@ -317,39 +354,90 @@ async def restore_backup(
|
||||
if not backup_db.exists():
|
||||
raise HTTPException(400, "Invalid backup: missing bambuddy.db")
|
||||
|
||||
# 3. Close current database connections
|
||||
await close_all_connections()
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
# 4. Replace database
|
||||
shutil.copy2(backup_db, db_path)
|
||||
# 3. Stop virtual printer if running (releases file locks)
|
||||
try:
|
||||
if virtual_printer_manager.is_enabled:
|
||||
logger.info("Stopping virtual printer for restore...")
|
||||
await virtual_printer_manager.configure(enabled=False)
|
||||
# Give it time to fully release file handles
|
||||
await asyncio.sleep(1)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to stop virtual printer: {e}")
|
||||
|
||||
# 5. Replace data directories
|
||||
dirs_to_restore = [
|
||||
("archive", base_dir / "archive"),
|
||||
("virtual_printer", base_dir / "virtual_printer"),
|
||||
("plate_calibration", app_settings.plate_calibration_dir),
|
||||
("icons", base_dir / "icons"),
|
||||
("projects", base_dir / "projects"),
|
||||
]
|
||||
# 4. Close current database connections
|
||||
logger.info("Closing database connections...")
|
||||
await close_all_connections()
|
||||
|
||||
for name, dest_dir in dirs_to_restore:
|
||||
src_dir = temp_path / name
|
||||
if src_dir.exists():
|
||||
if dest_dir.exists():
|
||||
shutil.rmtree(dest_dir)
|
||||
shutil.copytree(src_dir, dest_dir)
|
||||
# 5. Replace database
|
||||
logger.info("Restoring database from backup...")
|
||||
shutil.copy2(backup_db, db_path)
|
||||
|
||||
# 6. Note: Database connection will be reinitialized on restart
|
||||
# The application should be restarted after restore
|
||||
# 6. Replace data directories
|
||||
# For Docker compatibility: clear contents then copy (don't delete mount points)
|
||||
dirs_to_restore = [
|
||||
("archive", base_dir / "archive"),
|
||||
("virtual_printer", base_dir / "virtual_printer"),
|
||||
("plate_calibration", app_settings.plate_calibration_dir),
|
||||
("icons", base_dir / "icons"),
|
||||
("projects", base_dir / "projects"),
|
||||
]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Backup restored successfully. Please restart Bambuddy for changes to take effect.",
|
||||
}
|
||||
skipped_dirs = []
|
||||
for name, dest_dir in dirs_to_restore:
|
||||
src_dir = temp_path / name
|
||||
if src_dir.exists():
|
||||
logger.info(f"Restoring {name} directory...")
|
||||
try:
|
||||
# Clear destination contents (not the dir itself - may be Docker mount)
|
||||
if dest_dir.exists():
|
||||
for item in dest_dir.iterdir():
|
||||
try:
|
||||
if item.is_dir():
|
||||
shutil.rmtree(item)
|
||||
else:
|
||||
item.unlink()
|
||||
except OSError as e:
|
||||
logger.warning(f"Could not delete {item}: {e}")
|
||||
else:
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Copy contents from backup
|
||||
for item in src_dir.iterdir():
|
||||
dest_item = dest_dir / item.name
|
||||
if item.is_dir():
|
||||
shutil.copytree(item, dest_item)
|
||||
else:
|
||||
shutil.copy2(item, dest_item)
|
||||
except OSError as e:
|
||||
logger.warning(f"Could not restore {name} directory: {e}")
|
||||
skipped_dirs.append(name)
|
||||
|
||||
# 7. Note: Virtual printer and database will be reinitialized on restart
|
||||
# Do NOT try to restart services here - the database session is closed
|
||||
|
||||
logger.info("Restore complete - restart required")
|
||||
message = "Backup restored successfully. Please restart Bambuddy for changes to take effect."
|
||||
if skipped_dirs:
|
||||
message += f" Note: Some directories could not be restored ({', '.join(skipped_dirs)})."
|
||||
return {
|
||||
"success": True,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Restore failed: {e}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"success": False, "message": "Restore failed. Check server logs for details."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/virtual-printer/models")
|
||||
async def get_virtual_printer_models():
|
||||
async def get_virtual_printer_models(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
):
|
||||
"""Get available virtual printer models."""
|
||||
from backend.app.services.virtual_printer import (
|
||||
DEFAULT_VIRTUAL_PRINTER_MODEL,
|
||||
@@ -363,7 +451,10 @@ async def get_virtual_printer_models():
|
||||
|
||||
|
||||
@router.get("/virtual-printer")
|
||||
async def get_virtual_printer_settings(db: AsyncSession = Depends(get_db)):
|
||||
async def get_virtual_printer_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
):
|
||||
"""Get virtual printer settings and status."""
|
||||
from backend.app.services.virtual_printer import (
|
||||
DEFAULT_VIRTUAL_PRINTER_MODEL,
|
||||
@@ -374,12 +465,14 @@ async def get_virtual_printer_settings(db: AsyncSession = Depends(get_db)):
|
||||
access_code = await get_setting(db, "virtual_printer_access_code")
|
||||
mode = await get_setting(db, "virtual_printer_mode")
|
||||
model = await get_setting(db, "virtual_printer_model")
|
||||
target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
|
||||
|
||||
return {
|
||||
"enabled": enabled == "true" if enabled else False,
|
||||
"access_code_set": bool(access_code),
|
||||
"mode": mode or "immediate",
|
||||
"model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
|
||||
"target_printer_id": int(target_printer_id) if target_printer_id else None,
|
||||
"status": virtual_printer_manager.get_status(),
|
||||
}
|
||||
|
||||
@@ -390,9 +483,14 @@ async def update_virtual_printer_settings(
|
||||
access_code: str = None,
|
||||
mode: str = None,
|
||||
model: str = None,
|
||||
target_printer_id: int = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Update virtual printer settings and restart services if needed."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.services.virtual_printer import (
|
||||
DEFAULT_VIRTUAL_PRINTER_MODEL,
|
||||
VIRTUAL_PRINTER_MODELS,
|
||||
@@ -404,20 +502,24 @@ async def update_virtual_printer_settings(
|
||||
current_access_code = await get_setting(db, "virtual_printer_access_code") or ""
|
||||
current_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
|
||||
current_model = await get_setting(db, "virtual_printer_model") or DEFAULT_VIRTUAL_PRINTER_MODEL
|
||||
current_target_id_str = await get_setting(db, "virtual_printer_target_printer_id")
|
||||
current_target_id = int(current_target_id_str) if current_target_id_str else None
|
||||
|
||||
# Apply updates
|
||||
new_enabled = enabled if enabled is not None else current_enabled
|
||||
new_access_code = access_code if access_code is not None else current_access_code
|
||||
new_mode = mode if mode is not None else current_mode
|
||||
new_model = model if model is not None else current_model
|
||||
new_target_id = target_printer_id if target_printer_id is not None else current_target_id
|
||||
|
||||
# Validate mode
|
||||
# "review" is the new name for "queue" (pending review before archiving)
|
||||
# "print_queue" archives and adds to print queue (unassigned)
|
||||
if new_mode not in ("immediate", "queue", "review", "print_queue"):
|
||||
# "proxy" is transparent TCP proxy to a real printer
|
||||
if new_mode not in ("immediate", "queue", "review", "print_queue", "proxy"):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": "Mode must be 'immediate', 'review', or 'print_queue'"},
|
||||
content={"detail": "Mode must be 'immediate', 'review', 'print_queue', or 'proxy'"},
|
||||
)
|
||||
# Normalize legacy "queue" to "review" for storage
|
||||
if new_mode == "queue":
|
||||
@@ -430,19 +532,51 @@ async def update_virtual_printer_settings(
|
||||
content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
|
||||
)
|
||||
|
||||
# Validate access code when enabling
|
||||
if new_enabled and not new_access_code:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": "Access code is required when enabling virtual printer"},
|
||||
)
|
||||
# Mode-specific validation and printer lookup
|
||||
target_printer_ip = ""
|
||||
target_printer_serial = ""
|
||||
if new_mode == "proxy":
|
||||
# Proxy mode requires target printer when enabling
|
||||
if new_enabled and not new_target_id:
|
||||
# If just switching to proxy mode (not explicitly enabling), auto-disable
|
||||
if enabled is None:
|
||||
new_enabled = False
|
||||
else:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": "Target printer is required for proxy mode"},
|
||||
)
|
||||
|
||||
# Validate access code length (Bambu Studio requires exactly 8 characters)
|
||||
if access_code is not None and len(access_code) != 8:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": "Access code must be exactly 8 characters"},
|
||||
)
|
||||
# Look up printer IP and serial if we have a target
|
||||
if new_target_id:
|
||||
result = await db.execute(select(Printer).where(Printer.id == new_target_id))
|
||||
printer = result.scalar_one_or_none()
|
||||
if not printer:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": f"Printer with ID {new_target_id} not found"},
|
||||
)
|
||||
target_printer_ip = printer.ip_address
|
||||
target_printer_serial = printer.serial_number
|
||||
# Access code not required for proxy mode
|
||||
else:
|
||||
# Non-proxy modes require access code when enabling
|
||||
if new_enabled and not new_access_code:
|
||||
# If just switching modes (not explicitly enabling), auto-disable
|
||||
if enabled is None:
|
||||
new_enabled = False
|
||||
else:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": "Access code is required when enabling virtual printer"},
|
||||
)
|
||||
|
||||
# Validate access code length (Bambu Studio requires exactly 8 characters)
|
||||
if access_code is not None and access_code and len(access_code) != 8:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": "Access code must be exactly 8 characters"},
|
||||
)
|
||||
|
||||
# Save settings
|
||||
await set_setting(db, "virtual_printer_enabled", "true" if new_enabled else "false")
|
||||
@@ -451,6 +585,8 @@ async def update_virtual_printer_settings(
|
||||
await set_setting(db, "virtual_printer_mode", new_mode)
|
||||
if model is not None:
|
||||
await set_setting(db, "virtual_printer_model", model)
|
||||
if target_printer_id is not None:
|
||||
await set_setting(db, "virtual_printer_target_printer_id", str(target_printer_id))
|
||||
await db.commit()
|
||||
db.expire_all()
|
||||
|
||||
@@ -461,16 +597,20 @@ async def update_virtual_printer_settings(
|
||||
access_code=new_access_code,
|
||||
mode=new_mode,
|
||||
model=new_model,
|
||||
target_printer_ip=target_printer_ip,
|
||||
target_printer_serial=target_printer_serial,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Virtual printer configuration validation error: {e}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": str(e)},
|
||||
content={"detail": "Invalid virtual printer configuration. Check the provided values."},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to configure virtual printer: {e}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": f"Failed to configure virtual printer: {e}"},
|
||||
content={"detail": "Failed to configure virtual printer. Check server logs for details."},
|
||||
)
|
||||
|
||||
return await get_virtual_printer_settings(db)
|
||||
@@ -482,7 +622,9 @@ async def update_virtual_printer_settings(
|
||||
|
||||
|
||||
@router.get("/mqtt/status")
|
||||
async def get_mqtt_status():
|
||||
async def get_mqtt_status(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
):
|
||||
"""Get MQTT relay connection status."""
|
||||
from backend.app.services.mqtt_relay import mqtt_relay
|
||||
|
||||
|
||||
@@ -9,9 +9,12 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
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.printer import Printer
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.smart_plug import (
|
||||
HAEntity,
|
||||
HASensorEntity,
|
||||
@@ -38,7 +41,10 @@ router = APIRouter(prefix="/smart-plugs", tags=["smart-plugs"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SmartPlugResponse])
|
||||
async def list_smart_plugs(db: AsyncSession = Depends(get_db)):
|
||||
async def list_smart_plugs(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
|
||||
):
|
||||
"""List all smart plugs."""
|
||||
result = await db.execute(select(SmartPlug).order_by(SmartPlug.name))
|
||||
return list(result.scalars().all())
|
||||
@@ -48,6 +54,7 @@ async def list_smart_plugs(db: AsyncSession = Depends(get_db)):
|
||||
async def create_smart_plug(
|
||||
data: SmartPlugCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE),
|
||||
):
|
||||
"""Create a new smart plug."""
|
||||
# Validate printer_id if provided
|
||||
@@ -57,21 +64,17 @@ async def create_smart_plug(
|
||||
raise HTTPException(400, "Printer not found")
|
||||
|
||||
# Check if printer already has a plug assigned
|
||||
# Scripts can coexist with other plugs (they're for multi-device control, not power on/off)
|
||||
is_script = data.plug_type == "homeassistant" and data.ha_entity_id and data.ha_entity_id.startswith("script.")
|
||||
if not is_script:
|
||||
# For non-script plugs, check there's no other non-script plug assigned
|
||||
result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id))
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
# Allow if existing plug is a script
|
||||
existing_is_script = (
|
||||
existing.plug_type == "homeassistant"
|
||||
and existing.ha_entity_id
|
||||
and existing.ha_entity_id.startswith("script.")
|
||||
# Tasmota plugs: only one per printer (physical power device)
|
||||
# HA entities: allow multiple per printer (for different automations)
|
||||
if data.plug_type == "tasmota":
|
||||
result = await db.execute(
|
||||
select(SmartPlug).where(
|
||||
SmartPlug.printer_id == data.printer_id,
|
||||
SmartPlug.plug_type == "tasmota",
|
||||
)
|
||||
if not existing_is_script:
|
||||
raise HTTPException(400, "This printer already has a smart plug assigned")
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(400, "This printer already has a Tasmota plug assigned")
|
||||
|
||||
# For MQTT plugs, ensure MQTT broker is configured and service is connected
|
||||
if data.plug_type == "mqtt":
|
||||
@@ -103,7 +106,15 @@ async def create_smart_plug(
|
||||
f"Failed to connect to MQTT broker at {mqtt_broker}. Please check your MQTT settings.",
|
||||
)
|
||||
|
||||
plug = SmartPlug(**data.model_dump())
|
||||
plug_data = data.model_dump()
|
||||
|
||||
# For HA entities, default auto_on and auto_off to False
|
||||
# (they're for automations, not power control like Tasmota plugs)
|
||||
if data.plug_type == "homeassistant":
|
||||
plug_data["auto_on"] = False
|
||||
plug_data["auto_off"] = False
|
||||
|
||||
plug = SmartPlug(**plug_data)
|
||||
db.add(plug)
|
||||
await db.commit()
|
||||
await db.refresh(plug)
|
||||
@@ -142,7 +153,11 @@ async def create_smart_plug(
|
||||
|
||||
|
||||
@router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
|
||||
async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_smart_plug_by_printer(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
|
||||
):
|
||||
"""Get the main smart plug assigned to a printer.
|
||||
|
||||
When multiple plugs are assigned (e.g., a regular plug + script),
|
||||
@@ -165,26 +180,25 @@ async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(
|
||||
|
||||
|
||||
@router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse])
|
||||
async def get_script_plugs_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Get all HA script plugs assigned to a printer.
|
||||
async def get_script_plugs_by_printer(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
|
||||
):
|
||||
"""Get all HA entities assigned to a printer for display on printer card.
|
||||
|
||||
Returns only script entities (script.*) for the printer that have
|
||||
Returns HA entities (switches, scripts, lights, etc.) for the printer that have
|
||||
show_on_printer_card enabled.
|
||||
Used to display "Run Script" buttons alongside the main power plug.
|
||||
Used to display action buttons alongside the main power plug.
|
||||
"""
|
||||
result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
|
||||
plugs = result.scalars().all()
|
||||
|
||||
# Filter to only scripts with show_on_printer_card enabled
|
||||
scripts = [
|
||||
plug
|
||||
for plug in plugs
|
||||
if plug.plug_type == "homeassistant"
|
||||
and plug.ha_entity_id
|
||||
and plug.ha_entity_id.startswith("script.")
|
||||
and plug.show_on_printer_card
|
||||
# Filter to HA entities with show_on_printer_card enabled
|
||||
ha_entities = [
|
||||
plug for plug in plugs if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.show_on_printer_card
|
||||
]
|
||||
return scripts
|
||||
return ha_entities
|
||||
|
||||
|
||||
# Tasmota Discovery Endpoints
|
||||
@@ -244,7 +258,10 @@ class DiscoveredTasmotaDevice(BaseModel):
|
||||
|
||||
|
||||
@router.post("/discover/scan", response_model=TasmotaScanStatus)
|
||||
async def start_tasmota_scan(request: TasmotaScanRequest | None = Body(default=None)):
|
||||
async def start_tasmota_scan(
|
||||
request: TasmotaScanRequest | None = Body(default=None),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
|
||||
):
|
||||
"""Start an IP range scan for Tasmota devices.
|
||||
|
||||
Auto-detects local network if no IP range provided.
|
||||
@@ -268,7 +285,9 @@ async def start_tasmota_scan(request: TasmotaScanRequest | None = Body(default=N
|
||||
|
||||
|
||||
@router.get("/discover/status", response_model=TasmotaScanStatus)
|
||||
async def get_tasmota_scan_status():
|
||||
async def get_tasmota_scan_status(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
|
||||
):
|
||||
"""Get the current Tasmota scan status."""
|
||||
scanned, total = tasmota_scanner.progress
|
||||
return TasmotaScanStatus(
|
||||
@@ -279,7 +298,9 @@ async def get_tasmota_scan_status():
|
||||
|
||||
|
||||
@router.post("/discover/stop", response_model=TasmotaScanStatus)
|
||||
async def stop_tasmota_scan():
|
||||
async def stop_tasmota_scan(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
|
||||
):
|
||||
"""Stop the current Tasmota scan."""
|
||||
tasmota_scanner.stop()
|
||||
scanned, total = tasmota_scanner.progress
|
||||
@@ -291,7 +312,9 @@ async def stop_tasmota_scan():
|
||||
|
||||
|
||||
@router.get("/discover/devices", response_model=list[DiscoveredTasmotaDevice])
|
||||
async def get_discovered_tasmota_devices():
|
||||
async def get_discovered_tasmota_devices(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
|
||||
):
|
||||
"""Get list of discovered Tasmota devices."""
|
||||
return [
|
||||
DiscoveredTasmotaDevice(
|
||||
@@ -309,7 +332,10 @@ async def get_discovered_tasmota_devices():
|
||||
|
||||
|
||||
@router.post("/ha/test-connection", response_model=HATestConnectionResponse)
|
||||
async def test_ha_connection(request: HATestConnectionRequest):
|
||||
async def test_ha_connection(
|
||||
request: HATestConnectionRequest,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
|
||||
):
|
||||
"""Test connection to Home Assistant."""
|
||||
result = await homeassistant_service.test_connection(request.url, request.token)
|
||||
return HATestConnectionResponse(**result)
|
||||
@@ -319,6 +345,7 @@ async def test_ha_connection(request: HATestConnectionRequest):
|
||||
async def list_ha_entities(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
search: str | None = None,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
|
||||
):
|
||||
"""List available Home Assistant entities.
|
||||
|
||||
@@ -340,7 +367,10 @@ async def list_ha_entities(
|
||||
|
||||
|
||||
@router.get("/ha/sensors", response_model=list[HASensorEntity])
|
||||
async def list_ha_sensor_entities(db: AsyncSession = Depends(get_db)):
|
||||
async def list_ha_sensor_entities(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
|
||||
):
|
||||
"""List available Home Assistant sensor entities for energy monitoring.
|
||||
|
||||
Returns sensors with power/energy units (W, kW, kWh, Wh).
|
||||
@@ -359,7 +389,11 @@ async def list_ha_sensor_entities(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/{plug_id}", response_model=SmartPlugResponse)
|
||||
async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_smart_plug(
|
||||
plug_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
|
||||
):
|
||||
"""Get a specific smart plug."""
|
||||
result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
|
||||
plug = result.scalar_one_or_none()
|
||||
@@ -373,6 +407,7 @@ async def update_smart_plug(
|
||||
plug_id: int,
|
||||
data: SmartPlugUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE),
|
||||
):
|
||||
"""Update a smart plug."""
|
||||
result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
|
||||
@@ -391,30 +426,20 @@ async def update_smart_plug(
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(400, "Printer not found")
|
||||
|
||||
# Check if that printer already has a different plug assigned
|
||||
# Scripts can coexist with other plugs
|
||||
# Determine if the plug being updated is/will be a script
|
||||
new_entity_id = update_data.get("ha_entity_id", plug.ha_entity_id)
|
||||
# Check if that printer already has a different Tasmota plug assigned
|
||||
# Tasmota plugs: only one per printer (physical power device)
|
||||
# HA entities: allow multiple per printer (for different automations)
|
||||
new_plug_type = update_data.get("plug_type", plug.plug_type)
|
||||
is_script = new_plug_type == "homeassistant" and new_entity_id and new_entity_id.startswith("script.")
|
||||
|
||||
if not is_script:
|
||||
if new_plug_type == "tasmota":
|
||||
result = await db.execute(
|
||||
select(SmartPlug).where(
|
||||
SmartPlug.printer_id == new_printer_id,
|
||||
SmartPlug.id != plug_id,
|
||||
SmartPlug.plug_type == "tasmota",
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
# Allow if existing plug is a script
|
||||
existing_is_script = (
|
||||
existing.plug_type == "homeassistant"
|
||||
and existing.ha_entity_id
|
||||
and existing.ha_entity_id.startswith("script.")
|
||||
)
|
||||
if not existing_is_script:
|
||||
raise HTTPException(400, "This printer already has a smart plug assigned")
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(400, "This printer already has a Tasmota plug assigned")
|
||||
|
||||
# Track old MQTT settings for comparison
|
||||
old_plug_type = plug.plug_type
|
||||
@@ -489,7 +514,11 @@ async def update_smart_plug(
|
||||
|
||||
|
||||
@router.delete("/{plug_id}")
|
||||
async def delete_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_smart_plug(
|
||||
plug_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE),
|
||||
):
|
||||
"""Delete a smart plug."""
|
||||
result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
|
||||
plug = result.scalar_one_or_none()
|
||||
@@ -529,6 +558,7 @@ async def control_smart_plug(
|
||||
plug_id: int,
|
||||
control: SmartPlugControl,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
|
||||
):
|
||||
"""Manual control: on/off/toggle."""
|
||||
result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
|
||||
@@ -635,7 +665,11 @@ async def trigger_associated_scripts(printer_id: int, plug_state: str, db: Async
|
||||
|
||||
|
||||
@router.get("/{plug_id}/status", response_model=SmartPlugStatus)
|
||||
async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_plug_status(
|
||||
plug_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
|
||||
):
|
||||
"""Get current plug status from device including energy data."""
|
||||
result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
|
||||
plug = result.scalar_one_or_none()
|
||||
@@ -763,7 +797,10 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
|
||||
|
||||
|
||||
@router.post("/test-connection")
|
||||
async def test_connection(data: SmartPlugTestConnection):
|
||||
async def test_connection(
|
||||
data: SmartPlugTestConnection,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
|
||||
):
|
||||
"""Test connection to a Tasmota device."""
|
||||
result = await tasmota_service.test_connection(
|
||||
data.ip_address,
|
||||
|
||||
@@ -7,9 +7,12 @@ from pydantic import BaseModel
|
||||
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.printer import Printer
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
from backend.app.services.spoolman import (
|
||||
close_spoolman_client,
|
||||
@@ -72,7 +75,10 @@ async def get_spoolman_settings(db: AsyncSession) -> tuple[bool, str, str]:
|
||||
|
||||
|
||||
@router.get("/status", response_model=SpoolmanStatus)
|
||||
async def get_spoolman_status(db: AsyncSession = Depends(get_db)):
|
||||
async def get_spoolman_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
|
||||
):
|
||||
"""Get Spoolman integration status."""
|
||||
enabled, url, _ = await get_spoolman_settings(db)
|
||||
|
||||
@@ -89,7 +95,10 @@ async def get_spoolman_status(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/connect")
|
||||
async def connect_spoolman(db: AsyncSession = Depends(get_db)):
|
||||
async def connect_spoolman(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Connect to Spoolman server using configured URL."""
|
||||
enabled, url, _ = await get_spoolman_settings(db)
|
||||
|
||||
@@ -119,7 +128,9 @@ async def connect_spoolman(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/disconnect")
|
||||
async def disconnect_spoolman():
|
||||
async def disconnect_spoolman(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Disconnect from Spoolman server."""
|
||||
await close_spoolman_client()
|
||||
return {"success": True, "message": "Disconnected from Spoolman"}
|
||||
@@ -129,6 +140,7 @@ async def disconnect_spoolman():
|
||||
async def sync_printer_ams(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
|
||||
):
|
||||
"""Sync AMS data from a specific printer to Spoolman."""
|
||||
# Check if Spoolman is enabled and connected
|
||||
@@ -267,7 +279,10 @@ async def sync_printer_ams(
|
||||
|
||||
|
||||
@router.post("/sync-all", response_model=SyncResult)
|
||||
async def sync_all_printers(db: AsyncSession = Depends(get_db)):
|
||||
async def sync_all_printers(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
|
||||
):
|
||||
"""Sync AMS data from all connected printers to Spoolman."""
|
||||
# Check if Spoolman is enabled
|
||||
enabled, url, _ = await get_spoolman_settings(db)
|
||||
@@ -392,7 +407,10 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/spools")
|
||||
async def get_spools(db: AsyncSession = Depends(get_db)):
|
||||
async def get_spools(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
|
||||
):
|
||||
"""Get all spools from Spoolman."""
|
||||
enabled, url, _ = await get_spoolman_settings(db)
|
||||
if not enabled:
|
||||
@@ -413,7 +431,10 @@ async def get_spools(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/filaments")
|
||||
async def get_filaments(db: AsyncSession = Depends(get_db)):
|
||||
async def get_filaments(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
|
||||
):
|
||||
"""Get all filaments from Spoolman."""
|
||||
enabled, url, _ = await get_spoolman_settings(db)
|
||||
if not enabled:
|
||||
@@ -445,7 +466,10 @@ class UnlinkedSpool(BaseModel):
|
||||
|
||||
|
||||
@router.get("/spools/unlinked", response_model=list[UnlinkedSpool])
|
||||
async def get_unlinked_spools(db: AsyncSession = Depends(get_db)):
|
||||
async def get_unlinked_spools(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
|
||||
):
|
||||
"""Get all Spoolman spools that don't have a tag (not linked to AMS)."""
|
||||
enabled, url, _ = await get_spoolman_settings(db)
|
||||
if not enabled:
|
||||
@@ -468,7 +492,9 @@ async def get_unlinked_spools(db: AsyncSession = Depends(get_db)):
|
||||
# Check if spool has a tag in extra field
|
||||
extra = spool.get("extra", {}) or {}
|
||||
tag = extra.get("tag", "")
|
||||
if not tag:
|
||||
# Remove quotes if present (JSON encoded string) and check if empty
|
||||
clean_tag = tag.strip('"') if tag else ""
|
||||
if not clean_tag:
|
||||
filament = spool.get("filament", {}) or {}
|
||||
unlinked.append(
|
||||
UnlinkedSpool(
|
||||
@@ -484,6 +510,42 @@ async def get_unlinked_spools(db: AsyncSession = Depends(get_db)):
|
||||
return unlinked
|
||||
|
||||
|
||||
@router.get("/spools/linked")
|
||||
async def get_linked_spools(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
|
||||
):
|
||||
"""Get a map of tag -> spool_id for all Spoolman spools that have a tag assigned."""
|
||||
enabled, url, _ = await get_spoolman_settings(db)
|
||||
if not enabled:
|
||||
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
|
||||
|
||||
client = await get_spoolman_client()
|
||||
if not client:
|
||||
if url:
|
||||
client = await init_spoolman_client(url)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
|
||||
|
||||
if not await client.health_check():
|
||||
raise HTTPException(status_code=503, detail="Spoolman is not reachable")
|
||||
|
||||
spools = await client.get_spools()
|
||||
linked: dict[str, int] = {}
|
||||
|
||||
for spool in spools:
|
||||
# Check if spool has a tag in extra field
|
||||
extra = spool.get("extra", {}) or {}
|
||||
tag = extra.get("tag", "")
|
||||
if tag:
|
||||
# Remove quotes if present (JSON encoded string)
|
||||
clean_tag = tag.strip('"').upper()
|
||||
if clean_tag:
|
||||
linked[clean_tag] = spool["id"]
|
||||
|
||||
return {"linked": linked}
|
||||
|
||||
|
||||
class LinkSpoolRequest(BaseModel):
|
||||
"""Request to link a Spoolman spool to an AMS tray."""
|
||||
|
||||
@@ -495,6 +557,7 @@ async def link_spool(
|
||||
spool_id: int,
|
||||
request: LinkSpoolRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
|
||||
):
|
||||
"""Link a Spoolman spool to an AMS tray by setting the tag to tray_uuid."""
|
||||
enabled, url, _ = await get_spoolman_settings(db)
|
||||
|
||||
@@ -15,14 +15,17 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.config import APP_VERSION, settings
|
||||
from backend.app.core.database import async_session
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.filament import Filament
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.project import Project
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/support", tags=["support"])
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -107,7 +110,9 @@ def _apply_log_level(debug: bool):
|
||||
|
||||
|
||||
@router.get("/debug-logging", response_model=DebugLoggingState)
|
||||
async def get_debug_logging_state():
|
||||
async def get_debug_logging_state(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
):
|
||||
"""Get current debug logging state."""
|
||||
global _debug_logging_enabled, _debug_logging_enabled_at
|
||||
|
||||
@@ -128,7 +133,10 @@ async def get_debug_logging_state():
|
||||
|
||||
|
||||
@router.post("/debug-logging", response_model=DebugLoggingState)
|
||||
async def toggle_debug_logging(toggle: DebugLoggingToggle):
|
||||
async def toggle_debug_logging(
|
||||
toggle: DebugLoggingToggle,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Enable or disable debug logging."""
|
||||
global _debug_logging_enabled, _debug_logging_enabled_at
|
||||
|
||||
@@ -273,6 +281,7 @@ async def get_logs(
|
||||
limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
|
||||
level: str | None = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
|
||||
search: str | None = Query(None, description="Search in message or logger name"),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
):
|
||||
"""Get recent application log entries with optional filtering."""
|
||||
entries, total_lines = _read_log_entries(limit=limit, level_filter=level, search=search)
|
||||
@@ -285,7 +294,9 @@ async def get_logs(
|
||||
|
||||
|
||||
@router.delete("/logs")
|
||||
async def clear_logs():
|
||||
async def clear_logs(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Clear the application log file."""
|
||||
log_file = settings.log_dir / "bambuddy.log"
|
||||
|
||||
@@ -297,8 +308,8 @@ async def clear_logs():
|
||||
logger.info("Log file cleared by user")
|
||||
return {"message": "Logs cleared successfully"}
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing log file: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to clear logs: {e}")
|
||||
logger.error(f"Error clearing log file: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to clear logs. Check server logs for details.")
|
||||
|
||||
return {"message": "Log file does not exist"}
|
||||
|
||||
@@ -445,7 +456,9 @@ def _get_log_content(max_bytes: int = 10 * 1024 * 1024) -> bytes:
|
||||
|
||||
|
||||
@router.get("/bundle")
|
||||
async def generate_support_bundle():
|
||||
async def generate_support_bundle(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
):
|
||||
"""Generate a support bundle ZIP file for issue reporting."""
|
||||
global _debug_logging_enabled, _debug_logging_enabled_at
|
||||
|
||||
|
||||
@@ -9,13 +9,16 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.config import APP_VERSION, settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.filament import Filament
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.project import Project
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
router = APIRouter(prefix="/system", tags=["system"])
|
||||
@@ -60,7 +63,10 @@ def format_uptime(seconds: float) -> str:
|
||||
|
||||
|
||||
@router.get("/info")
|
||||
async def get_system_info(db: AsyncSession = Depends(get_db)):
|
||||
async def get_system_info(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
|
||||
):
|
||||
"""Get comprehensive system information."""
|
||||
|
||||
# Database stats
|
||||
|
||||
@@ -11,8 +11,11 @@ import httpx
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.config import APP_VERSION, GITHUB_REPO, settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -153,7 +156,10 @@ def is_newer_version(latest: str, current: str) -> bool:
|
||||
|
||||
@router.get("/version")
|
||||
async def get_version():
|
||||
"""Get current application version."""
|
||||
"""Get current application version.
|
||||
|
||||
Note: Unauthenticated - needed to display version in UI without login.
|
||||
"""
|
||||
return {
|
||||
"version": APP_VERSION,
|
||||
"repo": GITHUB_REPO,
|
||||
@@ -161,7 +167,10 @@ async def get_version():
|
||||
|
||||
|
||||
@router.get("/check")
|
||||
async def check_for_updates(db: AsyncSession = Depends(get_db)):
|
||||
async def check_for_updates(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
|
||||
):
|
||||
"""Check GitHub for available updates."""
|
||||
global _update_status
|
||||
|
||||
@@ -432,7 +441,10 @@ async def _perform_update():
|
||||
|
||||
|
||||
@router.post("/apply")
|
||||
async def apply_update(background_tasks: BackgroundTasks):
|
||||
async def apply_update(
|
||||
background_tasks: BackgroundTasks,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Apply available update (git pull + rebuild)."""
|
||||
global _update_status
|
||||
|
||||
@@ -473,6 +485,8 @@ async def apply_update(background_tasks: BackgroundTasks):
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_update_status():
|
||||
async def get_update_status(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
|
||||
):
|
||||
"""Get current update status."""
|
||||
return _update_status
|
||||
|
||||
Reference in New Issue
Block a user