mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 09:03:43 +02:00
Add authentication to 200+ API endpoints (CVE-2026-25505)
Security fix for critical vulnerability (CVSS 9.8) where API endpoints were accessible without authentication when auth was enabled. Changes: - Add RequirePermissionIfAuthEnabled() to all unprotected route files: archives, projects, settings, api_keys, groups, cloud, github_backup, support, notifications, notification_templates, maintenance, filaments, external_links, smart_plugs, discovery, firmware, kprofiles, camera, ams_history, pending_uploads, updates, spoolman, system, print_queue, printers - Keep image-serving endpoints (thumbnails, timelapse, photos, camera streams, icons) unauthenticated since <img> tags cannot send headers - Add backend integration tests for endpoint auth enforcement - Add frontend tests for ownership-based permissions (canModify) Fixes: CVE-2026-25505
This commit is contained in:
@@ -7,8 +7,11 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.ams_history import AMSSensorHistory
|
||||
from backend.app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/ams-history", tags=["ams-history"])
|
||||
|
||||
@@ -38,6 +41,7 @@ async def get_ams_history(
|
||||
ams_id: int,
|
||||
hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
|
||||
):
|
||||
"""Get AMS sensor history for a specific printer and AMS unit."""
|
||||
since = datetime.now() - timedelta(hours=hours)
|
||||
@@ -101,6 +105,7 @@ async def delete_old_history(
|
||||
printer_id: int,
|
||||
days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
|
||||
):
|
||||
"""Delete old AMS history data for a printer."""
|
||||
cutoff = datetime.now() - timedelta(days=days)
|
||||
|
||||
@@ -4,9 +4,11 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import generate_api_key
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled, generate_api_key
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.api_key import (
|
||||
APIKeyCreate,
|
||||
APIKeyCreateResponse,
|
||||
@@ -20,7 +22,10 @@ router = APIRouter(prefix="/api-keys", tags=["api-keys"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[APIKeyResponse])
|
||||
async def list_api_keys(db: AsyncSession = Depends(get_db)):
|
||||
async def list_api_keys(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
|
||||
):
|
||||
"""List all API keys (without full key values)."""
|
||||
result = await db.execute(select(APIKey).order_by(APIKey.created_at.desc()))
|
||||
return list(result.scalars().all())
|
||||
@@ -30,6 +35,7 @@ async def list_api_keys(db: AsyncSession = Depends(get_db)):
|
||||
async def create_api_key(
|
||||
data: APIKeyCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_CREATE),
|
||||
):
|
||||
"""Create a new API key.
|
||||
|
||||
@@ -74,6 +80,7 @@ async def create_api_key(
|
||||
async def get_api_key(
|
||||
key_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
|
||||
):
|
||||
"""Get an API key by ID."""
|
||||
result = await db.execute(select(APIKey).where(APIKey.id == key_id))
|
||||
@@ -90,6 +97,7 @@ async def update_api_key(
|
||||
key_id: int,
|
||||
data: APIKeyUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_UPDATE),
|
||||
):
|
||||
"""Update an API key."""
|
||||
result = await db.execute(select(APIKey).where(APIKey.id == key_id))
|
||||
@@ -124,6 +132,7 @@ async def update_api_key(
|
||||
async def delete_api_key(
|
||||
key_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_DELETE),
|
||||
):
|
||||
"""Delete (revoke) an API key."""
|
||||
result = await db.execute(select(APIKey).where(APIKey.id == key_id))
|
||||
|
||||
@@ -8,7 +8,10 @@ from fastapi.responses import FileResponse, Response
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import require_auth_if_enabled, require_ownership_permission
|
||||
from backend.app.core.auth import (
|
||||
RequirePermissionIfAuthEnabled,
|
||||
require_ownership_permission,
|
||||
)
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
@@ -118,6 +121,7 @@ async def list_archives(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""List archived prints."""
|
||||
service = ArchiveService(db)
|
||||
@@ -148,6 +152,7 @@ async def search_archives(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Full-text search across archives.
|
||||
|
||||
@@ -229,7 +234,10 @@ async def search_archives(
|
||||
|
||||
|
||||
@router.post("/search/rebuild-index")
|
||||
async def rebuild_search_index(db: AsyncSession = Depends(get_db)):
|
||||
async def rebuild_search_index(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
|
||||
):
|
||||
"""Rebuild the full-text search index from existing archives.
|
||||
|
||||
Use this if search results seem incomplete or incorrect.
|
||||
@@ -267,6 +275,7 @@ async def analyze_failures(
|
||||
printer_id: int | None = None,
|
||||
project_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Analyze failure patterns across prints.
|
||||
|
||||
@@ -291,6 +300,7 @@ async def analyze_failures(
|
||||
async def compare_archives(
|
||||
archive_ids: str = Query(..., description="Comma-separated archive IDs (2-5)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Compare multiple archives side by side.
|
||||
|
||||
@@ -331,6 +341,7 @@ async def export_archives(
|
||||
date_to: str | None = Query(None, description="End date (ISO format)"),
|
||||
search: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Export archives to CSV or Excel format.
|
||||
|
||||
@@ -393,6 +404,7 @@ async def export_stats(
|
||||
printer_id: int | None = None,
|
||||
project_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
|
||||
):
|
||||
"""Export statistics summary to CSV or Excel format."""
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -421,7 +433,10 @@ async def export_stats(
|
||||
|
||||
|
||||
@router.get("/stats", response_model=ArchiveStats)
|
||||
async def get_archive_stats(db: AsyncSession = Depends(get_db)):
|
||||
async def get_archive_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
|
||||
):
|
||||
"""Get statistics across all archives."""
|
||||
# Total counts
|
||||
total_result = await db.execute(select(func.count(PrintArchive.id)))
|
||||
@@ -574,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.
|
||||
@@ -604,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.
|
||||
|
||||
@@ -646,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.
|
||||
@@ -671,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)
|
||||
@@ -694,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.
|
||||
|
||||
@@ -762,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))
|
||||
@@ -776,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
|
||||
@@ -835,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
|
||||
|
||||
@@ -865,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
|
||||
|
||||
@@ -912,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)
|
||||
@@ -930,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())
|
||||
@@ -991,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)
|
||||
@@ -1018,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)
|
||||
@@ -1037,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:
|
||||
@@ -1062,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:
|
||||
@@ -1091,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
|
||||
@@ -1317,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
|
||||
@@ -1401,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)
|
||||
@@ -1421,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
|
||||
@@ -1450,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
|
||||
@@ -1489,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
|
||||
@@ -1592,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))
|
||||
@@ -1636,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:
|
||||
@@ -1666,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))
|
||||
@@ -1703,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
|
||||
@@ -1751,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
|
||||
@@ -1968,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)
|
||||
@@ -2001,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)
|
||||
@@ -2068,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"):
|
||||
@@ -2103,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 = []
|
||||
@@ -2157,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.
|
||||
|
||||
@@ -2337,7 +2428,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:
|
||||
@@ -2364,6 +2458,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.
|
||||
|
||||
@@ -2659,7 +2754,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
|
||||
@@ -2684,6 +2783,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
|
||||
@@ -2714,7 +2814,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)
|
||||
@@ -2750,6 +2853,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))
|
||||
@@ -2796,6 +2900,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))
|
||||
@@ -2825,6 +2930,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))
|
||||
@@ -2851,6 +2957,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.
|
||||
|
||||
@@ -2937,6 +3044,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))
|
||||
@@ -2969,6 +3077,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))
|
||||
@@ -3015,6 +3124,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))
|
||||
@@ -3043,6 +3153,7 @@ async def download_f3d(
|
||||
async def delete_f3d(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
|
||||
):
|
||||
"""Delete the Fusion 360 design file from an archive."""
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
||||
|
||||
@@ -9,8 +9,11 @@ from fastapi.responses import Response, StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.camera import (
|
||||
capture_camera_frame,
|
||||
generate_chamber_image_stream,
|
||||
@@ -353,6 +356,8 @@ async def camera_stream(
|
||||
This endpoint returns a multipart MJPEG stream that can be used directly
|
||||
in an <img> tag or video player.
|
||||
|
||||
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
|
||||
|
||||
Uses external camera if configured, otherwise uses built-in camera:
|
||||
- External: MJPEG, RTSP, or HTTP snapshot
|
||||
- A1/P1: Chamber image protocol (port 6000)
|
||||
@@ -476,7 +481,10 @@ async def camera_stream(
|
||||
|
||||
|
||||
@router.api_route("/{printer_id}/camera/stop", methods=["GET", "POST"])
|
||||
async def stop_camera_stream(printer_id: int):
|
||||
async def stop_camera_stream(
|
||||
printer_id: int,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Stop all active camera streams for a printer.
|
||||
|
||||
This can be called by the frontend when the camera window is closed.
|
||||
@@ -527,6 +535,8 @@ async def camera_snapshot(
|
||||
"""Capture a single frame from the printer camera.
|
||||
|
||||
Returns a JPEG image.
|
||||
|
||||
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
@@ -574,6 +584,7 @@ async def camera_snapshot(
|
||||
async def test_camera(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Test camera connection for a printer.
|
||||
|
||||
@@ -591,7 +602,10 @@ async def test_camera(
|
||||
|
||||
|
||||
@router.get("/{printer_id}/camera/status")
|
||||
async def camera_status(printer_id: int):
|
||||
async def camera_status(
|
||||
printer_id: int,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Get the status of an active camera stream.
|
||||
|
||||
Returns whether a stream is active and when the last frame was received.
|
||||
@@ -658,6 +672,7 @@ async def test_external_camera(
|
||||
url: str,
|
||||
camera_type: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Test external camera connection.
|
||||
|
||||
@@ -684,6 +699,7 @@ async def check_plate_empty(
|
||||
use_external: bool = False,
|
||||
include_debug_image: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Check if the build plate is empty using camera vision.
|
||||
|
||||
@@ -791,6 +807,7 @@ async def calibrate_plate_detection(
|
||||
label: str | None = None,
|
||||
use_external: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Calibrate plate detection by capturing a reference image of the empty plate.
|
||||
|
||||
@@ -854,6 +871,7 @@ async def delete_plate_calibration(
|
||||
printer_id: int,
|
||||
plate_type: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Delete the plate detection calibration for a printer and plate type.
|
||||
|
||||
@@ -894,6 +912,7 @@ async def get_plate_detection_status(
|
||||
printer_id: int,
|
||||
plate_type: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Check plate detection status for a printer and plate type.
|
||||
|
||||
@@ -937,6 +956,7 @@ async def get_plate_detection_status(
|
||||
async def get_plate_references(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Get all calibration references for a printer with metadata.
|
||||
|
||||
@@ -971,7 +991,10 @@ async def get_reference_thumbnail(
|
||||
index: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get thumbnail image for a calibration reference."""
|
||||
"""Get thumbnail image for a calibration reference.
|
||||
|
||||
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
|
||||
"""
|
||||
from fastapi.responses import Response
|
||||
|
||||
from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
|
||||
@@ -997,6 +1020,7 @@ async def update_reference_label(
|
||||
index: int,
|
||||
label: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Update the label for a calibration reference."""
|
||||
from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
|
||||
@@ -1021,6 +1045,7 @@ async def delete_reference(
|
||||
printer_id: int,
|
||||
index: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Delete a specific calibration reference."""
|
||||
from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
|
||||
|
||||
@@ -13,8 +13,11 @@ from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.cloud import (
|
||||
CloudAuthStatus,
|
||||
CloudDevice,
|
||||
@@ -74,7 +77,10 @@ async def clear_token(db: AsyncSession) -> None:
|
||||
|
||||
|
||||
@router.get("/status", response_model=CloudAuthStatus)
|
||||
async def get_auth_status(db: AsyncSession = Depends(get_db)):
|
||||
async def get_auth_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
|
||||
):
|
||||
"""Get current cloud authentication status."""
|
||||
token, email = await get_stored_token(db)
|
||||
cloud = get_cloud_service()
|
||||
@@ -89,7 +95,11 @@ 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.
|
||||
|
||||
@@ -126,7 +136,11 @@ 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 (email or TOTP).
|
||||
|
||||
@@ -162,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.
|
||||
|
||||
@@ -182,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()
|
||||
@@ -194,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).
|
||||
@@ -250,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.
|
||||
|
||||
@@ -311,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.
|
||||
|
||||
@@ -385,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.
|
||||
|
||||
@@ -423,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.
|
||||
|
||||
@@ -499,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.
|
||||
|
||||
@@ -539,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.
|
||||
@@ -570,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.
|
||||
|
||||
@@ -635,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.
|
||||
|
||||
@@ -654,7 +702,9 @@ async def get_preset_fields(preset_type: Literal["filament", "print", "process",
|
||||
|
||||
|
||||
@router.get("/fields")
|
||||
async def get_all_preset_fields():
|
||||
async def get_all_preset_fields(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
):
|
||||
"""
|
||||
Get all field definitions for all preset types.
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ import logging
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.discovery import (
|
||||
discovery_service,
|
||||
is_running_in_docker,
|
||||
@@ -60,7 +63,9 @@ class DiscoveredPrinterResponse(BaseModel):
|
||||
|
||||
|
||||
@router.get("/info", response_model=DiscoveryInfo)
|
||||
async def get_discovery_info():
|
||||
async def get_discovery_info(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
||||
):
|
||||
"""Get discovery environment info (Docker detection, etc.)."""
|
||||
return DiscoveryInfo(
|
||||
is_docker=is_running_in_docker(),
|
||||
@@ -70,13 +75,18 @@ async def get_discovery_info():
|
||||
|
||||
|
||||
@router.get("/status", response_model=DiscoveryStatus)
|
||||
async def get_discovery_status():
|
||||
async def get_discovery_status(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
||||
):
|
||||
"""Get the current SSDP discovery status."""
|
||||
return DiscoveryStatus(running=discovery_service.is_running)
|
||||
|
||||
|
||||
@router.post("/start", response_model=DiscoveryStatus)
|
||||
async def start_discovery(duration: float = 10.0):
|
||||
async def start_discovery(
|
||||
duration: float = 10.0,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
||||
):
|
||||
"""Start SSDP printer discovery.
|
||||
|
||||
Args:
|
||||
@@ -87,14 +97,18 @@ async def start_discovery(duration: float = 10.0):
|
||||
|
||||
|
||||
@router.post("/stop", response_model=DiscoveryStatus)
|
||||
async def stop_discovery():
|
||||
async def stop_discovery(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
||||
):
|
||||
"""Stop SSDP printer discovery."""
|
||||
await discovery_service.stop()
|
||||
return DiscoveryStatus(running=discovery_service.is_running)
|
||||
|
||||
|
||||
@router.get("/printers", response_model=list[DiscoveredPrinterResponse])
|
||||
async def get_discovered_printers():
|
||||
async def get_discovered_printers(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
||||
):
|
||||
"""Get list of discovered printers (from both SSDP and subnet scan)."""
|
||||
# Combine results from both discovery methods
|
||||
printers = {}
|
||||
@@ -124,7 +138,10 @@ async def get_discovered_printers():
|
||||
|
||||
|
||||
@router.post("/scan", response_model=SubnetScanStatus)
|
||||
async def start_subnet_scan(request: SubnetScanRequest):
|
||||
async def start_subnet_scan(
|
||||
request: SubnetScanRequest,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
||||
):
|
||||
"""Start a subnet scan for Bambu printers.
|
||||
|
||||
Use this when running in Docker where SSDP multicast doesn't work.
|
||||
@@ -147,7 +164,9 @@ async def start_subnet_scan(request: SubnetScanRequest):
|
||||
|
||||
|
||||
@router.get("/scan/status", response_model=SubnetScanStatus)
|
||||
async def get_scan_status():
|
||||
async def get_scan_status(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
||||
):
|
||||
"""Get the current subnet scan status."""
|
||||
scanned, total = subnet_scanner.progress
|
||||
return SubnetScanStatus(
|
||||
@@ -158,7 +177,9 @@ async def get_scan_status():
|
||||
|
||||
|
||||
@router.post("/scan/stop", response_model=SubnetScanStatus)
|
||||
async def stop_subnet_scan():
|
||||
async def stop_subnet_scan(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
|
||||
):
|
||||
"""Stop the current subnet scan."""
|
||||
subnet_scanner.stop()
|
||||
scanned, total = subnet_scanner.progress
|
||||
|
||||
@@ -9,9 +9,12 @@ from fastapi.responses import FileResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.config import settings as app_settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.external_link import ExternalLink
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.external_link import (
|
||||
ExternalLinkCreate,
|
||||
ExternalLinkReorder,
|
||||
@@ -29,7 +32,10 @@ router = APIRouter(prefix="/external-links", tags=["external-links"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[ExternalLinkResponse])
|
||||
async def list_external_links(db: AsyncSession = Depends(get_db)):
|
||||
async def list_external_links(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
|
||||
):
|
||||
"""List all external links ordered by sort_order."""
|
||||
result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
|
||||
links = result.scalars().all()
|
||||
@@ -40,6 +46,7 @@ async def list_external_links(db: AsyncSession = Depends(get_db)):
|
||||
async def create_external_link(
|
||||
link_data: ExternalLinkCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_CREATE),
|
||||
):
|
||||
"""Create a new external link."""
|
||||
# Get the highest sort_order to place new link at end
|
||||
@@ -67,6 +74,7 @@ async def create_external_link(
|
||||
async def get_external_link(
|
||||
link_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
|
||||
):
|
||||
"""Get a specific external link."""
|
||||
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
|
||||
@@ -83,6 +91,7 @@ async def update_external_link(
|
||||
link_id: int,
|
||||
update_data: ExternalLinkUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
|
||||
):
|
||||
"""Update an external link."""
|
||||
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
|
||||
@@ -108,6 +117,7 @@ async def update_external_link(
|
||||
async def delete_external_link(
|
||||
link_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_DELETE),
|
||||
):
|
||||
"""Delete an external link."""
|
||||
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
|
||||
@@ -129,6 +139,7 @@ async def delete_external_link(
|
||||
async def reorder_external_links(
|
||||
reorder_data: ExternalLinkReorder,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
|
||||
):
|
||||
"""Update the sort order of external links."""
|
||||
# Update sort_order for each link based on position in the list
|
||||
@@ -154,6 +165,7 @@ async def upload_icon(
|
||||
link_id: int,
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
|
||||
):
|
||||
"""Upload a custom icon for an external link."""
|
||||
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
|
||||
@@ -202,6 +214,7 @@ async def upload_icon(
|
||||
async def delete_icon(
|
||||
link_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
|
||||
):
|
||||
"""Delete the custom icon for an external link."""
|
||||
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
|
||||
@@ -227,7 +240,10 @@ async def get_icon(
|
||||
link_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get the custom icon for an external link."""
|
||||
"""Get the custom icon for an external link.
|
||||
|
||||
Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
|
||||
"""
|
||||
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
|
||||
link = result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.filament import Filament
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.filament import (
|
||||
FilamentCostCalculation,
|
||||
FilamentCreate,
|
||||
@@ -15,7 +18,10 @@ router = APIRouter(prefix="/filaments", tags=["filaments"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[FilamentResponse])
|
||||
async def list_filaments(db: AsyncSession = Depends(get_db)):
|
||||
async def list_filaments(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
|
||||
):
|
||||
"""List all filaments."""
|
||||
result = await db.execute(select(Filament).order_by(Filament.type, Filament.name))
|
||||
return list(result.scalars().all())
|
||||
@@ -25,6 +31,7 @@ async def list_filaments(db: AsyncSession = Depends(get_db)):
|
||||
async def create_filament(
|
||||
filament_data: FilamentCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_CREATE),
|
||||
):
|
||||
"""Create a new filament entry."""
|
||||
filament = Filament(**filament_data.model_dump())
|
||||
@@ -35,7 +42,11 @@ async def create_filament(
|
||||
|
||||
|
||||
@router.get("/{filament_id}", response_model=FilamentResponse)
|
||||
async def get_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_filament(
|
||||
filament_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
|
||||
):
|
||||
"""Get a specific filament."""
|
||||
result = await db.execute(select(Filament).where(Filament.id == filament_id))
|
||||
filament = result.scalar_one_or_none()
|
||||
@@ -49,6 +60,7 @@ async def update_filament(
|
||||
filament_id: int,
|
||||
filament_data: FilamentUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
|
||||
):
|
||||
"""Update a filament."""
|
||||
result = await db.execute(select(Filament).where(Filament.id == filament_id))
|
||||
@@ -65,7 +77,11 @@ async def update_filament(
|
||||
|
||||
|
||||
@router.delete("/{filament_id}")
|
||||
async def delete_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_filament(
|
||||
filament_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_DELETE),
|
||||
):
|
||||
"""Delete a filament."""
|
||||
result = await db.execute(select(Filament).where(Filament.id == filament_id))
|
||||
filament = result.scalar_one_or_none()
|
||||
@@ -82,6 +98,7 @@ async def calculate_cost(
|
||||
filament_id: int,
|
||||
weight_grams: float,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
|
||||
):
|
||||
"""Calculate the cost for a given weight of filament."""
|
||||
result = await db.execute(select(Filament).where(Filament.id == filament_id))
|
||||
@@ -104,6 +121,7 @@ async def calculate_cost(
|
||||
async def get_filaments_by_type(
|
||||
filament_type: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
|
||||
):
|
||||
"""Get all filaments of a specific type."""
|
||||
result = await db.execute(select(Filament).where(Filament.type.ilike(f"%{filament_type}%")).order_by(Filament.name))
|
||||
@@ -111,7 +129,10 @@ async def get_filaments_by_type(
|
||||
|
||||
|
||||
@router.post("/seed-defaults")
|
||||
async def seed_default_filaments(db: AsyncSession = Depends(get_db)):
|
||||
async def seed_default_filaments(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_CREATE),
|
||||
):
|
||||
"""Seed the database with common filament types."""
|
||||
defaults = [
|
||||
{
|
||||
|
||||
@@ -12,8 +12,11 @@ from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.firmware_check import get_firmware_service
|
||||
from backend.app.services.firmware_update import (
|
||||
FirmwareUploadStatus,
|
||||
@@ -59,6 +62,7 @@ class LatestFirmwareInfo(BaseModel):
|
||||
@router.get("/updates", response_model=FirmwareUpdatesResponse)
|
||||
async def check_firmware_updates(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
|
||||
):
|
||||
"""
|
||||
Check for firmware updates for all connected printers.
|
||||
@@ -112,6 +116,7 @@ async def check_firmware_updates(
|
||||
async def check_printer_firmware(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
|
||||
):
|
||||
"""
|
||||
Check for firmware update for a specific printer.
|
||||
@@ -148,7 +153,9 @@ async def check_printer_firmware(
|
||||
|
||||
|
||||
@router.get("/latest", response_model=list[LatestFirmwareInfo])
|
||||
async def get_all_latest_firmware():
|
||||
async def get_all_latest_firmware(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
|
||||
):
|
||||
"""
|
||||
Get the latest firmware versions for all Bambu Lab printer models.
|
||||
|
||||
@@ -211,6 +218,7 @@ class FirmwareUploadStartResponse(BaseModel):
|
||||
async def prepare_firmware_upload(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
|
||||
):
|
||||
"""
|
||||
Check prerequisites for uploading firmware to a printer.
|
||||
@@ -232,6 +240,7 @@ async def prepare_firmware_upload(
|
||||
async def start_firmware_upload(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_UPDATE),
|
||||
):
|
||||
"""
|
||||
Start uploading firmware to a printer's SD card.
|
||||
@@ -279,7 +288,10 @@ async def start_firmware_upload(
|
||||
|
||||
|
||||
@router.get("/updates/{printer_id}/upload/status", response_model=FirmwareUploadStatusResponse)
|
||||
async def get_firmware_upload_status(printer_id: int):
|
||||
async def get_firmware_upload_status(
|
||||
printer_id: int,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
|
||||
):
|
||||
"""
|
||||
Get the current status of a firmware upload operation.
|
||||
|
||||
|
||||
@@ -6,8 +6,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import delete, desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.github_backup import (
|
||||
GitHubBackupConfigCreate,
|
||||
GitHubBackupConfigResponse,
|
||||
@@ -48,7 +51,10 @@ def _config_to_response(config: GitHubBackupConfig) -> dict:
|
||||
|
||||
|
||||
@router.get("/config", response_model=GitHubBackupConfigResponse | None)
|
||||
async def get_config(db: AsyncSession = Depends(get_db)):
|
||||
async def get_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""Get the current GitHub backup configuration."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
@@ -63,6 +69,7 @@ async def get_config(db: AsyncSession = Depends(get_db)):
|
||||
async def save_config(
|
||||
config_data: GitHubBackupConfigCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""Create or update GitHub backup configuration.
|
||||
|
||||
@@ -121,6 +128,7 @@ async def save_config(
|
||||
async def update_config(
|
||||
update_data: GitHubBackupConfigUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""Partially update GitHub backup configuration."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
@@ -153,7 +161,10 @@ async def update_config(
|
||||
|
||||
|
||||
@router.delete("/config")
|
||||
async def delete_config(db: AsyncSession = Depends(get_db)):
|
||||
async def delete_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""Delete the GitHub backup configuration and all logs."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
@@ -173,6 +184,7 @@ async def delete_config(db: AsyncSession = Depends(get_db)):
|
||||
async def test_connection(
|
||||
repo_url: str = Query(..., description="GitHub repository URL"),
|
||||
token: str = Query(..., description="Personal Access Token"),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""Test GitHub connection with provided credentials."""
|
||||
result = await github_backup_service.test_connection(repo_url, token)
|
||||
@@ -180,7 +192,10 @@ async def test_connection(
|
||||
|
||||
|
||||
@router.post("/test-stored", response_model=GitHubTestConnectionResponse)
|
||||
async def test_stored_connection(db: AsyncSession = Depends(get_db)):
|
||||
async def test_stored_connection(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""Test GitHub connection using stored configuration."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
@@ -196,7 +211,10 @@ async def test_stored_connection(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/run", response_model=GitHubBackupTriggerResponse)
|
||||
async def trigger_backup(db: AsyncSession = Depends(get_db)):
|
||||
async def trigger_backup(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""Manually trigger a backup."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
@@ -213,7 +231,10 @@ async def trigger_backup(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/status", response_model=GitHubBackupStatus)
|
||||
async def get_status(db: AsyncSession = Depends(get_db)):
|
||||
async def get_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""Get current backup status."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
@@ -245,6 +266,7 @@ async def get_logs(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""Get backup logs."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
@@ -282,6 +304,7 @@ async def get_logs(
|
||||
async def clear_logs(
|
||||
keep_last: int = Query(default=10, ge=0, le=100, description="Number of recent logs to keep"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""Clear backup logs, optionally keeping the most recent entries."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
|
||||
@@ -7,9 +7,12 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.kprofile_note import KProfileNote as KProfileNoteModel
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.kprofile import (
|
||||
KProfile,
|
||||
KProfileCreate,
|
||||
@@ -30,6 +33,7 @@ async def get_kprofiles(
|
||||
printer_id: int,
|
||||
nozzle_diameter: str = "0.4",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
|
||||
):
|
||||
"""Get K-profiles from a printer.
|
||||
|
||||
@@ -78,6 +82,7 @@ async def set_kprofile(
|
||||
printer_id: int,
|
||||
profile: KProfileCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
|
||||
):
|
||||
"""Create or update a K-profile on the printer.
|
||||
|
||||
@@ -178,6 +183,7 @@ async def set_kprofiles_batch(
|
||||
printer_id: int,
|
||||
profiles: list[KProfileCreate],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
|
||||
):
|
||||
"""Create multiple K-profiles in a single command (for dual-nozzle).
|
||||
|
||||
@@ -236,6 +242,7 @@ async def delete_kprofile(
|
||||
printer_id: int,
|
||||
profile: KProfileDelete,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
|
||||
):
|
||||
"""Delete a K-profile from the printer.
|
||||
|
||||
@@ -278,6 +285,7 @@ async def delete_kprofile(
|
||||
async def get_kprofile_notes(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
|
||||
):
|
||||
"""Get all K-profile notes for a printer.
|
||||
|
||||
@@ -305,6 +313,7 @@ async def set_kprofile_note(
|
||||
printer_id: int,
|
||||
note_data: KProfileNote,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
|
||||
):
|
||||
"""Set or update a note for a K-profile.
|
||||
|
||||
@@ -353,6 +362,7 @@ async def delete_kprofile_note(
|
||||
printer_id: int,
|
||||
setting_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
|
||||
):
|
||||
"""Delete a note for a K-profile.
|
||||
|
||||
|
||||
@@ -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,8 +4,11 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.notification_template import (
|
||||
EVENT_VARIABLES,
|
||||
SAMPLE_DATA,
|
||||
@@ -45,14 +48,19 @@ EVENT_NAMES = {
|
||||
|
||||
@router.get("", response_model=list[NotificationTemplateResponse])
|
||||
@router.get("/", response_model=list[NotificationTemplateResponse])
|
||||
async def get_templates(db: AsyncSession = Depends(get_db)):
|
||||
async def get_templates(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
|
||||
):
|
||||
"""Get all notification templates."""
|
||||
result = await db.execute(select(NotificationTemplate).order_by(NotificationTemplate.id))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/variables", response_model=list[EventVariablesResponse])
|
||||
async def get_variables():
|
||||
async def get_variables(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
|
||||
):
|
||||
"""Get available variables for each event type."""
|
||||
return [
|
||||
EventVariablesResponse(
|
||||
@@ -65,7 +73,11 @@ async def get_variables():
|
||||
|
||||
|
||||
@router.get("/{template_id}", response_model=NotificationTemplateResponse)
|
||||
async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def get_template(
|
||||
template_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
|
||||
):
|
||||
"""Get a single notification template."""
|
||||
result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
|
||||
template = result.scalar_one_or_none()
|
||||
@@ -79,6 +91,7 @@ async def update_template(
|
||||
template_id: int,
|
||||
update: NotificationTemplateUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_UPDATE),
|
||||
):
|
||||
"""Update a notification template."""
|
||||
result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
|
||||
@@ -101,7 +114,11 @@ async def update_template(
|
||||
|
||||
|
||||
@router.post("/{template_id}/reset", response_model=NotificationTemplateResponse)
|
||||
async def reset_template(template_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def reset_template(
|
||||
template_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_UPDATE),
|
||||
):
|
||||
"""Reset a notification template to its default values."""
|
||||
result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
|
||||
template = result.scalar_one_or_none()
|
||||
@@ -129,7 +146,10 @@ async def reset_template(template_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/preview", response_model=TemplatePreviewResponse)
|
||||
async def preview_template(request: TemplatePreviewRequest):
|
||||
async def preview_template(
|
||||
request: TemplatePreviewRequest,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
|
||||
):
|
||||
"""Preview a template with sample data."""
|
||||
sample = SAMPLE_DATA.get(request.event_type, {})
|
||||
|
||||
|
||||
@@ -8,8 +8,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import delete, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.notification import NotificationLog, NotificationProvider
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.notification import (
|
||||
NotificationLogResponse,
|
||||
NotificationLogStats,
|
||||
@@ -86,7 +89,10 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
|
||||
|
||||
|
||||
@router.get("/", response_model=list[NotificationProviderResponse])
|
||||
async def list_notification_providers(db: AsyncSession = Depends(get_db)):
|
||||
async def list_notification_providers(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
|
||||
):
|
||||
"""List all notification providers."""
|
||||
result = await db.execute(select(NotificationProvider).order_by(NotificationProvider.created_at.desc()))
|
||||
providers = result.scalars().all()
|
||||
@@ -98,6 +104,7 @@ async def list_notification_providers(db: AsyncSession = Depends(get_db)):
|
||||
async def create_notification_provider(
|
||||
provider_data: NotificationProviderCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
|
||||
):
|
||||
"""Create a new notification provider."""
|
||||
provider = NotificationProvider(
|
||||
@@ -153,6 +160,7 @@ async def create_notification_provider(
|
||||
async def test_notification_config(
|
||||
test_request: NotificationTestRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
|
||||
):
|
||||
"""Test notification configuration before saving."""
|
||||
success, message = await notification_service.send_test_notification(
|
||||
@@ -163,7 +171,10 @@ async def test_notification_config(
|
||||
|
||||
|
||||
@router.post("/test-all")
|
||||
async def test_all_notification_providers(db: AsyncSession = Depends(get_db)):
|
||||
async def test_all_notification_providers(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
|
||||
):
|
||||
"""Send a test notification to all enabled providers."""
|
||||
result = await db.execute(select(NotificationProvider).where(NotificationProvider.enabled.is_(True)))
|
||||
providers = result.scalars().all()
|
||||
@@ -222,6 +233,7 @@ async def get_notification_logs(
|
||||
success: bool | None = Query(default=None),
|
||||
days: int | None = Query(default=7, ge=1, le=90, description="Filter logs from the last N days"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
|
||||
):
|
||||
"""Get notification logs with optional filters."""
|
||||
query = select(NotificationLog).order_by(desc(NotificationLog.created_at))
|
||||
@@ -278,6 +290,7 @@ async def get_notification_logs(
|
||||
async def get_notification_log_stats(
|
||||
days: int = Query(default=7, ge=1, le=90, description="Statistics for the last N days"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
|
||||
):
|
||||
"""Get notification log statistics."""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
@@ -323,6 +336,7 @@ async def get_notification_log_stats(
|
||||
async def clear_notification_logs(
|
||||
older_than_days: int = Query(default=30, ge=1, description="Delete logs older than N days"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
|
||||
):
|
||||
"""Clear old notification logs."""
|
||||
cutoff = datetime.utcnow() - timedelta(days=older_than_days)
|
||||
@@ -345,6 +359,7 @@ async def clear_notification_logs(
|
||||
async def get_notification_provider(
|
||||
provider_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
|
||||
):
|
||||
"""Get a specific notification provider."""
|
||||
result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
|
||||
@@ -361,6 +376,7 @@ async def update_notification_provider(
|
||||
provider_id: int,
|
||||
update_data: NotificationProviderUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
|
||||
):
|
||||
"""Update a notification provider."""
|
||||
result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
|
||||
@@ -392,6 +408,7 @@ async def update_notification_provider(
|
||||
async def delete_notification_provider(
|
||||
provider_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
|
||||
):
|
||||
"""Delete a notification provider."""
|
||||
result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
|
||||
@@ -413,6 +430,7 @@ async def delete_notification_provider(
|
||||
async def test_notification_provider(
|
||||
provider_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
|
||||
):
|
||||
"""Send a test notification using an existing provider."""
|
||||
result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
|
||||
|
||||
@@ -8,8 +8,11 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.pending_upload import PendingUpload
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.archive import ArchiveService
|
||||
|
||||
router = APIRouter(prefix="/pending-uploads", tags=["pending-uploads"])
|
||||
@@ -41,7 +44,10 @@ class PendingUploadResponse(BaseModel):
|
||||
|
||||
|
||||
@router.get("/", response_model=list[PendingUploadResponse])
|
||||
async def list_pending_uploads(db: AsyncSession = Depends(get_db)):
|
||||
async def list_pending_uploads(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
|
||||
):
|
||||
"""List all pending uploads."""
|
||||
result = await db.execute(
|
||||
select(PendingUpload).where(PendingUpload.status == "pending").order_by(PendingUpload.uploaded_at.desc())
|
||||
@@ -51,7 +57,10 @@ async def list_pending_uploads(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/count")
|
||||
async def get_pending_count(db: AsyncSession = Depends(get_db)):
|
||||
async def get_pending_count(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
|
||||
):
|
||||
"""Get count of pending uploads."""
|
||||
result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
|
||||
count = len(result.scalars().all())
|
||||
@@ -64,7 +73,10 @@ async def get_pending_count(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/archive-all")
|
||||
async def archive_all_pending(db: AsyncSession = Depends(get_db)):
|
||||
async def archive_all_pending(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
|
||||
):
|
||||
"""Archive all pending uploads."""
|
||||
result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
|
||||
pending_uploads = result.scalars().all()
|
||||
@@ -117,7 +129,10 @@ async def archive_all_pending(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.delete("/discard-all")
|
||||
async def discard_all_pending(db: AsyncSession = Depends(get_db)):
|
||||
async def discard_all_pending(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
|
||||
):
|
||||
"""Discard all pending uploads."""
|
||||
result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
|
||||
pending_uploads = result.scalars().all()
|
||||
@@ -144,6 +159,7 @@ async def discard_all_pending(db: AsyncSession = Depends(get_db)):
|
||||
async def get_pending_upload(
|
||||
upload_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
|
||||
):
|
||||
"""Get a specific pending upload."""
|
||||
result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
|
||||
@@ -160,6 +176,7 @@ async def archive_pending_upload(
|
||||
upload_id: int,
|
||||
request: ArchiveRequest = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
|
||||
):
|
||||
"""Archive a pending upload."""
|
||||
result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
|
||||
@@ -227,6 +244,7 @@ async def archive_pending_upload(
|
||||
async def discard_pending_upload(
|
||||
upload_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
|
||||
):
|
||||
"""Discard a pending upload without archiving."""
|
||||
result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
|
||||
|
||||
@@ -12,7 +12,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from backend.app.core.auth import require_auth_if_enabled, require_ownership_permission
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_ownership_permission
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
@@ -173,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 = (
|
||||
@@ -204,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")
|
||||
@@ -425,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)
|
||||
@@ -553,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:
|
||||
@@ -605,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
|
||||
@@ -675,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.
|
||||
|
||||
|
||||
@@ -1361,6 +1361,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.
|
||||
|
||||
@@ -1403,6 +1404,7 @@ async def reset_ams_slot(
|
||||
async def debug_simulate_print_complete(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
|
||||
):
|
||||
"""DEBUG: Simulate print completion to test freeze behavior.
|
||||
|
||||
|
||||
@@ -14,13 +14,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from backend.app.api.routes.library import get_library_dir
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.library import LibraryFile, LibraryFolder
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
from backend.app.models.project import Project
|
||||
from backend.app.models.project_bom import ProjectBOMItem
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.project import (
|
||||
ArchivePreview,
|
||||
BatchAddArchives,
|
||||
@@ -154,6 +157,7 @@ async def compute_project_stats(
|
||||
async def list_projects(
|
||||
status: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
||||
):
|
||||
"""List all projects with basic stats."""
|
||||
query = select(Project)
|
||||
@@ -258,6 +262,7 @@ async def list_projects(
|
||||
async def create_project(
|
||||
data: ProjectCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
|
||||
):
|
||||
"""Create a new project."""
|
||||
# Verify parent exists if specified
|
||||
@@ -319,6 +324,7 @@ async def create_project(
|
||||
@router.get("/templates", response_model=list[ProjectListResponse])
|
||||
async def list_templates(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
||||
):
|
||||
"""List all project templates."""
|
||||
result = await db.execute(select(Project).where(Project.is_template.is_(True)).order_by(Project.name))
|
||||
@@ -356,6 +362,7 @@ async def create_project_from_template(
|
||||
template_id: int,
|
||||
name: str = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
|
||||
):
|
||||
"""Create a new project from a template."""
|
||||
result = await db.execute(select(Project).where(Project.id == template_id))
|
||||
@@ -470,6 +477,7 @@ async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectCh
|
||||
async def get_project(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
||||
):
|
||||
"""Get a project by ID with detailed stats."""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
@@ -519,6 +527,7 @@ async def update_project(
|
||||
project_id: int,
|
||||
data: ProjectUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
|
||||
):
|
||||
"""Update a project."""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
@@ -609,6 +618,7 @@ async def update_project(
|
||||
async def delete_project(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_DELETE),
|
||||
):
|
||||
"""Delete a project. Archives and queue items will have project_id set to NULL."""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
@@ -628,6 +638,7 @@ async def list_project_archives(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
||||
):
|
||||
"""List archives in a project."""
|
||||
# Verify project exists
|
||||
@@ -657,6 +668,7 @@ async def list_project_archives(
|
||||
async def list_project_queue(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
||||
):
|
||||
"""List queue items in a project."""
|
||||
# Verify project exists
|
||||
@@ -677,6 +689,7 @@ async def add_archives_to_project(
|
||||
project_id: int,
|
||||
data: BatchAddArchives,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
|
||||
):
|
||||
"""Batch add archives to a project."""
|
||||
# Verify project exists
|
||||
@@ -701,6 +714,7 @@ async def add_queue_items_to_project(
|
||||
project_id: int,
|
||||
data: BatchAddQueueItems,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
|
||||
):
|
||||
"""Batch add queue items to a project."""
|
||||
# Verify project exists
|
||||
@@ -725,6 +739,7 @@ async def remove_archives_from_project(
|
||||
project_id: int,
|
||||
data: BatchAddArchives,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
|
||||
):
|
||||
"""Remove archives from a project (sets project_id to NULL)."""
|
||||
updated = 0
|
||||
@@ -811,6 +826,7 @@ async def upload_attachment(
|
||||
project_id: int,
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
|
||||
):
|
||||
"""Upload an attachment to a project."""
|
||||
logger.info(f"=== UPLOAD START: {file.filename} for project {project_id} ===")
|
||||
@@ -888,6 +904,7 @@ async def download_attachment(
|
||||
project_id: int,
|
||||
filename: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
||||
):
|
||||
"""Download an attachment from a project."""
|
||||
# Verify project exists
|
||||
@@ -919,6 +936,7 @@ async def delete_attachment(
|
||||
project_id: int,
|
||||
filename: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
|
||||
):
|
||||
"""Delete an attachment from a project."""
|
||||
# Verify project exists
|
||||
@@ -962,6 +980,7 @@ async def delete_attachment(
|
||||
async def list_bom_items(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
||||
):
|
||||
"""List all BOM items for a project."""
|
||||
# Verify project exists
|
||||
@@ -1013,6 +1032,7 @@ async def create_bom_item(
|
||||
project_id: int,
|
||||
data: BOMItemCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
|
||||
):
|
||||
"""Add a BOM item to a project."""
|
||||
# Verify project exists
|
||||
@@ -1072,6 +1092,7 @@ async def update_bom_item(
|
||||
item_id: int,
|
||||
data: BOMItemUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
|
||||
):
|
||||
"""Update a BOM item."""
|
||||
result = await db.execute(
|
||||
@@ -1135,6 +1156,7 @@ async def delete_bom_item(
|
||||
project_id: int,
|
||||
item_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
|
||||
):
|
||||
"""Delete a BOM item."""
|
||||
result = await db.execute(
|
||||
@@ -1157,6 +1179,7 @@ async def delete_bom_item(
|
||||
async def create_template_from_project(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
|
||||
):
|
||||
"""Create a template from an existing project."""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
@@ -1238,6 +1261,7 @@ async def get_project_timeline(
|
||||
project_id: int,
|
||||
limit: int = 50,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
||||
):
|
||||
"""Get timeline of events for a project."""
|
||||
# Verify project exists
|
||||
@@ -1338,6 +1362,7 @@ async def export_project(
|
||||
project_id: int,
|
||||
format: str = "zip", # "zip" (with files) or "json" (metadata only)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
||||
):
|
||||
"""Export a project. Use format=zip (default) for full export with files, or format=json for metadata only."""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
@@ -1458,6 +1483,7 @@ async def export_project(
|
||||
async def import_project(
|
||||
data: ProjectImport,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
|
||||
):
|
||||
"""Import a project with optional BOM items and linked folders."""
|
||||
# Create the project
|
||||
@@ -1551,6 +1577,7 @@ async def import_project(
|
||||
async def import_project_file(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
|
||||
):
|
||||
"""Import a project from a ZIP or JSON file."""
|
||||
if not file.filename:
|
||||
|
||||
@@ -8,9 +8,12 @@ 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
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
@@ -39,7 +42,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 +102,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 +160,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 +196,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 +216,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 +234,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
|
||||
@@ -280,6 +298,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.
|
||||
|
||||
@@ -363,7 +382,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,
|
||||
@@ -391,6 +413,7 @@ async def update_virtual_printer_settings(
|
||||
mode: str = None,
|
||||
model: str = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Update virtual printer settings and restart services if needed."""
|
||||
from backend.app.services.virtual_printer import (
|
||||
@@ -482,7 +505,9 @@ async def update_virtual_printer_settings(
|
||||
|
||||
|
||||
@router.get("/mqtt/status")
|
||||
async def get_mqtt_status():
|
||||
async def get_mqtt_status(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
):
|
||||
"""Get MQTT relay connection status."""
|
||||
from backend.app.services.mqtt_relay import mqtt_relay
|
||||
|
||||
|
||||
@@ -9,9 +9,12 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.smart_plug import (
|
||||
HAEntity,
|
||||
HASensorEntity,
|
||||
@@ -38,7 +41,10 @@ router = APIRouter(prefix="/smart-plugs", tags=["smart-plugs"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SmartPlugResponse])
|
||||
async def list_smart_plugs(db: AsyncSession = Depends(get_db)):
|
||||
async def list_smart_plugs(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
|
||||
):
|
||||
"""List all smart plugs."""
|
||||
result = await db.execute(select(SmartPlug).order_by(SmartPlug.name))
|
||||
return list(result.scalars().all())
|
||||
@@ -48,6 +54,7 @@ async def list_smart_plugs(db: AsyncSession = Depends(get_db)):
|
||||
async def create_smart_plug(
|
||||
data: SmartPlugCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE),
|
||||
):
|
||||
"""Create a new smart plug."""
|
||||
# Validate printer_id if provided
|
||||
@@ -142,7 +149,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,7 +176,11 @@ 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)):
|
||||
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 script plugs assigned to a printer.
|
||||
|
||||
Returns only script entities (script.*) for the printer that have
|
||||
@@ -244,7 +259,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 +286,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 +299,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 +313,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 +333,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 +346,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 +368,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 +390,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 +408,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))
|
||||
@@ -489,7 +525,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 +569,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 +676,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 +808,10 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
|
||||
|
||||
|
||||
@router.post("/test-connection")
|
||||
async def test_connection(data: SmartPlugTestConnection):
|
||||
async def test_connection(
|
||||
data: SmartPlugTestConnection,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
|
||||
):
|
||||
"""Test connection to a Tasmota device."""
|
||||
result = await tasmota_service.test_connection(
|
||||
data.ip_address,
|
||||
|
||||
@@ -7,9 +7,12 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
from backend.app.services.spoolman import (
|
||||
close_spoolman_client,
|
||||
@@ -72,7 +75,10 @@ async def get_spoolman_settings(db: AsyncSession) -> tuple[bool, str, str]:
|
||||
|
||||
|
||||
@router.get("/status", response_model=SpoolmanStatus)
|
||||
async def get_spoolman_status(db: AsyncSession = Depends(get_db)):
|
||||
async def get_spoolman_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
|
||||
):
|
||||
"""Get Spoolman integration status."""
|
||||
enabled, url, _ = await get_spoolman_settings(db)
|
||||
|
||||
@@ -89,7 +95,10 @@ async def get_spoolman_status(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/connect")
|
||||
async def connect_spoolman(db: AsyncSession = Depends(get_db)):
|
||||
async def connect_spoolman(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Connect to Spoolman server using configured URL."""
|
||||
enabled, url, _ = await get_spoolman_settings(db)
|
||||
|
||||
@@ -119,7 +128,9 @@ async def connect_spoolman(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/disconnect")
|
||||
async def disconnect_spoolman():
|
||||
async def disconnect_spoolman(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Disconnect from Spoolman server."""
|
||||
await close_spoolman_client()
|
||||
return {"success": True, "message": "Disconnected from Spoolman"}
|
||||
@@ -129,6 +140,7 @@ async def disconnect_spoolman():
|
||||
async def sync_printer_ams(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
|
||||
):
|
||||
"""Sync AMS data from a specific printer to Spoolman."""
|
||||
# Check if Spoolman is enabled and connected
|
||||
@@ -267,7 +279,10 @@ async def sync_printer_ams(
|
||||
|
||||
|
||||
@router.post("/sync-all", response_model=SyncResult)
|
||||
async def sync_all_printers(db: AsyncSession = Depends(get_db)):
|
||||
async def sync_all_printers(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
|
||||
):
|
||||
"""Sync AMS data from all connected printers to Spoolman."""
|
||||
# Check if Spoolman is enabled
|
||||
enabled, url, _ = await get_spoolman_settings(db)
|
||||
@@ -392,7 +407,10 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/spools")
|
||||
async def get_spools(db: AsyncSession = Depends(get_db)):
|
||||
async def get_spools(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
|
||||
):
|
||||
"""Get all spools from Spoolman."""
|
||||
enabled, url, _ = await get_spoolman_settings(db)
|
||||
if not enabled:
|
||||
@@ -413,7 +431,10 @@ async def get_spools(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/filaments")
|
||||
async def get_filaments(db: AsyncSession = Depends(get_db)):
|
||||
async def get_filaments(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
|
||||
):
|
||||
"""Get all filaments from Spoolman."""
|
||||
enabled, url, _ = await get_spoolman_settings(db)
|
||||
if not enabled:
|
||||
@@ -445,7 +466,10 @@ class UnlinkedSpool(BaseModel):
|
||||
|
||||
|
||||
@router.get("/spools/unlinked", response_model=list[UnlinkedSpool])
|
||||
async def get_unlinked_spools(db: AsyncSession = Depends(get_db)):
|
||||
async def get_unlinked_spools(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
|
||||
):
|
||||
"""Get all Spoolman spools that don't have a tag (not linked to AMS)."""
|
||||
enabled, url, _ = await get_spoolman_settings(db)
|
||||
if not enabled:
|
||||
@@ -487,7 +511,10 @@ async def get_unlinked_spools(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/spools/linked")
|
||||
async def get_linked_spools(db: AsyncSession = Depends(get_db)):
|
||||
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:
|
||||
@@ -530,6 +557,7 @@ async def link_spool(
|
||||
spool_id: int,
|
||||
request: LinkSpoolRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
|
||||
):
|
||||
"""Link a Spoolman spool to an AMS tray by setting the tag to tray_uuid."""
|
||||
enabled, url, _ = await get_spoolman_settings(db)
|
||||
|
||||
@@ -15,14 +15,17 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.config import APP_VERSION, settings
|
||||
from backend.app.core.database import async_session
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.filament import Filament
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.project import Project
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/support", tags=["support"])
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -107,7 +110,9 @@ def _apply_log_level(debug: bool):
|
||||
|
||||
|
||||
@router.get("/debug-logging", response_model=DebugLoggingState)
|
||||
async def get_debug_logging_state():
|
||||
async def get_debug_logging_state(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
):
|
||||
"""Get current debug logging state."""
|
||||
global _debug_logging_enabled, _debug_logging_enabled_at
|
||||
|
||||
@@ -128,7 +133,10 @@ async def get_debug_logging_state():
|
||||
|
||||
|
||||
@router.post("/debug-logging", response_model=DebugLoggingState)
|
||||
async def toggle_debug_logging(toggle: DebugLoggingToggle):
|
||||
async def toggle_debug_logging(
|
||||
toggle: DebugLoggingToggle,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Enable or disable debug logging."""
|
||||
global _debug_logging_enabled, _debug_logging_enabled_at
|
||||
|
||||
@@ -273,6 +281,7 @@ async def get_logs(
|
||||
limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
|
||||
level: str | None = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
|
||||
search: str | None = Query(None, description="Search in message or logger name"),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
):
|
||||
"""Get recent application log entries with optional filtering."""
|
||||
entries, total_lines = _read_log_entries(limit=limit, level_filter=level, search=search)
|
||||
@@ -285,7 +294,9 @@ async def get_logs(
|
||||
|
||||
|
||||
@router.delete("/logs")
|
||||
async def clear_logs():
|
||||
async def clear_logs(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Clear the application log file."""
|
||||
log_file = settings.log_dir / "bambuddy.log"
|
||||
|
||||
@@ -445,7 +456,9 @@ def _get_log_content(max_bytes: int = 10 * 1024 * 1024) -> bytes:
|
||||
|
||||
|
||||
@router.get("/bundle")
|
||||
async def generate_support_bundle():
|
||||
async def generate_support_bundle(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
):
|
||||
"""Generate a support bundle ZIP file for issue reporting."""
|
||||
global _debug_logging_enabled, _debug_logging_enabled_at
|
||||
|
||||
|
||||
@@ -9,13 +9,16 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.config import APP_VERSION, settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.filament import Filament
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.project import Project
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
router = APIRouter(prefix="/system", tags=["system"])
|
||||
@@ -60,7 +63,10 @@ def format_uptime(seconds: float) -> str:
|
||||
|
||||
|
||||
@router.get("/info")
|
||||
async def get_system_info(db: AsyncSession = Depends(get_db)):
|
||||
async def get_system_info(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
|
||||
):
|
||||
"""Get comprehensive system information."""
|
||||
|
||||
# Database stats
|
||||
|
||||
@@ -11,8 +11,11 @@ import httpx
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.config import APP_VERSION, GITHUB_REPO, settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -153,7 +156,10 @@ def is_newer_version(latest: str, current: str) -> bool:
|
||||
|
||||
@router.get("/version")
|
||||
async def get_version():
|
||||
"""Get current application version."""
|
||||
"""Get current application version.
|
||||
|
||||
Note: Unauthenticated - needed to display version in UI without login.
|
||||
"""
|
||||
return {
|
||||
"version": APP_VERSION,
|
||||
"repo": GITHUB_REPO,
|
||||
@@ -161,7 +167,10 @@ async def get_version():
|
||||
|
||||
|
||||
@router.get("/check")
|
||||
async def check_for_updates(db: AsyncSession = Depends(get_db)):
|
||||
async def check_for_updates(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
|
||||
):
|
||||
"""Check GitHub for available updates."""
|
||||
global _update_status
|
||||
|
||||
@@ -432,7 +441,10 @@ async def _perform_update():
|
||||
|
||||
|
||||
@router.post("/apply")
|
||||
async def apply_update(background_tasks: BackgroundTasks):
|
||||
async def apply_update(
|
||||
background_tasks: BackgroundTasks,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Apply available update (git pull + rebuild)."""
|
||||
global _update_status
|
||||
|
||||
@@ -473,6 +485,8 @@ async def apply_update(background_tasks: BackgroundTasks):
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_update_status():
|
||||
async def get_update_status(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
|
||||
):
|
||||
"""Get current update status."""
|
||||
return _update_status
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Integration tests for API endpoint authentication.
|
||||
|
||||
Tests that verify endpoints properly enforce authentication when auth is enabled,
|
||||
and allow access when auth is disabled (CVE-2026-25505 fix verification).
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
class TestEndpointAuthenticationEnforcement:
|
||||
"""Tests that endpoints enforce authentication when auth is enabled."""
|
||||
|
||||
@pytest.fixture
|
||||
async def user_factory(self, db_session):
|
||||
"""Factory to create test users."""
|
||||
|
||||
async def _create_user(**kwargs):
|
||||
from passlib.hash import bcrypt
|
||||
|
||||
from backend.app.models.user import User
|
||||
|
||||
defaults = {
|
||||
"username": "testuser",
|
||||
"password_hash": bcrypt.hash("testpass123"),
|
||||
"is_admin": False,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
|
||||
user = User(**defaults)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
return _create_user
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_user(self, user_factory, db_session):
|
||||
"""Create an admin user for testing."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models.group import Group
|
||||
|
||||
# Get or create admin group
|
||||
result = await db_session.execute(select(Group).where(Group.name == "Administrators"))
|
||||
admin_group = result.scalar_one_or_none()
|
||||
|
||||
user = await user_factory(username="admin", is_admin=True)
|
||||
if admin_group:
|
||||
user.groups.append(admin_group)
|
||||
await db_session.commit()
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_token(self, admin_user, async_client: AsyncClient):
|
||||
"""Get a valid auth token for the admin user."""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "testpass123"},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json().get("access_token")
|
||||
return None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_filaments_list_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
|
||||
"""Verify filaments list is accessible when auth is disabled."""
|
||||
with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
|
||||
response = await async_client.get("/api/v1/filaments/")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_external_links_list_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
|
||||
"""Verify external links list is accessible when auth is disabled."""
|
||||
with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
|
||||
response = await async_client.get("/api/v1/external-links/")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_notifications_list_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
|
||||
"""Verify notifications list is accessible when auth is disabled."""
|
||||
with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
|
||||
response = await async_client.get("/api/v1/notifications/")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_maintenance_types_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
|
||||
"""Verify maintenance types is accessible when auth is disabled."""
|
||||
with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
|
||||
response = await async_client.get("/api/v1/maintenance/types")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_system_info_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
|
||||
"""Verify system info is accessible when auth is disabled."""
|
||||
with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
|
||||
response = await async_client.get("/api/v1/system/info")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestImageEndpointsPublicAccess:
|
||||
"""Tests that image endpoints remain accessible without auth.
|
||||
|
||||
These endpoints serve images via <img> tags which cannot send Authorization headers.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
async def link_with_icon(self, db_session):
|
||||
"""Create an external link with a custom icon for testing."""
|
||||
from backend.app.models.external_link import ExternalLink
|
||||
|
||||
link = ExternalLink(
|
||||
name="Test Link",
|
||||
url="https://example.com",
|
||||
icon="Link",
|
||||
sort_order=0,
|
||||
custom_icon=None, # No custom icon set
|
||||
)
|
||||
db_session.add(link)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(link)
|
||||
return link
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_external_link_icon_returns_404_when_no_icon(self, async_client: AsyncClient, link_with_icon):
|
||||
"""Verify icon endpoint returns 404 (not 401) when no icon is set.
|
||||
|
||||
This confirms the endpoint doesn't require auth - a 401 would indicate
|
||||
auth is being enforced, but 404 means the endpoint is accessible but
|
||||
no icon exists.
|
||||
"""
|
||||
response = await async_client.get(f"/api/v1/external-links/{link_with_icon.id}/icon")
|
||||
# Should be 404 (no icon set), not 401 (unauthorized)
|
||||
assert response.status_code == 404
|
||||
assert "No custom icon set" in response.json().get("detail", "")
|
||||
|
||||
|
||||
class TestAuthenticationPatterns:
|
||||
"""Tests for authentication helper functions and patterns."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_require_permission_if_auth_enabled_allows_access_when_disabled(self, async_client: AsyncClient):
|
||||
"""Verify require_permission_if_auth_enabled allows access when auth disabled."""
|
||||
with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
|
||||
# Test a protected endpoint
|
||||
response = await async_client.get("/api/v1/filaments/")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_multiple_endpoints_accessible_when_auth_disabled(self, async_client: AsyncClient):
|
||||
"""Verify multiple protected endpoints are accessible when auth is disabled."""
|
||||
with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
|
||||
endpoints = [
|
||||
"/api/v1/filaments/",
|
||||
"/api/v1/external-links/",
|
||||
"/api/v1/notifications/",
|
||||
"/api/v1/maintenance/types",
|
||||
]
|
||||
|
||||
for endpoint in endpoints:
|
||||
response = await async_client.get(endpoint)
|
||||
assert response.status_code == 200, f"Endpoint {endpoint} should be accessible"
|
||||
Reference in New Issue
Block a user