Files
bambuddy/backend/app/api/routes/projects.py
T
maziggy d1518083fc ## New Features
### Projects / Print Grouping
  - Create projects to group related prints (e.g., "Voron Build" with 50 parts)
  - Track progress with target count and completion percentage
  - Assign archives to projects via edit modal or context menu
  - Project cards show archive thumbnails with clickable links
  - Color-coded project badges on archive cards
  - Filter and manage projects by status (active/completed/archived)

  ### Full-Text Search (FTS5)
  - SQLite FTS5 virtual table for efficient searching
  - Search across print_name, filename, tags, notes, designer, filament_type
  - Automatic index sync with triggers for INSERT/UPDATE/DELETE

  ### Webhooks & API Keys
  - API key authentication with granular permissions
  - Permissions: can_read_status, can_manage_queue, can_control_printer
  - Secure key generation with prefix display only after creation
  - Settings page API Keys tab for key management
  - Webhook endpoints for external integrations

  ### Failure Analysis
  - Dashboard widget showing failure rate with color coding
  - Correlate failures with conditions (filament type, printer, time)
  - Top failure reasons breakdown
  - Weekly trend visualization

  ### Archive Comparison
  - Select 2-5 archives to compare side-by-side
  - Highlight differences in print settings (yellow)
  - Success/failure correlation insights
  - Modal with close via button, X, Escape, or backdrop

  ### CSV/Excel Export
  - Export archives and statistics with current filters
  - Support for both CSV and Excel (.xlsx) formats
  - openpyxl dependency added

  ## Bug Fixes
  - Fixed context menu submenu not showing (removed overflow-hidden)
  - Fixed project card thumbnails using correct API endpoint
  - Fixed EditArchiveModal to invalidate projects query on save
  - Fixed clipboard API fallback for HTTP contexts
  - Fixed archive PATCH 500 error (FTS5 index rebuild)
  - Fixed FastAPI trailing slash routing for projects endpoint

  ## UI Improvements
  - Context menu submenu with hover/click support
  - Project badge on archive cards with project color
  - "Go to Project" context menu item for assigned archives
  - Clickable project card thumbnails linking to archives
  - Reset Layout button moved to Stats page header
2025-12-10 17:06:43 +00:00

429 lines
13 KiB
Python

