Merge branch '0.1.8b' into feature/updated_plate_view

This commit is contained in:
MartinNYHC
2026-02-03 17:15:21 +01:00
committed by GitHub
120 changed files with 24410 additions and 3206 deletions
+5
View File
@@ -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)
+11 -2
View File
@@ -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))
+141 -27
View File
@@ -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))
+28 -3
View File
@@ -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
+84 -19
View File
@@ -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.
+29 -8
View File
@@ -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
+18 -2
View File
@@ -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()
+25 -4
View File
@@ -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 = [
{
+14 -2
View File
@@ -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.
+28 -5
View File
@@ -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))
+10
View File
@@ -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.
+88 -17
View File
@@ -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)))
+25 -3
View File
@@ -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).
+4 -2
View File
@@ -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, {})
+20 -2
View File
@@ -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))
+22 -4
View File
@@ -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))
+13 -3
View File
@@ -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.
+6 -3
View File
@@ -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.
+27
View File
@@ -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:
+223 -81
View File
@@ -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
+94 -57
View File
@@ -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,
+71 -8
View File
@@ -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)
+19 -6
View File
@@ -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
+7 -1
View File
@@ -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
+18 -4
View File
@@ -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