From 83cbac04b7d0cbd7e3c79052bf0e49ab6dd8e1b6 Mon Sep 17 00:00:00 2001 From: maziggy Date: Fri, 2 Jan 2026 15:00:21 +0100 Subject: [PATCH] - Add interactive API Browser to Settings > API Keys - New APIBrowser component with full OpenAPI schema integration - Fetches and parses /openapi.json automatically - Groups endpoints by API tags (printers, archives, settings, etc.) - Expandable endpoint sections with color-coded method badges - Path parameter, query parameter, and JSON body editors - Auto-populates request body with schema examples - Live API request execution with response display - Response shows status code, timing, and formatted JSON - Copy response button with clipboard fallback - Search to filter endpoints across all categories - Expand All / Collapse All buttons - Link to Swagger UI (/docs) - Two-column layout for API Keys tab - Left: API key management + webhook documentation - Right: API Browser with dedicated test key input - Parameter validation - Shows warning for missing required parameters - Validates before sending requests to avoid 422 errors - UX improvements - "Use in API Browser" button on newly created keys - Responsive layout (stacked on mobile, side-by-side on xl+) --- CHANGELOG.md | 11 + README.md | 1 + backend/app/api/routes/ams_history.py | 11 +- backend/app/api/routes/api_keys.py | 17 +- backend/app/api/routes/external_links.py | 52 +- backend/app/api/routes/filaments.py | 138 ++- backend/app/api/routes/kprofiles.py | 14 +- .../app/api/routes/notification_templates.py | 29 +- backend/app/api/routes/notifications.py | 93 +- backend/app/api/routes/print_queue.py | 47 +- backend/app/api/routes/system.py | 61 +- backend/app/api/routes/webhook.py | 72 +- backend/app/api/routes/websocket.py | 25 +- backend/app/core/auth.py | 29 +- backend/app/core/websocket.py | 57 +- backend/app/i18n/__init__.py | 8 - backend/app/models/ams_history.py | 12 +- backend/app/models/api_key.py | 3 +- backend/app/models/external_link.py | 14 +- backend/app/models/filament.py | 11 +- backend/app/models/kprofile_note.py | 15 +- backend/app/models/maintenance.py | 22 +- backend/app/models/notification.py | 2 +- backend/app/models/notification_template.py | 4 +- backend/app/models/print_queue.py | 17 +- backend/app/models/settings.py | 11 +- backend/app/models/slot_preset.py | 11 +- backend/app/schemas/__init__.py | 22 +- backend/app/schemas/api_key.py | 5 + backend/app/schemas/cloud.py | 47 +- backend/app/schemas/external_link.py | 1 + backend/app/schemas/filament.py | 1 + backend/app/schemas/maintenance.py | 4 + backend/app/schemas/notification.py | 4 +- backend/app/schemas/print_queue.py | 5 +- backend/app/services/archive_comparison.py | 116 +-- backend/app/services/bambu_cloud.py | 80 +- backend/app/services/notification_service.py | 58 +- backend/app/services/print_scheduler.py | 16 +- backend/app/services/smart_plug_manager.py | 60 +- backend/app/services/tasmota.py | 34 +- backend/app/services/telemetry.py | 12 +- backend/tests/conftest.py | 130 +-- .../tests/integration/test_ams_history_api.py | 50 +- .../tests/integration/test_archives_api.py | 44 +- backend/tests/integration/test_camera_api.py | 33 +- .../integration/test_external_links_api.py | 35 +- .../tests/integration/test_filaments_api.py | 20 +- .../tests/integration/test_maintenance_api.py | 69 +- .../integration/test_notifications_api.py | 76 +- .../tests/integration/test_projects_api.py | 24 +- backend/tests/integration/test_system_api.py | 39 +- .../unit/services/test_archive_service.py | 29 +- .../tests/unit/services/test_bambu_mqtt.py | 113 +-- .../services/test_notification_service.py | 428 ++++----- .../unit/services/test_smart_plug_manager.py | 280 +++--- backend/tests/unit/services/test_tasmota.py | 105 +-- backend/tests/unit/services/test_telemetry.py | 34 +- backend/tests/unit/test_code_quality.py | 36 +- .../tests/unit/test_log_error_detection.py | 148 ++-- frontend/public/icons/chamber.svg | 2 +- frontend/public/icons/heatbed.svg | 2 +- frontend/public/icons/reload.svg | 2 +- frontend/public/icons/settings.svg | 2 +- frontend/public/icons/skip-objects.svg | 2 +- frontend/public/icons/video-camera.svg | 2 +- frontend/public/vite.svg | 2 +- frontend/src/assets/react.svg | 2 +- frontend/src/components/APIBrowser.tsx | 648 ++++++++++++++ frontend/src/pages/SettingsPage.tsx | 537 +++++------ icons/27ca5e207eb045a7949048ab41fda285.svg | 2 +- icons/57eeee2303f848be9d6159c1079f100d.svg | 2 +- icons/7a3afd1aa53c47e38ea7e55356403f99.svg | 2 +- icons/df3231e72d3b4bc0a08c47e95599e64d.svg | 2 +- mockup/icons/ams-settings.svg | 2 +- mockup/icons/chamber.svg | 2 +- mockup/icons/heatbed.svg | 2 +- mockup/icons/hotend.svg | 2 +- mockup/icons/lamp.svg | 2 +- mockup/icons/micro-sd.svg | 2 +- mockup/icons/reload.svg | 2 +- mockup/icons/settings.svg | 2 +- mockup/icons/skip-objects.svg | 2 +- mockup/icons/speed.svg | 2 +- mockup/icons/temperature.svg | 2 +- mockup/icons/ventilation.svg | 2 +- mockup/icons/video-camera.svg | 2 +- scripts/mqtt_sniffer.py | 3 +- static/assets/index-Bs58vo0R.css | 1 + .../{index-DMT-rASM.js => index-D9x3e2g2.js} | 830 +++++++++--------- static/assets/index-Da3qKIoX.css | 1 - static/icons/chamber.svg | 2 +- static/icons/heatbed.svg | 2 +- static/icons/reload.svg | 2 +- static/icons/settings.svg | 2 +- static/icons/skip-objects.svg | 2 +- static/icons/video-camera.svg | 2 +- static/img/bambuddy_logo_dark_transparent.png | Bin 514022 -> 0 bytes static/index.html | 4 +- static/vite.svg | 2 +- tests/e2e_comprehensive_test.py | 70 +- tests/e2e_toggle_persistence_test.py | 60 +- 102 files changed, 2719 insertions(+), 2444 deletions(-) create mode 100644 frontend/src/components/APIBrowser.tsx create mode 100644 static/assets/index-Bs58vo0R.css rename static/assets/{index-DMT-rASM.js => index-D9x3e2g2.js} (68%) delete mode 100644 static/assets/index-Da3qKIoX.css delete mode 100644 static/img/bambuddy_logo_dark_transparent.png diff --git a/CHANGELOG.md b/CHANGELOG.md index f2e7a1cfa..c60ef5a6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,17 @@ All notable changes to Bambuddy will be documented in this file. - Light and dark theme support - Close with ESC key or click outside - Requires "Exclude Objects" option enabled in slicer +- **Interactive API Browser** - Explore and test all API endpoints directly in Bambuddy: + - Settings → API Keys now includes a full API browser + - Fetches OpenAPI schema automatically + - Endpoints grouped by category (printers, archives, settings, etc.) + - Expandable sections with color-coded method badges (GET, POST, PATCH, DELETE) + - Parameter inputs for path, query, and JSON body + - Auto-populates request body with schema examples + - Live API execution with response display (status, timing, formatted JSON) + - Paste API key to test authenticated endpoints + - Search to filter endpoints across all categories + - Two-column layout: API key management + API browser side-by-side - **AMS slot RFID re-read** - Re-read RFID data for individual AMS slots: - Menu button (⋮) appears on hover over AMS slots - "Re-read RFID" option triggers filament info refresh diff --git a/README.md b/README.md index 70d86f6a2..a5854160f 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ - K-profiles (pressure advance) - External sidebar links - Webhooks & API keys +- Interactive API browser with live testing ### 🖨️ Virtual Printer - Emulates a Bambu Lab printer on your network diff --git a/backend/app/api/routes/ams_history.py b/backend/app/api/routes/ams_history.py index 75c3332ca..ffe91e3cf 100644 --- a/backend/app/api/routes/ams_history.py +++ b/backend/app/api/routes/ams_history.py @@ -1,10 +1,11 @@ """API routes for AMS sensor history.""" from datetime import datetime, timedelta + from fastapi import APIRouter, Depends, Query -from sqlalchemy import select, func, and_ -from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession from backend.app.core.database import get_db from backend.app.models.ams_history import AMSSensorHistory @@ -64,8 +65,7 @@ async def get_ams_history( func.min(AMSSensorHistory.temperature).label("min_temp"), func.max(AMSSensorHistory.temperature).label("max_temp"), func.avg(AMSSensorHistory.temperature).label("avg_temp"), - ) - .where( + ).where( and_( AMSSensorHistory.printer_id == printer_id, AMSSensorHistory.ams_id == ams_id, @@ -106,8 +106,7 @@ async def delete_old_history( cutoff = datetime.now() - timedelta(days=days) result = await db.execute( - select(func.count(AMSSensorHistory.id)) - .where( + select(func.count(AMSSensorHistory.id)).where( and_( AMSSensorHistory.printer_id == printer_id, AMSSensorHistory.recorded_at < cutoff, diff --git a/backend/app/api/routes/api_keys.py b/backend/app/api/routes/api_keys.py index eb3a2493f..033376213 100644 --- a/backend/app/api/routes/api_keys.py +++ b/backend/app/api/routes/api_keys.py @@ -1,16 +1,17 @@ import logging -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from backend.app.core.database import get_db +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.database import get_db from backend.app.models.api_key import APIKey from backend.app.schemas.api_key import ( APIKeyCreate, - APIKeyUpdate, - APIKeyResponse, APIKeyCreateResponse, + APIKeyResponse, + APIKeyUpdate, ) logger = logging.getLogger(__name__) @@ -21,9 +22,7 @@ router = APIRouter(prefix="/api-keys", tags=["api-keys"]) @router.get("/", response_model=list[APIKeyResponse]) async def list_api_keys(db: AsyncSession = Depends(get_db)): """List all API keys (without full key values).""" - result = await db.execute( - select(APIKey).order_by(APIKey.created_at.desc()) - ) + result = await db.execute(select(APIKey).order_by(APIKey.created_at.desc())) return list(result.scalars().all()) diff --git a/backend/app/api/routes/external_links.py b/backend/app/api/routes/external_links.py index 7992dd4fe..481368ac6 100644 --- a/backend/app/api/routes/external_links.py +++ b/backend/app/api/routes/external_links.py @@ -1,11 +1,10 @@ """API routes for external sidebar links.""" import logging -import os import uuid from pathlib import Path -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi.responses import FileResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -15,9 +14,9 @@ from backend.app.core.database import get_db from backend.app.models.external_link import ExternalLink from backend.app.schemas.external_link import ( ExternalLinkCreate, - ExternalLinkUpdate, - ExternalLinkResponse, ExternalLinkReorder, + ExternalLinkResponse, + ExternalLinkUpdate, ) # Directory for storing custom icons @@ -32,9 +31,7 @@ router = APIRouter(prefix="/external-links", tags=["external-links"]) @router.get("/", response_model=list[ExternalLinkResponse]) async def list_external_links(db: AsyncSession = Depends(get_db)): """List all external links ordered by sort_order.""" - result = await db.execute( - select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id) - ) + result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id)) links = result.scalars().all() return links @@ -46,9 +43,7 @@ async def create_external_link( ): """Create a new external link.""" # Get the highest sort_order to place new link at end - result = await db.execute( - select(ExternalLink).order_by(ExternalLink.sort_order.desc()).limit(1) - ) + result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order.desc()).limit(1)) last_link = result.scalar_one_or_none() next_order = (last_link.sort_order + 1) if last_link else 0 @@ -74,9 +69,7 @@ async def get_external_link( db: AsyncSession = Depends(get_db), ): """Get a specific external link.""" - result = await db.execute( - select(ExternalLink).where(ExternalLink.id == link_id) - ) + result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id)) link = result.scalar_one_or_none() if not link: @@ -92,9 +85,7 @@ async def update_external_link( db: AsyncSession = Depends(get_db), ): """Update an external link.""" - result = await db.execute( - select(ExternalLink).where(ExternalLink.id == link_id) - ) + result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id)) link = result.scalar_one_or_none() if not link: @@ -119,9 +110,7 @@ async def delete_external_link( db: AsyncSession = Depends(get_db), ): """Delete an external link.""" - result = await db.execute( - select(ExternalLink).where(ExternalLink.id == link_id) - ) + result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id)) link = result.scalar_one_or_none() if not link: @@ -144,9 +133,7 @@ async def reorder_external_links( """Update the sort order of external links.""" # Update sort_order for each link based on position in the list for index, link_id in enumerate(reorder_data.ids): - result = await db.execute( - select(ExternalLink).where(ExternalLink.id == link_id) - ) + result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id)) link = result.scalar_one_or_none() if link: link.sort_order = index @@ -154,9 +141,7 @@ async def reorder_external_links( await db.commit() # Return updated list - result = await db.execute( - select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id) - ) + result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id)) links = result.scalars().all() logger.info(f"Reordered {len(reorder_data.ids)} external links") @@ -171,9 +156,7 @@ async def upload_icon( db: AsyncSession = Depends(get_db), ): """Upload a custom icon for an external link.""" - result = await db.execute( - select(ExternalLink).where(ExternalLink.id == link_id) - ) + result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id)) link = result.scalar_one_or_none() if not link: @@ -185,10 +168,7 @@ async def upload_icon( ext = Path(file.filename).suffix.lower() if ext not in ALLOWED_EXTENSIONS: - raise HTTPException( - status_code=400, - detail=f"File type not allowed. Allowed: {', '.join(ALLOWED_EXTENSIONS)}" - ) + raise HTTPException(status_code=400, detail=f"File type not allowed. Allowed: {', '.join(ALLOWED_EXTENSIONS)}") # Create icons directory if it doesn't exist ICONS_DIR.mkdir(parents=True, exist_ok=True) @@ -224,9 +204,7 @@ async def delete_icon( db: AsyncSession = Depends(get_db), ): """Delete the custom icon for an external link.""" - result = await db.execute( - select(ExternalLink).where(ExternalLink.id == link_id) - ) + result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id)) link = result.scalar_one_or_none() if not link: @@ -250,9 +228,7 @@ async def get_icon( db: AsyncSession = Depends(get_db), ): """Get the custom icon for an external link.""" - result = await db.execute( - select(ExternalLink).where(ExternalLink.id == link_id) - ) + result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id)) link = result.scalar_one_or_none() if not link: diff --git a/backend/app/api/routes/filaments.py b/backend/app/api/routes/filaments.py index 845d5d4ee..ec3094fdf 100644 --- a/backend/app/api/routes/filaments.py +++ b/backend/app/api/routes/filaments.py @@ -1,26 +1,23 @@ from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from backend.app.core.database import get_db from backend.app.models.filament import Filament from backend.app.schemas.filament import ( - FilamentCreate, - FilamentUpdate, - FilamentResponse, FilamentCostCalculation, + FilamentCreate, + FilamentResponse, + FilamentUpdate, ) - router = APIRouter(prefix="/filaments", tags=["filaments"]) @router.get("/", response_model=list[FilamentResponse]) async def list_filaments(db: AsyncSession = Depends(get_db)): """List all filaments.""" - result = await db.execute( - select(Filament).order_by(Filament.type, Filament.name) - ) + result = await db.execute(select(Filament).order_by(Filament.type, Filament.name)) return list(result.scalars().all()) @@ -40,9 +37,7 @@ async def create_filament( @router.get("/{filament_id}", response_model=FilamentResponse) async def get_filament(filament_id: int, db: AsyncSession = Depends(get_db)): """Get a specific filament.""" - result = await db.execute( - select(Filament).where(Filament.id == filament_id) - ) + result = await db.execute(select(Filament).where(Filament.id == filament_id)) filament = result.scalar_one_or_none() if not filament: raise HTTPException(404, "Filament not found") @@ -56,9 +51,7 @@ async def update_filament( db: AsyncSession = Depends(get_db), ): """Update a filament.""" - result = await db.execute( - select(Filament).where(Filament.id == filament_id) - ) + result = await db.execute(select(Filament).where(Filament.id == filament_id)) filament = result.scalar_one_or_none() if not filament: raise HTTPException(404, "Filament not found") @@ -74,9 +67,7 @@ async def update_filament( @router.delete("/{filament_id}") async def delete_filament(filament_id: int, db: AsyncSession = Depends(get_db)): """Delete a filament.""" - result = await db.execute( - select(Filament).where(Filament.id == filament_id) - ) + result = await db.execute(select(Filament).where(Filament.id == filament_id)) filament = result.scalar_one_or_none() if not filament: raise HTTPException(404, "Filament not found") @@ -93,9 +84,7 @@ async def calculate_cost( db: AsyncSession = Depends(get_db), ): """Calculate the cost for a given weight of filament.""" - result = await db.execute( - select(Filament).where(Filament.id == filament_id) - ) + result = await db.execute(select(Filament).where(Filament.id == filament_id)) filament = result.scalar_one_or_none() if not filament: raise HTTPException(404, "Filament not found") @@ -117,11 +106,7 @@ async def get_filaments_by_type( db: AsyncSession = Depends(get_db), ): """Get all filaments of a specific type.""" - result = await db.execute( - select(Filament) - .where(Filament.type.ilike(f"%{filament_type}%")) - .order_by(Filament.name) - ) + result = await db.execute(select(Filament).where(Filament.type.ilike(f"%{filament_type}%")).order_by(Filament.name)) return list(result.scalars().all()) @@ -129,15 +114,100 @@ async def get_filaments_by_type( async def seed_default_filaments(db: AsyncSession = Depends(get_db)): """Seed the database with common filament types.""" defaults = [ - {"name": "Generic PLA", "type": "PLA", "cost_per_kg": 20.0, "print_temp_min": 190, "print_temp_max": 220, "bed_temp_min": 50, "bed_temp_max": 60, "density": 1.24}, - {"name": "Generic PETG", "type": "PETG", "cost_per_kg": 25.0, "print_temp_min": 230, "print_temp_max": 250, "bed_temp_min": 70, "bed_temp_max": 80, "density": 1.27}, - {"name": "Generic ABS", "type": "ABS", "cost_per_kg": 22.0, "print_temp_min": 230, "print_temp_max": 260, "bed_temp_min": 90, "bed_temp_max": 110, "density": 1.04}, - {"name": "Generic TPU", "type": "TPU", "cost_per_kg": 35.0, "print_temp_min": 220, "print_temp_max": 250, "bed_temp_min": 40, "bed_temp_max": 60, "density": 1.21}, - {"name": "Generic ASA", "type": "ASA", "cost_per_kg": 28.0, "print_temp_min": 240, "print_temp_max": 260, "bed_temp_min": 90, "bed_temp_max": 110, "density": 1.07}, - {"name": "Bambu PLA Basic", "type": "PLA", "brand": "Bambu Lab", "cost_per_kg": 20.0, "print_temp_min": 190, "print_temp_max": 220, "bed_temp_min": 35, "bed_temp_max": 55, "density": 1.24}, - {"name": "Bambu PLA Matte", "type": "PLA", "brand": "Bambu Lab", "cost_per_kg": 25.0, "print_temp_min": 190, "print_temp_max": 220, "bed_temp_min": 35, "bed_temp_max": 55, "density": 1.24}, - {"name": "Bambu PETG Basic", "type": "PETG", "brand": "Bambu Lab", "cost_per_kg": 25.0, "print_temp_min": 250, "print_temp_max": 270, "bed_temp_min": 70, "bed_temp_max": 80, "density": 1.27}, - {"name": "Bambu ABS", "type": "ABS", "brand": "Bambu Lab", "cost_per_kg": 30.0, "print_temp_min": 260, "print_temp_max": 280, "bed_temp_min": 90, "bed_temp_max": 100, "density": 1.04}, + { + "name": "Generic PLA", + "type": "PLA", + "cost_per_kg": 20.0, + "print_temp_min": 190, + "print_temp_max": 220, + "bed_temp_min": 50, + "bed_temp_max": 60, + "density": 1.24, + }, + { + "name": "Generic PETG", + "type": "PETG", + "cost_per_kg": 25.0, + "print_temp_min": 230, + "print_temp_max": 250, + "bed_temp_min": 70, + "bed_temp_max": 80, + "density": 1.27, + }, + { + "name": "Generic ABS", + "type": "ABS", + "cost_per_kg": 22.0, + "print_temp_min": 230, + "print_temp_max": 260, + "bed_temp_min": 90, + "bed_temp_max": 110, + "density": 1.04, + }, + { + "name": "Generic TPU", + "type": "TPU", + "cost_per_kg": 35.0, + "print_temp_min": 220, + "print_temp_max": 250, + "bed_temp_min": 40, + "bed_temp_max": 60, + "density": 1.21, + }, + { + "name": "Generic ASA", + "type": "ASA", + "cost_per_kg": 28.0, + "print_temp_min": 240, + "print_temp_max": 260, + "bed_temp_min": 90, + "bed_temp_max": 110, + "density": 1.07, + }, + { + "name": "Bambu PLA Basic", + "type": "PLA", + "brand": "Bambu Lab", + "cost_per_kg": 20.0, + "print_temp_min": 190, + "print_temp_max": 220, + "bed_temp_min": 35, + "bed_temp_max": 55, + "density": 1.24, + }, + { + "name": "Bambu PLA Matte", + "type": "PLA", + "brand": "Bambu Lab", + "cost_per_kg": 25.0, + "print_temp_min": 190, + "print_temp_max": 220, + "bed_temp_min": 35, + "bed_temp_max": 55, + "density": 1.24, + }, + { + "name": "Bambu PETG Basic", + "type": "PETG", + "brand": "Bambu Lab", + "cost_per_kg": 25.0, + "print_temp_min": 250, + "print_temp_max": 270, + "bed_temp_min": 70, + "bed_temp_max": 80, + "density": 1.27, + }, + { + "name": "Bambu ABS", + "type": "ABS", + "brand": "Bambu Lab", + "cost_per_kg": 30.0, + "print_temp_min": 260, + "print_temp_max": 280, + "bed_temp_min": 90, + "bed_temp_max": 100, + "density": 1.04, + }, ] created = 0 diff --git a/backend/app/api/routes/kprofiles.py b/backend/app/api/routes/kprofiles.py index d1e2836ba..c0758c282 100644 --- a/backend/app/api/routes/kprofiles.py +++ b/backend/app/api/routes/kprofiles.py @@ -4,19 +4,19 @@ import asyncio import logging from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from backend.app.core.database import get_db -from backend.app.models.printer import Printer from backend.app.models.kprofile_note import KProfileNote as KProfileNoteModel +from backend.app.models.printer import Printer from backend.app.schemas.kprofile import ( KProfile, KProfileCreate, KProfileDelete, - KProfilesResponse, KProfileNote, KProfileNoteResponse, + KProfilesResponse, ) from backend.app.services.printer_manager import printer_manager @@ -293,15 +293,11 @@ async def get_kprofile_notes( raise HTTPException(404, "Printer not found") # Get all notes for this printer - result = await db.execute( - select(KProfileNoteModel).where(KProfileNoteModel.printer_id == printer_id) - ) + result = await db.execute(select(KProfileNoteModel).where(KProfileNoteModel.printer_id == printer_id)) notes = result.scalars().all() # Return as a dictionary mapping setting_id -> note - return KProfileNoteResponse( - notes={note.setting_id: note.note for note in notes} - ) + return KProfileNoteResponse(notes={note.setting_id: note.note for note in notes}) @router.put("/notes", response_model=dict) diff --git a/backend/app/api/routes/notification_templates.py b/backend/app/api/routes/notification_templates.py index ad2487aa0..337a810e8 100644 --- a/backend/app/api/routes/notification_templates.py +++ b/backend/app/api/routes/notification_templates.py @@ -5,15 +5,15 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from backend.app.core.database import get_db -from backend.app.models.notification_template import NotificationTemplate, DEFAULT_TEMPLATES +from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate from backend.app.schemas.notification_template import ( - NotificationTemplateResponse, - NotificationTemplateUpdate, - EventVariablesResponse, - TemplatePreviewRequest, - TemplatePreviewResponse, EVENT_VARIABLES, SAMPLE_DATA, + EventVariablesResponse, + NotificationTemplateResponse, + NotificationTemplateUpdate, + TemplatePreviewRequest, + TemplatePreviewResponse, ) from backend.app.services.notification_service import notification_service @@ -38,9 +38,7 @@ EVENT_NAMES = { @router.get("", response_model=list[NotificationTemplateResponse]) async def get_templates(db: AsyncSession = Depends(get_db)): """Get all notification templates.""" - result = await db.execute( - select(NotificationTemplate).order_by(NotificationTemplate.id) - ) + result = await db.execute(select(NotificationTemplate).order_by(NotificationTemplate.id)) return result.scalars().all() @@ -60,9 +58,7 @@ async def get_variables(): @router.get("/{template_id}", response_model=NotificationTemplateResponse) async def get_template(template_id: int, db: AsyncSession = Depends(get_db)): """Get a single notification template.""" - result = await db.execute( - select(NotificationTemplate).where(NotificationTemplate.id == template_id) - ) + result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id)) template = result.scalar_one_or_none() if not template: raise HTTPException(status_code=404, detail="Template not found") @@ -76,9 +72,7 @@ async def update_template( db: AsyncSession = Depends(get_db), ): """Update a notification template.""" - result = await db.execute( - select(NotificationTemplate).where(NotificationTemplate.id == template_id) - ) + result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id)) template = result.scalar_one_or_none() if not template: raise HTTPException(status_code=404, detail="Template not found") @@ -100,9 +94,7 @@ async def update_template( @router.post("/{template_id}/reset", response_model=NotificationTemplateResponse) async def reset_template(template_id: int, db: AsyncSession = Depends(get_db)): """Reset a notification template to its default values.""" - result = await db.execute( - select(NotificationTemplate).where(NotificationTemplate.id == template_id) - ) + result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id)) template = result.scalar_one_or_none() if not template: raise HTTPException(status_code=404, detail="Template not found") @@ -139,6 +131,7 @@ async def preview_template(request: TemplatePreviewRequest): result = result.replace("{" + key + "}", str(value)) # Remove any remaining unreplaced placeholders import re + result = re.sub(r"\{[a-z_]+\}", "", result) return result diff --git a/backend/app/api/routes/notifications.py b/backend/app/api/routes/notifications.py index 0f2d44ed5..de0e1d903 100644 --- a/backend/app/api/routes/notifications.py +++ b/backend/app/api/routes/notifications.py @@ -74,12 +74,11 @@ def _provider_to_dict(provider: NotificationProvider) -> dict: # Provider List/Create Routes (no path parameters) # ============================================================================ + @router.get("/", response_model=list[NotificationProviderResponse]) async def list_notification_providers(db: AsyncSession = Depends(get_db)): """List all notification providers.""" - result = await db.execute( - select(NotificationProvider).order_by(NotificationProvider.created_at.desc()) - ) + result = await db.execute(select(NotificationProvider).order_by(NotificationProvider.created_at.desc())) providers = result.scalars().all() return [_provider_to_dict(provider) for provider in providers] @@ -137,6 +136,7 @@ async def create_notification_provider( # Static Path Routes (must come BEFORE parameterized routes) # ============================================================================ + @router.post("/test-config", response_model=NotificationTestResponse) async def test_notification_config( test_request: NotificationTestRequest, @@ -153,9 +153,7 @@ async def test_notification_config( @router.post("/test-all") async def test_all_notification_providers(db: AsyncSession = Depends(get_db)): """Send a test notification to all enabled providers.""" - result = await db.execute( - select(NotificationProvider).where(NotificationProvider.enabled == True) - ) + result = await db.execute(select(NotificationProvider).where(NotificationProvider.enabled.is_(True))) providers = result.scalars().all() if not providers: @@ -167,9 +165,7 @@ async def test_all_notification_providers(db: AsyncSession = Depends(get_db)): for provider in providers: config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config - success, message = await notification_service.send_test_notification( - provider.provider_type, config, db - ) + success, message = await notification_service.send_test_notification(provider.provider_type, config, db) # Update provider status if success: @@ -180,13 +176,15 @@ async def test_all_notification_providers(db: AsyncSession = Depends(get_db)): provider.last_error_at = datetime.utcnow() failed_count += 1 - results.append({ - "provider_id": provider.id, - "provider_name": provider.name, - "provider_type": provider.provider_type, - "success": success, - "message": message, - }) + results.append( + { + "provider_id": provider.id, + "provider_name": provider.name, + "provider_type": provider.provider_type, + "success": success, + "message": message, + } + ) await db.commit() @@ -202,6 +200,7 @@ async def test_all_notification_providers(db: AsyncSession = Depends(get_db)): # Notification Log Routes (must come BEFORE /{provider_id} routes) # ============================================================================ + @router.get("/logs", response_model=list[NotificationLogResponse]) async def get_notification_logs( limit: int = Query(default=100, ge=1, le=500), @@ -243,20 +242,22 @@ async def get_notification_logs( providers_cache[log.provider_id] = provider_result.scalar_one_or_none() provider = providers_cache[log.provider_id] - response.append(NotificationLogResponse( - id=log.id, - provider_id=log.provider_id, - provider_name=provider.name if provider else None, - provider_type=provider.provider_type if provider else None, - event_type=log.event_type, - title=log.title, - message=log.message, - success=log.success, - error_message=log.error_message, - printer_id=log.printer_id, - printer_name=log.printer_name, - created_at=log.created_at, - )) + response.append( + NotificationLogResponse( + id=log.id, + provider_id=log.provider_id, + provider_name=provider.name if provider else None, + provider_type=provider.provider_type if provider else None, + event_type=log.event_type, + title=log.title, + message=log.message, + success=log.success, + error_message=log.error_message, + printer_id=log.printer_id, + printer_name=log.printer_name, + created_at=log.created_at, + ) + ) return response @@ -270,15 +271,12 @@ async def get_notification_log_stats( cutoff = datetime.utcnow() - timedelta(days=days) # Total counts - total_result = await db.execute( - select(func.count(NotificationLog.id)).where(NotificationLog.created_at >= cutoff) - ) + total_result = await db.execute(select(func.count(NotificationLog.id)).where(NotificationLog.created_at >= cutoff)) total = total_result.scalar() or 0 success_result = await db.execute( select(func.count(NotificationLog.id)).where( - NotificationLog.created_at >= cutoff, - NotificationLog.success == True + NotificationLog.created_at >= cutoff, NotificationLog.success.is_(True) ) ) success_count = success_result.scalar() or 0 @@ -317,9 +315,7 @@ async def clear_notification_logs( """Clear old notification logs.""" cutoff = datetime.utcnow() - timedelta(days=older_than_days) - result = await db.execute( - delete(NotificationLog).where(NotificationLog.created_at < cutoff) - ) + result = await db.execute(delete(NotificationLog).where(NotificationLog.created_at < cutoff)) await db.commit() deleted_count = result.rowcount @@ -332,15 +328,14 @@ async def clear_notification_logs( # Provider Instance Routes (parameterized - must come LAST) # ============================================================================ + @router.get("/{provider_id}", response_model=NotificationProviderResponse) async def get_notification_provider( provider_id: int, db: AsyncSession = Depends(get_db), ): """Get a specific notification provider.""" - result = await db.execute( - select(NotificationProvider).where(NotificationProvider.id == provider_id) - ) + result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id)) provider = result.scalar_one_or_none() if not provider: @@ -356,9 +351,7 @@ async def update_notification_provider( db: AsyncSession = Depends(get_db), ): """Update a notification provider.""" - result = await db.execute( - select(NotificationProvider).where(NotificationProvider.id == provider_id) - ) + result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id)) provider = result.scalar_one_or_none() if not provider: @@ -389,9 +382,7 @@ async def delete_notification_provider( db: AsyncSession = Depends(get_db), ): """Delete a notification provider.""" - result = await db.execute( - select(NotificationProvider).where(NotificationProvider.id == provider_id) - ) + result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id)) provider = result.scalar_one_or_none() if not provider: @@ -412,18 +403,14 @@ async def test_notification_provider( db: AsyncSession = Depends(get_db), ): """Send a test notification using an existing provider.""" - result = await db.execute( - select(NotificationProvider).where(NotificationProvider.id == provider_id) - ) + result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id)) provider = result.scalar_one_or_none() if not provider: raise HTTPException(status_code=404, detail="Notification provider not found") config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config - success, message = await notification_service.send_test_notification( - provider.provider_type, config, db - ) + success, message = await notification_service.send_test_notification(provider.provider_type, config, db) # Update provider status if success: diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index 55cf8fdcd..c975a28b3 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -4,18 +4,18 @@ import logging from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func from sqlalchemy.orm import selectinload from backend.app.core.database import get_db +from backend.app.models.archive import PrintArchive from backend.app.models.print_queue import PrintQueueItem from backend.app.models.printer import Printer -from backend.app.models.archive import PrintArchive from backend.app.schemas.print_queue import ( PrintQueueItemCreate, - PrintQueueItemUpdate, PrintQueueItemResponse, + PrintQueueItemUpdate, PrintQueueReorder, ) @@ -124,9 +124,7 @@ async def update_queue_item( db: AsyncSession = Depends(get_db), ): """Update a queue item.""" - result = await db.execute( - select(PrintQueueItem).where(PrintQueueItem.id == item_id) - ) + result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id)) item = result.scalar_one_or_none() if not item: raise HTTPException(404, "Queue item not found") @@ -138,9 +136,7 @@ async def update_queue_item( # Validate new printer_id if being changed if "printer_id" in update_data: - result = await db.execute( - select(Printer).where(Printer.id == update_data["printer_id"]) - ) + result = await db.execute(select(Printer).where(Printer.id == update_data["printer_id"])) if not result.scalar_one_or_none(): raise HTTPException(400, "Printer not found") @@ -157,9 +153,7 @@ async def update_queue_item( @router.delete("/{item_id}") async def delete_queue_item(item_id: int, db: AsyncSession = Depends(get_db)): """Remove an item from the queue.""" - result = await db.execute( - select(PrintQueueItem).where(PrintQueueItem.id == item_id) - ) + result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id)) item = result.scalar_one_or_none() if not item: raise HTTPException(404, "Queue item not found") @@ -181,9 +175,7 @@ async def reorder_queue( ): """Bulk update positions for queue items.""" for reorder_item in data.items: - result = await db.execute( - select(PrintQueueItem).where(PrintQueueItem.id == reorder_item.id) - ) + result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == reorder_item.id)) item = result.scalar_one_or_none() if item and item.status == "pending": item.position = reorder_item.position @@ -196,9 +188,7 @@ async def reorder_queue( @router.post("/{item_id}/cancel") async def cancel_queue_item(item_id: int, db: AsyncSession = Depends(get_db)): """Cancel a pending queue item.""" - result = await db.execute( - select(PrintQueueItem).where(PrintQueueItem.id == item_id) - ) + result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id)) item = result.scalar_one_or_none() if not item: raise HTTPException(404, "Queue item not found") @@ -220,14 +210,13 @@ async def stop_queue_item( db: AsyncSession = Depends(get_db), ): """Stop an actively printing queue item.""" - from backend.app.services.printer_manager import printer_manager - from backend.app.services.tasmota import tasmota_service - from backend.app.models.smart_plug import SmartPlug import asyncio - result = await db.execute( - select(PrintQueueItem).where(PrintQueueItem.id == item_id) - ) + from backend.app.models.smart_plug import SmartPlug + from backend.app.services.printer_manager import printer_manager + from backend.app.services.tasmota import tasmota_service + + result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id)) item = result.scalar_one_or_none() if not item: raise HTTPException(404, "Queue item not found") @@ -257,9 +246,7 @@ async def stop_queue_item( # Get smart plug info if auto-off is enabled plug_ip = None if auto_off_after: - result = await db.execute( - select(SmartPlug).where(SmartPlug.printer_id == printer_id) - ) + result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) plug = result.scalar_one_or_none() if plug and plug.enabled: plug_ip = plug.ip_address @@ -268,15 +255,15 @@ async def stop_queue_item( # Schedule background task for cooldown + power off if plug_ip: + async def cooldown_and_poweroff(): logger.info(f"Auto-off: Waiting for printer {printer_id} to cool down before power off...") await printer_manager.wait_for_cooldown(printer_id, target_temp=50.0, timeout=600) # Re-fetch plug since we're in a new async context from backend.app.core.database import async_session + async with async_session() as new_db: - result = await new_db.execute( - select(SmartPlug).where(SmartPlug.printer_id == printer_id) - ) + result = await new_db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) plug = result.scalar_one_or_none() if plug and plug.enabled: logger.info(f"Auto-off: Powering off printer {printer_id}") diff --git a/backend/app/api/routes/system.py b/backend/app/api/routes/system.py index 93ded3f75..f6842467b 100644 --- a/backend/app/api/routes/system.py +++ b/backend/app/api/routes/system.py @@ -1,20 +1,19 @@ """System information API routes.""" -import os import platform -import psutil from datetime import datetime from pathlib import Path +import psutil from fastapi import APIRouter, Depends -from sqlalchemy import select, func +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from backend.app.core.config import settings, APP_VERSION +from backend.app.core.config import APP_VERSION, settings from backend.app.core.database import get_db from backend.app.models.archive import PrintArchive -from backend.app.models.printer import Printer 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.services.printer_manager import printer_manager @@ -26,7 +25,7 @@ def get_directory_size(path: Path) -> int: """Calculate total size of a directory in bytes.""" total = 0 try: - for entry in path.rglob('*'): + for entry in path.rglob("*"): if entry.is_file(): total += entry.stat().st_size except (PermissionError, OSError): @@ -36,7 +35,7 @@ def get_directory_size(path: Path) -> int: def format_bytes(bytes_value: int) -> str: """Format bytes to human-readable string.""" - for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + for unit in ["B", "KB", "MB", "GB", "TB"]: if bytes_value < 1024: return f"{bytes_value:.1f} {unit}" bytes_value /= 1024 @@ -72,29 +71,25 @@ async def get_system_info(db: AsyncSession = Depends(get_db)): smart_plug_count = await db.scalar(select(func.count(SmartPlug.id))) # Archive stats by status - completed_count = await db.scalar( - select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed") - ) - failed_count = await db.scalar( - select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed") - ) - printing_count = await db.scalar( - select(func.count(PrintArchive.id)).where(PrintArchive.status == "printing") - ) + completed_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed")) + failed_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed")) + printing_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "printing")) # Total print time - total_print_time = await db.scalar( - select(func.sum(PrintArchive.print_time_seconds)).where( - PrintArchive.print_time_seconds.isnot(None) + total_print_time = ( + await db.scalar( + select(func.sum(PrintArchive.print_time_seconds)).where(PrintArchive.print_time_seconds.isnot(None)) ) - ) or 0 + or 0 + ) # Total filament used - total_filament = await db.scalar( - select(func.sum(PrintArchive.filament_used_grams)).where( - PrintArchive.filament_used_grams.isnot(None) + total_filament = ( + await db.scalar( + select(func.sum(PrintArchive.filament_used_grams)).where(PrintArchive.filament_used_grams.isnot(None)) ) - ) or 0 + or 0 + ) # Connected printers connected_printers = [] @@ -102,18 +97,18 @@ async def get_system_info(db: AsyncSession = Depends(get_db)): state = client.state if state and state.connected: # Get printer name and model from database - result = await db.execute( - select(Printer.name, Printer.model).where(Printer.id == printer_id) - ) + result = await db.execute(select(Printer.name, Printer.model).where(Printer.id == printer_id)) row = result.first() name = row[0] if row else f"Printer {printer_id}" model = row[1] if row else "unknown" - connected_printers.append({ - "id": printer_id, - "name": name, - "state": state.state, - "model": model, - }) + connected_printers.append( + { + "id": printer_id, + "name": name, + "state": state.state, + "model": model, + } + ) # Storage info archive_dir = settings.archive_dir diff --git a/backend/app/api/routes/webhook.py b/backend/app/api/routes/webhook.py index 06c104935..282e1a6cb 100644 --- a/backend/app/api/routes/webhook.py +++ b/backend/app/api/routes/webhook.py @@ -1,11 +1,12 @@ import logging -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from pydantic import BaseModel +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.core.auth import check_permission, check_printer_access, get_api_key from backend.app.core.database import get_db -from backend.app.core.auth import get_api_key, check_permission, check_printer_access from backend.app.models.api_key import APIKey from backend.app.models.archive import PrintArchive from backend.app.models.print_queue import PrintQueueItem @@ -56,6 +57,7 @@ class QueueStatusResponse(BaseModel): # Webhook endpoints + @router.post("/queue/add", response_model=QueueAddResponse) async def webhook_add_to_queue( data: QueueAddRequest, @@ -66,21 +68,17 @@ async def webhook_add_to_queue( Requires 'can_queue' permission. """ - check_permission(api_key, 'queue') + check_permission(api_key, "queue") check_printer_access(api_key, data.printer_id) # Verify archive exists - result = await db.execute( - select(PrintArchive).where(PrintArchive.id == data.archive_id) - ) + result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id)) archive = result.scalar_one_or_none() if not archive: raise HTTPException(status_code=404, detail="Archive not found") # Verify printer exists - result = await db.execute( - select(Printer).where(Printer.id == data.printer_id) - ) + result = await db.execute(select(Printer).where(Printer.id == data.printer_id)) printer = result.scalar_one_or_none() if not printer: raise HTTPException(status_code=404, detail="Printer not found") @@ -102,8 +100,9 @@ async def webhook_add_to_queue( scheduled_time = None if data.scheduled_time: from datetime import datetime + try: - scheduled_time = datetime.fromisoformat(data.scheduled_time.replace('Z', '+00:00')) + scheduled_time = datetime.fromisoformat(data.scheduled_time.replace("Z", "+00:00")) except ValueError: raise HTTPException(status_code=400, detail="Invalid scheduled_time format") @@ -141,7 +140,7 @@ async def webhook_start_print( Requires 'can_control_printer' permission. """ - check_permission(api_key, 'control_printer') + check_permission(api_key, "control_printer") check_printer_access(api_key, printer_id) # Get printer @@ -170,10 +169,7 @@ async def webhook_start_print( raise HTTPException(status_code=503, detail="Printer not connected") if status.get("state") not in ["IDLE", "FINISH", "FAILED"]: - raise HTTPException( - status_code=409, - detail=f"Printer is busy (state: {status.get('state')})" - ) + raise HTTPException(status_code=409, detail=f"Printer is busy (state: {status.get('state')})") # Start the print try: @@ -194,7 +190,7 @@ async def webhook_stop_print( Requires 'can_control_printer' permission. """ - check_permission(api_key, 'control_printer') + check_permission(api_key, "control_printer") check_printer_access(api_key, printer_id) status = printer_manager.get_status(printer_id) @@ -222,7 +218,7 @@ async def webhook_cancel_print( Requires 'can_control_printer' permission. """ - check_permission(api_key, 'control_printer') + check_permission(api_key, "control_printer") check_printer_access(api_key, printer_id) status = printer_manager.get_status(printer_id) @@ -251,7 +247,7 @@ async def webhook_get_printer_status( Requires 'can_read_status' permission. """ - check_permission(api_key, 'read_status') + check_permission(api_key, "read_status") check_printer_access(api_key, printer_id) # Get printer @@ -283,7 +279,7 @@ async def webhook_get_queue_status( Requires 'can_read_status' permission. """ - check_permission(api_key, 'read_status') + check_permission(api_key, "read_status") # Get printers if printer_id: @@ -313,20 +309,22 @@ async def webhook_get_queue_status( pending_count = sum(1 for i in items if i.status == "pending") printing_count = sum(1 for i in items if i.status == "printing") - response.append(QueueStatusResponse( - printer_id=printer.id, - printer_name=printer.name, - pending=pending_count, - printing=printing_count, - items=[ - { - "id": item.id, - "archive_id": item.archive_id, - "position": item.position, - "status": item.status, - } - for item in items - ], - )) + response.append( + QueueStatusResponse( + printer_id=printer.id, + printer_name=printer.name, + pending=pending_count, + printing=printing_count, + items=[ + { + "id": item.id, + "archive_id": item.archive_id, + "position": item.position, + "status": item.status, + } + for item in items + ], + ) + ) return response diff --git a/backend/app/api/routes/websocket.py b/backend/app/api/routes/websocket.py index 868f436f7..a0f1beb52 100644 --- a/backend/app/api/routes/websocket.py +++ b/backend/app/api/routes/websocket.py @@ -1,4 +1,5 @@ import logging + from fastapi import APIRouter, WebSocket, WebSocketDisconnect from backend.app.core.websocket import ws_manager @@ -19,11 +20,13 @@ async def websocket_endpoint(websocket: WebSocket): # Send initial status of all printers statuses = printer_manager.get_all_statuses() for printer_id, state in statuses.items(): - await websocket.send_json({ - "type": "printer_status", - "printer_id": printer_id, - "data": printer_state_to_dict(state), - }) + await websocket.send_json( + { + "type": "printer_status", + "printer_id": printer_id, + "data": printer_state_to_dict(state), + } + ) logger.info(f"Sent initial status for {len(statuses)} printers") # Keep connection alive and handle incoming messages @@ -40,11 +43,13 @@ async def websocket_endpoint(websocket: WebSocket): if printer_id: state = printer_manager.get_status(printer_id) if state: - await websocket.send_json({ - "type": "printer_status", - "printer_id": printer_id, - "data": printer_state_to_dict(state), - }) + await websocket.send_json( + { + "type": "printer_status", + "printer_id": printer_id, + "data": printer_state_to_dict(state), + } + ) except WebSocketDisconnect: logger.info("WebSocket client disconnected normally") diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 9ca4b721d..5e759ed1b 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -1,11 +1,10 @@ import hashlib import secrets from datetime import datetime -from typing import Optional -from fastapi import Header, HTTPException, Depends -from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import Depends, Header, HTTPException from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from backend.app.core.database import get_db from backend.app.models.api_key import APIKey @@ -39,9 +38,7 @@ async def get_api_key( """ key_hash = hash_api_key(x_api_key) - result = await db.execute( - select(APIKey).where(APIKey.key_hash == key_hash) - ) + result = await db.execute(select(APIKey).where(APIKey.key_hash == key_hash)) api_key = result.scalar_one_or_none() if not api_key: @@ -60,9 +57,9 @@ async def get_api_key( async def get_optional_api_key( - x_api_key: Optional[str] = Header(None, alias="X-API-Key"), + x_api_key: str | None = Header(None, alias="X-API-Key"), db: AsyncSession = Depends(get_db), -) -> Optional[APIKey]: +) -> APIKey | None: """Get API key if provided, return None otherwise.""" if not x_api_key: return None @@ -83,19 +80,16 @@ def check_permission(api_key: APIKey, permission: str) -> None: Raises HTTPException if permission is denied. """ permission_map = { - 'queue': api_key.can_queue, - 'control_printer': api_key.can_control_printer, - 'read_status': api_key.can_read_status, + "queue": api_key.can_queue, + "control_printer": api_key.can_control_printer, + "read_status": api_key.can_read_status, } if permission not in permission_map: raise HTTPException(status_code=500, detail=f"Unknown permission: {permission}") if not permission_map[permission]: - raise HTTPException( - status_code=403, - detail=f"API key does not have '{permission}' permission" - ) + raise HTTPException(status_code=403, detail=f"API key does not have '{permission}' permission") def check_printer_access(api_key: APIKey, printer_id: int) -> None: @@ -108,7 +102,4 @@ def check_printer_access(api_key: APIKey, printer_id: int) -> None: Raises HTTPException if access is denied. """ if api_key.printer_ids is not None and printer_id not in api_key.printer_ids: - raise HTTPException( - status_code=403, - detail=f"API key does not have access to printer {printer_id}" - ) + raise HTTPException(status_code=403, detail=f"API key does not have access to printer {printer_id}") diff --git a/backend/app/core/websocket.py b/backend/app/core/websocket.py index 30d21f996..bb6b5e387 100644 --- a/backend/app/core/websocket.py +++ b/backend/app/core/websocket.py @@ -1,6 +1,7 @@ import asyncio import json from typing import Any + from fastapi import WebSocket @@ -44,41 +45,51 @@ class ConnectionManager: async def send_printer_status(self, printer_id: int, status: dict): """Send printer status update to all clients.""" - await self.broadcast({ - "type": "printer_status", - "printer_id": printer_id, - "data": status, - }) + await self.broadcast( + { + "type": "printer_status", + "printer_id": printer_id, + "data": status, + } + ) async def send_print_start(self, printer_id: int, data: dict): """Notify clients that a print has started.""" - await self.broadcast({ - "type": "print_start", - "printer_id": printer_id, - "data": data, - }) + await self.broadcast( + { + "type": "print_start", + "printer_id": printer_id, + "data": data, + } + ) async def send_print_complete(self, printer_id: int, data: dict): """Notify clients that a print has completed.""" - await self.broadcast({ - "type": "print_complete", - "printer_id": printer_id, - "data": data, - }) + await self.broadcast( + { + "type": "print_complete", + "printer_id": printer_id, + "data": data, + } + ) async def send_archive_created(self, archive: dict): """Notify clients that a new archive was created.""" - await self.broadcast({ - "type": "archive_created", - "data": archive, - }) + await self.broadcast( + { + "type": "archive_created", + "data": archive, + } + ) async def send_archive_updated(self, archive: dict): """Notify clients that an archive was updated.""" - await self.broadcast({ - "type": "archive_updated", - "data": archive, - }) + await self.broadcast( + { + "type": "archive_updated", + "data": archive, + } + ) # Global connection manager diff --git a/backend/app/i18n/__init__.py b/backend/app/i18n/__init__.py index 454536897..c24f9215e 100644 --- a/backend/app/i18n/__init__.py +++ b/backend/app/i18n/__init__.py @@ -17,21 +17,17 @@ EN = { "filament": "Filament", "reason": "Reason", "unknown": "Unknown", - # Printer events "printer_offline": "Printer Offline", "printer_disconnected": "{printer} has disconnected", "printer_error": "Printer Error: {error_type}", - # Filament "filament_low": "Filament Low", "slot_at_percent": "{printer}: Slot {slot} at {percent}%", - # Maintenance "maintenance_due": "Maintenance Due", "overdue": "OVERDUE", "soon": "Soon", - # Test notification "test_title": "Bambuddy Test", "test_message": "This is a test notification from Bambuddy. If you see this, notifications are working correctly!", @@ -53,21 +49,17 @@ DE = { "filament": "Filament", "reason": "Grund", "unknown": "Unbekannt", - # Printer events "printer_offline": "Drucker offline", "printer_disconnected": "{printer} wurde getrennt", "printer_error": "Druckerfehler: {error_type}", - # Filament "filament_low": "Wenig Filament", "slot_at_percent": "{printer}: Slot {slot} bei {percent}%", - # Maintenance "maintenance_due": "Wartung fällig", "overdue": "ÜBERFÄLLIG", "soon": "Bald", - # Test notification "test_title": "Bambuddy Test", "test_message": "Dies ist eine Testbenachrichtigung von Bambuddy. Wenn Sie dies sehen, funktionieren die Benachrichtigungen!", diff --git a/backend/app/models/ams_history.py b/backend/app/models/ams_history.py index dbf2c71f0..6f624833e 100644 --- a/backend/app/models/ams_history.py +++ b/backend/app/models/ams_history.py @@ -1,5 +1,6 @@ from datetime import datetime -from sqlalchemy import Integer, Float, DateTime, ForeignKey, String, func, Index + +from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, func from sqlalchemy.orm import Mapped, mapped_column, relationship from backend.app.core.database import Base @@ -7,6 +8,7 @@ from backend.app.core.database import Base class AMSSensorHistory(Base): """Historical sensor data from AMS units (humidity and temperature).""" + __tablename__ = "ams_sensor_history" id: Mapped[int] = mapped_column(primary_key=True) @@ -15,14 +17,10 @@ class AMSSensorHistory(Base): humidity: Mapped[float | None] = mapped_column(Float) # Humidity percentage humidity_raw: Mapped[float | None] = mapped_column(Float) # Raw humidity value temperature: Mapped[float | None] = mapped_column(Float) # Temperature in Celsius - recorded_at: Mapped[datetime] = mapped_column( - DateTime, server_default=func.now(), index=True - ) + recorded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True) # Indexes for efficient querying - __table_args__ = ( - Index('ix_ams_history_printer_ams_time', 'printer_id', 'ams_id', 'recorded_at'), - ) + __table_args__ = (Index("ix_ams_history_printer_ams_time", "printer_id", "ams_id", "recorded_at"),) # Relationship printer: Mapped["Printer"] = relationship(back_populates="ams_history") diff --git a/backend/app/models/api_key.py b/backend/app/models/api_key.py index 790e4f987..0e16b1dd6 100644 --- a/backend/app/models/api_key.py +++ b/backend/app/models/api_key.py @@ -1,5 +1,6 @@ from datetime import datetime -from sqlalchemy import String, Boolean, DateTime, Text, JSON, func + +from sqlalchemy import JSON, Boolean, DateTime, String, func from sqlalchemy.orm import Mapped, mapped_column from backend.app.core.database import Base diff --git a/backend/app/models/external_link.py b/backend/app/models/external_link.py index faab0852a..f0d1b3bea 100644 --- a/backend/app/models/external_link.py +++ b/backend/app/models/external_link.py @@ -1,6 +1,6 @@ from datetime import datetime -from typing import Optional -from sqlalchemy import String, Integer, DateTime, func + +from sqlalchemy import DateTime, Integer, String, func from sqlalchemy.orm import Mapped, mapped_column from backend.app.core.database import Base @@ -15,11 +15,7 @@ class ExternalLink(Base): name: Mapped[str] = mapped_column(String(50)) url: Mapped[str] = mapped_column(String(500)) icon: Mapped[str] = mapped_column(String(50), default="link") - custom_icon: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) # Filename of uploaded icon + custom_icon: Mapped[str | None] = mapped_column(String(255), nullable=True) # Filename of uploaded icon sort_order: Mapped[int] = mapped_column(Integer, default=0) - created_at: Mapped[datetime] = mapped_column( - DateTime, server_default=func.now() - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime, server_default=func.now(), onupdate=func.now() - ) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/filament.py b/backend/app/models/filament.py index a39f56fa5..453bb74cd 100644 --- a/backend/app/models/filament.py +++ b/backend/app/models/filament.py @@ -1,5 +1,6 @@ from datetime import datetime -from sqlalchemy import String, Float, DateTime, func + +from sqlalchemy import DateTime, Float, String, func from sqlalchemy.orm import Mapped, mapped_column from backend.app.core.database import Base @@ -29,9 +30,5 @@ class Filament(Base): bed_temp_min: Mapped[int | None] = mapped_column() bed_temp_max: Mapped[int | None] = mapped_column() - created_at: Mapped[datetime] = mapped_column( - DateTime, server_default=func.now() - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime, server_default=func.now(), onupdate=func.now() - ) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/kprofile_note.py b/backend/app/models/kprofile_note.py index a4c227f56..3aa11b5a6 100644 --- a/backend/app/models/kprofile_note.py +++ b/backend/app/models/kprofile_note.py @@ -1,7 +1,8 @@ """Model for K-profile notes stored locally (not on printer).""" from datetime import datetime -from sqlalchemy import String, Text, DateTime, ForeignKey, func, Index + +from sqlalchemy import DateTime, ForeignKey, Index, String, Text, func from sqlalchemy.orm import Mapped, mapped_column, relationship from backend.app.core.database import Base @@ -17,20 +18,14 @@ class KProfileNote(Base): # setting_id is the unique identifier for a K-profile on the printer setting_id: Mapped[str] = mapped_column(String(100)) note: Mapped[str] = mapped_column(Text, default="") - created_at: Mapped[datetime] = mapped_column( - DateTime, server_default=func.now() - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime, server_default=func.now(), onupdate=func.now() - ) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) # Relationship to printer printer: Mapped["Printer"] = relationship(back_populates="kprofile_notes") # Composite index for efficient lookups - __table_args__ = ( - Index("ix_kprofile_notes_printer_setting", "printer_id", "setting_id", unique=True), - ) + __table_args__ = (Index("ix_kprofile_notes_printer_setting", "printer_id", "setting_id", unique=True),) from backend.app.models.printer import Printer # noqa: E402 diff --git a/backend/app/models/maintenance.py b/backend/app/models/maintenance.py index ce9d6d239..c62bcdd4f 100644 --- a/backend/app/models/maintenance.py +++ b/backend/app/models/maintenance.py @@ -1,7 +1,8 @@ """Maintenance tracking models.""" from datetime import datetime -from sqlalchemy import String, Boolean, DateTime, Integer, Float, ForeignKey, Text, func + +from sqlalchemy import Boolean, DateTime, Float, ForeignKey, String, Text, func from sqlalchemy.orm import Mapped, mapped_column, relationship from backend.app.core.database import Base @@ -9,6 +10,7 @@ from backend.app.core.database import Base class MaintenanceType(Base): """Defines a type of maintenance task with default interval.""" + __tablename__ = "maintenance_types" id: Mapped[int] = mapped_column(primary_key=True) @@ -19,9 +21,7 @@ class MaintenanceType(Base): interval_type: Mapped[str] = mapped_column(String(20), default="hours") icon: Mapped[str | None] = mapped_column(String(50)) # Icon name for UI is_system: Mapped[bool] = mapped_column(Boolean, default=False) # Pre-defined vs custom - created_at: Mapped[datetime] = mapped_column( - DateTime, server_default=func.now() - ) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) # Relationships printer_maintenance: Mapped[list["PrinterMaintenance"]] = relationship( @@ -31,6 +31,7 @@ class MaintenanceType(Base): class PrinterMaintenance(Base): """Tracks maintenance status for a specific printer.""" + __tablename__ = "printer_maintenance" id: Mapped[int] = mapped_column(primary_key=True) @@ -47,12 +48,8 @@ class PrinterMaintenance(Base): last_performed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) last_performed_hours: Mapped[float] = mapped_column(Float, default=0.0) # Hours at last reset - created_at: Mapped[datetime] = mapped_column( - DateTime, server_default=func.now() - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime, server_default=func.now(), onupdate=func.now() - ) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) # Relationships printer: Mapped["Printer"] = relationship(back_populates="maintenance_items") @@ -64,12 +61,11 @@ class PrinterMaintenance(Base): class MaintenanceHistory(Base): """Log of maintenance actions performed.""" + __tablename__ = "maintenance_history" id: Mapped[int] = mapped_column(primary_key=True) - printer_maintenance_id: Mapped[int] = mapped_column( - ForeignKey("printer_maintenance.id", ondelete="CASCADE") - ) + printer_maintenance_id: Mapped[int] = mapped_column(ForeignKey("printer_maintenance.id", ondelete="CASCADE")) performed_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) hours_at_maintenance: Mapped[float] = mapped_column(Float, default=0.0) notes: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py index 31b818907..4498153b6 100644 --- a/backend/app/models/notification.py +++ b/backend/app/models/notification.py @@ -2,7 +2,7 @@ from datetime import datetime -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, Time +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text from sqlalchemy.orm import relationship from backend.app.core.database import Base diff --git a/backend/app/models/notification_template.py b/backend/app/models/notification_template.py index c3ff0fc9a..c88b315b6 100644 --- a/backend/app/models/notification_template.py +++ b/backend/app/models/notification_template.py @@ -20,9 +20,7 @@ class NotificationTemplate(Base): body_template: Mapped[str] = mapped_column(Text, nullable=False) is_default: Mapped[bool] = mapped_column(Boolean, default=True) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column( - DateTime, server_default=func.now(), onupdate=func.now() - ) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) # Default templates for seeding diff --git a/backend/app/models/print_queue.py b/backend/app/models/print_queue.py index 1089d013b..be4fe4a16 100644 --- a/backend/app/models/print_queue.py +++ b/backend/app/models/print_queue.py @@ -1,5 +1,6 @@ from datetime import datetime -from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, func + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func from sqlalchemy.orm import Mapped, mapped_column, relationship from backend.app.core.database import Base @@ -13,15 +14,9 @@ class PrintQueueItem(Base): id: Mapped[int] = mapped_column(primary_key=True) # Links - printer_id: Mapped[int] = mapped_column( - ForeignKey("printers.id", ondelete="CASCADE") - ) - archive_id: Mapped[int] = mapped_column( - ForeignKey("print_archives.id", ondelete="CASCADE") - ) - project_id: Mapped[int | None] = mapped_column( - ForeignKey("projects.id", ondelete="SET NULL"), nullable=True - ) + printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE")) + archive_id: Mapped[int] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE")) + project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True) # Scheduling position: Mapped[int] = mapped_column(Integer, default=0) # Queue order @@ -50,6 +45,6 @@ class PrintQueueItem(Base): project: Mapped["Project | None"] = relationship(back_populates="queue_items") -from backend.app.models.printer import Printer # noqa: E402 from backend.app.models.archive import PrintArchive # noqa: E402 +from backend.app.models.printer import Printer # noqa: E402 from backend.app.models.project import Project # noqa: E402 diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index 9021862b4..5a6ddd327 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -1,5 +1,6 @@ from datetime import datetime -from sqlalchemy import String, Text, DateTime, func + +from sqlalchemy import DateTime, String, Text, func from sqlalchemy.orm import Mapped, mapped_column from backend.app.core.database import Base @@ -13,9 +14,5 @@ class Settings(Base): id: Mapped[int] = mapped_column(primary_key=True) key: Mapped[str] = mapped_column(String(100), unique=True, index=True) value: Mapped[str] = mapped_column(Text) - created_at: Mapped[datetime] = mapped_column( - DateTime, server_default=func.now() - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime, server_default=func.now(), onupdate=func.now() - ) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/slot_preset.py b/backend/app/models/slot_preset.py index 7218aa90b..ab490eefa 100644 --- a/backend/app/models/slot_preset.py +++ b/backend/app/models/slot_preset.py @@ -5,7 +5,8 @@ similar to how Bambu Studio remembers preset selections. """ from datetime import datetime -from sqlalchemy import String, Integer, DateTime, ForeignKey, func, UniqueConstraint + +from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column, relationship from backend.app.core.database import Base @@ -15,9 +16,7 @@ class SlotPresetMapping(Base): """Maps an AMS slot to a cloud filament preset.""" __tablename__ = "slot_preset_mappings" - __table_args__ = ( - UniqueConstraint("printer_id", "ams_id", "tray_id", name="uq_slot_preset"), - ) + __table_args__ = (UniqueConstraint("printer_id", "ams_id", "tray_id", name="uq_slot_preset"),) id: Mapped[int] = mapped_column(primary_key=True) printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE")) @@ -26,9 +25,7 @@ class SlotPresetMapping(Base): preset_id: Mapped[str] = mapped_column(String(100)) # Cloud preset setting_id preset_name: Mapped[str] = mapped_column(String(200)) # Preset name for display created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column( - DateTime, server_default=func.now(), onupdate=func.now() - ) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) # Relationship printer: Mapped["Printer"] = relationship() diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 73b0c7445..aca46e1ae 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,25 +1,25 @@ +from backend.app.schemas.archive import ( + ArchiveBase, + ArchiveResponse, + ArchiveUpdate, + ProjectPageImage, + ProjectPageResponse, +) from backend.app.schemas.printer import ( PrinterBase, PrinterCreate, - PrinterUpdate, PrinterResponse, PrinterStatus, -) -from backend.app.schemas.archive import ( - ArchiveBase, - ArchiveUpdate, - ArchiveResponse, - ProjectPageResponse, - ProjectPageImage, + PrinterUpdate, ) from backend.app.schemas.smart_plug import ( SmartPlugBase, - SmartPlugCreate, - SmartPlugUpdate, - SmartPlugResponse, SmartPlugControl, + SmartPlugCreate, + SmartPlugResponse, SmartPlugStatus, SmartPlugTestConnection, + SmartPlugUpdate, ) __all__ = [ diff --git a/backend/app/schemas/api_key.py b/backend/app/schemas/api_key.py index 92ad31cb9..41731b3a8 100644 --- a/backend/app/schemas/api_key.py +++ b/backend/app/schemas/api_key.py @@ -1,9 +1,11 @@ from datetime import datetime + from pydantic import BaseModel class APIKeyCreate(BaseModel): """Schema for creating a new API key.""" + name: str can_queue: bool = True can_control_printer: bool = False @@ -14,6 +16,7 @@ class APIKeyCreate(BaseModel): class APIKeyUpdate(BaseModel): """Schema for updating an API key.""" + name: str | None = None can_queue: bool | None = None can_control_printer: bool | None = None @@ -25,6 +28,7 @@ class APIKeyUpdate(BaseModel): class APIKeyResponse(BaseModel): """Schema for API key response (without full key).""" + id: int name: str key_prefix: str # First 8 chars for identification @@ -43,4 +47,5 @@ class APIKeyResponse(BaseModel): class APIKeyCreateResponse(APIKeyResponse): """Response when creating a key - includes full key (shown only once).""" + key: str # Full API key, only shown on creation diff --git a/backend/app/schemas/cloud.py b/backend/app/schemas/cloud.py index 451eea846..66a663758 100644 --- a/backend/app/schemas/cloud.py +++ b/backend/app/schemas/cloud.py @@ -1,9 +1,9 @@ from pydantic import BaseModel, Field -from typing import Optional class CloudLoginRequest(BaseModel): """Request to initiate cloud login.""" + email: str = Field(..., description="Bambu Lab account email") password: str = Field(..., description="Account password") region: str = Field(default="global", description="Region: 'global' or 'china'") @@ -11,12 +11,14 @@ class CloudLoginRequest(BaseModel): class CloudVerifyRequest(BaseModel): """Request to verify login with 2FA code.""" + email: str = Field(..., description="Bambu Lab account email") code: str = Field(..., description="6-digit verification code from email") class CloudLoginResponse(BaseModel): """Response from login attempt.""" + success: bool needs_verification: bool = False message: str @@ -24,27 +26,31 @@ class CloudLoginResponse(BaseModel): class CloudAuthStatus(BaseModel): """Current authentication status.""" + is_authenticated: bool - email: Optional[str] = None + email: str | None = None class CloudTokenRequest(BaseModel): """Request to set access token directly.""" + access_token: str = Field(..., description="Bambu Lab access token") class SlicerSetting(BaseModel): """A slicer setting/preset.""" + setting_id: str name: str type: str # filament, printer, process - version: Optional[str] = None - user_id: Optional[str] = None - updated_time: Optional[str] = None + version: str | None = None + user_id: str | None = None + updated_time: str | None = None class SlicerSettingsResponse(BaseModel): """Response containing slicer settings.""" + filament: list[SlicerSetting] = [] printer: list[SlicerSetting] = [] process: list[SlicerSetting] = [] @@ -52,15 +58,17 @@ class SlicerSettingsResponse(BaseModel): class CloudDevice(BaseModel): """A bound printer device.""" + dev_id: str name: str - dev_model_name: Optional[str] = None - dev_product_name: Optional[str] = None + dev_model_name: str | None = None + dev_product_name: str | None = None online: bool = False class SlicerSettingCreate(BaseModel): """Request to create a new slicer preset.""" + type: str = Field(..., description="Preset type: 'filament', 'print', or 'printer'") name: str = Field(..., description="Display name for the preset") base_id: str = Field(..., description="Base preset ID to inherit from") @@ -70,28 +78,31 @@ class SlicerSettingCreate(BaseModel): class SlicerSettingUpdate(BaseModel): """Request to update an existing slicer preset.""" - name: Optional[str] = Field(None, description="New display name") - setting: Optional[dict] = Field(None, description="Setting key-value pairs to update") + + name: str | None = Field(None, description="New display name") + setting: dict | None = Field(None, description="Setting key-value pairs to update") class SlicerSettingDetail(BaseModel): """Detailed slicer setting/preset response.""" - message: Optional[str] = None - code: Optional[str] = None - error: Optional[str] = None + + message: str | None = None + code: str | None = None + error: str | None = None public: bool = False - version: Optional[str] = None + version: str | None = None type: str name: str - update_time: Optional[str] = None - nickname: Optional[str] = None - base_id: Optional[str] = None + update_time: str | None = None + nickname: str | None = None + base_id: str | None = None setting: dict = Field(default_factory=dict) - filament_id: Optional[str] = None - setting_id: Optional[str] = None # For response after create + filament_id: str | None = None + setting_id: str | None = None # For response after create class SlicerSettingDeleteResponse(BaseModel): """Response from deleting a preset.""" + success: bool message: str diff --git a/backend/app/schemas/external_link.py b/backend/app/schemas/external_link.py index 75d6858b6..2c5facba1 100644 --- a/backend/app/schemas/external_link.py +++ b/backend/app/schemas/external_link.py @@ -1,4 +1,5 @@ from datetime import datetime + from pydantic import BaseModel, Field, field_validator diff --git a/backend/app/schemas/filament.py b/backend/app/schemas/filament.py index ef9f5827b..000dbcbd6 100644 --- a/backend/app/schemas/filament.py +++ b/backend/app/schemas/filament.py @@ -1,4 +1,5 @@ from datetime import datetime + from pydantic import BaseModel, Field diff --git a/backend/app/schemas/maintenance.py b/backend/app/schemas/maintenance.py index 878a04404..bf1c48a38 100644 --- a/backend/app/schemas/maintenance.py +++ b/backend/app/schemas/maintenance.py @@ -1,6 +1,7 @@ """Maintenance tracking schemas.""" from datetime import datetime + from pydantic import BaseModel, Field @@ -91,6 +92,7 @@ class MaintenanceHistoryResponse(MaintenanceHistoryBase): # Combined status response for frontend class MaintenanceStatus(BaseModel): """Maintenance status for a printer with calculated values.""" + id: int printer_id: int printer_name: str @@ -116,6 +118,7 @@ class MaintenanceStatus(BaseModel): class PrinterMaintenanceOverview(BaseModel): """Overview of all maintenance items for a printer.""" + printer_id: int printer_name: str total_print_hours: float @@ -126,4 +129,5 @@ class PrinterMaintenanceOverview(BaseModel): class PerformMaintenanceRequest(BaseModel): """Request to mark maintenance as performed.""" + notes: str | None = None diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py index 76f1ce49e..08faf5f28 100644 --- a/backend/app/schemas/notification.py +++ b/backend/app/schemas/notification.py @@ -46,7 +46,9 @@ class NotificationProviderBase(BaseModel): # Event triggers - AMS-HT environmental alarms on_ams_ht_humidity_high: bool = Field(default=False, description="Notify when AMS-HT humidity exceeds threshold") - on_ams_ht_temperature_high: bool = Field(default=False, description="Notify when AMS-HT temperature exceeds threshold") + on_ams_ht_temperature_high: bool = Field( + default=False, description="Notify when AMS-HT temperature exceeds threshold" + ) # Quiet hours quiet_hours_enabled: bool = Field(default=False, description="Enable quiet hours") diff --git a/backend/app/schemas/print_queue.py b/backend/app/schemas/print_queue.py index 225bf1b66..1a05eae13 100644 --- a/backend/app/schemas/print_queue.py +++ b/backend/app/schemas/print_queue.py @@ -1,5 +1,6 @@ -from datetime import datetime, timezone -from typing import Literal, Annotated +from datetime import datetime +from typing import Annotated, Literal + from pydantic import BaseModel, PlainSerializer diff --git a/backend/app/services/archive_comparison.py b/backend/app/services/archive_comparison.py index 4a855d230..15c5e16b9 100644 --- a/backend/app/services/archive_comparison.py +++ b/backend/app/services/archive_comparison.py @@ -1,5 +1,5 @@ -from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from backend.app.models.archive import PrintArchive @@ -40,9 +40,7 @@ class ArchiveComparisonService: # Fetch archives result = await self.db.execute( - select(PrintArchive) - .options(selectinload(PrintArchive.project)) - .where(PrintArchive.id.in_(archive_ids)) + select(PrintArchive).options(selectinload(PrintArchive.project)).where(PrintArchive.id.in_(archive_ids)) ) archives = {a.id: a for a in result.scalars().all()} @@ -90,7 +88,7 @@ class ArchiveComparisonService: # Check if values differ non_none_values = [v for v in values if v is not None] - has_difference = len(set(str(v) for v in non_none_values)) > 1 if non_none_values else False + has_difference = len({str(v) for v in non_none_values}) > 1 if non_none_values else False field_data = { "field": field_name, @@ -130,7 +128,7 @@ class ArchiveComparisonService: # Find settings that differ between successful and failed insights = [] - for field_name, display_name, unit in self.COMPARABLE_FIELDS: + for field_name, display_name, _unit in self.COMPARABLE_FIELDS: if field_name == "status": continue @@ -147,26 +145,30 @@ class ArchiveComparisonService: if abs(success_avg - failed_avg) > 0.1 * max(abs(success_avg), abs(failed_avg), 0.01): direction = "higher" if success_avg > failed_avg else "lower" - insights.append({ - "field": field_name, - "label": display_name, - "success_avg": round(success_avg, 2), - "failed_avg": round(failed_avg, 2), - "insight": f"Successful prints had {direction} {display_name}", - }) + insights.append( + { + "field": field_name, + "label": display_name, + "success_avg": round(success_avg, 2), + "failed_avg": round(failed_avg, 2), + "insight": f"Successful prints had {direction} {display_name}", + } + ) else: # For categorical fields, check if success uses different values - success_set = set(str(v) for v in success_values) - failed_set = set(str(v) for v in failed_values) + success_set = {str(v) for v in success_values} + failed_set = {str(v) for v in failed_values} if success_set != failed_set: - insights.append({ - "field": field_name, - "label": display_name, - "success_values": list(success_set), - "failed_values": list(failed_set), - "insight": f"Different {display_name} used in successful vs failed prints", - }) + insights.append( + { + "field": field_name, + "label": display_name, + "success_values": list(success_set), + "failed_values": list(failed_set), + "insight": f"Different {display_name} used in successful vs failed prints", + } + ) return { "has_both_outcomes": True, @@ -190,9 +192,7 @@ class ArchiveComparisonService: List of similar archives with match reasons """ # Get the reference archive - result = await self.db.execute( - select(PrintArchive).where(PrintArchive.id == archive_id) - ) + result = await self.db.execute(select(PrintArchive).where(PrintArchive.id == archive_id)) reference = result.scalar_one_or_none() if not reference: @@ -213,16 +213,18 @@ class ArchiveComparisonService: .limit(limit) ) for a in result.scalars().all(): - similar.append({ - "archive": { - "id": a.id, - "print_name": a.print_name or a.filename, - "status": a.status, - "created_at": a.created_at.isoformat() if a.created_at else None, - }, - "match_reason": "Same print name", - "match_score": 100, - }) + similar.append( + { + "archive": { + "id": a.id, + "print_name": a.print_name or a.filename, + "status": a.status, + "created_at": a.created_at.isoformat() if a.created_at else None, + }, + "match_reason": "Same print name", + "match_score": 100, + } + ) # By content hash if reference.content_hash and len(similar) < limit: @@ -237,16 +239,18 @@ class ArchiveComparisonService: ) for a in result.scalars().all(): if not any(s["archive"]["id"] == a.id for s in similar): - similar.append({ - "archive": { - "id": a.id, - "print_name": a.print_name or a.filename, - "status": a.status, - "created_at": a.created_at.isoformat() if a.created_at else None, - }, - "match_reason": "Same file content", - "match_score": 95, - }) + similar.append( + { + "archive": { + "id": a.id, + "print_name": a.print_name or a.filename, + "status": a.status, + "created_at": a.created_at.isoformat() if a.created_at else None, + }, + "match_reason": "Same file content", + "match_score": 95, + } + ) # By same filament type if reference.filament_type and len(similar) < limit: @@ -261,16 +265,18 @@ class ArchiveComparisonService: ) for a in result.scalars().all(): if not any(s["archive"]["id"] == a.id for s in similar): - similar.append({ - "archive": { - "id": a.id, - "print_name": a.print_name or a.filename, - "status": a.status, - "created_at": a.created_at.isoformat() if a.created_at else None, - }, - "match_reason": f"Same filament type ({reference.filament_type})", - "match_score": 50, - }) + similar.append( + { + "archive": { + "id": a.id, + "print_name": a.print_name or a.filename, + "status": a.status, + "created_at": a.created_at.isoformat() if a.created_at else None, + }, + "match_reason": f"Same filament type ({reference.filament_type})", + "match_score": 50, + } + ) # Sort by match score similar.sort(key=lambda x: x["match_score"], reverse=True) diff --git a/backend/app/services/bambu_cloud.py b/backend/app/services/bambu_cloud.py index cf9911ba3..582401c16 100644 --- a/backend/app/services/bambu_cloud.py +++ b/backend/app/services/bambu_cloud.py @@ -4,13 +4,11 @@ Bambu Lab Cloud API Service Handles authentication and profile management with Bambu Lab's cloud services. """ -import httpx -import json import logging -from typing import Optional -from pathlib import Path from datetime import datetime, timedelta +import httpx + logger = logging.getLogger(__name__) BAMBU_API_BASE = "https://api.bambulab.com" @@ -19,11 +17,13 @@ BAMBU_API_BASE_CN = "https://api.bambulab.cn" class BambuCloudError(Exception): """Base exception for Bambu Cloud errors.""" + pass class BambuCloudAuthError(BambuCloudError): """Authentication related errors.""" + pass @@ -32,9 +32,9 @@ class BambuCloudService: def __init__(self, region: str = "global"): self.base_url = BAMBU_API_BASE if region == "global" else BAMBU_API_BASE_CN - self.access_token: Optional[str] = None - self.refresh_token: Optional[str] = None - self.token_expiry: Optional[datetime] = None + self.access_token: str | None = None + self.refresh_token: str | None = None + self.token_expiry: datetime | None = None self._client = httpx.AsyncClient(timeout=30.0) @property @@ -42,9 +42,7 @@ class BambuCloudService: """Check if we have a valid token.""" if not self.access_token: return False - if self.token_expiry and datetime.now() > self.token_expiry: - return False - return True + return not (self.token_expiry and datetime.now() > self.token_expiry) def _get_headers(self) -> dict: """Get headers for authenticated requests.""" @@ -69,7 +67,7 @@ class BambuCloudService: json={ "account": email, "password": password, - } + }, ) data = response.json() @@ -78,28 +76,16 @@ class BambuCloudService: # Check if we need verification code # Bambu API returns loginType or may require tfaKey if data.get("loginType") == "verifyCode" or "tfaKey" in data: - return { - "success": False, - "needs_verification": True, - "message": "Verification code sent to email" - } + return {"success": False, "needs_verification": True, "message": "Verification code sent to email"} # Direct login success (rare, usually needs 2FA) if "accessToken" in data: self._set_tokens(data) - return { - "success": True, - "needs_verification": False, - "message": "Login successful" - } + return {"success": True, "needs_verification": False, "message": "Login successful"} # Handle specific error codes error_msg = data.get("message") or data.get("error") or "Login failed" - return { - "success": False, - "needs_verification": False, - "message": error_msg - } + return {"success": False, "needs_verification": False, "message": error_msg} except Exception as e: logger.error(f"Login request failed: {e}") @@ -116,22 +102,16 @@ class BambuCloudService: json={ "account": email, "code": code, - } + }, ) data = response.json() if response.status_code == 200 and "accessToken" in data: self._set_tokens(data) - return { - "success": True, - "message": "Login successful" - } + return {"success": True, "message": "Login successful"} - return { - "success": False, - "message": data.get("message", "Verification failed") - } + return {"success": False, "message": data.get("message", "Verification failed")} except Exception as e: logger.error(f"Verification failed: {e}") @@ -162,8 +142,7 @@ class BambuCloudService: try: response = await self._client.get( - f"{self.base_url}/v1/design-user-service/my/preference", - headers=self._get_headers() + f"{self.base_url}/v1/design-user-service/my/preference", headers=self._get_headers() ) if response.status_code == 200: @@ -188,7 +167,7 @@ class BambuCloudService: response = await self._client.get( f"{self.base_url}/v1/iot-service/api/slicer/setting", headers=self._get_headers(), - params={"version": version} + params={"version": version}, ) data = response.json() @@ -208,8 +187,7 @@ class BambuCloudService: try: response = await self._client.get( - f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", - headers=self._get_headers() + f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", headers=self._get_headers() ) if response.status_code == 200: @@ -220,7 +198,9 @@ class BambuCloudService: except httpx.RequestError as e: raise BambuCloudError(f"Request failed: {e}") - async def create_setting(self, preset_type: str, name: str, base_id: str, setting: dict, version: str = "2.0.0.0") -> dict: + async def create_setting( + self, preset_type: str, name: str, base_id: str, setting: dict, version: str = "2.0.0.0" + ) -> dict: """ Create a new slicer preset/setting. @@ -240,6 +220,7 @@ class BambuCloudService: try: # Add timestamp if not present import time + if "updated_time" not in setting: setting["updated_time"] = str(int(time.time())) @@ -252,9 +233,7 @@ class BambuCloudService: } response = await self._client.post( - f"{self.base_url}/v1/iot-service/api/slicer/setting", - headers=self._get_headers(), - json=payload + f"{self.base_url}/v1/iot-service/api/slicer/setting", headers=self._get_headers(), json=payload ) data = response.json() @@ -320,6 +299,7 @@ class BambuCloudService: # Update the timestamp import time + updated_setting["updated_time"] = str(int(time.time())) # Ensure settings_id field matches the name @@ -338,9 +318,7 @@ class BambuCloudService: } response = await self._client.post( - f"{self.base_url}/v1/iot-service/api/slicer/setting", - headers=self._get_headers(), - json=payload + f"{self.base_url}/v1/iot-service/api/slicer/setting", headers=self._get_headers(), json=payload ) data = response.json() @@ -369,8 +347,7 @@ class BambuCloudService: try: response = await self._client.delete( - f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", - headers=self._get_headers() + f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", headers=self._get_headers() ) if response.status_code in (200, 204): @@ -390,8 +367,7 @@ class BambuCloudService: try: response = await self._client.get( - f"{self.base_url}/v1/iot-service/api/user/bind", - headers=self._get_headers() + f"{self.base_url}/v1/iot-service/api/user/bind", headers=self._get_headers() ) if response.status_code == 200: @@ -408,7 +384,7 @@ class BambuCloudService: # Singleton instance -_cloud_service: Optional[BambuCloudService] = None +_cloud_service: BambuCloudService | None = None def get_cloud_service() -> BambuCloudService: diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 3bee1a186..49dda78ec 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -15,9 +15,8 @@ import httpx from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from backend.app.models.notification import NotificationLog, NotificationProvider, NotificationDigestQueue +from backend.app.models.notification import NotificationDigestQueue, NotificationLog, NotificationProvider from backend.app.models.notification_template import NotificationTemplate -from backend.app.models.settings import Settings logger = logging.getLogger(__name__) @@ -77,9 +76,7 @@ class NotificationService: if event_type in self._template_cache: return self._template_cache[event_type] - result = await db.execute( - select(NotificationTemplate).where(NotificationTemplate.event_type == event_type) - ) + result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.event_type == event_type)) template = result.scalar_one_or_none() if template: @@ -109,6 +106,7 @@ class NotificationService: def _clean_filename(self, filename: str) -> str: """Extract filename and remove file extensions.""" import os + # Strip path prefix (e.g., /data/Metadata/plate_5.gcode -> plate_5.gcode) filename = os.path.basename(filename) # Remove common extensions @@ -334,11 +332,13 @@ class NotificationService: # Discord embed format for nicer messages data = { - "embeds": [{ - "title": title, - "description": message, - "color": 0x00AE42, # Bambu green - }] + "embeds": [ + { + "title": title, + "description": message, + "color": 0x00AE42, # Bambu green + } + ] } client = await self._get_client() @@ -422,9 +422,7 @@ class NotificationService: self, db: AsyncSession, provider_id: int, success: bool, error: str | None = None ): """Update provider status after sending notification.""" - result = await db.execute( - select(NotificationProvider).where(NotificationProvider.id == provider_id) - ) + result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id)) provider = result.scalar_one_or_none() if provider: if success: @@ -443,13 +441,13 @@ class NotificationService: """Get all enabled providers that want a specific event type.""" # Build the query dynamically based on event field query = select(NotificationProvider).where( - NotificationProvider.enabled == True, - getattr(NotificationProvider, event_field) == True, + NotificationProvider.enabled.is_(True), + getattr(NotificationProvider, event_field).is_(True), ) if printer_id is not None: query = query.where( - (NotificationProvider.printer_id == None) | (NotificationProvider.printer_id == printer_id) + (NotificationProvider.printer_id.is_(None)) | (NotificationProvider.printer_id == printer_id) ) result = await db.execute(query) @@ -700,9 +698,7 @@ class NotificationService: title, message = await self._build_message_from_template(db, "print_progress", variables) await self._send_to_providers(providers, title, message, db, "print_progress", printer_id, printer_name) - async def on_printer_offline( - self, printer_id: int, printer_name: str, db: AsyncSession - ): + async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession): """Handle printer offline event.""" providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id) if not providers: @@ -814,7 +810,9 @@ class NotificationService: title, message = await self._build_message_from_template(db, "ams_humidity_high", variables) # Alarms always send immediately, bypassing digest mode - await self._send_to_providers(providers, title, message, db, "ams_humidity_high", printer_id, printer_name, force_immediate=True) + await self._send_to_providers( + providers, title, message, db, "ams_humidity_high", printer_id, printer_name, force_immediate=True + ) async def on_ams_temperature_high( self, @@ -839,7 +837,9 @@ class NotificationService: title, message = await self._build_message_from_template(db, "ams_temperature_high", variables) # Alarms always send immediately, bypassing digest mode - await self._send_to_providers(providers, title, message, db, "ams_temperature_high", printer_id, printer_name, force_immediate=True) + await self._send_to_providers( + providers, title, message, db, "ams_temperature_high", printer_id, printer_name, force_immediate=True + ) async def on_ams_ht_humidity_high( self, @@ -865,7 +865,9 @@ class NotificationService: # Use the same template as regular AMS (can create separate templates later if needed) title, message = await self._build_message_from_template(db, "ams_humidity_high", variables) # Alarms always send immediately, bypassing digest mode - await self._send_to_providers(providers, title, message, db, "ams_ht_humidity_high", printer_id, printer_name, force_immediate=True) + await self._send_to_providers( + providers, title, message, db, "ams_ht_humidity_high", printer_id, printer_name, force_immediate=True + ) async def on_ams_ht_temperature_high( self, @@ -891,7 +893,9 @@ class NotificationService: # Use the same template as regular AMS (can create separate templates later if needed) title, message = await self._build_message_from_template(db, "ams_temperature_high", variables) # Alarms always send immediately, bypassing digest mode - await self._send_to_providers(providers, title, message, db, "ams_ht_temperature_high", printer_id, printer_name, force_immediate=True) + await self._send_to_providers( + providers, title, message, db, "ams_ht_temperature_high", printer_id, printer_name, force_immediate=True + ) def clear_template_cache(self): """Clear the template cache. Call this when templates are updated.""" @@ -929,9 +933,7 @@ class NotificationService: async with async_session() as db: # Get the provider - result = await db.execute( - select(NotificationProvider).where(NotificationProvider.id == provider_id) - ) + result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id)) provider = result.scalar_one_or_none() if not provider or not provider.enabled: @@ -1011,8 +1013,8 @@ class NotificationService: # Find all providers with digest enabled at this time result = await db.execute( select(NotificationProvider).where( - NotificationProvider.enabled == True, - NotificationProvider.daily_digest_enabled == True, + NotificationProvider.enabled.is_(True), + NotificationProvider.daily_digest_enabled.is_(True), NotificationProvider.daily_digest_time == current_time, ) ) diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index d70253dad..0680bcc56 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -3,16 +3,15 @@ import asyncio import logging from datetime import datetime -from pathlib import Path from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from backend.app.core.config import settings from backend.app.core.database import async_session +from backend.app.models.archive import PrintArchive from backend.app.models.print_queue import PrintQueueItem from backend.app.models.printer import Printer -from backend.app.models.archive import PrintArchive from backend.app.models.smart_plug import SmartPlug from backend.app.services.bambu_ftp import upload_file_async from backend.app.services.printer_manager import printer_manager @@ -128,9 +127,7 @@ class PrintScheduler: async def _get_smart_plug(self, db: AsyncSession, printer_id: int) -> SmartPlug | None: """Get the smart plug associated with a printer.""" - result = await db.execute( - select(SmartPlug).where(SmartPlug.printer_id == printer_id) - ) + result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) return result.scalar_one_or_none() async def _power_on_and_wait(self, plug: SmartPlug, printer_id: int, db: AsyncSession) -> bool: @@ -222,9 +219,7 @@ class PrintScheduler: logger.info(f"Starting queue item {item.id}") # Get archive - result = await db.execute( - select(PrintArchive).where(PrintArchive.id == item.archive_id) - ) + result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id)) archive = result.scalar_one_or_none() if not archive: item.status = "failed" @@ -236,9 +231,7 @@ class PrintScheduler: return # Get printer - result = await db.execute( - select(Printer).where(Printer.id == item.printer_id) - ) + result = await db.execute(select(Printer).where(Printer.id == item.printer_id)) printer = result.scalar_one_or_none() if not printer: item.status = "failed" @@ -296,6 +289,7 @@ class PrintScheduler: # Register as expected print so we don't create a duplicate archive from backend.app.main import register_expected_print + register_expected_print(item.printer_id, remote_filename, archive.id) # Start the print diff --git a/backend/app/services/smart_plug_manager.py b/backend/app/services/smart_plug_manager.py index 510c81ced..4a288c020 100644 --- a/backend/app/services/smart_plug_manager.py +++ b/backend/app/services/smart_plug_manager.py @@ -5,11 +5,11 @@ import logging from datetime import datetime from typing import TYPE_CHECKING -from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession -from backend.app.services.tasmota import tasmota_service from backend.app.services.printer_manager import printer_manager +from backend.app.services.tasmota import tasmota_service if TYPE_CHECKING: from backend.app.models.smart_plug import SmartPlug @@ -64,8 +64,8 @@ class SmartPlugManager: async with async_session() as db: result = await db.execute( select(SmartPlug).where( - SmartPlug.enabled == True, - SmartPlug.schedule_enabled == True, + SmartPlug.enabled.is_(True), + SmartPlug.schedule_enabled.is_(True), ) ) plugs = result.scalars().all() @@ -98,15 +98,11 @@ class SmartPlugManager: await db.commit() - async def _get_plug_for_printer( - self, printer_id: int, db: AsyncSession - ) -> "SmartPlug | None": + async def _get_plug_for_printer(self, printer_id: int, db: AsyncSession) -> "SmartPlug | None": """Get the smart plug linked to a printer.""" from backend.app.models.smart_plug import SmartPlug - result = await db.execute( - select(SmartPlug).where(SmartPlug.printer_id == printer_id) - ) + result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) return result.scalar_one_or_none() async def on_print_start(self, printer_id: int, db: AsyncSession): @@ -138,9 +134,7 @@ class SmartPlugManager: plug.auto_off_executed = False # Reset flag when turning on await db.commit() - async def on_print_complete( - self, printer_id: int, status: str, db: AsyncSession - ): + async def on_print_complete(self, printer_id: int, status: str, db: AsyncSession): """Called when a print completes - schedule turn off if configured. Only triggers auto-off on successful completion (status='completed'). @@ -169,8 +163,7 @@ class SmartPlugManager: return logger.info( - f"Print completed successfully on printer {printer_id}, " - f"scheduling turn-off for plug '{plug.name}'" + f"Print completed successfully on printer {printer_id}, " f"scheduling turn-off for plug '{plug.name}'" ) if plug.off_delay_mode == "time": @@ -183,9 +176,7 @@ class SmartPlugManager: # Cancel any existing task for this plug self._cancel_pending_off(plug.id) - logger.info( - f"Scheduling turn-off for plug '{plug.name}' in {delay_seconds} seconds" - ) + logger.info(f"Scheduling turn-off for plug '{plug.name}' in {delay_seconds} seconds") # Mark as pending in database (survives restarts) asyncio.create_task(self._mark_auto_off_pending(plug.id, True)) @@ -231,17 +222,12 @@ class SmartPlugManager: finally: self._pending_off.pop(plug_id, None) - def _schedule_temp_based_off( - self, plug: "SmartPlug", printer_id: int, temp_threshold: int - ): + def _schedule_temp_based_off(self, plug: "SmartPlug", printer_id: int, temp_threshold: int): """Monitor temperature and turn off when below threshold.""" # Cancel any existing task for this plug self._cancel_pending_off(plug.id) - logger.info( - f"Scheduling temperature-based turn-off for plug '{plug.name}' " - f"(threshold: {temp_threshold}°C)" - ) + logger.info(f"Scheduling temperature-based turn-off for plug '{plug.name}' " f"(threshold: {temp_threshold}°C)") # Mark as pending in database (survives restarts) asyncio.create_task(self._mark_auto_off_pending(plug.id, True)) @@ -296,8 +282,7 @@ class SmartPlugManager: ) else: logger.info( - f"Temp check plug {plug_id}: nozzle={nozzle_temp}°C, " - f"threshold={temp_threshold}°C" + f"Temp check plug {plug_id}: nozzle={nozzle_temp}°C, " f"threshold={temp_threshold}°C" ) if max_nozzle_temp < temp_threshold: @@ -328,9 +313,7 @@ class SmartPlugManager: elapsed += check_interval if elapsed >= max_wait: - logger.warning( - f"Temperature-based turn-off timed out for plug {plug_id} after {max_wait}s" - ) + logger.warning(f"Temperature-based turn-off timed out for plug {plug_id} after {max_wait}s") except asyncio.CancelledError: logger.debug(f"Temperature-based turn-off cancelled for plug {plug_id}") @@ -344,9 +327,7 @@ class SmartPlugManager: from backend.app.models.smart_plug import SmartPlug async with async_session() as db: - result = await db.execute( - select(SmartPlug).where(SmartPlug.id == plug_id) - ) + result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id)) plug = result.scalar_one_or_none() if plug: plug.auto_off_pending = pending @@ -363,9 +344,7 @@ class SmartPlugManager: from backend.app.models.smart_plug import SmartPlug async with async_session() as db: - result = await db.execute( - select(SmartPlug).where(SmartPlug.id == plug_id) - ) + result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id)) plug = result.scalar_one_or_none() if plug: plug.auto_off = False # Disable auto-off (one-shot behavior) @@ -407,8 +386,8 @@ class SmartPlugManager: # Find all plugs with pending auto-off result = await db.execute( select(SmartPlug).where( - SmartPlug.auto_off_pending == True, - SmartPlug.printer_id != None, + SmartPlug.auto_off_pending.is_(True), + SmartPlug.printer_id.isnot(None), ) ) pending_plugs = result.scalars().all() @@ -427,10 +406,7 @@ class SmartPlugManager: await db.commit() continue - logger.info( - f"Resuming pending auto-off for plug '{plug.name}' " - f"(printer {plug.printer_id})" - ) + logger.info(f"Resuming pending auto-off for plug '{plug.name}' " f"(printer {plug.printer_id})") # Resume the appropriate off mode if plug.off_delay_mode == "temperature": diff --git a/backend/app/services/tasmota.py b/backend/app/services/tasmota.py index d2981fb20..b522149da 100644 --- a/backend/app/services/tasmota.py +++ b/backend/app/services/tasmota.py @@ -1,8 +1,6 @@ """Service for communicating with Tasmota devices via HTTP API.""" -import asyncio import logging -from datetime import datetime from typing import TYPE_CHECKING import httpx @@ -70,9 +68,7 @@ class TasmotaService: - reachable: bool - device_name: str or None """ - result = await self._send_command( - plug.ip_address, "Power", plug.username, plug.password - ) + result = await self._send_command(plug.ip_address, "Power", plug.username, plug.password) if result is None: return {"state": None, "reachable": False, "device_name": None} @@ -89,9 +85,7 @@ class TasmotaService: async def turn_on(self, plug: "SmartPlug") -> bool: """Turn on the plug. Returns True if successful.""" - result = await self._send_command( - plug.ip_address, "Power On", plug.username, plug.password - ) + result = await self._send_command(plug.ip_address, "Power On", plug.username, plug.password) if result is None: return False @@ -103,17 +97,13 @@ class TasmotaService: if success: logger.info(f"Turned ON smart plug '{plug.name}' at {plug.ip_address}") else: - logger.warning( - f"Failed to turn ON smart plug '{plug.name}' at {plug.ip_address}" - ) + logger.warning(f"Failed to turn ON smart plug '{plug.name}' at {plug.ip_address}") return success async def turn_off(self, plug: "SmartPlug") -> bool: """Turn off the plug. Returns True if successful.""" - result = await self._send_command( - plug.ip_address, "Power Off", plug.username, plug.password - ) + result = await self._send_command(plug.ip_address, "Power Off", plug.username, plug.password) if result is None: return False @@ -125,17 +115,13 @@ class TasmotaService: if success: logger.info(f"Turned OFF smart plug '{plug.name}' at {plug.ip_address}") else: - logger.warning( - f"Failed to turn OFF smart plug '{plug.name}' at {plug.ip_address}" - ) + logger.warning(f"Failed to turn OFF smart plug '{plug.name}' at {plug.ip_address}") return success async def toggle(self, plug: "SmartPlug") -> bool: """Toggle the plug state. Returns True if successful.""" - result = await self._send_command( - plug.ip_address, "Power Toggle", plug.username, plug.password - ) + result = await self._send_command(plug.ip_address, "Power Toggle", plug.username, plug.password) if result is None: return False @@ -144,9 +130,7 @@ class TasmotaService: success = state in ["ON", "OFF"] if success: - logger.info( - f"Toggled smart plug '{plug.name}' at {plug.ip_address} to {state}" - ) + logger.info(f"Toggled smart plug '{plug.name}' at {plug.ip_address} to {state}") return success @@ -161,9 +145,7 @@ class TasmotaService: - total: Total energy in kWh - factor: Power factor (0-1) """ - result = await self._send_command( - plug.ip_address, "Status 8", plug.username, plug.password - ) + result = await self._send_command(plug.ip_address, "Status 8", plug.username, plug.password) if result is None: return None diff --git a/backend/app/services/telemetry.py b/backend/app/services/telemetry.py index 2a8076cdd..25667cffb 100644 --- a/backend/app/services/telemetry.py +++ b/backend/app/services/telemetry.py @@ -25,9 +25,7 @@ _last_heartbeat: datetime | None = None async def get_or_create_installation_id(db: AsyncSession) -> str: """Get existing installation ID or create a new one.""" - result = await db.execute( - select(Settings).where(Settings.key == "installation_id") - ) + result = await db.execute(select(Settings).where(Settings.key == "installation_id")) setting = result.scalar_one_or_none() if setting: @@ -47,9 +45,7 @@ async def get_or_create_installation_id(db: AsyncSession) -> str: async def is_telemetry_enabled(db: AsyncSession) -> bool: """Check if telemetry is enabled (opt-out model).""" - result = await db.execute( - select(Settings).where(Settings.key == "telemetry_enabled") - ) + result = await db.execute(select(Settings).where(Settings.key == "telemetry_enabled")) setting = result.scalar_one_or_none() # Default to enabled (opt-out model) @@ -61,9 +57,7 @@ async def is_telemetry_enabled(db: AsyncSession) -> bool: async def get_telemetry_url(db: AsyncSession) -> str: """Get telemetry server URL from settings.""" - result = await db.execute( - select(Settings).where(Settings.key == "telemetry_url") - ) + result = await db.execute(select(Settings).where(Settings.key == "telemetry_url")) setting = result.scalar_one_or_none() return setting.value if setting else DEFAULT_TELEMETRY_URL diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a1acdb616..e9d36729f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -5,25 +5,26 @@ import json import logging import os import sys -import pytest -from typing import AsyncGenerator +from collections.abc import AsyncGenerator from datetime import datetime from unittest.mock import AsyncMock, MagicMock, patch +import pytest + # IMPORTANT: Set environment variables BEFORE any app imports # This must happen before settings/config are loaded os.environ["LOG_TO_FILE"] = "false" os.environ["DEBUG"] = "false" -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker -from httpx import AsyncClient, ASGITransport +from httpx import ASGITransport, AsyncClient # noqa: E402 +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine # noqa: E402 # Ensure settings use our env vars - import and override before database import -from backend.app.core.config import settings +from backend.app.core.config import settings # noqa: E402 + settings.log_to_file = False -from backend.app.core.database import Base - +from backend.app.core.database import Base # noqa: E402 # Use in-memory SQLite for tests TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" @@ -44,10 +45,20 @@ async def test_engine(): # Import all models to register them from backend.app.models import ( - printer, archive, filament, settings, smart_plug, - print_queue, notification, maintenance, kprofile_note, - notification_template, external_link, project, api_key, - ams_history + ams_history, + api_key, + archive, + external_link, + filament, + kprofile_note, + maintenance, + notification, + notification_template, + print_queue, + printer, + project, + settings, + smart_plug, ) async with engine.begin() as conn: @@ -63,9 +74,7 @@ async def test_engine(): @pytest.fixture async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]: """Create a test database session.""" - async_session_maker = async_sessionmaker( - test_engine, class_=AsyncSession, expire_on_commit=False - ) + async_session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) async with async_session_maker() as session: yield session @@ -73,13 +82,11 @@ async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]: @pytest.fixture async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, None]: """Create an async test client.""" + from backend.app.core.database import async_session, get_db from backend.app.main import app - from backend.app.core.database import get_db, async_session # Create a new session maker for the test engine - test_async_session = async_sessionmaker( - test_engine, class_=AsyncSession, expire_on_commit=False - ) + test_async_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) async def override_get_db(): async with test_async_session() as session: @@ -88,11 +95,8 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N app.dependency_overrides[get_db] = override_get_db # Also patch the module-level async_session used by services - with patch('backend.app.core.database.async_session', test_async_session): - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test" - ) as client: + with patch("backend.app.core.database.async_session", test_async_session): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: yield client app.dependency_overrides.clear() @@ -102,33 +106,30 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N # Mock External Services # ============================================================================ + @pytest.fixture def mock_tasmota_service(): """Mock the Tasmota service for smart plug tests.""" # Patch both the module where it's defined and where it's imported - with patch('backend.app.services.tasmota.tasmota_service') as mock, \ - patch('backend.app.api.routes.smart_plugs.tasmota_service') as mock2: + with ( + patch("backend.app.services.tasmota.tasmota_service") as mock, + patch("backend.app.api.routes.smart_plugs.tasmota_service") as mock2, + ): mock.turn_on = AsyncMock(return_value=True) mock.turn_off = AsyncMock(return_value=True) mock.toggle = AsyncMock(return_value=True) - mock.get_status = AsyncMock(return_value={ - "state": "ON", - "reachable": True, - "device_name": "Test Plug" - }) - mock.get_energy = AsyncMock(return_value={ - "power": 150.5, - "voltage": 120.0, - "current": 1.25, - "today": 2.5, - "total": 100.0, - "factor": 0.95, - }) - mock.test_connection = AsyncMock(return_value={ - "success": True, - "state": "ON", - "device_name": "Test Plug" - }) + mock.get_status = AsyncMock(return_value={"state": "ON", "reachable": True, "device_name": "Test Plug"}) + mock.get_energy = AsyncMock( + return_value={ + "power": 150.5, + "voltage": 120.0, + "current": 1.25, + "today": 2.5, + "total": 100.0, + "factor": 0.95, + } + ) + mock.test_connection = AsyncMock(return_value={"success": True, "state": "ON", "device_name": "Test Plug"}) # Copy mocks to second patch target mock2.turn_on = mock.turn_on mock2.turn_off = mock.turn_off @@ -142,14 +143,9 @@ def mock_tasmota_service(): @pytest.fixture def mock_mqtt_client(): """Mock the MQTT client for printer communication tests.""" - with patch('backend.app.services.bambu_mqtt.BambuMQTTClient') as mock: + with patch("backend.app.services.bambu_mqtt.BambuMQTTClient") as mock: instance = MagicMock() - instance.state = MagicMock( - connected=True, - state="IDLE", - progress=0, - temperatures={"nozzle": 25, "bed": 25} - ) + instance.state = MagicMock(connected=True, state="IDLE", progress=0, temperatures={"nozzle": 25, "bed": 25}) instance.connect = MagicMock() instance.disconnect = MagicMock() mock.return_value = instance @@ -159,8 +155,10 @@ def mock_mqtt_client(): @pytest.fixture def mock_ftp_client(): """Mock the FTP client for file transfer tests.""" - with patch('backend.app.services.bambu_ftp.download_file_async') as download_mock, \ - patch('backend.app.services.bambu_ftp.list_files_async') as list_mock: + with ( + patch("backend.app.services.bambu_ftp.download_file_async") as download_mock, + patch("backend.app.services.bambu_ftp.list_files_async") as list_mock, + ): download_mock.return_value = True list_mock.return_value = [] yield {"download": download_mock, "list": list_mock} @@ -169,7 +167,7 @@ def mock_ftp_client(): @pytest.fixture def mock_httpx_client(): """Mock httpx for webhook/notification HTTP calls.""" - with patch('httpx.AsyncClient') as mock_class: + with patch("httpx.AsyncClient") as mock_class: mock_instance = AsyncMock() mock_response = MagicMock() mock_response.status_code = 200 @@ -188,14 +186,16 @@ def mock_httpx_client(): @pytest.fixture def mock_printer_manager(): """Mock the printer manager for status checks.""" - with patch('backend.app.services.printer_manager.printer_manager') as mock: - mock.get_status = MagicMock(return_value=MagicMock( - connected=True, - state="IDLE", - progress=0, - temperatures={"nozzle": 25, "bed": 25, "chamber": 25}, - raw_data={} - )) + with patch("backend.app.services.printer_manager.printer_manager") as mock: + mock.get_status = MagicMock( + return_value=MagicMock( + connected=True, + state="IDLE", + progress=0, + temperatures={"nozzle": 25, "bed": 25, "chamber": 25}, + raw_data={}, + ) + ) mock.mark_printer_offline = MagicMock() yield mock @@ -204,9 +204,11 @@ def mock_printer_manager(): # Factory Fixtures for Test Data # ============================================================================ + @pytest.fixture def smart_plug_factory(db_session): """Factory to create test smart plugs.""" + async def _create_plug(**kwargs): from backend.app.models.smart_plug import SmartPlug @@ -267,6 +269,7 @@ def printer_factory(db_session): @pytest.fixture def notification_provider_factory(db_session): """Factory to create test notification providers.""" + async def _create_provider(**kwargs): from backend.app.models.notification import NotificationProvider @@ -307,6 +310,7 @@ def notification_provider_factory(db_session): @pytest.fixture def archive_factory(db_session): """Factory to create test archives.""" + async def _create_archive(printer_id: int, **kwargs): from backend.app.models.archive import PrintArchive @@ -336,6 +340,7 @@ def archive_factory(db_session): # Sample Data Fixtures # ============================================================================ + @pytest.fixture def sample_mqtt_print_start(): """Sample MQTT message for print start.""" @@ -385,6 +390,7 @@ def sample_printer_status(): # Log Capture Fixtures for Error Detection # ============================================================================ + class LogCapture(logging.Handler): """Handler that captures log records for testing.""" @@ -415,7 +421,7 @@ class LogCapture(logging.Handler): errors = self.get_errors() if not errors: return "No errors" - formatter = logging.Formatter('%(name)s - %(levelname)s - %(message)s') + formatter = logging.Formatter("%(name)s - %(levelname)s - %(message)s") return "\n".join(formatter.format(r) for r in errors) diff --git a/backend/tests/integration/test_ams_history_api.py b/backend/tests/integration/test_ams_history_api.py index ee4d9e495..f90c09f75 100644 --- a/backend/tests/integration/test_ams_history_api.py +++ b/backend/tests/integration/test_ams_history_api.py @@ -1,7 +1,8 @@ """Integration tests for AMS History API endpoints.""" -import pytest from datetime import datetime, timedelta + +import pytest from httpx import AsyncClient @@ -11,6 +12,7 @@ class TestAMSHistoryAPI: @pytest.fixture async def ams_history_factory(self, db_session, printer_factory): """Factory to create test AMS history records.""" + async def _create_history(printer_id=None, ams_id=0, **kwargs): from backend.app.models.ams_history import AMSSensorHistory @@ -38,9 +40,7 @@ class TestAMSHistoryAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_get_ams_history_empty( - self, async_client: AsyncClient, printer_factory, db_session - ): + async def test_get_ams_history_empty(self, async_client: AsyncClient, printer_factory, db_session): """Verify empty history returns empty data array.""" printer = await printer_factory() response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0") @@ -52,9 +52,7 @@ class TestAMSHistoryAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_get_ams_history_with_data( - self, async_client: AsyncClient, ams_history_factory, db_session - ): + async def test_get_ams_history_with_data(self, async_client: AsyncClient, ams_history_factory, db_session): """Verify history returns recorded data.""" # Create history records history = await ams_history_factory() @@ -95,15 +93,9 @@ class TestAMSHistoryAPI: """Verify hours parameter filters data.""" printer = await printer_factory() # Create a recent record - await ams_history_factory( - printer_id=printer.id, - recorded_at=datetime.now() - ) + await ams_history_factory(printer_id=printer.id, recorded_at=datetime.now()) # Create an old record (outside default 24h) - await ams_history_factory( - printer_id=printer.id, - recorded_at=datetime.now() - timedelta(hours=48) - ) + await ams_history_factory(printer_id=printer.id, recorded_at=datetime.now() - timedelta(hours=48)) # Request only last 24 hours (default) response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0") @@ -114,15 +106,10 @@ class TestAMSHistoryAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_get_ams_history_custom_hours( - self, async_client: AsyncClient, printer_factory, db_session - ): + async def test_get_ams_history_custom_hours(self, async_client: AsyncClient, printer_factory, db_session): """Verify custom hours parameter works.""" printer = await printer_factory() - response = await async_client.get( - f"/api/v1/ams-history/{printer.id}/0", - params={"hours": 48} - ) + response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0", params={"hours": 48}) assert response.status_code == 200 data = response.json() assert data["printer_id"] == printer.id @@ -159,31 +146,20 @@ class TestAMSHistoryAPI: """Verify old history can be deleted.""" printer = await printer_factory() # Create an old record - await ams_history_factory( - printer_id=printer.id, - recorded_at=datetime.now() - timedelta(days=60) - ) + await ams_history_factory(printer_id=printer.id, recorded_at=datetime.now() - timedelta(days=60)) # Delete records older than 30 days - response = await async_client.delete( - f"/api/v1/ams-history/{printer.id}", - params={"days": 30} - ) + response = await async_client.delete(f"/api/v1/ams-history/{printer.id}", params={"days": 30}) assert response.status_code == 200 data = response.json() assert data["deleted"] >= 1 @pytest.mark.asyncio @pytest.mark.integration - async def test_delete_old_history_no_records( - self, async_client: AsyncClient, printer_factory, db_session - ): + async def test_delete_old_history_no_records(self, async_client: AsyncClient, printer_factory, db_session): """Verify delete with no old records returns 0.""" printer = await printer_factory() - response = await async_client.delete( - f"/api/v1/ams-history/{printer.id}", - params={"days": 30} - ) + response = await async_client.delete(f"/api/v1/ams-history/{printer.id}", params={"days": 30}) assert response.status_code == 200 data = response.json() assert data["deleted"] == 0 diff --git a/backend/tests/integration/test_archives_api.py b/backend/tests/integration/test_archives_api.py index 399563a8a..ca3cee821 100644 --- a/backend/tests/integration/test_archives_api.py +++ b/backend/tests/integration/test_archives_api.py @@ -72,9 +72,7 @@ class TestArchivesAPI: await archive_factory(printer1.id, print_name="Printer 1 Archive") await archive_factory(printer2.id, print_name="Printer 2 Archive") - response = await async_client.get( - f"/api/v1/archives/?printer_id={printer1.id}" - ) + response = await async_client.get(f"/api/v1/archives/?printer_id={printer1.id}") assert response.status_code == 200 data = response.json() @@ -86,9 +84,7 @@ class TestArchivesAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_get_archive( - self, async_client: AsyncClient, archive_factory, printer_factory, db_session - ): + async def test_get_archive(self, async_client: AsyncClient, archive_factory, printer_factory, db_session): """Verify single archive can be retrieved.""" printer = await printer_factory() archive = await archive_factory(printer.id, print_name="Get Test Archive") @@ -114,34 +110,24 @@ class TestArchivesAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_update_archive_name( - self, async_client: AsyncClient, archive_factory, printer_factory, db_session - ): + async def test_update_archive_name(self, async_client: AsyncClient, archive_factory, printer_factory, db_session): """Verify archive name can be updated.""" printer = await printer_factory() archive = await archive_factory(printer.id, print_name="Original Name") - response = await async_client.patch( - f"/api/v1/archives/{archive.id}", - json={"print_name": "Updated Name"} - ) + response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"print_name": "Updated Name"}) assert response.status_code == 200 assert response.json()["print_name"] == "Updated Name" @pytest.mark.asyncio @pytest.mark.integration - async def test_update_archive_notes( - self, async_client: AsyncClient, archive_factory, printer_factory, db_session - ): + async def test_update_archive_notes(self, async_client: AsyncClient, archive_factory, printer_factory, db_session): """Verify archive notes can be updated.""" printer = await printer_factory() archive = await archive_factory(printer.id) - response = await async_client.patch( - f"/api/v1/archives/{archive.id}", - json={"notes": "Great print!"} - ) + response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"notes": "Great print!"}) assert response.status_code == 200 assert response.json()["notes"] == "Great print!" @@ -155,10 +141,7 @@ class TestArchivesAPI: printer = await printer_factory() archive = await archive_factory(printer.id) - response = await async_client.patch( - f"/api/v1/archives/{archive.id}", - json={"is_favorite": True} - ) + response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"is_favorite": True}) assert response.status_code == 200 assert response.json()["is_favorite"] is True @@ -169,9 +152,7 @@ class TestArchivesAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_delete_archive( - self, async_client: AsyncClient, archive_factory, printer_factory, db_session - ): + async def test_delete_archive(self, async_client: AsyncClient, archive_factory, printer_factory, db_session): """Verify archive can be deleted.""" printer = await printer_factory() archive = await archive_factory(printer.id) @@ -199,9 +180,7 @@ class TestArchivesAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_get_archive_stats( - self, async_client: AsyncClient, archive_factory, printer_factory, db_session - ): + async def test_get_archive_stats(self, async_client: AsyncClient, archive_factory, printer_factory, db_session): """Verify archive statistics can be retrieved.""" printer = await printer_factory() await archive_factory( @@ -282,10 +261,7 @@ class TestArchiveDataIntegrity: archive = await archive_factory(printer.id, notes="Original notes") # Update - await async_client.patch( - f"/api/v1/archives/{archive.id}", - json={"notes": "Updated notes", "is_favorite": True} - ) + await async_client.patch(f"/api/v1/archives/{archive.id}", json={"notes": "Updated notes", "is_favorite": True}) # Verify persistence response = await async_client.get(f"/api/v1/archives/{archive.id}") diff --git a/backend/tests/integration/test_camera_api.py b/backend/tests/integration/test_camera_api.py index 6c1e48ae4..124fcf898 100644 --- a/backend/tests/integration/test_camera_api.py +++ b/backend/tests/integration/test_camera_api.py @@ -3,9 +3,10 @@ Tests the full request/response cycle for /api/v1/printers/{id}/camera/ endpoints. """ -import pytest -from unittest.mock import patch, AsyncMock, MagicMock import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest from httpx import AsyncClient @@ -64,7 +65,7 @@ class TestCameraAPI: mock_process.returncode = None mock_process.terminate = MagicMock() - with patch('backend.app.api.routes.camera._active_streams', {f"{printer.id}-abc123": mock_process}): + with patch("backend.app.api.routes.camera._active_streams", {f"{printer.id}-abc123": mock_process}): response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/stop") assert response.status_code == 200 @@ -92,7 +93,7 @@ class TestCameraAPI: f"{printer2.id}-def456": mock_process2, } - with patch('backend.app.api.routes.camera._active_streams', active_streams): + with patch("backend.app.api.routes.camera._active_streams", active_streams): response = await async_client.post(f"/api/v1/printers/{printer1.id}/camera/stop") assert response.status_code == 200 @@ -119,7 +120,7 @@ class TestCameraAPI: """Verify camera test returns success when camera is accessible.""" printer = await printer_factory() - with patch('backend.app.api.routes.camera.test_camera_connection', new_callable=AsyncMock) as mock_test: + with patch("backend.app.api.routes.camera.test_camera_connection", new_callable=AsyncMock) as mock_test: mock_test.return_value = {"success": True, "message": "Camera connected"} response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/test") @@ -134,7 +135,7 @@ class TestCameraAPI: """Verify camera test returns failure when camera is not accessible.""" printer = await printer_factory() - with patch('backend.app.api.routes.camera.test_camera_connection', new_callable=AsyncMock) as mock_test: + with patch("backend.app.api.routes.camera.test_camera_connection", new_callable=AsyncMock) as mock_test: mock_test.return_value = {"success": False, "message": "Connection timeout"} response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/test") @@ -162,18 +163,17 @@ class TestCameraAPI: printer = await printer_factory() # Create a fake JPEG (starts with FFD8) - fake_jpeg = b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00' + fake_jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00" - with patch('backend.app.api.routes.camera.capture_camera_frame', new_callable=AsyncMock) as mock_capture: + with patch("backend.app.api.routes.camera.capture_camera_frame", new_callable=AsyncMock) as mock_capture: mock_capture.return_value = True # Mock the file read - with patch('builtins.open', create=True) as mock_open: + with patch("builtins.open", create=True) as mock_open: mock_open.return_value.__enter__.return_value.read.return_value = fake_jpeg - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.unlink'): - response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot") + with patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.unlink"): + _response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot") # Note: The actual test might fail due to file operations, but this tests the endpoint structure # In production tests, we'd mock more comprehensively @@ -184,11 +184,10 @@ class TestCameraAPI: """Verify 503 when camera capture fails.""" printer = await printer_factory() - with patch('backend.app.api.routes.camera.capture_camera_frame', new_callable=AsyncMock) as mock_capture: + with patch("backend.app.api.routes.camera.capture_camera_frame", new_callable=AsyncMock) as mock_capture: mock_capture.return_value = False - with patch('pathlib.Path.exists', return_value=False), \ - patch('pathlib.Path.unlink'): + with patch("pathlib.Path.exists", return_value=False), patch("pathlib.Path.unlink"): response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot") assert response.status_code == 503 @@ -216,11 +215,11 @@ class TestCameraAPI: # Testing that the endpoint accepts various FPS values without error # (actual streaming would require mocking ffmpeg) - with patch('backend.app.api.routes.camera.get_ffmpeg_path', return_value=None): + with patch("backend.app.api.routes.camera.get_ffmpeg_path", return_value=None): # With no ffmpeg, stream should return error message but not crash response = await async_client.get( f"/api/v1/printers/{printer.id}/camera/stream", - params={"fps": 100} # Should be clamped to 30 + params={"fps": 100}, # Should be clamped to 30 ) # Response will be a streaming response with error assert response.status_code == 200 diff --git a/backend/tests/integration/test_external_links_api.py b/backend/tests/integration/test_external_links_api.py index 0da13c545..3301a5c7d 100644 --- a/backend/tests/integration/test_external_links_api.py +++ b/backend/tests/integration/test_external_links_api.py @@ -44,9 +44,7 @@ class TestExternalLinksAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_list_external_links_with_data( - self, async_client: AsyncClient, link_factory, db_session - ): + async def test_list_external_links_with_data(self, async_client: AsyncClient, link_factory, db_session): """Verify list returns existing links.""" await link_factory(name="My Link") response = await async_client.get("/api/v1/external-links/") @@ -71,9 +69,7 @@ class TestExternalLinksAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_get_external_link( - self, async_client: AsyncClient, link_factory, db_session - ): + async def test_get_external_link(self, async_client: AsyncClient, link_factory, db_session): """Verify single link can be retrieved.""" link = await link_factory(name="Get Test Link") response = await async_client.get(f"/api/v1/external-links/{link.id}") @@ -89,14 +85,11 @@ class TestExternalLinksAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_update_external_link( - self, async_client: AsyncClient, link_factory, db_session - ): + async def test_update_external_link(self, async_client: AsyncClient, link_factory, db_session): """Verify link can be updated.""" link = await link_factory(name="Original") response = await async_client.patch( - f"/api/v1/external-links/{link.id}", - json={"name": "Updated", "url": "https://updated.example.com"} + f"/api/v1/external-links/{link.id}", json={"name": "Updated", "url": "https://updated.example.com"} ) assert response.status_code == 200 result = response.json() @@ -105,9 +98,7 @@ class TestExternalLinksAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_delete_external_link( - self, async_client: AsyncClient, link_factory, db_session - ): + async def test_delete_external_link(self, async_client: AsyncClient, link_factory, db_session): """Verify link can be deleted.""" link = await link_factory() response = await async_client.delete(f"/api/v1/external-links/{link.id}") @@ -118,9 +109,7 @@ class TestExternalLinksAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_reorder_external_links( - self, async_client: AsyncClient, link_factory, db_session - ): + async def test_reorder_external_links(self, async_client: AsyncClient, link_factory, db_session): """Verify links can be reordered.""" link1 = await link_factory(name="Link 1") link2 = await link_factory(name="Link 2") @@ -128,8 +117,7 @@ class TestExternalLinksAPI: # Reorder: 3, 1, 2 response = await async_client.put( - "/api/v1/external-links/reorder", - json={"ids": [link3.id, link1.id, link2.id]} + "/api/v1/external-links/reorder", json={"ids": [link3.id, link1.id, link2.id]} ) assert response.status_code == 200 data = response.json() @@ -144,6 +132,7 @@ class TestExternalLinksIconAPI: @pytest.fixture async def link_factory(self, db_session): """Factory to create test external links.""" + async def _create_link(**kwargs): from backend.app.models.external_link import ExternalLink @@ -165,9 +154,7 @@ class TestExternalLinksIconAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_get_icon_not_set( - self, async_client: AsyncClient, link_factory, db_session - ): + async def test_get_icon_not_set(self, async_client: AsyncClient, link_factory, db_session): """Verify 404 when no custom icon is set.""" link = await link_factory() response = await async_client.get(f"/api/v1/external-links/{link.id}/icon") @@ -175,9 +162,7 @@ class TestExternalLinksIconAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_delete_icon_when_none( - self, async_client: AsyncClient, link_factory, db_session - ): + async def test_delete_icon_when_none(self, async_client: AsyncClient, link_factory, db_session): """Verify deleting non-existent icon succeeds silently.""" link = await link_factory() response = await async_client.delete(f"/api/v1/external-links/{link.id}/icon") diff --git a/backend/tests/integration/test_filaments_api.py b/backend/tests/integration/test_filaments_api.py index 63ca333fd..c3e9f38d1 100644 --- a/backend/tests/integration/test_filaments_api.py +++ b/backend/tests/integration/test_filaments_api.py @@ -10,6 +10,7 @@ class TestFilamentsAPI: @pytest.fixture async def filament_factory(self, db_session): """Factory to create test filaments.""" + async def _create_filament(**kwargs): from backend.app.models.filament import Filament @@ -41,9 +42,7 @@ class TestFilamentsAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_list_filaments_with_data( - self, async_client: AsyncClient, filament_factory, db_session - ): + async def test_list_filaments_with_data(self, async_client: AsyncClient, filament_factory, db_session): """Verify list returns existing filaments.""" await filament_factory(name="Test Filament") response = await async_client.get("/api/v1/filaments/") @@ -71,9 +70,7 @@ class TestFilamentsAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_get_filament( - self, async_client: AsyncClient, filament_factory, db_session - ): + async def test_get_filament(self, async_client: AsyncClient, filament_factory, db_session): """Verify single filament can be retrieved.""" filament = await filament_factory(name="Get Test") response = await async_client.get(f"/api/v1/filaments/{filament.id}") @@ -89,14 +86,11 @@ class TestFilamentsAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_update_filament( - self, async_client: AsyncClient, filament_factory, db_session - ): + async def test_update_filament(self, async_client: AsyncClient, filament_factory, db_session): """Verify filament can be updated.""" filament = await filament_factory(name="Original") response = await async_client.patch( - f"/api/v1/filaments/{filament.id}", - json={"name": "Updated", "cost_per_kg": 35.0} + f"/api/v1/filaments/{filament.id}", json={"name": "Updated", "cost_per_kg": 35.0} ) assert response.status_code == 200 result = response.json() @@ -105,9 +99,7 @@ class TestFilamentsAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_delete_filament( - self, async_client: AsyncClient, filament_factory, db_session - ): + async def test_delete_filament(self, async_client: AsyncClient, filament_factory, db_session): """Verify filament can be deleted.""" filament = await filament_factory() response = await async_client.delete(f"/api/v1/filaments/{filament.id}") diff --git a/backend/tests/integration/test_maintenance_api.py b/backend/tests/integration/test_maintenance_api.py index 72faa8c6d..8894aa776 100644 --- a/backend/tests/integration/test_maintenance_api.py +++ b/backend/tests/integration/test_maintenance_api.py @@ -56,17 +56,13 @@ class TestMaintenanceTypesAPI: "description": "Original", "default_interval_hours": 100.0, } - create_response = await async_client.post( - "/api/v1/maintenance/types", json=create_data - ) + create_response = await async_client.post("/api/v1/maintenance/types", json=create_data) assert create_response.status_code == 200 type_id = create_response.json()["id"] # Update it update_data = {"description": "Updated description"} - response = await async_client.patch( - f"/api/v1/maintenance/types/{type_id}", json=update_data - ) + response = await async_client.patch(f"/api/v1/maintenance/types/{type_id}", json=update_data) assert response.status_code == 200 assert response.json()["description"] == "Updated description" @@ -80,9 +76,7 @@ class TestMaintenanceTypesAPI: "description": "To be deleted", "default_interval_hours": 50.0, } - create_response = await async_client.post( - "/api/v1/maintenance/types", json=create_data - ) + create_response = await async_client.post("/api/v1/maintenance/types", json=create_data) type_id = create_response.json()["id"] # Delete it @@ -102,9 +96,7 @@ class TestPrinterMaintenanceAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_get_printer_maintenance( - self, async_client: AsyncClient, printer_factory, db_session - ): + async def test_get_printer_maintenance(self, async_client: AsyncClient, printer_factory, db_session): """Verify maintenance overview for a printer.""" printer = await printer_factory(name="Maintenance Test Printer") response = await async_client.get(f"/api/v1/maintenance/printers/{printer.id}") @@ -117,9 +109,7 @@ class TestPrinterMaintenanceAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_get_all_maintenance_overview( - self, async_client: AsyncClient, printer_factory, db_session - ): + async def test_get_all_maintenance_overview(self, async_client: AsyncClient, printer_factory, db_session): """Verify overview endpoint returns all printers.""" await printer_factory(name="Overview Printer 1") await printer_factory(name="Overview Printer 2") @@ -158,50 +148,39 @@ class TestMaintenanceItemsAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_update_maintenance_item( - self, async_client: AsyncClient, maintenance_item - ): + async def test_update_maintenance_item(self, async_client: AsyncClient, maintenance_item): """Verify maintenance item can be updated.""" if not maintenance_item: pytest.skip("No maintenance items available") item_id = maintenance_item["id"] response = await async_client.patch( - f"/api/v1/maintenance/items/{item_id}", - json={"custom_interval_hours": 150.0} + f"/api/v1/maintenance/items/{item_id}", json={"custom_interval_hours": 150.0} ) assert response.status_code == 200 @pytest.mark.asyncio @pytest.mark.integration - async def test_disable_maintenance_item( - self, async_client: AsyncClient, maintenance_item - ): + async def test_disable_maintenance_item(self, async_client: AsyncClient, maintenance_item): """Verify maintenance item can be disabled.""" if not maintenance_item: pytest.skip("No maintenance items available") item_id = maintenance_item["id"] - response = await async_client.patch( - f"/api/v1/maintenance/items/{item_id}", - json={"enabled": False} - ) + response = await async_client.patch(f"/api/v1/maintenance/items/{item_id}", json={"enabled": False}) assert response.status_code == 200 assert response.json()["enabled"] is False @pytest.mark.asyncio @pytest.mark.integration - async def test_perform_maintenance( - self, async_client: AsyncClient, maintenance_item - ): + async def test_perform_maintenance(self, async_client: AsyncClient, maintenance_item): """Verify maintenance can be marked as performed.""" if not maintenance_item: pytest.skip("No maintenance items available") item_id = maintenance_item["id"] response = await async_client.post( - f"/api/v1/maintenance/items/{item_id}/perform", - json={"notes": "Test maintenance performed"} + f"/api/v1/maintenance/items/{item_id}/perform", json={"notes": "Test maintenance performed"} ) assert response.status_code == 200 data = response.json() @@ -209,19 +188,14 @@ class TestMaintenanceItemsAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_get_maintenance_history( - self, async_client: AsyncClient, maintenance_item - ): + async def test_get_maintenance_history(self, async_client: AsyncClient, maintenance_item): """Verify maintenance history can be retrieved.""" if not maintenance_item: pytest.skip("No maintenance items available") item_id = maintenance_item["id"] # First perform maintenance to create history - await async_client.post( - f"/api/v1/maintenance/items/{item_id}/perform", - json={"notes": "History test"} - ) + await async_client.post(f"/api/v1/maintenance/items/{item_id}/perform", json={"notes": "History test"}) response = await async_client.get(f"/api/v1/maintenance/items/{item_id}/history") assert response.status_code == 200 @@ -232,10 +206,7 @@ class TestMaintenanceItemsAPI: @pytest.mark.integration async def test_update_maintenance_item_not_found(self, async_client: AsyncClient): """Verify 404 for non-existent maintenance item.""" - response = await async_client.patch( - "/api/v1/maintenance/items/9999", - json={"enabled": False} - ) + response = await async_client.patch("/api/v1/maintenance/items/9999", json={"enabled": False}) assert response.status_code == 404 @@ -244,14 +215,11 @@ class TestPrinterHoursAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_set_printer_hours( - self, async_client: AsyncClient, printer_factory, db_session - ): + async def test_set_printer_hours(self, async_client: AsyncClient, printer_factory, db_session): """Verify printer hours can be set.""" printer = await printer_factory(name="Hours Test Printer") response = await async_client.patch( - f"/api/v1/maintenance/printers/{printer.id}/hours", - params={"total_hours": 500.0} + f"/api/v1/maintenance/printers/{printer.id}/hours", params={"total_hours": 500.0} ) assert response.status_code == 200 data = response.json() @@ -261,8 +229,5 @@ class TestPrinterHoursAPI: @pytest.mark.integration async def test_set_printer_hours_not_found(self, async_client: AsyncClient): """Verify 404 for non-existent printer.""" - response = await async_client.patch( - "/api/v1/maintenance/printers/9999/hours", - params={"total_hours": 100.0} - ) + response = await async_client.patch("/api/v1/maintenance/printers/9999/hours", params={"total_hours": 100.0}) assert response.status_code == 404 diff --git a/backend/tests/integration/test_notifications_api.py b/backend/tests/integration/test_notifications_api.py index 9233348c3..bb3a1e1cc 100644 --- a/backend/tests/integration/test_notifications_api.py +++ b/backend/tests/integration/test_notifications_api.py @@ -16,9 +16,7 @@ class TestNotificationsAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_list_notification_providers_empty( - self, async_client: AsyncClient - ): + async def test_list_notification_providers_empty(self, async_client: AsyncClient): """Verify empty list is returned when no providers exist.""" response = await async_client.get("/api/v1/notifications/") @@ -31,7 +29,7 @@ class TestNotificationsAPI: self, async_client: AsyncClient, notification_provider_factory, db_session ): """Verify list returns existing providers.""" - provider = await notification_provider_factory(name="Test Provider") + _provider = await notification_provider_factory(name="Test Provider") response = await async_client.get("/api/v1/notifications/") @@ -91,9 +89,7 @@ class TestNotificationsAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_create_provider_with_printer( - self, async_client: AsyncClient, printer_factory, db_session - ): + async def test_create_provider_with_printer(self, async_client: AsyncClient, printer_factory, db_session): """Verify provider can be linked to specific printer.""" printer = await printer_factory(name="Test Printer") @@ -143,9 +139,7 @@ class TestNotificationsAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_update_event_toggles( - self, async_client: AsyncClient, notification_provider_factory, db_session - ): + async def test_update_event_toggles(self, async_client: AsyncClient, notification_provider_factory, db_session): """CRITICAL: Verify notification event toggles persist correctly.""" provider = await notification_provider_factory( on_print_start=True, @@ -154,10 +148,7 @@ class TestNotificationsAPI: ) # Toggle on_print_stopped to True - response = await async_client.patch( - f"/api/v1/notifications/{provider.id}", - json={"on_print_stopped": True} - ) + response = await async_client.patch(f"/api/v1/notifications/{provider.id}", json={"on_print_stopped": True}) assert response.status_code == 200 assert response.json()["on_print_stopped"] is True @@ -168,9 +159,7 @@ class TestNotificationsAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_update_ams_alarm_toggles( - self, async_client: AsyncClient, notification_provider_factory, db_session - ): + async def test_update_ams_alarm_toggles(self, async_client: AsyncClient, notification_provider_factory, db_session): """CRITICAL: Verify AMS alarm toggles persist correctly.""" provider = await notification_provider_factory( on_ams_humidity_high=False, @@ -183,7 +172,7 @@ class TestNotificationsAPI: json={ "on_ams_humidity_high": True, "on_ams_temperature_high": True, - } + }, ) assert response.status_code == 200 @@ -199,35 +188,25 @@ class TestNotificationsAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_enable_disable_provider( - self, async_client: AsyncClient, notification_provider_factory, db_session - ): + async def test_enable_disable_provider(self, async_client: AsyncClient, notification_provider_factory, db_session): """Verify provider can be enabled/disabled.""" provider = await notification_provider_factory(enabled=True) # Disable - response = await async_client.patch( - f"/api/v1/notifications/{provider.id}", - json={"enabled": False} - ) + response = await async_client.patch(f"/api/v1/notifications/{provider.id}", json={"enabled": False}) assert response.status_code == 200 assert response.json()["enabled"] is False # Enable - response = await async_client.patch( - f"/api/v1/notifications/{provider.id}", - json={"enabled": True} - ) + response = await async_client.patch(f"/api/v1/notifications/{provider.id}", json={"enabled": True}) assert response.status_code == 200 assert response.json()["enabled"] is True @pytest.mark.asyncio @pytest.mark.integration - async def test_update_quiet_hours( - self, async_client: AsyncClient, notification_provider_factory, db_session - ): + async def test_update_quiet_hours(self, async_client: AsyncClient, notification_provider_factory, db_session): """Verify quiet hours can be configured.""" provider = await notification_provider_factory(quiet_hours_enabled=False) @@ -237,7 +216,7 @@ class TestNotificationsAPI: "quiet_hours_enabled": True, "quiet_hours_start": "22:00", "quiet_hours_end": "07:00", - } + }, ) assert response.status_code == 200 @@ -248,9 +227,7 @@ class TestNotificationsAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_update_daily_digest( - self, async_client: AsyncClient, notification_provider_factory, db_session - ): + async def test_update_daily_digest(self, async_client: AsyncClient, notification_provider_factory, db_session): """Verify daily digest can be configured.""" provider = await notification_provider_factory(daily_digest_enabled=False) @@ -259,7 +236,7 @@ class TestNotificationsAPI: json={ "daily_digest_enabled": True, "daily_digest_time": "09:00", - } + }, ) assert response.status_code == 200 @@ -287,7 +264,7 @@ class TestNotificationsAPI: "on_print_start": False, "on_print_stopped": True, "on_printer_offline": True, - } + }, ) assert response.status_code == 200 @@ -306,15 +283,12 @@ class TestNotificationsAPI: @pytest.mark.asyncio @pytest.mark.integration async def test_test_notification( - self, async_client: AsyncClient, notification_provider_factory, - mock_httpx_client, db_session + self, async_client: AsyncClient, notification_provider_factory, mock_httpx_client, db_session ): """Verify test notification can be sent.""" provider = await notification_provider_factory() - response = await async_client.post( - f"/api/v1/notifications/{provider.id}/test" - ) + response = await async_client.post(f"/api/v1/notifications/{provider.id}/test") assert response.status_code == 200 result = response.json() @@ -328,9 +302,7 @@ class TestNotificationsAPI: """Verify test notification works even for disabled provider.""" provider = await notification_provider_factory(enabled=False) - response = await async_client.post( - f"/api/v1/notifications/{provider.id}/test" - ) + response = await async_client.post(f"/api/v1/notifications/{provider.id}/test") # Test should still work for disabled providers assert response.status_code == 200 @@ -371,7 +343,7 @@ class TestNotificationTemplatesAPI: @pytest.fixture async def seeded_templates(self, db_session): """Seed notification templates for tests.""" - from backend.app.models.notification_template import NotificationTemplate, DEFAULT_TEMPLATES + from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate templates = [] for template_data in DEFAULT_TEMPLATES: @@ -401,9 +373,7 @@ class TestNotificationTemplatesAPI: # Get first template ID from seeded data template_id = seeded_templates[0].id - response = await async_client.get( - f"/api/v1/notification-templates/{template_id}" - ) + response = await async_client.get(f"/api/v1/notification-templates/{template_id}") assert response.status_code == 200 template = response.json() @@ -422,7 +392,7 @@ class TestNotificationTemplatesAPI: json={ "title_template": "Custom Title: {printer}", "body_template": "Custom body for {filename}", - } + }, ) assert response.status_code == 200 @@ -436,9 +406,7 @@ class TestNotificationTemplatesAPI: """Verify template can be reset to default.""" template_id = seeded_templates[0].id - response = await async_client.post( - f"/api/v1/notification-templates/{template_id}/reset" - ) + response = await async_client.post(f"/api/v1/notification-templates/{template_id}/reset") assert response.status_code == 200 result = response.json() diff --git a/backend/tests/integration/test_projects_api.py b/backend/tests/integration/test_projects_api.py index b97953405..c6f1f5d84 100644 --- a/backend/tests/integration/test_projects_api.py +++ b/backend/tests/integration/test_projects_api.py @@ -43,9 +43,7 @@ class TestProjectsAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_list_projects_with_data( - self, async_client: AsyncClient, project_factory, db_session - ): + async def test_list_projects_with_data(self, async_client: AsyncClient, project_factory, db_session): """Verify list returns existing projects.""" await project_factory(name="My Project") response = await async_client.get("/api/v1/projects/") @@ -70,9 +68,7 @@ class TestProjectsAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_get_project( - self, async_client: AsyncClient, project_factory, db_session - ): + async def test_get_project(self, async_client: AsyncClient, project_factory, db_session): """Verify single project can be retrieved.""" project = await project_factory(name="Get Test Project") response = await async_client.get(f"/api/v1/projects/{project.id}") @@ -88,14 +84,11 @@ class TestProjectsAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_update_project( - self, async_client: AsyncClient, project_factory, db_session - ): + async def test_update_project(self, async_client: AsyncClient, project_factory, db_session): """Verify project can be updated.""" project = await project_factory(name="Original") response = await async_client.patch( - f"/api/v1/projects/{project.id}", - json={"name": "Updated", "description": "Updated description"} + f"/api/v1/projects/{project.id}", json={"name": "Updated", "description": "Updated description"} ) assert response.status_code == 200 result = response.json() @@ -104,9 +97,7 @@ class TestProjectsAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_delete_project( - self, async_client: AsyncClient, project_factory, db_session - ): + async def test_delete_project(self, async_client: AsyncClient, project_factory, db_session): """Verify project can be deleted.""" project = await project_factory() response = await async_client.delete(f"/api/v1/projects/{project.id}") @@ -128,6 +119,7 @@ class TestProjectArchivesAPI: @pytest.fixture async def project_factory(self, db_session): """Factory to create test projects.""" + async def _create_project(**kwargs): from backend.app.models.project import Project @@ -148,9 +140,7 @@ class TestProjectArchivesAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_get_project_with_archives( - self, async_client: AsyncClient, project_factory, db_session - ): + async def test_get_project_with_archives(self, async_client: AsyncClient, project_factory, db_session): """Verify project can be retrieved with archive count.""" project = await project_factory() response = await async_client.get(f"/api/v1/projects/{project.id}") diff --git a/backend/tests/integration/test_system_api.py b/backend/tests/integration/test_system_api.py index eff7359e6..4a7b4e563 100644 --- a/backend/tests/integration/test_system_api.py +++ b/backend/tests/integration/test_system_api.py @@ -3,8 +3,9 @@ Tests the full request/response cycle for /api/v1/system/ endpoints. """ +from unittest.mock import MagicMock, patch + import pytest -from unittest.mock import patch, MagicMock from httpx import AsyncClient @@ -20,18 +21,12 @@ class TestSystemAPI: async def test_get_system_info(self, async_client: AsyncClient): """Verify system info endpoint returns expected structure.""" # Mock psutil to avoid system-specific values - with patch('backend.app.api.routes.system.psutil') as mock_psutil: + with patch("backend.app.api.routes.system.psutil") as mock_psutil: mock_psutil.disk_usage.return_value = MagicMock( - total=500000000000, - used=250000000000, - free=250000000000, - percent=50.0 + total=500000000000, used=250000000000, free=250000000000, percent=50.0 ) mock_psutil.virtual_memory.return_value = MagicMock( - total=16000000000, - available=8000000000, - used=8000000000, - percent=50.0 + total=16000000000, available=8000000000, used=8000000000, percent=50.0 ) mock_psutil.boot_time.return_value = 1700000000.0 mock_psutil.cpu_count.return_value = 4 @@ -55,7 +50,7 @@ class TestSystemAPI: @pytest.mark.integration async def test_system_info_app_section(self, async_client: AsyncClient): """Verify app section contains version and directory info.""" - with patch('backend.app.api.routes.system.psutil') as mock_psutil: + with patch("backend.app.api.routes.system.psutil") as mock_psutil: mock_psutil.disk_usage.return_value = MagicMock( total=500000000000, used=250000000000, free=250000000000, percent=50.0 ) @@ -79,7 +74,7 @@ class TestSystemAPI: @pytest.mark.integration async def test_system_info_database_section(self, async_client: AsyncClient): """Verify database section contains counts and statistics.""" - with patch('backend.app.api.routes.system.psutil') as mock_psutil: + with patch("backend.app.api.routes.system.psutil") as mock_psutil: mock_psutil.disk_usage.return_value = MagicMock( total=500000000000, used=250000000000, free=250000000000, percent=50.0 ) @@ -111,7 +106,7 @@ class TestSystemAPI: @pytest.mark.integration async def test_system_info_storage_section(self, async_client: AsyncClient): """Verify storage section contains disk usage info.""" - with patch('backend.app.api.routes.system.psutil') as mock_psutil: + with patch("backend.app.api.routes.system.psutil") as mock_psutil: mock_psutil.disk_usage.return_value = MagicMock( total=500000000000, used=250000000000, free=250000000000, percent=50.0 ) @@ -141,7 +136,7 @@ class TestSystemAPI: @pytest.mark.integration async def test_system_info_memory_section(self, async_client: AsyncClient): """Verify memory section contains RAM usage info.""" - with patch('backend.app.api.routes.system.psutil') as mock_psutil: + with patch("backend.app.api.routes.system.psutil") as mock_psutil: mock_psutil.disk_usage.return_value = MagicMock( total=500000000000, used=250000000000, free=250000000000, percent=50.0 ) @@ -167,7 +162,7 @@ class TestSystemAPI: @pytest.mark.integration async def test_system_info_cpu_section(self, async_client: AsyncClient): """Verify CPU section contains processor info.""" - with patch('backend.app.api.routes.system.psutil') as mock_psutil: + with patch("backend.app.api.routes.system.psutil") as mock_psutil: mock_psutil.disk_usage.return_value = MagicMock( total=500000000000, used=250000000000, free=250000000000, percent=50.0 ) @@ -192,10 +187,12 @@ class TestSystemAPI: async def test_system_info_printers_section(self, async_client: AsyncClient, printer_factory): """Verify printers section contains connected printer info.""" # Create a test printer - printer = await printer_factory(name="Test Printer", model="X1C") + _printer = await printer_factory(name="Test Printer", model="X1C") - with patch('backend.app.api.routes.system.psutil') as mock_psutil, \ - patch('backend.app.api.routes.system.printer_manager') as mock_pm: + with ( + patch("backend.app.api.routes.system.psutil") as mock_psutil, + patch("backend.app.api.routes.system.printer_manager") as mock_pm, + ): mock_psutil.disk_usage.return_value = MagicMock( total=500000000000, used=250000000000, free=250000000000, percent=50.0 ) @@ -227,8 +224,10 @@ class TestSystemAPI: await archive_factory(printer.id, status="completed", print_time_seconds=3600) await archive_factory(printer.id, status="failed", print_time_seconds=1800) - with patch('backend.app.api.routes.system.psutil') as mock_psutil, \ - patch('backend.app.api.routes.system.printer_manager') as mock_pm: + with ( + patch("backend.app.api.routes.system.psutil") as mock_psutil, + patch("backend.app.api.routes.system.printer_manager") as mock_pm, + ): mock_psutil.disk_usage.return_value = MagicMock( total=500000000000, used=250000000000, free=250000000000, percent=50.0 ) diff --git a/backend/tests/unit/services/test_archive_service.py b/backend/tests/unit/services/test_archive_service.py index e129df93a..9c49d94ac 100644 --- a/backend/tests/unit/services/test_archive_service.py +++ b/backend/tests/unit/services/test_archive_service.py @@ -1,8 +1,9 @@ """Unit tests for the archive service.""" -import pytest from datetime import datetime -from unittest.mock import MagicMock, AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest class TestArchiveServiceHelpers: @@ -12,7 +13,7 @@ class TestArchiveServiceHelpers: """Test parsing print time to seconds.""" # Import the actual function if available, otherwise test the logic # 2h 30m 15s = 2*3600 + 30*60 + 15 = 9015 seconds - time_str = "2h 30m 15s" + _time_str = "2h 30m 15s" # Example format # Parse hours hours = 2 minutes = 30 @@ -24,7 +25,7 @@ class TestArchiveServiceHelpers: """Test parsing filament usage to grams.""" # Example: "150.5g" -> 150.5 filament_str = "150.5g" - grams = float(filament_str.replace('g', '')) + grams = float(filament_str.replace("g", "")) assert grams == 150.5 def test_format_duration(self): @@ -95,7 +96,7 @@ class TestArchiveFilePaths: def test_generate_archive_path(self): """Test generating archive file paths.""" printer_name = "X1C_01" - print_name = "benchy" + _print_name = "benchy" # Example print name timestamp = datetime(2024, 1, 15, 14, 30, 0) # Expected pattern: archives/{printer}/{year}/{month}/{filename} @@ -110,25 +111,25 @@ class TestArchiveFilePaths: def test_sanitize_filename(self): """Test filename sanitization.""" # Characters to remove: / \ : * ? " < > | - dirty_name = 'test:file.3mf' + dirty_name = "test:file.3mf" # Simple sanitization safe_chars = [] for c in dirty_name: if c not in '\\/:*?"<>|': safe_chars.append(c) - clean_name = ''.join(safe_chars) - assert ':' not in clean_name - assert '<' not in clean_name - assert '>' not in clean_name + clean_name = "".join(safe_chars) + assert ":" not in clean_name + assert "<" not in clean_name + assert ">" not in clean_name def test_thumbnail_path(self): """Test thumbnail path generation.""" archive_path = "archives/X1C_01/2024/01/benchy.3mf" # Thumbnail typically has same path with _thumb.png suffix - base_path = archive_path.rsplit('.', 1)[0] + base_path = archive_path.rsplit(".", 1)[0] thumbnail_path = f"{base_path}_thumb.png" - assert thumbnail_path.endswith('_thumb.png') - assert 'benchy' in thumbnail_path + assert thumbnail_path.endswith("_thumb.png") + assert "benchy" in thumbnail_path class TestArchiveStatus: @@ -199,7 +200,7 @@ class TestArchiveThumbnails: """Test supported thumbnail file types.""" supported_types = [".png", ".jpg", ".jpeg"] for ext in supported_types: - assert ext.startswith('.') + assert ext.startswith(".") assert ext.lower() in [".png", ".jpg", ".jpeg"] def test_extract_thumbnail_from_3mf(self): diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index 4aef18545..88d4600da 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/backend/tests/unit/services/test_bambu_mqtt.py @@ -4,9 +4,10 @@ Tests for the BambuMQTTClient service. These tests focus on timelapse tracking during prints. """ -import pytest from unittest.mock import MagicMock, patch +import pytest + class TestTimelapseTracking: """Tests for timelapse state tracking during prints.""" @@ -140,12 +141,14 @@ class TestPrintCompletionWithTimelapse: mqtt_client._completion_triggered = True mqtt_client._was_running = False mqtt_client._timelapse_during_print = False - mqtt_client.on_print_complete({ - "status": status, - "filename": mqtt_client._previous_gcode_file, - "subtask_name": mqtt_client.state.subtask_name, - "timelapse_was_active": timelapse_was_active, - }) + mqtt_client.on_print_complete( + { + "status": status, + "filename": mqtt_client._previous_gcode_file, + "subtask_name": mqtt_client.state.subtask_name, + "timelapse_was_active": timelapse_was_active, + } + ) assert "timelapse_was_active" in callback_data assert callback_data["timelapse_was_active"] is True @@ -170,12 +173,14 @@ class TestPrintCompletionWithTimelapse: # Trigger completion timelapse_was_active = mqtt_client._timelapse_during_print - mqtt_client.on_print_complete({ - "status": "completed", - "filename": mqtt_client._previous_gcode_file, - "subtask_name": mqtt_client.state.subtask_name, - "timelapse_was_active": timelapse_was_active, - }) + mqtt_client.on_print_complete( + { + "status": "completed", + "filename": mqtt_client._previous_gcode_file, + "subtask_name": mqtt_client.state.subtask_name, + "timelapse_was_active": timelapse_was_active, + } + ) assert callback_data["timelapse_was_active"] is False @@ -254,9 +259,9 @@ class TestRealisticMessageFlow: # Verify timelapse was detected even though xcam is parsed before state assert mqtt_client._was_running is True, "_was_running should be True after RUNNING state" assert mqtt_client.state.timelapse is True, "state.timelapse should be True" - assert mqtt_client._timelapse_during_print is True, ( - "timelapse_during_print should be True when timelapse is in the same message as RUNNING state" - ) + assert ( + mqtt_client._timelapse_during_print is True + ), "timelapse_during_print should be True when timelapse is in the same message as RUNNING state" def test_timelapse_not_detected_when_disabled(self, mqtt_client): """Test that timelapse is NOT detected when disabled in xcam data.""" @@ -334,40 +339,46 @@ class TestRealisticMessageFlow: mqtt_client.on_print_complete = on_complete # 1. Print starts with timelapse - mqtt_client._process_message({ - "print": { - "gcode_state": "RUNNING", - "gcode_file": "/data/Metadata/test.gcode", - "subtask_name": "Test", - "xcam": {"timelapse": "enable"}, + mqtt_client._process_message( + { + "print": { + "gcode_state": "RUNNING", + "gcode_file": "/data/Metadata/test.gcode", + "subtask_name": "Test", + "xcam": {"timelapse": "enable"}, + } } - }) + ) assert mqtt_client._timelapse_during_print is True assert "subtask_name" in start_data # 2. Print continues (multiple messages) for _ in range(3): - mqtt_client._process_message({ - "print": { - "gcode_state": "RUNNING", - "gcode_file": "/data/Metadata/test.gcode", - "subtask_name": "Test", - "mc_percent": 50, + mqtt_client._process_message( + { + "print": { + "gcode_state": "RUNNING", + "gcode_file": "/data/Metadata/test.gcode", + "subtask_name": "Test", + "mc_percent": 50, + } } - }) + ) # Timelapse flag should still be True assert mqtt_client._timelapse_during_print is True # 3. Print completes - mqtt_client._process_message({ - "print": { - "gcode_state": "FINISH", - "gcode_file": "/data/Metadata/test.gcode", - "subtask_name": "Test", + mqtt_client._process_message( + { + "print": { + "gcode_state": "FINISH", + "gcode_file": "/data/Metadata/test.gcode", + "subtask_name": "Test", + } } - }) + ) # Verify completion callback received timelapse flag assert "timelapse_was_active" in complete_data @@ -389,23 +400,27 @@ class TestRealisticMessageFlow: mqtt_client.on_print_complete = on_complete # Start with timelapse - mqtt_client._process_message({ - "print": { - "gcode_state": "RUNNING", - "gcode_file": "/data/Metadata/test.gcode", - "subtask_name": "Test", - "xcam": {"timelapse": "enable"}, + mqtt_client._process_message( + { + "print": { + "gcode_state": "RUNNING", + "gcode_file": "/data/Metadata/test.gcode", + "subtask_name": "Test", + "xcam": {"timelapse": "enable"}, + } } - }) + ) # Print fails - mqtt_client._process_message({ - "print": { - "gcode_state": "FAILED", - "gcode_file": "/data/Metadata/test.gcode", - "subtask_name": "Test", + mqtt_client._process_message( + { + "print": { + "gcode_state": "FAILED", + "gcode_file": "/data/Metadata/test.gcode", + "subtask_name": "Test", + } } - }) + ) assert complete_data["timelapse_was_active"] is True assert complete_data["status"] == "failed" diff --git a/backend/tests/unit/services/test_notification_service.py b/backend/tests/unit/services/test_notification_service.py index 603f02002..65da6ae5b 100644 --- a/backend/tests/unit/services/test_notification_service.py +++ b/backend/tests/unit/services/test_notification_service.py @@ -3,11 +3,12 @@ Tests event-based notifications and toggle behavior. """ -import pytest import json from datetime import datetime from unittest.mock import AsyncMock, MagicMock, patch +import pytest + from backend.app.services.notification_service import NotificationService @@ -59,20 +60,13 @@ class TestNotificationService: # ======================================================================== @pytest.mark.asyncio - async def test_on_print_start_sends_notification( - self, service, mock_provider, mock_db - ): + async def test_on_print_start_sends_notification(self, service, mock_provider, mock_db): """Verify notification is sent when print starts.""" - with patch.object( - service, '_get_providers_for_event', new_callable=AsyncMock - ) as mock_get, \ - patch.object( - service, '_send_to_providers', new_callable=AsyncMock - ) as mock_send, \ - patch.object( - service, '_build_message_from_template', new_callable=AsyncMock - ) as mock_build: - + with ( + patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get, + patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send, + patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build, + ): mock_get.return_value = [mock_provider] mock_build.return_value = ("Print Started", "Test Printer: test.3mf") @@ -87,17 +81,12 @@ class TestNotificationService: mock_send.assert_called_once() @pytest.mark.asyncio - async def test_on_print_start_skipped_when_no_providers( - self, service, mock_db - ): + async def test_on_print_start_skipped_when_no_providers(self, service, mock_db): """Verify no error when no providers are configured for event.""" - with patch.object( - service, '_get_providers_for_event', new_callable=AsyncMock - ) as mock_get, \ - patch.object( - service, '_send_to_providers', new_callable=AsyncMock - ) as mock_send: - + with ( + patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get, + patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send, + ): mock_get.return_value = [] await service.on_print_start( @@ -114,20 +103,13 @@ class TestNotificationService: # ======================================================================== @pytest.mark.asyncio - async def test_on_print_complete_routes_completed_status( - self, service, mock_provider, mock_db - ): + async def test_on_print_complete_routes_completed_status(self, service, mock_provider, mock_db): """Verify completed status uses on_print_complete field.""" - with patch.object( - service, '_get_providers_for_event', new_callable=AsyncMock - ) as mock_get, \ - patch.object( - service, '_send_to_providers', new_callable=AsyncMock - ), \ - patch.object( - service, '_build_message_from_template', new_callable=AsyncMock - ) as mock_build: - + with ( + patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get, + patch.object(service, "_send_to_providers", new_callable=AsyncMock), + patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build, + ): mock_get.return_value = [mock_provider] mock_build.return_value = ("Test", "Test") @@ -144,20 +126,13 @@ class TestNotificationService: assert call_args[0][1] == "on_print_complete" @pytest.mark.asyncio - async def test_on_print_complete_routes_failed_status( - self, service, mock_provider, mock_db - ): + async def test_on_print_complete_routes_failed_status(self, service, mock_provider, mock_db): """Verify failed status uses on_print_failed field.""" - with patch.object( - service, '_get_providers_for_event', new_callable=AsyncMock - ) as mock_get, \ - patch.object( - service, '_send_to_providers', new_callable=AsyncMock - ), \ - patch.object( - service, '_build_message_from_template', new_callable=AsyncMock - ) as mock_build: - + with ( + patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get, + patch.object(service, "_send_to_providers", new_callable=AsyncMock), + patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build, + ): mock_get.return_value = [mock_provider] mock_build.return_value = ("Test", "Test") @@ -173,20 +148,13 @@ class TestNotificationService: assert call_args[0][1] == "on_print_failed" @pytest.mark.asyncio - async def test_on_print_complete_routes_stopped_status( - self, service, mock_provider, mock_db - ): + async def test_on_print_complete_routes_stopped_status(self, service, mock_provider, mock_db): """Verify stopped status uses on_print_stopped field.""" - with patch.object( - service, '_get_providers_for_event', new_callable=AsyncMock - ) as mock_get, \ - patch.object( - service, '_send_to_providers', new_callable=AsyncMock - ), \ - patch.object( - service, '_build_message_from_template', new_callable=AsyncMock - ) as mock_build: - + with ( + patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get, + patch.object(service, "_send_to_providers", new_callable=AsyncMock), + patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build, + ): mock_get.return_value = [mock_provider] mock_build.return_value = ("Test", "Test") @@ -202,20 +170,13 @@ class TestNotificationService: assert call_args[0][1] == "on_print_stopped" @pytest.mark.asyncio - async def test_on_print_complete_routes_aborted_status( - self, service, mock_provider, mock_db - ): + async def test_on_print_complete_routes_aborted_status(self, service, mock_provider, mock_db): """Verify aborted status uses on_print_stopped field.""" - with patch.object( - service, '_get_providers_for_event', new_callable=AsyncMock - ) as mock_get, \ - patch.object( - service, '_send_to_providers', new_callable=AsyncMock - ), \ - patch.object( - service, '_build_message_from_template', new_callable=AsyncMock - ) as mock_build: - + with ( + patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get, + patch.object(service, "_send_to_providers", new_callable=AsyncMock), + patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build, + ): mock_get.return_value = [mock_provider] mock_build.return_value = ("Test", "Test") @@ -241,15 +202,11 @@ class TestNotificationService: # The actual filtering happens in _get_providers_for_event # which queries only enabled providers - with patch.object( - service, '_get_providers_for_event', new_callable=AsyncMock - ) as mock_get: + with patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get: # Simulate the query filtering out disabled providers mock_get.return_value = [] - result = await service._get_providers_for_event( - mock_db, "on_print_start", printer_id=1 - ) + result = await service._get_providers_for_event(mock_db, "on_print_start", printer_id=1) assert len(result) == 0 @@ -258,15 +215,11 @@ class TestNotificationService: """Verify providers can be filtered by specific printer.""" mock_provider.printer_id = 2 # Linked to printer 2 - with patch.object( - service, '_get_providers_for_event', new_callable=AsyncMock - ) as mock_get: + with patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get: # When querying for printer 1, provider linked to printer 2 is excluded mock_get.return_value = [] - result = await service._get_providers_for_event( - mock_db, "on_print_start", printer_id=1 - ) + result = await service._get_providers_for_event(mock_db, "on_print_start", printer_id=1) assert len(result) == 0 @@ -280,9 +233,7 @@ class TestNotificationService: mock_provider.quiet_hours_start = "22:00" mock_provider.quiet_hours_end = "07:00" - with patch( - 'backend.app.services.notification_service.datetime' - ) as mock_datetime: + with patch("backend.app.services.notification_service.datetime") as mock_datetime: # Test during quiet hours (23:00) mock_now = MagicMock() mock_now.hour = 23 @@ -299,9 +250,7 @@ class TestNotificationService: mock_provider.quiet_hours_start = "22:00" mock_provider.quiet_hours_end = "07:00" - with patch( - 'backend.app.services.notification_service.datetime' - ) as mock_datetime: + with patch("backend.app.services.notification_service.datetime") as mock_datetime: # Test outside quiet hours (12:00) mock_now = MagicMock() mock_now.hour = 12 @@ -326,9 +275,7 @@ class TestNotificationService: mock_provider.quiet_hours_start = "22:00" mock_provider.quiet_hours_end = "07:00" - with patch( - 'backend.app.services.notification_service.datetime' - ) as mock_datetime: + with patch("backend.app.services.notification_service.datetime") as mock_datetime: # Test early morning (03:00) - should be in quiet hours mock_now = MagicMock() mock_now.hour = 3 @@ -344,22 +291,15 @@ class TestNotificationService: # ======================================================================== @pytest.mark.asyncio - async def test_on_ams_humidity_high_sends_notification( - self, service, mock_provider, mock_db - ): + async def test_on_ams_humidity_high_sends_notification(self, service, mock_provider, mock_db): """Verify AMS humidity alarm sends notification.""" mock_provider.on_ams_humidity_high = True - with patch.object( - service, '_get_providers_for_event', new_callable=AsyncMock - ) as mock_get, \ - patch.object( - service, '_send_to_providers', new_callable=AsyncMock - ) as mock_send, \ - patch.object( - service, '_build_message_from_template', new_callable=AsyncMock - ) as mock_build: - + with ( + patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get, + patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send, + patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build, + ): mock_get.return_value = [mock_provider] mock_build.return_value = ("AMS Humidity Alert", "High humidity detected") @@ -375,25 +315,18 @@ class TestNotificationService: mock_send.assert_called_once() # Verify force_immediate is True for alarms call_kwargs = mock_send.call_args[1] - assert call_kwargs.get('force_immediate') is True + assert call_kwargs.get("force_immediate") is True @pytest.mark.asyncio - async def test_on_ams_temperature_high_sends_notification( - self, service, mock_provider, mock_db - ): + async def test_on_ams_temperature_high_sends_notification(self, service, mock_provider, mock_db): """Verify AMS temperature alarm sends notification.""" mock_provider.on_ams_temperature_high = True - with patch.object( - service, '_get_providers_for_event', new_callable=AsyncMock - ) as mock_get, \ - patch.object( - service, '_send_to_providers', new_callable=AsyncMock - ) as mock_send, \ - patch.object( - service, '_build_message_from_template', new_callable=AsyncMock - ) as mock_build: - + with ( + patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get, + patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send, + patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build, + ): mock_get.return_value = [mock_provider] mock_build.return_value = ("AMS Temperature Alert", "High temp detected") @@ -409,22 +342,17 @@ class TestNotificationService: mock_send.assert_called_once() # Verify force_immediate is True for alarms call_kwargs = mock_send.call_args[1] - assert call_kwargs.get('force_immediate') is True + assert call_kwargs.get("force_immediate") is True @pytest.mark.asyncio - async def test_ams_alarm_skipped_when_toggle_disabled( - self, service, mock_provider, mock_db - ): + async def test_ams_alarm_skipped_when_toggle_disabled(self, service, mock_provider, mock_db): """CRITICAL: Verify AMS alarms respect toggle setting.""" mock_provider.on_ams_humidity_high = False - with patch.object( - service, '_get_providers_for_event', new_callable=AsyncMock - ) as mock_get, \ - patch.object( - service, '_send_to_providers', new_callable=AsyncMock - ) as mock_send: - + with ( + patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get, + patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send, + ): # Provider with toggle disabled won't be returned mock_get.return_value = [] @@ -444,23 +372,16 @@ class TestNotificationService: # ======================================================================== @pytest.mark.asyncio - async def test_daily_digest_queues_notification( - self, service, mock_provider, mock_db - ): + async def test_daily_digest_queues_notification(self, service, mock_provider, mock_db): """Verify notifications are queued when digest mode is enabled.""" mock_provider.daily_digest_enabled = True mock_provider.daily_digest_time = "09:00" - with patch.object( - service, '_get_providers_for_event', new_callable=AsyncMock - ) as mock_get, \ - patch.object( - service, '_send_to_providers', new_callable=AsyncMock - ) as mock_send, \ - patch.object( - service, '_build_message_from_template', new_callable=AsyncMock - ) as mock_build: - + with ( + patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get, + patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send, + patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build, + ): mock_get.return_value = [mock_provider] mock_build.return_value = ("Test", "Test") @@ -477,23 +398,16 @@ class TestNotificationService: mock_send.assert_called_once() @pytest.mark.asyncio - async def test_force_immediate_bypasses_digest( - self, service, mock_provider, mock_db - ): + async def test_force_immediate_bypasses_digest(self, service, mock_provider, mock_db): """Verify force_immediate=True bypasses digest mode.""" mock_provider.daily_digest_enabled = True mock_provider.on_ams_humidity_high = True - with patch.object( - service, '_get_providers_for_event', new_callable=AsyncMock - ) as mock_get, \ - patch.object( - service, '_send_to_providers', new_callable=AsyncMock - ) as mock_send, \ - patch.object( - service, '_build_message_from_template', new_callable=AsyncMock - ) as mock_build: - + with ( + patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get, + patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send, + patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build, + ): mock_get.return_value = [mock_provider] mock_build.return_value = ("Alert", "Alert message") @@ -508,7 +422,7 @@ class TestNotificationService: # Verify force_immediate is passed call_kwargs = mock_send.call_args[1] - assert call_kwargs.get('force_immediate') is True + assert call_kwargs.get("force_immediate") is True class TestDigestModeAlwaysSendsImmediately: @@ -534,25 +448,27 @@ class TestDigestModeAlwaysSendsImmediately: mock_db = AsyncMock() # Mock the _send_to_provider method - with patch.object(service, '_send_to_provider', new_callable=AsyncMock) as mock_send: + with ( + patch.object(service, "_send_to_provider", new_callable=AsyncMock) as mock_send, + patch.object(service, "_queue_for_digest", new_callable=AsyncMock) as mock_queue, + patch.object(service, "_update_provider_status", new_callable=AsyncMock), + patch.object(service, "_log_notification", new_callable=AsyncMock), + ): mock_send.return_value = (True, None) - with patch.object(service, '_queue_for_digest', new_callable=AsyncMock) as mock_queue: - with patch.object(service, '_update_provider_status', new_callable=AsyncMock): - with patch.object(service, '_log_notification', new_callable=AsyncMock): - await service._send_to_providers( - providers=[mock_provider], - title="Print Started", - message="Your print has started", - db=mock_db, - event_type="print_start", - ) + await service._send_to_providers( + providers=[mock_provider], + title="Print Started", + message="Your print has started", + db=mock_db, + event_type="print_start", + ) - # CRITICAL: _send_to_provider MUST be called (immediate send) - mock_send.assert_called_once() + # CRITICAL: _send_to_provider MUST be called (immediate send) + mock_send.assert_called_once() - # Digest queue should also be called (for daily summary) - mock_queue.assert_called_once() + # Digest queue should also be called (for daily summary) + mock_queue.assert_called_once() @pytest.mark.asyncio async def test_notification_sends_without_digest_queue_when_disabled(self, service): @@ -568,25 +484,27 @@ class TestDigestModeAlwaysSendsImmediately: mock_db = AsyncMock() - with patch.object(service, '_send_to_provider', new_callable=AsyncMock) as mock_send: + with ( + patch.object(service, "_send_to_provider", new_callable=AsyncMock) as mock_send, + patch.object(service, "_queue_for_digest", new_callable=AsyncMock) as mock_queue, + patch.object(service, "_update_provider_status", new_callable=AsyncMock), + patch.object(service, "_log_notification", new_callable=AsyncMock), + ): mock_send.return_value = (True, None) - with patch.object(service, '_queue_for_digest', new_callable=AsyncMock) as mock_queue: - with patch.object(service, '_update_provider_status', new_callable=AsyncMock): - with patch.object(service, '_log_notification', new_callable=AsyncMock): - await service._send_to_providers( - providers=[mock_provider], - title="Print Started", - message="Your print has started", - db=mock_db, - event_type="print_start", - ) + await service._send_to_providers( + providers=[mock_provider], + title="Print Started", + message="Your print has started", + db=mock_db, + event_type="print_start", + ) - # Notification must still be sent immediately - mock_send.assert_called_once() + # Notification must still be sent immediately + mock_send.assert_called_once() - # Digest queue should NOT be called when digest is disabled - mock_queue.assert_not_called() + # Digest queue should NOT be called when digest is disabled + mock_queue.assert_not_called() class TestNotificationProviderTypes: @@ -613,12 +531,10 @@ class TestNotificationProviderTypes: mock_client = AsyncMock() mock_client.post = AsyncMock(return_value=mock_response) - with patch.object(service, '_get_client', new_callable=AsyncMock) as mock_get_client: + with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client: mock_get_client.return_value = mock_client - success, message = await service._send_webhook( - config, "Test Title", "Test Message" - ) + success, message = await service._send_webhook(config, "Test Title", "Test Message") assert success is True mock_client.post.assert_called_once() @@ -630,17 +546,13 @@ class TestNotificationProviderTypes: "webhook_url": "http://test.local/webhook", } - with patch('httpx.AsyncClient') as mock_client_class: + with patch("httpx.AsyncClient") as mock_client_class: mock_instance = AsyncMock() mock_instance.post.side_effect = Exception("Connection failed") - mock_client_class.return_value.__aenter__ = AsyncMock( - return_value=mock_instance - ) + mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_instance) mock_client_class.return_value.__aexit__ = AsyncMock() - success, message = await service._send_webhook( - config, "Test", "Test" - ) + success, message = await service._send_webhook(config, "Test", "Test") assert success is False assert "Connection failed" in message or "error" in message.lower() @@ -686,16 +598,11 @@ class TestNotificationVariableFallbacks: """CRITICAL: Verify fallback values when archive_data is missing.""" mock_db = AsyncMock() - with patch.object( - service, '_get_providers_for_event', new_callable=AsyncMock - ) as mock_get, \ - patch.object( - service, '_send_to_providers', new_callable=AsyncMock - ) as mock_send, \ - patch.object( - service, '_build_message_from_template', new_callable=AsyncMock - ) as mock_build: - + with ( + patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get, + patch.object(service, "_send_to_providers", new_callable=AsyncMock), + patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build, + ): mock_get.return_value = [] # No providers, just testing variable setup mock_build.return_value = ("Test", "Test") @@ -721,16 +628,11 @@ class TestNotificationVariableFallbacks: captured_variables.update(variables) return ("Test", "Test") - with patch.object( - service, '_get_providers_for_event', new_callable=AsyncMock - ) as mock_get, \ - patch.object( - service, '_send_to_providers', new_callable=AsyncMock - ), \ - patch.object( - service, '_build_message_from_template', side_effect=capture_build + with ( + patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get, + patch.object(service, "_send_to_providers", new_callable=AsyncMock), + patch.object(service, "_build_message_from_template", side_effect=capture_build), ): - mock_get.return_value = [] await service.on_print_complete( @@ -762,16 +664,11 @@ class TestNotificationVariableFallbacks: captured_variables.update(variables) return ("Test", "Test") - with patch.object( - service, '_get_providers_for_event', new_callable=AsyncMock - ) as mock_get, \ - patch.object( - service, '_send_to_providers', new_callable=AsyncMock - ), \ - patch.object( - service, '_build_message_from_template', side_effect=capture_build + with ( + patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get, + patch.object(service, "_send_to_providers", new_callable=AsyncMock), + patch.object(service, "_build_message_from_template", side_effect=capture_build), ): - # Need at least one provider to trigger message building mock_get.return_value = [mock_provider] @@ -801,16 +698,11 @@ class TestNotificationVariableFallbacks: captured_variables.update(variables) return ("Test", "Test") - with patch.object( - service, '_get_providers_for_event', new_callable=AsyncMock - ) as mock_get, \ - patch.object( - service, '_send_to_providers', new_callable=AsyncMock - ), \ - patch.object( - service, '_build_message_from_template', side_effect=capture_build + with ( + patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get, + patch.object(service, "_send_to_providers", new_callable=AsyncMock), + patch.object(service, "_build_message_from_template", side_effect=capture_build), ): - # Need at least one provider to trigger message building mock_get.return_value = [mock_provider] @@ -839,16 +731,11 @@ class TestNotificationVariableFallbacks: captured_variables.update(variables) return ("Test", "Test") - with patch.object( - service, '_get_providers_for_event', new_callable=AsyncMock - ) as mock_get, \ - patch.object( - service, '_send_to_providers', new_callable=AsyncMock - ), \ - patch.object( - service, '_build_message_from_template', side_effect=capture_build + with ( + patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get, + patch.object(service, "_send_to_providers", new_callable=AsyncMock), + patch.object(service, "_build_message_from_template", side_effect=capture_build), ): - # Need at least one provider to trigger message building mock_get.return_value = [mock_provider] @@ -876,16 +763,11 @@ class TestNotificationVariableFallbacks: captured_variables.update(variables) return ("Test", "Test") - with patch.object( - service, '_get_providers_for_event', new_callable=AsyncMock - ) as mock_get, \ - patch.object( - service, '_send_to_providers', new_callable=AsyncMock - ), \ - patch.object( - service, '_build_message_from_template', side_effect=capture_build + with ( + patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get, + patch.object(service, "_send_to_providers", new_callable=AsyncMock), + patch.object(service, "_build_message_from_template", side_effect=capture_build), ): - mock_get.return_value = [mock_provider] # Pass archive_data with print_time_seconds (7200 seconds = 2 hours) @@ -913,16 +795,11 @@ class TestNotificationVariableFallbacks: captured_variables.update(variables) return ("Test", "Test") - with patch.object( - service, '_get_providers_for_event', new_callable=AsyncMock - ) as mock_get, \ - patch.object( - service, '_send_to_providers', new_callable=AsyncMock - ), \ - patch.object( - service, '_build_message_from_template', side_effect=capture_build + with ( + patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get, + patch.object(service, "_send_to_providers", new_callable=AsyncMock), + patch.object(service, "_build_message_from_template", side_effect=capture_build), ): - mock_get.return_value = [mock_provider] # Both archive_data and MQTT remaining_time provided @@ -954,16 +831,11 @@ class TestNotificationVariableFallbacks: captured_variables.update(variables) return ("Test", "Test") - with patch.object( - service, '_get_providers_for_event', new_callable=AsyncMock - ) as mock_get, \ - patch.object( - service, '_send_to_providers', new_callable=AsyncMock - ), \ - patch.object( - service, '_build_message_from_template', side_effect=capture_build + with ( + patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get, + patch.object(service, "_send_to_providers", new_callable=AsyncMock), + patch.object(service, "_build_message_from_template", side_effect=capture_build), ): - mock_get.return_value = [mock_provider] # Only MQTT remaining_time provided (1800 seconds = 30 minutes) @@ -1018,9 +890,7 @@ class TestNotificationTemplates: # Should handle gracefully - either leave placeholder or skip try: - result = template.format_map( - {**variables, "unknown_var": "{unknown_var}"} - ) + result = template.format_map({**variables, "unknown_var": "{unknown_var}"}) assert "Test" in result except KeyError: pytest.fail("Template should handle missing variables gracefully") diff --git a/backend/tests/unit/services/test_smart_plug_manager.py b/backend/tests/unit/services/test_smart_plug_manager.py index e6cfc2919..5d66c0da0 100644 --- a/backend/tests/unit/services/test_smart_plug_manager.py +++ b/backend/tests/unit/services/test_smart_plug_manager.py @@ -4,11 +4,12 @@ These tests specifically target the auto-off behavior and toggle functionality that were identified as common regression points. """ -import pytest import asyncio from datetime import datetime from unittest.mock import AsyncMock, MagicMock, patch +import pytest + from backend.app.services.smart_plug_manager import SmartPlugManager @@ -57,11 +58,10 @@ class TestSmartPlugManager: @pytest.mark.asyncio async def test_on_print_start_turns_on_plug(self, manager, mock_plug, mock_db): """Verify plug is turned ON when print starts with auto_on enabled.""" - with patch.object( - manager, '_get_plug_for_printer', new_callable=AsyncMock - ) as mock_get_plug, \ - patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota: - + with ( + patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug, + patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota, + ): mock_get_plug.return_value = mock_plug mock_tasmota.turn_on = AsyncMock(return_value=True) @@ -70,17 +70,14 @@ class TestSmartPlugManager: mock_tasmota.turn_on.assert_called_once_with(mock_plug) @pytest.mark.asyncio - async def test_on_print_start_skipped_when_auto_on_disabled( - self, manager, mock_plug, mock_db - ): + async def test_on_print_start_skipped_when_auto_on_disabled(self, manager, mock_plug, mock_db): """Verify plug is NOT turned on when auto_on is disabled.""" mock_plug.auto_on = False - with patch.object( - manager, '_get_plug_for_printer', new_callable=AsyncMock - ) as mock_get_plug, \ - patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota: - + with ( + patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug, + patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota, + ): mock_get_plug.return_value = mock_plug mock_tasmota.turn_on = AsyncMock() @@ -89,17 +86,14 @@ class TestSmartPlugManager: mock_tasmota.turn_on.assert_not_called() @pytest.mark.asyncio - async def test_on_print_start_skipped_when_plug_disabled( - self, manager, mock_plug, mock_db - ): + async def test_on_print_start_skipped_when_plug_disabled(self, manager, mock_plug, mock_db): """Verify plug is NOT turned on when plug.enabled is False.""" mock_plug.enabled = False - with patch.object( - manager, '_get_plug_for_printer', new_callable=AsyncMock - ) as mock_get_plug, \ - patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota: - + with ( + patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug, + patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota, + ): mock_get_plug.return_value = mock_plug mock_tasmota.turn_on = AsyncMock() @@ -108,15 +102,12 @@ class TestSmartPlugManager: mock_tasmota.turn_on.assert_not_called() @pytest.mark.asyncio - async def test_on_print_start_skipped_when_no_plug_found( - self, manager, mock_db - ): + async def test_on_print_start_skipped_when_no_plug_found(self, manager, mock_db): """Verify graceful handling when no plug is linked to printer.""" - with patch.object( - manager, '_get_plug_for_printer', new_callable=AsyncMock - ) as mock_get_plug, \ - patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota: - + with ( + patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug, + patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota, + ): mock_get_plug.return_value = None mock_tasmota.turn_on = AsyncMock() @@ -126,22 +117,17 @@ class TestSmartPlugManager: mock_tasmota.turn_on.assert_not_called() @pytest.mark.asyncio - async def test_on_print_start_cancels_pending_off( - self, manager, mock_plug, mock_db - ): + async def test_on_print_start_cancels_pending_off(self, manager, mock_plug, mock_db): """Verify starting a new print cancels any pending auto-off.""" # Set up a pending task mock_task = MagicMock() manager._pending_off[mock_plug.id] = mock_task - with patch.object( - manager, '_get_plug_for_printer', new_callable=AsyncMock - ) as mock_get_plug, \ - patch.object( - manager, '_mark_auto_off_pending', new_callable=AsyncMock - ), \ - patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota: - + with ( + patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug, + patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock), + patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota, + ): mock_get_plug.return_value = mock_plug mock_tasmota.turn_on = AsyncMock(return_value=True) @@ -151,17 +137,14 @@ class TestSmartPlugManager: assert mock_plug.id not in manager._pending_off @pytest.mark.asyncio - async def test_on_print_start_resets_auto_off_executed_flag( - self, manager, mock_plug, mock_db - ): + async def test_on_print_start_resets_auto_off_executed_flag(self, manager, mock_plug, mock_db): """Verify auto_off_executed flag is reset when turning on.""" mock_plug.auto_off_executed = True - with patch.object( - manager, '_get_plug_for_printer', new_callable=AsyncMock - ) as mock_get_plug, \ - patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota: - + with ( + patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug, + patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota, + ): mock_get_plug.return_value = mock_plug mock_tasmota.turn_on = AsyncMock(return_value=True) @@ -174,125 +157,95 @@ class TestSmartPlugManager: # ======================================================================== @pytest.mark.asyncio - async def test_on_print_complete_schedules_time_based_off( - self, manager, mock_plug, mock_db - ): + async def test_on_print_complete_schedules_time_based_off(self, manager, mock_plug, mock_db): """Verify time-based auto-off is scheduled when print completes.""" mock_plug.off_delay_mode = "time" mock_plug.off_delay_minutes = 5 - with patch.object( - manager, '_get_plug_for_printer', new_callable=AsyncMock - ) as mock_get_plug, \ - patch.object(manager, '_schedule_delayed_off') as mock_schedule: - + with ( + patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug, + patch.object(manager, "_schedule_delayed_off") as mock_schedule, + ): mock_get_plug.return_value = mock_plug - await manager.on_print_complete( - printer_id=1, status="completed", db=mock_db - ) + await manager.on_print_complete(printer_id=1, status="completed", db=mock_db) mock_schedule.assert_called_once_with(mock_plug, 1, 300) # 5 min * 60 sec @pytest.mark.asyncio - async def test_on_print_complete_schedules_temp_based_off( - self, manager, mock_plug, mock_db - ): + async def test_on_print_complete_schedules_temp_based_off(self, manager, mock_plug, mock_db): """Verify temperature-based auto-off is scheduled when print completes.""" mock_plug.off_delay_mode = "temperature" mock_plug.off_temp_threshold = 70 - with patch.object( - manager, '_get_plug_for_printer', new_callable=AsyncMock - ) as mock_get_plug, \ - patch.object(manager, '_schedule_temp_based_off') as mock_schedule: - + with ( + patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug, + patch.object(manager, "_schedule_temp_based_off") as mock_schedule, + ): mock_get_plug.return_value = mock_plug - await manager.on_print_complete( - printer_id=1, status="completed", db=mock_db - ) + await manager.on_print_complete(printer_id=1, status="completed", db=mock_db) mock_schedule.assert_called_once_with(mock_plug, 1, 70) @pytest.mark.asyncio - async def test_on_print_complete_skipped_when_auto_off_disabled( - self, manager, mock_plug, mock_db - ): + async def test_on_print_complete_skipped_when_auto_off_disabled(self, manager, mock_plug, mock_db): """CRITICAL: Verify auto-off does NOT trigger when auto_off is False. This is a key regression test - the toggle must respect the setting. """ mock_plug.auto_off = False - with patch.object( - manager, '_get_plug_for_printer', new_callable=AsyncMock - ) as mock_get_plug, \ - patch.object(manager, '_schedule_delayed_off') as mock_schedule, \ - patch.object(manager, '_schedule_temp_based_off') as mock_temp: - + with ( + patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug, + patch.object(manager, "_schedule_delayed_off") as mock_schedule, + patch.object(manager, "_schedule_temp_based_off") as mock_temp, + ): mock_get_plug.return_value = mock_plug - await manager.on_print_complete( - printer_id=1, status="completed", db=mock_db - ) + await manager.on_print_complete(printer_id=1, status="completed", db=mock_db) mock_schedule.assert_not_called() mock_temp.assert_not_called() @pytest.mark.asyncio - async def test_on_print_complete_skipped_when_plug_disabled( - self, manager, mock_plug, mock_db - ): + async def test_on_print_complete_skipped_when_plug_disabled(self, manager, mock_plug, mock_db): """Verify auto-off does NOT trigger when plug is disabled.""" mock_plug.enabled = False - with patch.object( - manager, '_get_plug_for_printer', new_callable=AsyncMock - ) as mock_get_plug, \ - patch.object(manager, '_schedule_delayed_off') as mock_schedule: - + with ( + patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug, + patch.object(manager, "_schedule_delayed_off") as mock_schedule, + ): mock_get_plug.return_value = mock_plug - await manager.on_print_complete( - printer_id=1, status="completed", db=mock_db - ) + await manager.on_print_complete(printer_id=1, status="completed", db=mock_db) mock_schedule.assert_not_called() @pytest.mark.asyncio - async def test_on_print_complete_skipped_on_failed_print( - self, manager, mock_plug, mock_db - ): + async def test_on_print_complete_skipped_on_failed_print(self, manager, mock_plug, mock_db): """Verify auto-off does NOT trigger on failed prints for investigation.""" - with patch.object( - manager, '_get_plug_for_printer', new_callable=AsyncMock - ) as mock_get_plug, \ - patch.object(manager, '_schedule_delayed_off') as mock_schedule: - + with ( + patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug, + patch.object(manager, "_schedule_delayed_off") as mock_schedule, + ): mock_get_plug.return_value = mock_plug - await manager.on_print_complete( - printer_id=1, status="failed", db=mock_db - ) + await manager.on_print_complete(printer_id=1, status="failed", db=mock_db) mock_schedule.assert_not_called() @pytest.mark.asyncio - async def test_on_print_complete_skipped_on_aborted_print( - self, manager, mock_plug, mock_db - ): + async def test_on_print_complete_skipped_on_aborted_print(self, manager, mock_plug, mock_db): """Verify auto-off does NOT trigger on aborted prints.""" - with patch.object( - manager, '_get_plug_for_printer', new_callable=AsyncMock - ) as mock_get_plug, \ - patch.object(manager, '_schedule_delayed_off') as mock_schedule: - + with ( + patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug, + patch.object(manager, "_schedule_delayed_off") as mock_schedule, + ): mock_get_plug.return_value = mock_plug - await manager.on_print_complete( - printer_id=1, status="aborted", db=mock_db - ) + await manager.on_print_complete(printer_id=1, status="aborted", db=mock_db) mock_schedule.assert_not_called() @@ -306,9 +259,7 @@ class TestSmartPlugManager: mock_task = MagicMock() manager._pending_off[mock_plug.id] = mock_task - with patch.object( - manager, '_mark_auto_off_pending', new_callable=AsyncMock - ): + with patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock): manager._cancel_pending_off(mock_plug.id) assert mock_plug.id not in manager._pending_off @@ -318,9 +269,7 @@ class TestSmartPlugManager: async def test_cancel_pending_off_handles_missing_task(self, manager): """Verify no error when cancelling non-existent task.""" # Should not raise any exception - with patch.object( - manager, '_mark_auto_off_pending', new_callable=AsyncMock - ): + with patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock): manager._cancel_pending_off(999) # Non-existent plug ID @pytest.mark.asyncio @@ -331,7 +280,7 @@ class TestSmartPlugManager: manager._pending_off[1] = mock_task1 manager._pending_off[2] = mock_task2 - with patch('asyncio.create_task') as mock_create: + with patch("asyncio.create_task"): manager.cancel_all_pending() assert len(manager._pending_off) == 0 @@ -347,8 +296,7 @@ class TestSmartPlugManager: assert manager._scheduler_task is None # Mock _schedule_loop to return a mock coroutine to avoid unawaited coroutine warning - with patch.object(manager, '_schedule_loop') as mock_loop, \ - patch('asyncio.create_task') as mock_create: + with patch.object(manager, "_schedule_loop") as mock_loop, patch("asyncio.create_task") as mock_create: mock_create.return_value = MagicMock() manager.start_scheduler() @@ -371,8 +319,7 @@ class TestSmartPlugManager: manager._scheduler_task = mock_task # Mock _schedule_loop to avoid unawaited coroutine warning (in case it's called) - with patch.object(manager, '_schedule_loop') as mock_loop, \ - patch('asyncio.create_task') as mock_create: + with patch.object(manager, "_schedule_loop") as mock_loop, patch("asyncio.create_task") as mock_create: manager.start_scheduler() mock_create.assert_not_called() # Should not create new task @@ -399,16 +346,11 @@ class TestScheduleLoop: mock_plug.printer_id = None mock_plug.last_state = "OFF" - with patch( - 'backend.app.services.smart_plug_manager.datetime' - ) as mock_datetime, \ - patch( - 'backend.app.core.database.async_session' - ) as mock_session_ctx, \ - patch( - 'backend.app.services.smart_plug_manager.tasmota_service' - ) as mock_tasmota: - + with ( + patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime, + patch("backend.app.core.database.async_session") as mock_session_ctx, + patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota, + ): # Set current time to 08:00 mock_now = MagicMock() mock_now.strftime.return_value = "08:00" @@ -444,19 +386,12 @@ class TestScheduleLoop: mock_plug.printer_id = 1 mock_plug.last_state = "ON" - with patch( - 'backend.app.services.smart_plug_manager.datetime' - ) as mock_datetime, \ - patch( - 'backend.app.core.database.async_session' - ) as mock_session_ctx, \ - patch( - 'backend.app.services.smart_plug_manager.tasmota_service' - ) as mock_tasmota, \ - patch( - 'backend.app.services.smart_plug_manager.printer_manager' - ) as mock_pm: - + with ( + patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime, + patch("backend.app.core.database.async_session") as mock_session_ctx, + patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota, + patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm, + ): # Set current time to 22:00 mock_now = MagicMock() mock_now.strftime.return_value = "22:00" @@ -488,16 +423,11 @@ class TestScheduleLoop: mock_plug.enabled = True mock_plug.schedule_enabled = False # Disabled - with patch( - 'backend.app.services.smart_plug_manager.datetime' - ) as mock_datetime, \ - patch( - 'backend.app.core.database.async_session' - ) as mock_session_ctx, \ - patch( - 'backend.app.services.smart_plug_manager.tasmota_service' - ) as mock_tasmota: - + with ( + patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime, + patch("backend.app.core.database.async_session") as mock_session_ctx, + patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota, + ): mock_now = MagicMock() mock_now.strftime.return_value = "08:00" mock_datetime.now.return_value = mock_now @@ -541,13 +471,10 @@ class TestPendingAutoOffPersistence: mock_plug.off_delay_mode = "temperature" mock_plug.off_temp_threshold = 70 - with patch( - 'backend.app.core.database.async_session' - ) as mock_session_ctx, \ - patch.object( - manager, '_schedule_temp_based_off' - ) as mock_schedule: - + with ( + patch("backend.app.core.database.async_session") as mock_session_ctx, + patch.object(manager, "_schedule_temp_based_off") as mock_schedule, + ): mock_db = AsyncMock() mock_result = MagicMock() mock_result.scalars.return_value.all.return_value = [mock_plug] @@ -574,19 +501,12 @@ class TestPendingAutoOffPersistence: mock_plug.auto_off_pending_since = datetime.utcnow() mock_plug.off_delay_mode = "time" - with patch( - 'backend.app.core.database.async_session' - ) as mock_session_ctx, \ - patch( - 'backend.app.services.smart_plug_manager.tasmota_service' - ) as mock_tasmota, \ - patch.object( - manager, '_mark_auto_off_executed', new_callable=AsyncMock - ) as mock_mark, \ - patch( - 'backend.app.services.smart_plug_manager.printer_manager' - ) as mock_pm: - + with ( + patch("backend.app.core.database.async_session") as mock_session_ctx, + patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota, + patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock) as mock_mark, + patch("backend.app.services.smart_plug_manager.printer_manager"), + ): mock_db = AsyncMock() mock_result = MagicMock() mock_result.scalars.return_value.all.return_value = [mock_plug] diff --git a/backend/tests/unit/services/test_tasmota.py b/backend/tests/unit/services/test_tasmota.py index 9282116ec..cfb6316f9 100644 --- a/backend/tests/unit/services/test_tasmota.py +++ b/backend/tests/unit/services/test_tasmota.py @@ -3,9 +3,10 @@ Tests smart plug HTTP communication and error handling. """ -import pytest from unittest.mock import AsyncMock, MagicMock, patch + import httpx +import pytest from backend.app.services.tasmota import TasmotaService @@ -39,9 +40,7 @@ class TestTasmotaService: def test_build_url_with_auth(self, service): """Verify URL includes credentials when provided.""" - url = service._build_url( - "192.168.1.100", "Power On", username="admin", password="secret" - ) + url = service._build_url("192.168.1.100", "Power On", username="admin", password="secret") assert url == "http://admin:secret@192.168.1.100/cm?cmnd=Power%20On" def test_build_url_encodes_special_characters(self, service): @@ -56,24 +55,18 @@ class TestTasmotaService: @pytest.mark.asyncio async def test_turn_on_success(self, service, mock_plug): """Verify turn_on returns True on success.""" - with patch.object( - service, '_send_command', new_callable=AsyncMock - ) as mock_send: + with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send: mock_send.return_value = {"POWER": "ON"} result = await service.turn_on(mock_plug) assert result is True - mock_send.assert_called_once_with( - "192.168.1.100", "Power On", None, None - ) + mock_send.assert_called_once_with("192.168.1.100", "Power On", None, None) @pytest.mark.asyncio async def test_turn_on_failure(self, service, mock_plug): """Verify turn_on returns False on failure.""" - with patch.object( - service, '_send_command', new_callable=AsyncMock - ) as mock_send: + with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send: mock_send.return_value = None result = await service.turn_on(mock_plug) @@ -86,16 +79,12 @@ class TestTasmotaService: mock_plug.username = "admin" mock_plug.password = "secret" - with patch.object( - service, '_send_command', new_callable=AsyncMock - ) as mock_send: + with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send: mock_send.return_value = {"POWER": "ON"} await service.turn_on(mock_plug) - mock_send.assert_called_once_with( - "192.168.1.100", "Power On", "admin", "secret" - ) + mock_send.assert_called_once_with("192.168.1.100", "Power On", "admin", "secret") # ======================================================================== # Tests for turn_off @@ -104,9 +93,7 @@ class TestTasmotaService: @pytest.mark.asyncio async def test_turn_off_success(self, service, mock_plug): """Verify turn_off returns True on success.""" - with patch.object( - service, '_send_command', new_callable=AsyncMock - ) as mock_send: + with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send: mock_send.return_value = {"POWER": "OFF"} result = await service.turn_off(mock_plug) @@ -116,9 +103,7 @@ class TestTasmotaService: @pytest.mark.asyncio async def test_turn_off_failure(self, service, mock_plug): """Verify turn_off returns False on failure.""" - with patch.object( - service, '_send_command', new_callable=AsyncMock - ) as mock_send: + with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send: mock_send.return_value = None result = await service.turn_off(mock_plug) @@ -132,17 +117,13 @@ class TestTasmotaService: @pytest.mark.asyncio async def test_toggle_success(self, service, mock_plug): """Verify toggle returns True on success.""" - with patch.object( - service, '_send_command', new_callable=AsyncMock - ) as mock_send: + with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send: mock_send.return_value = {"POWER": "ON"} result = await service.toggle(mock_plug) assert result is True - mock_send.assert_called_once_with( - "192.168.1.100", "Power Toggle", None, None - ) + mock_send.assert_called_once_with("192.168.1.100", "Power Toggle", None, None) # ======================================================================== # Tests for get_status @@ -151,9 +132,7 @@ class TestTasmotaService: @pytest.mark.asyncio async def test_get_status_returns_on(self, service, mock_plug): """Verify get_status returns correct state when ON.""" - with patch.object( - service, '_send_command', new_callable=AsyncMock - ) as mock_send: + with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send: # Tasmota returns {"POWER": "ON"} for Power command mock_send.return_value = {"POWER": "ON"} @@ -166,9 +145,7 @@ class TestTasmotaService: @pytest.mark.asyncio async def test_get_status_returns_off(self, service, mock_plug): """Verify get_status returns correct state when OFF.""" - with patch.object( - service, '_send_command', new_callable=AsyncMock - ) as mock_send: + with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send: # Tasmota returns {"POWER": "OFF"} for Power command mock_send.return_value = {"POWER": "OFF"} @@ -180,9 +157,7 @@ class TestTasmotaService: @pytest.mark.asyncio async def test_get_status_unreachable(self, service, mock_plug): """Verify get_status handles unreachable device.""" - with patch.object( - service, '_send_command', new_callable=AsyncMock - ) as mock_send: + with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send: mock_send.return_value = None result = await service.get_status(mock_plug) @@ -197,9 +172,7 @@ class TestTasmotaService: @pytest.mark.asyncio async def test_get_energy_returns_data(self, service, mock_plug): """Verify get_energy parses energy data correctly.""" - with patch.object( - service, '_send_command', new_callable=AsyncMock - ) as mock_send: + with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send: mock_send.return_value = { "StatusSNS": { "ENERGY": { @@ -226,9 +199,7 @@ class TestTasmotaService: @pytest.mark.asyncio async def test_get_energy_handles_missing_data(self, service, mock_plug): """Verify get_energy handles devices without energy monitoring.""" - with patch.object( - service, '_send_command', new_callable=AsyncMock - ) as mock_send: + with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send: mock_send.return_value = {"StatusSNS": {}} result = await service.get_energy(mock_plug) @@ -238,9 +209,7 @@ class TestTasmotaService: @pytest.mark.asyncio async def test_get_energy_handles_unreachable(self, service, mock_plug): """Verify get_energy handles unreachable device.""" - with patch.object( - service, '_send_command', new_callable=AsyncMock - ) as mock_send: + with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send: mock_send.return_value = None result = await service.get_energy(mock_plug) @@ -250,9 +219,7 @@ class TestTasmotaService: @pytest.mark.asyncio async def test_get_energy_handles_partial_data(self, service, mock_plug): """Verify get_energy handles partial energy data.""" - with patch.object( - service, '_send_command', new_callable=AsyncMock - ) as mock_send: + with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send: mock_send.return_value = { "StatusSNS": { "ENERGY": { @@ -276,13 +243,11 @@ class TestTasmotaService: @pytest.mark.asyncio async def test_test_connection_success(self, service): """Verify test_connection returns success on reachable device.""" - with patch.object( - service, '_send_command', new_callable=AsyncMock - ) as mock_send: + with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send: # First call (Power) returns state, second call (Status 0) returns device info mock_send.side_effect = [ {"POWER": "ON"}, # Power command response - {"Status": {"DeviceName": "Test Plug"}} # Status 0 response + {"Status": {"DeviceName": "Test Plug"}}, # Status 0 response ] result = await service.test_connection("192.168.1.100") @@ -294,9 +259,7 @@ class TestTasmotaService: @pytest.mark.asyncio async def test_test_connection_failure(self, service): """Verify test_connection returns failure on unreachable device.""" - with patch.object( - service, '_send_command', new_callable=AsyncMock - ) as mock_send: + with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send: mock_send.return_value = None result = await service.test_connection("192.168.1.100") @@ -310,12 +273,10 @@ class TestTasmotaService: @pytest.mark.asyncio async def test_send_command_handles_timeout(self, service): """Verify timeout is handled gracefully.""" - with patch('httpx.AsyncClient') as mock_client_class: + with patch("httpx.AsyncClient") as mock_client_class: mock_client = AsyncMock() mock_client.get.side_effect = httpx.TimeoutException("Timeout") - mock_client_class.return_value.__aenter__ = AsyncMock( - return_value=mock_client - ) + mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client) mock_client_class.return_value.__aexit__ = AsyncMock() result = await service._send_command("192.168.1.100", "Power") @@ -325,12 +286,10 @@ class TestTasmotaService: @pytest.mark.asyncio async def test_send_command_handles_connection_error(self, service): """Verify connection error is handled gracefully.""" - with patch('httpx.AsyncClient') as mock_client_class: + with patch("httpx.AsyncClient") as mock_client_class: mock_client = AsyncMock() mock_client.get.side_effect = httpx.ConnectError("Connection refused") - mock_client_class.return_value.__aenter__ = AsyncMock( - return_value=mock_client - ) + mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client) mock_client_class.return_value.__aexit__ = AsyncMock() result = await service._send_command("192.168.1.100", "Power") @@ -340,14 +299,12 @@ class TestTasmotaService: @pytest.mark.asyncio async def test_send_command_handles_invalid_json(self, service): """Verify invalid JSON response is handled gracefully.""" - with patch('httpx.AsyncClient') as mock_client_class: + with patch("httpx.AsyncClient") as mock_client_class: mock_client = AsyncMock() mock_response = MagicMock() mock_response.json.side_effect = ValueError("Invalid JSON") mock_client.get.return_value = mock_response - mock_client_class.return_value.__aenter__ = AsyncMock( - return_value=mock_client - ) + mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client) mock_client_class.return_value.__aexit__ = AsyncMock() result = await service._send_command("192.168.1.100", "Power") @@ -357,14 +314,12 @@ class TestTasmotaService: @pytest.mark.asyncio async def test_send_command_success(self, service): """Verify successful command returns parsed JSON.""" - with patch('httpx.AsyncClient') as mock_client_class: + with patch("httpx.AsyncClient") as mock_client_class: mock_client = AsyncMock() mock_response = MagicMock() mock_response.json.return_value = {"POWER": "ON"} mock_client.get.return_value = mock_response - mock_client_class.return_value.__aenter__ = AsyncMock( - return_value=mock_client - ) + mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client) mock_client_class.return_value.__aexit__ = AsyncMock() result = await service._send_command("192.168.1.100", "Power") diff --git a/backend/tests/unit/services/test_telemetry.py b/backend/tests/unit/services/test_telemetry.py index 27561aa3c..dadd40ba9 100644 --- a/backend/tests/unit/services/test_telemetry.py +++ b/backend/tests/unit/services/test_telemetry.py @@ -3,20 +3,21 @@ Tests the anonymous telemetry/stats collection functionality. """ -import pytest -from unittest.mock import patch, AsyncMock, MagicMock from datetime import datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch +import pytest + +from backend.app.models.settings import Settings from backend.app.services.telemetry import ( - get_or_create_installation_id, - is_telemetry_enabled, - get_telemetry_url, - send_heartbeat, DEFAULT_TELEMETRY_URL, HEARTBEAT_INTERVAL, _last_heartbeat, + get_or_create_installation_id, + get_telemetry_url, + is_telemetry_enabled, + send_heartbeat, ) -from backend.app.models.settings import Settings class TestTelemetryService: @@ -134,7 +135,7 @@ class TestTelemetryService: db_session.add(setting) await db_session.commit() - with patch('httpx.AsyncClient') as mock_client: + with patch("httpx.AsyncClient") as mock_client: result = await send_heartbeat(db_session) assert result is False @@ -145,6 +146,7 @@ class TestTelemetryService: """Verify heartbeat is sent successfully when enabled.""" # Reset the last heartbeat to allow sending import backend.app.services.telemetry as telemetry_module + telemetry_module._last_heartbeat = None result = await send_heartbeat(db_session) @@ -159,7 +161,7 @@ class TestTelemetryService: # Set last heartbeat to recent time telemetry_module._last_heartbeat = datetime.now() - with patch('httpx.AsyncClient') as mock_client: + with patch("httpx.AsyncClient") as mock_client: result = await send_heartbeat(db_session) # Should return True (already sent) without making HTTP request @@ -193,14 +195,14 @@ class TestTelemetryService: captured_data = {} - with patch('httpx.AsyncClient') as mock_class: + with patch("httpx.AsyncClient") as mock_class: mock_instance = AsyncMock() mock_response = MagicMock() mock_response.raise_for_status = MagicMock() async def capture_post(url, json=None): - captured_data['url'] = url - captured_data['json'] = json + captured_data["url"] = url + captured_data["json"] = json return mock_response mock_instance.post = capture_post @@ -210,9 +212,9 @@ class TestTelemetryService: await send_heartbeat(db_session) - assert "heartbeat" in captured_data['url'] - assert "installation_id" in captured_data['json'] - assert captured_data['json']['version'] == APP_VERSION + assert "heartbeat" in captured_data["url"] + assert "installation_id" in captured_data["json"] + assert captured_data["json"]["version"] == APP_VERSION class TestHeartbeatInterval: @@ -220,7 +222,7 @@ class TestHeartbeatInterval: def test_heartbeat_interval_is_24_hours(self): """Verify heartbeat interval is set to 24 hours.""" - assert HEARTBEAT_INTERVAL == timedelta(hours=24) + assert timedelta(hours=24) == HEARTBEAT_INTERVAL def test_default_telemetry_url(self): """Verify default telemetry URL is correct.""" diff --git a/backend/tests/unit/test_code_quality.py b/backend/tests/unit/test_code_quality.py index 346d15732..d56a53af6 100644 --- a/backend/tests/unit/test_code_quality.py +++ b/backend/tests/unit/test_code_quality.py @@ -7,9 +7,9 @@ that could cause runtime errors but aren't caught by normal tests. import ast import os -import pytest from pathlib import Path +import pytest # Get the backend source directory BACKEND_DIR = Path(__file__).parent.parent.parent / "app" @@ -18,8 +18,22 @@ BACKEND_DIR = Path(__file__).parent.parent.parent / "app" # Safe imports that are commonly re-imported in functions without issues # These are typically imported at the START of a function, not midway through SAFE_REIMPORT_NAMES = { - 'logging', 're', 'os', 'sys', 'json', 'Path', 'datetime', 'timedelta', - 'asyncio', 'time', 'typing', 'Optional', 'List', 'Dict', 'Any', 'Union', + "logging", + "re", + "os", + "sys", + "json", + "Path", + "datetime", + "timedelta", + "asyncio", + "time", + "typing", + "Optional", + "List", + "Dict", + "Any", + "Union", } @@ -68,16 +82,10 @@ class DangerousImportVisitor(ast.NodeVisitor): for child in ast.walk(node): # Find local imports if isinstance(child, (ast.Import, ast.ImportFrom)): - if isinstance(child, ast.Import): - for alias in child.names: - name = alias.asname or alias.name - if name in self.module_imports and name not in SAFE_REIMPORT_NAMES: - local_imports[name] = child.lineno - elif isinstance(child, ast.ImportFrom): - for alias in child.names: - name = alias.asname or alias.name - if name in self.module_imports and name not in SAFE_REIMPORT_NAMES: - local_imports[name] = child.lineno + for alias in child.names: + name = alias.asname or alias.name + if name in self.module_imports and name not in SAFE_REIMPORT_NAMES: + local_imports[name] = child.lineno # Find name uses if isinstance(child, ast.Name): @@ -130,7 +138,7 @@ def find_import_shadowing(file_path: Path) -> list[tuple[str, int, str]]: Returns list of (name, line_number, function_name) tuples. """ try: - with open(file_path, 'r') as f: + with open(file_path) as f: source = f.read() tree = ast.parse(source) visitor = DangerousImportVisitor() diff --git a/backend/tests/unit/test_log_error_detection.py b/backend/tests/unit/test_log_error_detection.py index f1a4ef955..93d0fa865 100644 --- a/backend/tests/unit/test_log_error_detection.py +++ b/backend/tests/unit/test_log_error_detection.py @@ -5,9 +5,10 @@ These tests use the capture_logs fixture to detect runtime errors that might not cause test failures but indicate problems. """ -import pytest from unittest.mock import AsyncMock, MagicMock, patch +import pytest + class TestMQTTMessageProcessingNoErrors: """Verify MQTT message processing doesn't log errors.""" @@ -39,8 +40,7 @@ class TestMQTTMessageProcessingNoErrors: client._process_message(message) - assert not capture_logs.has_errors(), \ - f"Errors during message processing: {capture_logs.format_errors()}" + assert not capture_logs.has_errors(), f"Errors during message processing: {capture_logs.format_errors()}" def test_process_xcam_data(self, capture_logs): """Test processing xcam (camera/AI) data.""" @@ -66,8 +66,7 @@ class TestMQTTMessageProcessingNoErrors: client._process_message(message) - assert not capture_logs.has_errors(), \ - f"Errors during xcam processing: {capture_logs.format_errors()}" + assert not capture_logs.has_errors(), f"Errors during xcam processing: {capture_logs.format_errors()}" def test_process_ams_data(self, capture_logs): """Test processing AMS (Automatic Material System) data.""" @@ -94,7 +93,7 @@ class TestMQTTMessageProcessingNoErrors: "tray_color": "FF0000", "remain": 80, } - ] + ], } ] } @@ -103,8 +102,7 @@ class TestMQTTMessageProcessingNoErrors: client._process_message(message) - assert not capture_logs.has_errors(), \ - f"Errors during AMS processing: {capture_logs.format_errors()}" + assert not capture_logs.has_errors(), f"Errors during AMS processing: {capture_logs.format_errors()}" def test_process_hms_errors(self, capture_logs): """Test processing HMS (Health Management System) errors.""" @@ -129,8 +127,7 @@ class TestMQTTMessageProcessingNoErrors: client._process_message(message) - assert not capture_logs.has_errors(), \ - f"Errors during HMS processing: {capture_logs.format_errors()}" + assert not capture_logs.has_errors(), f"Errors during HMS processing: {capture_logs.format_errors()}" class TestPrintLifecycleNoErrors: @@ -149,36 +146,41 @@ class TestPrintLifecycleNoErrors: client.on_print_complete = lambda data: None # Start print - client._process_message({ - "print": { - "gcode_state": "RUNNING", - "gcode_file": "/data/Metadata/test.gcode", - "subtask_name": "Test", - "mc_percent": 0, - } - }) - - # Progress updates - for percent in [25, 50, 75]: - client._process_message({ + client._process_message( + { "print": { "gcode_state": "RUNNING", "gcode_file": "/data/Metadata/test.gcode", - "mc_percent": percent, + "subtask_name": "Test", + "mc_percent": 0, } - }) + } + ) + + # Progress updates + for percent in [25, 50, 75]: + client._process_message( + { + "print": { + "gcode_state": "RUNNING", + "gcode_file": "/data/Metadata/test.gcode", + "mc_percent": percent, + } + } + ) # Complete - client._process_message({ - "print": { - "gcode_state": "FINISH", - "gcode_file": "/data/Metadata/test.gcode", - "subtask_name": "Test", + client._process_message( + { + "print": { + "gcode_state": "FINISH", + "gcode_file": "/data/Metadata/test.gcode", + "subtask_name": "Test", + } } - }) + ) - assert not capture_logs.has_errors(), \ - f"Errors during print lifecycle: {capture_logs.format_errors()}" + assert not capture_logs.has_errors(), f"Errors during print lifecycle: {capture_logs.format_errors()}" def test_print_failure_handling(self, capture_logs): """Test print failure is handled without errors.""" @@ -193,26 +195,29 @@ class TestPrintLifecycleNoErrors: client.on_print_complete = lambda data: None # Start print - client._process_message({ - "print": { - "gcode_state": "RUNNING", - "gcode_file": "/data/Metadata/test.gcode", - "subtask_name": "Test", + client._process_message( + { + "print": { + "gcode_state": "RUNNING", + "gcode_file": "/data/Metadata/test.gcode", + "subtask_name": "Test", + } } - }) + ) # Fail - client._process_message({ - "print": { - "gcode_state": "FAILED", - "gcode_file": "/data/Metadata/test.gcode", - "subtask_name": "Test", - "print_error": 117506052, + client._process_message( + { + "print": { + "gcode_state": "FAILED", + "gcode_file": "/data/Metadata/test.gcode", + "subtask_name": "Test", + "print_error": 117506052, + } } - }) + ) - assert not capture_logs.has_errors(), \ - f"Errors during print failure: {capture_logs.format_errors()}" + assert not capture_logs.has_errors(), f"Errors during print failure: {capture_logs.format_errors()}" class TestServiceImports: @@ -221,18 +226,21 @@ class TestServiceImports: def test_archive_service_import(self, capture_logs): """Verify ArchiveService can be imported without errors.""" from backend.app.services.archive import ArchiveService + assert ArchiveService is not None assert not capture_logs.has_errors() def test_notification_service_import(self, capture_logs): """Verify NotificationService can be imported without errors.""" from backend.app.services.notification_service import notification_service + assert notification_service is not None assert not capture_logs.has_errors() def test_printer_manager_import(self, capture_logs): """Verify PrinterManager can be imported without errors.""" from backend.app.services.printer_manager import printer_manager + assert printer_manager is not None assert not capture_logs.has_errors() @@ -240,11 +248,12 @@ class TestServiceImports: """Verify main module imports cleanly.""" # This will fail if there are import shadowing issues from backend.app import main + assert main is not None # Verify key functions exist - assert hasattr(main, 'on_print_start') - assert hasattr(main, 'on_print_complete') + assert hasattr(main, "on_print_start") + assert hasattr(main, "on_print_complete") assert not capture_logs.has_errors() @@ -263,8 +272,7 @@ class TestEdgeCases: client._process_message({}) - assert not capture_logs.has_errors(), \ - f"Errors with empty message: {capture_logs.format_errors()}" + assert not capture_logs.has_errors(), f"Errors with empty message: {capture_logs.format_errors()}" def test_message_with_unknown_fields(self, capture_logs): """Test handling of message with unknown fields.""" @@ -276,17 +284,18 @@ class TestEdgeCases: access_code="12345678", ) - client._process_message({ - "print": { - "gcode_state": "RUNNING", - "unknown_field_1": "value1", - "unknown_field_2": 12345, - "unknown_nested": {"a": 1, "b": 2}, + client._process_message( + { + "print": { + "gcode_state": "RUNNING", + "unknown_field_1": "value1", + "unknown_field_2": 12345, + "unknown_nested": {"a": 1, "b": 2}, + } } - }) + ) - assert not capture_logs.has_errors(), \ - f"Errors with unknown fields: {capture_logs.format_errors()}" + assert not capture_logs.has_errors(), f"Errors with unknown fields: {capture_logs.format_errors()}" def test_message_with_null_values(self, capture_logs): """Test handling of message with null values for optional fields.""" @@ -300,14 +309,15 @@ class TestEdgeCases: # Only test null values for fields that should handle them gracefully # mc_percent is expected to be a number when present - client._process_message({ - "print": { - "gcode_state": "IDLE", - "gcode_file": None, - "subtask_name": None, - "bed_temper": 0.0, # Use 0 instead of None + client._process_message( + { + "print": { + "gcode_state": "IDLE", + "gcode_file": None, + "subtask_name": None, + "bed_temper": 0.0, # Use 0 instead of None + } } - }) + ) - assert not capture_logs.has_errors(), \ - f"Errors with null values: {capture_logs.format_errors()}" + assert not capture_logs.has_errors(), f"Errors with null values: {capture_logs.format_errors()}" diff --git a/frontend/public/icons/chamber.svg b/frontend/public/icons/chamber.svg index 8c3e510c2..7db181be5 100644 --- a/frontend/public/icons/chamber.svg +++ b/frontend/public/icons/chamber.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/public/icons/heatbed.svg b/frontend/public/icons/heatbed.svg index cb8c96ca6..11f9ac2db 100644 --- a/frontend/public/icons/heatbed.svg +++ b/frontend/public/icons/heatbed.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/public/icons/reload.svg b/frontend/public/icons/reload.svg index 5d3fae2f4..6f67f5cf7 100644 --- a/frontend/public/icons/reload.svg +++ b/frontend/public/icons/reload.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/public/icons/settings.svg b/frontend/public/icons/settings.svg index a4c852ffe..9097c5302 100644 --- a/frontend/public/icons/settings.svg +++ b/frontend/public/icons/settings.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/public/icons/skip-objects.svg b/frontend/public/icons/skip-objects.svg index 7aaf958e0..d78b60f18 100644 --- a/frontend/public/icons/skip-objects.svg +++ b/frontend/public/icons/skip-objects.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/public/icons/video-camera.svg b/frontend/public/icons/video-camera.svg index 11b170197..40132a207 100644 --- a/frontend/public/icons/video-camera.svg +++ b/frontend/public/icons/video-camera.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg index e7b8dfb1b..ee9fadaf9 100644 --- a/frontend/public/vite.svg +++ b/frontend/public/vite.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg index 6c87de9bb..8e0e0f15c 100644 --- a/frontend/src/assets/react.svg +++ b/frontend/src/assets/react.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/src/components/APIBrowser.tsx b/frontend/src/components/APIBrowser.tsx new file mode 100644 index 000000000..c49c40bd9 --- /dev/null +++ b/frontend/src/components/APIBrowser.tsx @@ -0,0 +1,648 @@ +import { useState, useEffect } from 'react'; +import { ChevronDown, ChevronRight, Play, Copy, Loader2, ExternalLink, AlertCircle, CheckCircle } from 'lucide-react'; +import { Card, CardContent } from './Card'; +import { Button } from './Button'; + +interface OpenAPISchema { + paths: Record>; + components?: { + schemas?: Record; + }; +} + +interface EndpointSpec { + summary?: string; + description?: string; + tags?: string[]; + parameters?: ParameterSpec[]; + requestBody?: { + content?: { + 'application/json'?: { + schema?: SchemaSpec; + }; + }; + }; + responses?: Record; +} + +interface ParameterSpec { + name: string; + in: 'path' | 'query' | 'header'; + required?: boolean; + description?: string; + schema?: { + type?: string; + default?: unknown; + enum?: string[]; + }; +} + +interface SchemaSpec { + type?: string; + properties?: Record; + required?: string[]; + items?: SchemaSpec; + $ref?: string; + allOf?: SchemaSpec[]; + anyOf?: SchemaSpec[]; + oneOf?: SchemaSpec[]; + default?: unknown; + description?: string; + enum?: string[]; + example?: unknown; +} + +interface ResponseSpec { + description?: string; + content?: { + 'application/json'?: { + schema?: SchemaSpec; + }; + }; +} + +interface APIResponse { + status: number; + statusText: string; + headers: Record; + body: unknown; + duration: number; +} + +const METHOD_COLORS: Record = { + get: 'bg-blue-500/20 text-blue-400 border-blue-500/30', + post: 'bg-green-500/20 text-green-400 border-green-500/30', + put: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', + patch: 'bg-orange-500/20 text-orange-400 border-orange-500/30', + delete: 'bg-red-500/20 text-red-400 border-red-500/30', +}; + +function resolveRef(schema: OpenAPISchema, ref: string): SchemaSpec { + // Parse $ref like "#/components/schemas/PrinterCreate" + const parts = ref.replace('#/', '').split('/'); + let current: unknown = schema; + for (const part of parts) { + current = (current as Record)[part]; + } + return current as SchemaSpec; +} + +function getSchemaExample(schema: OpenAPISchema, spec: SchemaSpec, depth = 0): unknown { + if (depth > 5) return '...'; + + if (spec.$ref) { + return getSchemaExample(schema, resolveRef(schema, spec.$ref), depth + 1); + } + + if (spec.allOf) { + const merged: Record = {}; + for (const sub of spec.allOf) { + const subExample = getSchemaExample(schema, sub, depth + 1); + if (typeof subExample === 'object' && subExample !== null) { + Object.assign(merged, subExample); + } + } + return merged; + } + + if (spec.example !== undefined) return spec.example; + if (spec.default !== undefined) return spec.default; + + switch (spec.type) { + case 'string': + if (spec.enum) return spec.enum[0]; + return 'string'; + case 'integer': + case 'number': + return 0; + case 'boolean': + return false; + case 'array': + return spec.items ? [getSchemaExample(schema, spec.items, depth + 1)] : []; + case 'object': + if (spec.properties) { + const obj: Record = {}; + for (const [key, propSpec] of Object.entries(spec.properties)) { + obj[key] = getSchemaExample(schema, propSpec, depth + 1); + } + return obj; + } + return {}; + default: + return null; + } +} + +interface EndpointItemProps { + path: string; + method: string; + spec: EndpointSpec; + schema: OpenAPISchema; + apiKey: string; +} + +function EndpointItem({ path, method, spec, schema, apiKey }: EndpointItemProps) { + const [expanded, setExpanded] = useState(false); + const [params, setParams] = useState>({}); + const [bodyText, setBodyText] = useState(''); + const [response, setResponse] = useState(null); + const [loading, setLoading] = useState(false); + const [copied, setCopied] = useState(false); + + // Initialize params with defaults + useEffect(() => { + if (expanded && spec.parameters) { + const defaults: Record = {}; + for (const param of spec.parameters) { + if (param.schema?.default !== undefined) { + defaults[param.name] = String(param.schema.default); + } + } + setParams(prev => ({ ...defaults, ...prev })); + } + }, [expanded, spec.parameters]); + + // Initialize body with example + useEffect(() => { + if (expanded && spec.requestBody?.content?.['application/json']?.schema && !bodyText) { + const bodySchema = spec.requestBody.content['application/json'].schema; + const example = getSchemaExample(schema, bodySchema); + setBodyText(JSON.stringify(example, null, 2)); + } + }, [expanded, spec.requestBody, schema, bodyText]); + + // Check for missing required parameters + const getMissingParams = () => { + const missing: string[] = []; + for (const param of spec.parameters || []) { + if (param.in === 'path' || param.required) { + const value = params[param.name]; + if (value === undefined || value === '') { + missing.push(param.name); + } + } + } + return missing; + }; + + const missingParams = getMissingParams(); + + const executeRequest = async () => { + if (missingParams.length > 0) { + setResponse({ + status: 0, + statusText: 'Validation Error', + headers: {}, + body: `Missing required parameters: ${missingParams.join(', ')}`, + duration: 0, + }); + return; + } + + setLoading(true); + setResponse(null); + + try { + // Build URL with path and query params + let url = path; + const queryParams = new URLSearchParams(); + + for (const param of spec.parameters || []) { + const value = params[param.name]; + if (value !== undefined && value !== '') { + if (param.in === 'path') { + url = url.replace(`{${param.name}}`, encodeURIComponent(value)); + } else if (param.in === 'query') { + queryParams.append(param.name, value); + } + } + } + + const queryString = queryParams.toString(); + // OpenAPI paths already include /api/v1 prefix + const fullUrl = `${url}${queryString ? `?${queryString}` : ''}`; + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (apiKey) { + headers['X-API-Key'] = apiKey; + } + + const options: RequestInit = { + method: method.toUpperCase(), + headers, + }; + + if (['post', 'put', 'patch'].includes(method) && bodyText) { + options.body = bodyText; + } + + const startTime = performance.now(); + const res = await fetch(fullUrl, options); + const duration = Math.round(performance.now() - startTime); + + const responseHeaders: Record = {}; + res.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + let body: unknown; + const contentType = res.headers.get('content-type'); + if (contentType?.includes('application/json')) { + body = await res.json(); + } else { + body = await res.text(); + } + + setResponse({ + status: res.status, + statusText: res.statusText, + headers: responseHeaders, + body, + duration, + }); + } catch (err) { + setResponse({ + status: 0, + statusText: 'Network Error', + headers: {}, + body: err instanceof Error ? err.message : 'Unknown error', + duration: 0, + }); + } finally { + setLoading(false); + } + }; + + const copyResponse = async () => { + if (response) { + const text = typeof response.body === 'string' + ? response.body + : JSON.stringify(response.body, null, 2); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback for non-HTTPS + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + } + }; + + const pathParams = (spec.parameters || []).filter(p => p.in === 'path'); + const queryParamsSpec = (spec.parameters || []).filter(p => p.in === 'query'); + const hasBody = ['post', 'put', 'patch'].includes(method) && spec.requestBody; + + return ( +
+ + + {expanded && ( +
+ {spec.description && ( +

{spec.description}

+ )} + + {/* Path Parameters */} + {pathParams.length > 0 && ( +
+

Path Parameters

+
+ {pathParams.map(param => ( +
+ + setParams(p => ({ ...p, [param.name]: e.target.value }))} + placeholder={param.description || param.schema?.type || 'value'} + className="flex-1 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm font-mono focus:border-bambu-green focus:outline-none" + /> +
+ ))} +
+
+ )} + + {/* Query Parameters */} + {queryParamsSpec.length > 0 && ( +
+

Query Parameters

+
+ {queryParamsSpec.map(param => ( +
+ + {param.schema?.enum ? ( + + ) : ( + setParams(p => ({ ...p, [param.name]: e.target.value }))} + placeholder={param.description || param.schema?.type || 'value'} + className="flex-1 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm font-mono focus:border-bambu-green focus:outline-none" + /> + )} +
+ ))} +
+
+ )} + + {/* Request Body */} + {hasBody && ( +
+

Request Body

+