import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from backend.app.core.database import get_db
from backend.app.models.project import Project
from backend.app.models.archive import PrintArchive
from backend.app.models.print_queue import PrintQueueItem
from backend.app.schemas.project import (
ProjectCreate,
ProjectUpdate,
ProjectResponse,
ProjectListResponse,
ProjectStats,
BatchAddArchives,
BatchAddQueueItems,
ArchivePreview,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/projects", tags=["projects"])
async def compute_project_stats(
db: AsyncSession, project_id: int, target_count: int | None = None
) -> ProjectStats:
"""Compute statistics for a project."""
# Count total archives
total_result = await db.execute(
select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id)
)
total_archives = total_result.scalar() or 0
# Count completed archives
completed_result = await db.execute(
select(func.count(PrintArchive.id)).where(
PrintArchive.project_id == project_id,
PrintArchive.status == "completed"
)
)
completed_prints = completed_result.scalar() or 0
# Count failed archives
failed_result = await db.execute(
select(func.count(PrintArchive.id)).where(
PrintArchive.project_id == project_id,
PrintArchive.status == "failed"
)
)
failed_prints = failed_result.scalar() or 0
# Sum print time and filament
sums_result = await db.execute(
select(
func.coalesce(func.sum(PrintArchive.print_time_seconds), 0).label("total_time"),
func.coalesce(func.sum(PrintArchive.filament_used_grams), 0).label("total_filament"),
).where(PrintArchive.project_id == project_id)
)
sums = sums_result.first()
# Count queued items
queued_result = await db.execute(
select(func.count(PrintQueueItem.id)).where(
PrintQueueItem.project_id == project_id,
PrintQueueItem.status == "pending"
)
)
queued_prints = queued_result.scalar() or 0
# Count in-progress items
in_progress_result = await db.execute(
select(func.count(PrintQueueItem.id)).where(
PrintQueueItem.project_id == project_id,
PrintQueueItem.status == "printing"
)
)
in_progress_prints = in_progress_result.scalar() or 0
# Calculate progress
progress_percent = None
if target_count and target_count > 0:
progress_percent = round((completed_prints / target_count) * 100, 1)
return ProjectStats(
total_archives=total_archives,
completed_prints=completed_prints,
failed_prints=failed_prints,
queued_prints=queued_prints,
in_progress_prints=in_progress_prints,
total_print_time_hours=round((sums.total_time or 0) / 3600, 2),
total_filament_grams=round(sums.total_filament or 0, 2),
progress_percent=progress_percent,
)
@router.get("", response_model=list[ProjectListResponse])
@router.get("/", response_model=list[ProjectListResponse])
async def list_projects(
status: str | None = None,
db: AsyncSession = Depends(get_db),
):
"""List all projects with basic stats."""
query = select(Project)
if status:
query = query.where(Project.status == status)
query = query.order_by(Project.updated_at.desc())
result = await db.execute(query)
projects = result.scalars().all()
# Compute quick stats for each project
response = []
for project in projects:
# Get archive count
archive_count_result = await db.execute(
select(func.count(PrintArchive.id)).where(
PrintArchive.project_id == project.id
)
)
archive_count = archive_count_result.scalar() or 0
# Get queue count
queue_count_result = await db.execute(
select(func.count(PrintQueueItem.id)).where(
PrintQueueItem.project_id == project.id,
PrintQueueItem.status.in_(["pending", "printing"]),
)
)
queue_count = queue_count_result.scalar() or 0
# Get completed count for progress
completed_result = await db.execute(
select(func.count(PrintArchive.id)).where(
PrintArchive.project_id == project.id,
PrintArchive.status == "completed",
)
)
completed_count = completed_result.scalar() or 0
progress_percent = None
if project.target_count and project.target_count > 0:
progress_percent = round((completed_count / project.target_count) * 100, 1)
# Get archive previews (up to 6 most recent)
archives_result = await db.execute(
select(PrintArchive)
.where(PrintArchive.project_id == project.id)
.order_by(PrintArchive.created_at.desc())
.limit(6)
)
archives = archives_result.scalars().all()
archive_previews = [
ArchivePreview(
id=a.id,
print_name=a.print_name,
thumbnail_path=a.thumbnail_path,
status=a.status,
)
for a in archives
]
response.append(
ProjectListResponse(
id=project.id,
name=project.name,
description=project.description,
color=project.color,
status=project.status,
target_count=project.target_count,
created_at=project.created_at,
archive_count=archive_count,
queue_count=queue_count,
progress_percent=progress_percent,
archives=archive_previews,
)
)
return response
@router.post("/", response_model=ProjectResponse)
async def create_project(
data: ProjectCreate,
db: AsyncSession = Depends(get_db),
):
"""Create a new project."""
project = Project(
name=data.name,
description=data.description,
color=data.color,
target_count=data.target_count,
)
db.add(project)
await db.flush()
await db.refresh(project)
stats = await compute_project_stats(db, project.id, project.target_count)
return ProjectResponse(
id=project.id,
name=project.name,
description=project.description,
color=project.color,
status=project.status,
target_count=project.target_count,
created_at=project.created_at,
updated_at=project.updated_at,
stats=stats,
)
@router.get("/{project_id}", response_model=ProjectResponse)
async def get_project(
project_id: int,
db: AsyncSession = Depends(get_db),
):
"""Get a project by ID with detailed stats."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
stats = await compute_project_stats(db, project.id, project.target_count)
return ProjectResponse(
id=project.id,
name=project.name,
description=project.description,
color=project.color,
status=project.status,
target_count=project.target_count,
created_at=project.created_at,
updated_at=project.updated_at,
stats=stats,
)
@router.patch("/{project_id}", response_model=ProjectResponse)
async def update_project(
project_id: int,
data: ProjectUpdate,
db: AsyncSession = Depends(get_db),
):
"""Update a project."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Update fields if provided
if data.name is not None:
project.name = data.name
if data.description is not None:
project.description = data.description
if data.color is not None:
project.color = data.color
if data.status is not None:
if data.status not in ["active", "completed", "archived"]:
raise HTTPException(status_code=400, detail="Invalid status")
project.status = data.status
if data.target_count is not None:
project.target_count = data.target_count
await db.flush()
await db.refresh(project)
stats = await compute_project_stats(db, project.id, project.target_count)
return ProjectResponse(
id=project.id,
name=project.name,
description=project.description,
color=project.color,
status=project.status,
target_count=project.target_count,
created_at=project.created_at,
updated_at=project.updated_at,
stats=stats,
)
@router.delete("/{project_id}")
async def delete_project(
project_id: int,
db: AsyncSession = Depends(get_db),
):
"""Delete a project. Archives and queue items will have project_id set to NULL."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
await db.delete(project)
return {"message": "Project deleted"}
@router.get("/{project_id}/archives")
async def list_project_archives(
project_id: int,
limit: int = 100,
offset: int = 0,
db: AsyncSession = Depends(get_db),
):
"""List archives in a project."""
# Verify project exists
result = await db.execute(select(Project).where(Project.id == project_id))
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
# Get archives
query = (
select(PrintArchive)
.where(PrintArchive.project_id == project_id)
.order_by(PrintArchive.created_at.desc())
.limit(limit)
.offset(offset)
)
result = await db.execute(query)
archives = result.scalars().all()
# Import the response converter from archives module
from backend.app.api.routes.archives import archive_to_response
return [archive_to_response(a) for a in archives]
@router.get("/{project_id}/queue")
async def list_project_queue(
project_id: int,
db: AsyncSession = Depends(get_db),
):
"""List queue items in a project."""
# Verify project exists
result = await db.execute(select(Project).where(Project.id == project_id))
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
# Get queue items
query = (
select(PrintQueueItem)
.where(PrintQueueItem.project_id == project_id)
.order_by(PrintQueueItem.position)
)
result = await db.execute(query)
items = result.scalars().all()
return items
@router.post("/{project_id}/add-archives")
async def add_archives_to_project(
project_id: int,
data: BatchAddArchives,
db: AsyncSession = Depends(get_db),
):
"""Batch add archives to a project."""
# Verify project exists
result = await db.execute(select(Project).where(Project.id == project_id))
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
# Update archives
updated = 0
for archive_id in data.archive_ids:
result = await db.execute(
select(PrintArchive).where(PrintArchive.id == archive_id)
)
archive = result.scalar_one_or_none()
if archive:
archive.project_id = project_id
updated += 1
return {"message": f"Added {updated} archives to project"}
@router.post("/{project_id}/add-queue")
async def add_queue_items_to_project(
project_id: int,
data: BatchAddQueueItems,
db: AsyncSession = Depends(get_db),
):
"""Batch add queue items to a project."""
# Verify project exists
result = await db.execute(select(Project).where(Project.id == project_id))
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
# Update queue items
updated = 0
for item_id in data.queue_item_ids:
result = await db.execute(
select(PrintQueueItem).where(PrintQueueItem.id == item_id)
)
item = result.scalar_one_or_none()
if item:
item.project_id = project_id
updated += 1
return {"message": f"Added {updated} queue items to project"}
@router.post("/{project_id}/remove-archives")
async def remove_archives_from_project(
project_id: int,
data: BatchAddArchives,
db: AsyncSession = Depends(get_db),
):
"""Remove archives from a project (sets project_id to NULL)."""
updated = 0
for archive_id in data.archive_ids:
result = await db.execute(
select(PrintArchive).where(
PrintArchive.id == archive_id,
PrintArchive.project_id == project_id,
)
)
archive = result.scalar_one_or_none()
if archive:
archive.project_id = None
updated += 1
return {"message": f"Removed {updated} archives from project"}