|
|
@@ -0,0 +1,428 @@
|
|
|
+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"}
|