- Fix total print hours calculation in set_total_hours to include all

prints (not just completed), matching get_printer_total_hours behavior
  - Add option to keep or delete archives when deleting a printer
  - Custom maintenance types no longer auto-assign to all printers
  - Add UI to manually assign/remove custom maintenance types per printer
  - Add backend endpoints for assigning types to printers and removing items
  - Exclude static/assets from large file pre-commit check
This commit is contained in:
maziggy
2025-12-23 09:00:36 +01:00
parent 288a495041
commit 929c4c8cd6
19 changed files with 11483 additions and 8893 deletions
+77 -4
View File
@@ -231,6 +231,11 @@ async def _get_printer_maintenance_internal(
last_performed_at = item.last_performed_at
item_id = item.id
else:
# Only auto-create maintenance items for system types
# Custom types need to be manually assigned per printer
if not maint_type.is_system:
continue
# Create default entry for this printer/type
item = PrinterMaintenance(
printer_id=printer_id,
@@ -373,6 +378,75 @@ async def update_printer_maintenance(
return item
@router.post("/printers/{printer_id}/assign/{type_id}", response_model=PrinterMaintenanceResponse)
async def assign_maintenance_type(
printer_id: int,
type_id: int,
db: AsyncSession = Depends(get_db),
):
"""Assign a maintenance type to a specific printer (for custom types)."""
# Verify printer exists
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
# Verify maintenance type exists
result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
maint_type = result.scalar_one_or_none()
if not maint_type:
raise HTTPException(status_code=404, detail="Maintenance type not found")
# Check if already assigned
result = await db.execute(
select(PrinterMaintenance).where(
PrinterMaintenance.printer_id == printer_id,
PrinterMaintenance.maintenance_type_id == type_id,
)
)
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail="Maintenance type already assigned to this printer")
# Create the assignment
item = PrinterMaintenance(
printer_id=printer_id,
maintenance_type_id=type_id,
enabled=True,
last_performed_hours=0.0,
)
db.add(item)
await db.commit()
await db.refresh(item)
return item
@router.delete("/items/{item_id}")
async def remove_maintenance_item(
item_id: int,
db: AsyncSession = Depends(get_db),
):
"""Remove a maintenance item (unassign a custom type from a printer)."""
result = await db.execute(
select(PrinterMaintenance)
.where(PrinterMaintenance.id == item_id)
.options(selectinload(PrinterMaintenance.maintenance_type))
)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Maintenance item not found")
# Only allow removing custom (non-system) types
if item.maintenance_type.is_system:
raise HTTPException(status_code=400, detail="Cannot remove system maintenance types")
await db.delete(item)
await db.commit()
return {"status": "removed"}
@router.post("/items/{item_id}/perform", response_model=MaintenanceStatus)
async def perform_maintenance(
item_id: int,
@@ -497,11 +571,10 @@ async def set_printer_hours(
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
# Get current archive hours
# Get current archive hours (all prints, not just completed)
# Must match get_printer_total_hours() which includes all prints
result = await db.execute(
select(func.sum(PrintArchive.print_time_seconds))
.where(PrintArchive.printer_id == printer_id)
.where(PrintArchive.status == "completed")
select(func.sum(PrintArchive.print_time_seconds)).where(PrintArchive.printer_id == printer_id)
)
total_seconds = result.scalar() or 0
archive_hours = total_seconds / 3600.0
+22 -3
View File
@@ -103,18 +103,37 @@ async def update_printer(
@router.delete("/{printer_id}")
async def delete_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
"""Delete a printer."""
async def delete_printer(
printer_id: int,
delete_archives: bool = True,
db: AsyncSession = Depends(get_db),
):
"""Delete a printer.
Args:
printer_id: ID of the printer to delete
delete_archives: If True (default), delete all print archives for this printer.
If False, keep archives but remove their printer association.
"""
from backend.app.models.archive import PrintArchive
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(404, "Printer not found")
printer_manager.disconnect_printer(printer_id)
if not delete_archives:
# Orphan the archives instead of deleting them
from sqlalchemy import update
await db.execute(update(PrintArchive).where(PrintArchive.printer_id == printer_id).values(printer_id=None))
await db.delete(printer)
await db.commit()
return {"status": "deleted"}
return {"status": "deleted", "archives_deleted": delete_archives}
@router.get("/{printer_id}/status", response_model=PrinterStatus)
+789 -40
View File
@@ -1,21 +1,35 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
import os
import uuid
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from backend.app.core.config import settings
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.models.project import Project
from backend.app.models.project_bom import ProjectBOMItem
from backend.app.schemas.project import (
ProjectCreate,
ProjectUpdate,
ProjectResponse,
ProjectListResponse,
ProjectStats,
ArchivePreview,
BatchAddArchives,
BatchAddQueueItems,
ArchivePreview,
BOMItemCreate,
BOMItemResponse,
BOMItemUpdate,
ProjectChildPreview,
ProjectCreate,
ProjectListResponse,
ProjectResponse,
ProjectStats,
ProjectUpdate,
TimelineEvent,
)
logger = logging.getLogger(__name__)
@@ -23,21 +37,16 @@ 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:
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_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"
PrintArchive.project_id == project_id, PrintArchive.status == "completed"
)
)
completed_prints = completed_result.scalar() or 0
@@ -45,17 +54,19 @@ async def compute_project_stats(
# Count failed archives
failed_result = await db.execute(
select(func.count(PrintArchive.id)).where(
PrintArchive.project_id == project_id,
PrintArchive.status == "failed"
PrintArchive.project_id == project_id, PrintArchive.status == "failed"
)
)
failed_prints = failed_result.scalar() or 0
# Sum print time and filament
# Sum print time, filament, and energy
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"),
func.coalesce(func.sum(PrintArchive.cost), 0).label("total_filament_cost"),
func.coalesce(func.sum(PrintArchive.energy_kwh), 0).label("total_energy"),
func.coalesce(func.sum(PrintArchive.energy_cost), 0).label("total_energy_cost"),
).where(PrintArchive.project_id == project_id)
)
sums = sums_result.first()
@@ -63,8 +74,7 @@ async def compute_project_stats(
# Count queued items
queued_result = await db.execute(
select(func.count(PrintQueueItem.id)).where(
PrintQueueItem.project_id == project_id,
PrintQueueItem.status == "pending"
PrintQueueItem.project_id == project_id, PrintQueueItem.status == "pending"
)
)
queued_prints = queued_result.scalar() or 0
@@ -72,16 +82,28 @@ async def compute_project_stats(
# Count in-progress items
in_progress_result = await db.execute(
select(func.count(PrintQueueItem.id)).where(
PrintQueueItem.project_id == project_id,
PrintQueueItem.status == "printing"
PrintQueueItem.project_id == project_id, PrintQueueItem.status == "printing"
)
)
in_progress_prints = in_progress_result.scalar() or 0
# Calculate progress
progress_percent = None
remaining_prints = None
if target_count and target_count > 0:
progress_percent = round((completed_prints / target_count) * 100, 1)
remaining_prints = max(0, target_count - completed_prints)
# BOM stats
bom_result = await db.execute(
select(
func.count(ProjectBOMItem.id).label("total"),
func.sum(case((ProjectBOMItem.quantity_printed >= ProjectBOMItem.quantity_needed, 1), else_=0)).label(
"completed"
),
).where(ProjectBOMItem.project_id == project_id)
)
bom_stats = bom_result.first()
return ProjectStats(
total_archives=total_archives,
@@ -92,6 +114,12 @@ async def compute_project_stats(
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,
estimated_cost=round((sums.total_filament_cost or 0), 2),
total_energy_kwh=round((sums.total_energy or 0), 3),
total_energy_cost=round((sums.total_energy_cost or 0), 2),
remaining_prints=remaining_prints,
bom_total_items=bom_stats.total or 0,
bom_completed_items=int(bom_stats.completed or 0),
)
@@ -115,9 +143,7 @@ async def list_projects(
for project in projects:
# Get archive count
archive_count_result = await db.execute(
select(func.count(PrintArchive.id)).where(
PrintArchive.project_id == project.id
)
select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id)
)
archive_count = archive_count_result.scalar() or 0
@@ -186,11 +212,26 @@ async def create_project(
db: AsyncSession = Depends(get_db),
):
"""Create a new project."""
# Verify parent exists if specified
parent_name = None
if data.parent_id:
parent_result = await db.execute(select(Project).where(Project.id == data.parent_id))
parent = parent_result.scalar_one_or_none()
if not parent:
raise HTTPException(status_code=400, detail="Parent project not found")
parent_name = parent.name
project = Project(
name=data.name,
description=data.description,
color=data.color,
target_count=data.target_count,
notes=data.notes,
tags=data.tags,
due_date=data.due_date,
priority=data.priority,
budget=data.budget,
parent_id=data.parent_id,
)
db.add(project)
await db.flush()
@@ -205,12 +246,172 @@ async def create_project(
color=project.color,
status=project.status,
target_count=project.target_count,
notes=project.notes,
attachments=project.attachments,
tags=project.tags,
due_date=project.due_date,
priority=project.priority,
budget=project.budget,
is_template=project.is_template,
template_source_id=project.template_source_id,
parent_id=project.parent_id,
parent_name=parent_name,
children=[],
created_at=project.created_at,
updated_at=project.updated_at,
stats=stats,
)
# ============ Phase 8: Template Endpoints (Static routes BEFORE dynamic {project_id}) ============
@router.get("/templates", response_model=list[ProjectListResponse])
async def list_templates(
db: AsyncSession = Depends(get_db),
):
"""List all project templates."""
result = await db.execute(select(Project).where(Project.is_template.is_(True)).order_by(Project.name))
templates = result.scalars().all()
response = []
for project in templates:
# 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
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=0,
progress_percent=None,
archives=[],
)
)
return response
@router.post("/from-template/{template_id}", response_model=ProjectResponse)
async def create_project_from_template(
template_id: int,
name: str = None,
db: AsyncSession = Depends(get_db),
):
"""Create a new project from a template."""
result = await db.execute(select(Project).where(Project.id == template_id))
template = result.scalar_one_or_none()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
if not template.is_template:
raise HTTPException(status_code=400, detail="Project is not a template")
# Create new project
project = Project(
name=name or template.name.replace(" (Template)", ""),
description=template.description,
color=template.color,
target_count=template.target_count,
notes=template.notes,
tags=template.tags,
priority=template.priority,
budget=template.budget,
is_template=False,
template_source_id=template.id,
)
db.add(project)
await db.flush()
# Copy BOM items
bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == template_id))
bom_items = bom_result.scalars().all()
for item in bom_items:
new_item = ProjectBOMItem(
project_id=project.id,
name=item.name,
quantity_needed=item.quantity_needed,
quantity_printed=0,
stl_filename=item.stl_filename,
notes=item.notes,
sort_order=item.sort_order,
)
db.add(new_item)
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,
notes=project.notes,
attachments=project.attachments,
tags=project.tags,
due_date=project.due_date,
priority=project.priority,
budget=project.budget,
is_template=project.is_template,
template_source_id=project.template_source_id,
parent_id=project.parent_id,
parent_name=None,
children=[],
created_at=project.created_at,
updated_at=project.updated_at,
stats=stats,
)
# ============ Dynamic {project_id} Routes ============
async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectChildPreview]:
"""Get preview info for child projects."""
result = await db.execute(select(Project).where(Project.parent_id == parent_id).order_by(Project.name))
children = result.scalars().all()
previews = []
for child in children:
# Get completed count for progress
completed_result = await db.execute(
select(func.count(PrintArchive.id)).where(
PrintArchive.project_id == child.id,
PrintArchive.status == "completed",
)
)
completed_count = completed_result.scalar() or 0
progress = None
if child.target_count and child.target_count > 0:
progress = round((completed_count / child.target_count) * 100, 1)
previews.append(
ProjectChildPreview(
id=child.id,
name=child.name,
color=child.color,
status=child.status,
progress_percent=progress,
)
)
return previews
@router.get("/{project_id}", response_model=ProjectResponse)
async def get_project(
project_id: int,
@@ -223,6 +424,15 @@ async def get_project(
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Get parent name
parent_name = None
if project.parent_id:
parent_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))
parent_name = parent_result.scalar()
# Get children
children = await get_child_previews(db, project.id)
stats = await compute_project_stats(db, project.id, project.target_count)
return ProjectResponse(
@@ -232,6 +442,17 @@ async def get_project(
color=project.color,
status=project.status,
target_count=project.target_count,
notes=project.notes,
attachments=project.attachments,
tags=project.tags,
due_date=project.due_date,
priority=project.priority,
budget=project.budget,
is_template=project.is_template,
template_source_id=project.template_source_id,
parent_id=project.parent_id,
parent_name=parent_name,
children=children,
created_at=project.created_at,
updated_at=project.updated_at,
stats=stats,
@@ -264,10 +485,42 @@ async def update_project(
project.status = data.status
if data.target_count is not None:
project.target_count = data.target_count
if data.notes is not None:
project.notes = data.notes
if data.tags is not None:
project.tags = data.tags
if data.due_date is not None:
project.due_date = data.due_date
if data.priority is not None:
if data.priority not in ["low", "normal", "high", "urgent"]:
raise HTTPException(status_code=400, detail="Invalid priority")
project.priority = data.priority
if data.budget is not None:
project.budget = data.budget
if data.parent_id is not None:
# Verify parent exists and prevent circular reference
if data.parent_id == project_id:
raise HTTPException(status_code=400, detail="Project cannot be its own parent")
if data.parent_id != 0: # 0 means remove parent
parent_result = await db.execute(select(Project).where(Project.id == data.parent_id))
if not parent_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Parent project not found")
project.parent_id = data.parent_id
else:
project.parent_id = None
await db.flush()
await db.refresh(project)
# Get parent name
parent_name = None
if project.parent_id:
parent_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))
parent_name = parent_result.scalar()
# Get children
children = await get_child_previews(db, project.id)
stats = await compute_project_stats(db, project.id, project.target_count)
return ProjectResponse(
@@ -277,6 +530,17 @@ async def update_project(
color=project.color,
status=project.status,
target_count=project.target_count,
notes=project.notes,
attachments=project.attachments,
tags=project.tags,
due_date=project.due_date,
priority=project.priority,
budget=project.budget,
is_template=project.is_template,
template_source_id=project.template_source_id,
parent_id=project.parent_id,
parent_name=parent_name,
children=children,
created_at=project.created_at,
updated_at=project.updated_at,
stats=stats,
@@ -313,9 +577,10 @@ async def list_project_archives(
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
# Get archives
# Get archives with project relationship eagerly loaded
query = (
select(PrintArchive)
.options(selectinload(PrintArchive.project))
.where(PrintArchive.project_id == project_id)
.order_by(PrintArchive.created_at.desc())
.limit(limit)
@@ -342,11 +607,7 @@ async def list_project_queue(
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)
)
query = select(PrintQueueItem).where(PrintQueueItem.project_id == project_id).order_by(PrintQueueItem.position)
result = await db.execute(query)
items = result.scalars().all()
@@ -368,9 +629,7 @@ async def add_archives_to_project(
# Update archives
updated = 0
for archive_id in data.archive_ids:
result = await db.execute(
select(PrintArchive).where(PrintArchive.id == archive_id)
)
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
archive = result.scalar_one_or_none()
if archive:
archive.project_id = project_id
@@ -394,9 +653,7 @@ async def add_queue_items_to_project(
# Update queue items
updated = 0
for item_id in data.queue_item_ids:
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 item:
item.project_id = project_id
@@ -426,3 +683,495 @@ async def remove_archives_from_project(
updated += 1
return {"message": f"Removed {updated} archives from project"}
def get_project_attachments_dir(project_id: int) -> Path:
"""Get the attachments directory for a project."""
base_dir = Path(settings.archive_dir)
return base_dir / "projects" / str(project_id) / "attachments"
@router.post("/{project_id}/attachments")
async def upload_attachment(
project_id: int,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
):
"""Upload an attachment to a project."""
# Verify project exists
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")
# Create attachments directory
attachments_dir = get_project_attachments_dir(project_id)
attachments_dir.mkdir(parents=True, exist_ok=True)
# Generate unique filename
original_name = file.filename or "unknown"
ext = os.path.splitext(original_name)[1]
unique_filename = f"{uuid.uuid4().hex}{ext}"
file_path = attachments_dir / unique_filename
# Save file
try:
with open(file_path, "wb") as f:
content = await file.read()
f.write(content)
except Exception as e:
logger.error(f"Failed to save attachment: {e}")
raise HTTPException(status_code=500, detail="Failed to save attachment")
# Update project attachments JSON
attachments = project.attachments or []
attachments.append(
{
"filename": unique_filename,
"original_name": original_name,
"size": len(content),
"uploaded_at": datetime.now().isoformat(),
}
)
project.attachments = attachments
await db.flush()
await db.refresh(project)
return {
"status": "success",
"filename": unique_filename,
"original_name": original_name,
"attachments": project.attachments,
}
@router.get("/{project_id}/attachments/{filename}")
async def download_attachment(
project_id: int,
filename: str,
db: AsyncSession = Depends(get_db),
):
"""Download an attachment from a project."""
# Verify project exists
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")
# Verify attachment exists in project
attachments = project.attachments or []
attachment = next((a for a in attachments if a.get("filename") == filename), None)
if not attachment:
raise HTTPException(status_code=404, detail="Attachment not found")
# Check file exists
file_path = get_project_attachments_dir(project_id) / filename
if not file_path.exists():
raise HTTPException(status_code=404, detail="Attachment file not found")
return FileResponse(
file_path,
filename=attachment.get("original_name", filename),
media_type="application/octet-stream",
)
@router.delete("/{project_id}/attachments/{filename}")
async def delete_attachment(
project_id: int,
filename: str,
db: AsyncSession = Depends(get_db),
):
"""Delete an attachment from a project."""
# Verify project exists
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")
# Find and remove attachment from list
attachments = project.attachments or []
attachment = next((a for a in attachments if a.get("filename") == filename), None)
if not attachment:
raise HTTPException(status_code=404, detail="Attachment not found")
# Remove from list
attachments = [a for a in attachments if a.get("filename") != filename]
project.attachments = attachments if attachments else None
# Delete file
file_path = get_project_attachments_dir(project_id) / filename
if file_path.exists():
try:
os.remove(file_path)
except Exception as e:
logger.warning(f"Failed to delete attachment file: {e}")
await db.flush()
await db.refresh(project)
return {
"status": "success",
"message": "Attachment deleted",
"attachments": project.attachments,
}
# ============ Phase 7: BOM Endpoints ============
@router.get("/{project_id}/bom", response_model=list[BOMItemResponse])
async def list_bom_items(
project_id: int,
db: AsyncSession = Depends(get_db),
):
"""List all BOM items for 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 BOM items
result = await db.execute(
select(ProjectBOMItem)
.where(ProjectBOMItem.project_id == project_id)
.order_by(ProjectBOMItem.sort_order, ProjectBOMItem.id)
)
items = result.scalars().all()
response = []
for item in items:
# Get archive name if linked
archive_name = None
if item.archive_id:
archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == item.archive_id))
archive_name = archive_result.scalar()
response.append(
BOMItemResponse(
id=item.id,
project_id=item.project_id,
name=item.name,
quantity_needed=item.quantity_needed,
quantity_printed=item.quantity_printed,
archive_id=item.archive_id,
archive_name=archive_name,
stl_filename=item.stl_filename,
notes=item.notes,
sort_order=item.sort_order,
is_complete=item.quantity_printed >= item.quantity_needed,
created_at=item.created_at,
updated_at=item.updated_at,
)
)
return response
@router.post("/{project_id}/bom", response_model=BOMItemResponse)
async def create_bom_item(
project_id: int,
data: BOMItemCreate,
db: AsyncSession = Depends(get_db),
):
"""Add a BOM item 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")
# Get max sort order
max_order_result = await db.execute(
select(func.max(ProjectBOMItem.sort_order)).where(ProjectBOMItem.project_id == project_id)
)
max_order = max_order_result.scalar() or 0
item = ProjectBOMItem(
project_id=project_id,
name=data.name,
quantity_needed=data.quantity_needed,
archive_id=data.archive_id,
stl_filename=data.stl_filename,
notes=data.notes,
sort_order=max_order + 1,
)
db.add(item)
await db.flush()
await db.refresh(item)
# Get archive name if linked
archive_name = None
if item.archive_id:
archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == item.archive_id))
archive_name = archive_result.scalar()
return BOMItemResponse(
id=item.id,
project_id=item.project_id,
name=item.name,
quantity_needed=item.quantity_needed,
quantity_printed=item.quantity_printed,
archive_id=item.archive_id,
archive_name=archive_name,
stl_filename=item.stl_filename,
notes=item.notes,
sort_order=item.sort_order,
is_complete=item.quantity_printed >= item.quantity_needed,
created_at=item.created_at,
updated_at=item.updated_at,
)
@router.patch("/{project_id}/bom/{item_id}", response_model=BOMItemResponse)
async def update_bom_item(
project_id: int,
item_id: int,
data: BOMItemUpdate,
db: AsyncSession = Depends(get_db),
):
"""Update a BOM item."""
result = await db.execute(
select(ProjectBOMItem).where(
ProjectBOMItem.id == item_id,
ProjectBOMItem.project_id == project_id,
)
)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="BOM item not found")
if data.name is not None:
item.name = data.name
if data.quantity_needed is not None:
item.quantity_needed = data.quantity_needed
if data.quantity_printed is not None:
item.quantity_printed = data.quantity_printed
if data.archive_id is not None:
item.archive_id = data.archive_id if data.archive_id != 0 else None
if data.stl_filename is not None:
item.stl_filename = data.stl_filename if data.stl_filename else None
if data.notes is not None:
item.notes = data.notes if data.notes else None
await db.flush()
await db.refresh(item)
# Get archive name if linked
archive_name = None
if item.archive_id:
archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == item.archive_id))
archive_name = archive_result.scalar()
return BOMItemResponse(
id=item.id,
project_id=item.project_id,
name=item.name,
quantity_needed=item.quantity_needed,
quantity_printed=item.quantity_printed,
archive_id=item.archive_id,
archive_name=archive_name,
stl_filename=item.stl_filename,
notes=item.notes,
sort_order=item.sort_order,
is_complete=item.quantity_printed >= item.quantity_needed,
created_at=item.created_at,
updated_at=item.updated_at,
)
@router.delete("/{project_id}/bom/{item_id}")
async def delete_bom_item(
project_id: int,
item_id: int,
db: AsyncSession = Depends(get_db),
):
"""Delete a BOM item."""
result = await db.execute(
select(ProjectBOMItem).where(
ProjectBOMItem.id == item_id,
ProjectBOMItem.project_id == project_id,
)
)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="BOM item not found")
await db.delete(item)
return {"status": "success", "message": "BOM item deleted"}
@router.post("/{project_id}/create-template", response_model=ProjectResponse)
async def create_template_from_project(
project_id: int,
db: AsyncSession = Depends(get_db),
):
"""Create a template from an existing project."""
result = await db.execute(select(Project).where(Project.id == project_id))
source = result.scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Project not found")
# Create template
template = Project(
name=f"{source.name} (Template)",
description=source.description,
color=source.color,
target_count=source.target_count,
notes=source.notes,
tags=source.tags,
priority=source.priority,
budget=source.budget,
is_template=True,
template_source_id=source.id,
)
db.add(template)
await db.flush()
# Copy BOM items
bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == project_id))
bom_items = bom_result.scalars().all()
for item in bom_items:
new_item = ProjectBOMItem(
project_id=template.id,
name=item.name,
quantity_needed=item.quantity_needed,
quantity_printed=0,
stl_filename=item.stl_filename,
notes=item.notes,
sort_order=item.sort_order,
)
db.add(new_item)
await db.flush()
await db.refresh(template)
stats = await compute_project_stats(db, template.id, template.target_count)
return ProjectResponse(
id=template.id,
name=template.name,
description=template.description,
color=template.color,
status=template.status,
target_count=template.target_count,
notes=template.notes,
attachments=template.attachments,
tags=template.tags,
due_date=template.due_date,
priority=template.priority,
budget=template.budget,
is_template=template.is_template,
template_source_id=template.template_source_id,
parent_id=template.parent_id,
parent_name=None,
children=[],
created_at=template.created_at,
updated_at=template.updated_at,
stats=stats,
)
# ============ Phase 9: Timeline Endpoint ============
@router.get("/{project_id}/timeline", response_model=list[TimelineEvent])
async def get_project_timeline(
project_id: int,
limit: int = 50,
db: AsyncSession = Depends(get_db),
):
"""Get timeline of events for a project."""
# Verify project exists
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")
events = []
# Project creation event
events.append(
TimelineEvent(
event_type="project_created",
timestamp=project.created_at,
title="Project created",
description=f"Project '{project.name}' was created",
)
)
# Get archives and add events
archives_result = await db.execute(
select(PrintArchive)
.where(PrintArchive.project_id == project_id)
.order_by(PrintArchive.created_at.desc())
.limit(limit)
)
archives = archives_result.scalars().all()
for archive in archives:
if archive.status == "completed":
events.append(
TimelineEvent(
event_type="print_completed",
timestamp=archive.completed_at or archive.created_at,
title="Print completed",
description=archive.print_name,
metadata={
"archive_id": archive.id,
"print_time_hours": round((archive.print_time_seconds or 0) / 3600, 2),
"filament_grams": round(archive.filament_used_grams or 0, 1),
},
)
)
elif archive.status == "failed":
events.append(
TimelineEvent(
event_type="print_failed",
timestamp=archive.completed_at or archive.created_at,
title="Print failed",
description=archive.print_name,
metadata={"archive_id": archive.id},
)
)
# Get queue items
queue_result = await db.execute(
select(PrintQueueItem)
.where(PrintQueueItem.project_id == project_id)
.order_by(PrintQueueItem.created_at.desc())
.limit(limit)
)
queue_items = queue_result.scalars().all()
for item in queue_items:
if item.status == "printing":
events.append(
TimelineEvent(
event_type="print_started",
timestamp=item.started_at or item.created_at,
title="Print started",
description=item.print_name,
metadata={"queue_item_id": item.id},
)
)
elif item.status == "pending":
events.append(
TimelineEvent(
event_type="queued",
timestamp=item.created_at,
title="Added to queue",
description=item.print_name,
metadata={"queue_item_id": item.id},
)
)
# Sort by timestamp descending
events.sort(key=lambda e: e.timestamp, reverse=True)
return events[:limit]
+127 -90
View File
@@ -1,9 +1,8 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from backend.app.core.config import settings
engine = create_async_engine(
settings.database_url,
echo=settings.debug,
@@ -34,7 +33,22 @@ async def get_db() -> AsyncSession:
async def init_db():
# Import models to register them with SQLAlchemy
from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template, external_link, project, api_key # noqa: F401
from backend.app.models import ( # noqa: F401
api_key,
archive,
external_link,
filament,
kprofile_note,
maintenance,
notification,
notification_template,
print_queue,
printer,
project,
project_bom,
settings,
smart_plug,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@@ -52,164 +66,129 @@ async def run_migrations(conn):
# Migration: Add is_favorite column to print_archives
try:
await conn.execute(text(
"ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0"
))
await conn.execute(text("ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0"))
except Exception:
# Column already exists
pass
# Migration: Add content_hash column to print_archives for duplicate detection
try:
await conn.execute(text(
"ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)"
))
await conn.execute(text("ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)"))
except Exception:
# Column already exists
pass
# Migration: Add auto_off_executed column to smart_plugs
try:
await conn.execute(text(
"ALTER TABLE smart_plugs ADD COLUMN auto_off_executed BOOLEAN DEFAULT 0"
))
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_executed BOOLEAN DEFAULT 0"))
except Exception:
# Column already exists
pass
# Migration: Add on_print_stopped column to notification_providers
try:
await conn.execute(text(
"ALTER TABLE notification_providers ADD COLUMN on_print_stopped BOOLEAN DEFAULT 1"
))
await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_print_stopped BOOLEAN DEFAULT 1"))
except Exception:
# Column already exists
pass
# Migration: Add source_3mf_path column to print_archives
try:
await conn.execute(text(
"ALTER TABLE print_archives ADD COLUMN source_3mf_path VARCHAR(500)"
))
await conn.execute(text("ALTER TABLE print_archives ADD COLUMN source_3mf_path VARCHAR(500)"))
except Exception:
# Column already exists
pass
# Migration: Add on_maintenance_due column to notification_providers
try:
await conn.execute(text(
"ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0"
))
await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0"))
except Exception:
# Column already exists
pass
# Migration: Add location column to printers for grouping
try:
await conn.execute(text(
"ALTER TABLE printers ADD COLUMN location VARCHAR(100)"
))
await conn.execute(text("ALTER TABLE printers ADD COLUMN location VARCHAR(100)"))
except Exception:
# Column already exists
pass
# Migration: Add interval_type column to maintenance_types
try:
await conn.execute(text(
"ALTER TABLE maintenance_types ADD COLUMN interval_type VARCHAR(20) DEFAULT 'hours'"
))
await conn.execute(text("ALTER TABLE maintenance_types ADD COLUMN interval_type VARCHAR(20) DEFAULT 'hours'"))
except Exception:
# Column already exists
pass
# Migration: Add custom_interval_type column to printer_maintenance
try:
await conn.execute(text(
"ALTER TABLE printer_maintenance ADD COLUMN custom_interval_type VARCHAR(20)"
))
await conn.execute(text("ALTER TABLE printer_maintenance ADD COLUMN custom_interval_type VARCHAR(20)"))
except Exception:
# Column already exists
pass
# Migration: Add power alert columns to smart_plugs
try:
await conn.execute(text(
"ALTER TABLE smart_plugs ADD COLUMN power_alert_enabled BOOLEAN DEFAULT 0"
))
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_enabled BOOLEAN DEFAULT 0"))
except Exception:
pass
try:
await conn.execute(text(
"ALTER TABLE smart_plugs ADD COLUMN power_alert_high REAL"
))
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_high REAL"))
except Exception:
pass
try:
await conn.execute(text(
"ALTER TABLE smart_plugs ADD COLUMN power_alert_low REAL"
))
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_low REAL"))
except Exception:
pass
try:
await conn.execute(text(
"ALTER TABLE smart_plugs ADD COLUMN power_alert_last_triggered DATETIME"
))
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_last_triggered DATETIME"))
except Exception:
pass
# Migration: Add schedule columns to smart_plugs
try:
await conn.execute(text(
"ALTER TABLE smart_plugs ADD COLUMN schedule_enabled BOOLEAN DEFAULT 0"
))
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN schedule_enabled BOOLEAN DEFAULT 0"))
except Exception:
pass
try:
await conn.execute(text(
"ALTER TABLE smart_plugs ADD COLUMN schedule_on_time VARCHAR(5)"
))
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN schedule_on_time VARCHAR(5)"))
except Exception:
pass
try:
await conn.execute(text(
"ALTER TABLE smart_plugs ADD COLUMN schedule_off_time VARCHAR(5)"
))
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN schedule_off_time VARCHAR(5)"))
except Exception:
pass
# Migration: Add daily digest columns to notification_providers
try:
await conn.execute(text(
"ALTER TABLE notification_providers ADD COLUMN daily_digest_enabled BOOLEAN DEFAULT 0"
))
await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN daily_digest_enabled BOOLEAN DEFAULT 0"))
except Exception:
pass
try:
await conn.execute(text(
"ALTER TABLE notification_providers ADD COLUMN daily_digest_time VARCHAR(5)"
))
await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN daily_digest_time VARCHAR(5)"))
except Exception:
pass
# Migration: Add project_id column to print_archives
try:
await conn.execute(text(
"ALTER TABLE print_archives ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
))
await conn.execute(
text("ALTER TABLE print_archives ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
)
except Exception:
pass
# Migration: Add project_id column to print_queue
try:
await conn.execute(text(
"ALTER TABLE print_queue ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
))
await conn.execute(
text("ALTER TABLE print_queue ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
)
except Exception:
pass
# Migration: Create FTS5 virtual table for archive full-text search
try:
await conn.execute(text("""
await conn.execute(
text("""
CREATE VIRTUAL TABLE IF NOT EXISTS archive_fts USING fts5(
print_name,
filename,
@@ -220,82 +199,139 @@ async def run_migrations(conn):
content='print_archives',
content_rowid='id'
)
"""))
""")
)
except Exception:
pass
# Migration: Create triggers to keep FTS index in sync
try:
await conn.execute(text("""
await conn.execute(
text("""
CREATE TRIGGER IF NOT EXISTS archive_fts_insert AFTER INSERT ON print_archives BEGIN
INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
END
"""))
""")
)
except Exception:
pass
try:
await conn.execute(text("""
await conn.execute(
text("""
CREATE TRIGGER IF NOT EXISTS archive_fts_delete AFTER DELETE ON print_archives BEGIN
INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
END
"""))
""")
)
except Exception:
pass
try:
await conn.execute(text("""
await conn.execute(
text("""
CREATE TRIGGER IF NOT EXISTS archive_fts_update AFTER UPDATE ON print_archives BEGIN
INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
END
"""))
""")
)
except Exception:
pass
# Migration: Add auto_off_pending columns to smart_plugs (for restart recovery)
try:
await conn.execute(text(
"ALTER TABLE smart_plugs ADD COLUMN auto_off_pending BOOLEAN DEFAULT 0"
))
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_pending BOOLEAN DEFAULT 0"))
except Exception:
pass
try:
await conn.execute(text(
"ALTER TABLE smart_plugs ADD COLUMN auto_off_pending_since DATETIME"
))
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_pending_since DATETIME"))
except Exception:
pass
# Migration: Add AMS alarm notification columns to notification_providers
try:
await conn.execute(text(
"ALTER TABLE notification_providers ADD COLUMN on_ams_humidity_high BOOLEAN DEFAULT 0"
))
await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_ams_humidity_high BOOLEAN DEFAULT 0"))
except Exception:
pass
try:
await conn.execute(text(
"ALTER TABLE notification_providers ADD COLUMN on_ams_temperature_high BOOLEAN DEFAULT 0"
))
await conn.execute(
text("ALTER TABLE notification_providers ADD COLUMN on_ams_temperature_high BOOLEAN DEFAULT 0")
)
except Exception:
pass
# Migration: Add AMS-HT alarm notification columns to notification_providers
try:
await conn.execute(text(
"ALTER TABLE notification_providers ADD COLUMN on_ams_ht_humidity_high BOOLEAN DEFAULT 0"
))
await conn.execute(
text("ALTER TABLE notification_providers ADD COLUMN on_ams_ht_humidity_high BOOLEAN DEFAULT 0")
)
except Exception:
pass
try:
await conn.execute(text(
"ALTER TABLE notification_providers ADD COLUMN on_ams_ht_temperature_high BOOLEAN DEFAULT 0"
))
await conn.execute(
text("ALTER TABLE notification_providers ADD COLUMN on_ams_ht_temperature_high BOOLEAN DEFAULT 0")
)
except Exception:
pass
# Migration: Add notes column to projects (Phase 2)
try:
await conn.execute(text("ALTER TABLE projects ADD COLUMN notes TEXT"))
except Exception:
pass
# Migration: Add attachments column to projects (Phase 3)
try:
await conn.execute(text("ALTER TABLE projects ADD COLUMN attachments JSON"))
except Exception:
pass
# Migration: Add tags column to projects (Phase 4)
try:
await conn.execute(text("ALTER TABLE projects ADD COLUMN tags TEXT"))
except Exception:
pass
# Migration: Add due_date column to projects (Phase 5)
try:
await conn.execute(text("ALTER TABLE projects ADD COLUMN due_date DATETIME"))
except Exception:
pass
# Migration: Add priority column to projects (Phase 5)
try:
await conn.execute(text("ALTER TABLE projects ADD COLUMN priority VARCHAR(20) DEFAULT 'normal'"))
except Exception:
pass
# Migration: Add budget column to projects (Phase 6)
try:
await conn.execute(text("ALTER TABLE projects ADD COLUMN budget REAL"))
except Exception:
pass
# Migration: Add is_template column to projects (Phase 8)
try:
await conn.execute(text("ALTER TABLE projects ADD COLUMN is_template BOOLEAN DEFAULT 0"))
except Exception:
pass
# Migration: Add template_source_id column to projects (Phase 8)
try:
await conn.execute(text("ALTER TABLE projects ADD COLUMN template_source_id INTEGER"))
except Exception:
pass
# Migration: Add parent_id column to projects (Phase 10)
try:
await conn.execute(
text("ALTER TABLE projects ADD COLUMN parent_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
)
except Exception:
pass
@@ -303,7 +339,8 @@ async def run_migrations(conn):
async def seed_notification_templates():
"""Seed default notification templates if they don't exist."""
from sqlalchemy import select
from backend.app.models.notification_template import NotificationTemplate, DEFAULT_TEMPLATES
from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
async with async_session() as session:
# Check if templates already exist
+40 -4
View File
@@ -1,5 +1,6 @@
from datetime import datetime
from sqlalchemy import String, Integer, DateTime, Text, func
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
@@ -17,16 +18,51 @@ class Project(Base):
status: Mapped[str] = mapped_column(String(20), default="active") # active, completed, archived
target_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # Optional target number of prints
# Phase 2: Rich text notes (HTML from WYSIWYG editor)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# Phase 3: File attachments stored as JSON array
# Format: [{"filename": "x.stl", "original_name": "part.stl", "size": 1234, "uploaded_at": "..."}]
attachments: Mapped[list | None] = mapped_column(JSON, nullable=True)
# Phase 4: Tags (comma-separated)
tags: Mapped[str | None] = mapped_column(Text, nullable=True)
# Phase 5: Due dates and priority
due_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
priority: Mapped[str] = mapped_column(String(20), default="normal") # low, normal, high, urgent
# Phase 6: Budget tracking
budget: Mapped[float | None] = mapped_column(Float, nullable=True)
# Phase 8: Templates
is_template: Mapped[bool] = mapped_column(Boolean, default=False)
template_source_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Phase 10: Sub-projects (hierarchical)
parent_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id"), nullable=True)
# Timestamps
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())
# Relationships
archives: Mapped[list["PrintArchive"]] = relationship(back_populates="project")
queue_items: Mapped[list["PrintQueueItem"]] = relationship(back_populates="project")
children: Mapped[list["Project"]] = relationship(
"Project",
back_populates="parent",
foreign_keys="Project.parent_id",
)
parent: Mapped["Project | None"] = relationship(
"Project",
back_populates="children",
remote_side="Project.id",
foreign_keys="Project.parent_id",
)
bom_items: Mapped[list["ProjectBOMItem"]] = relationship(back_populates="project", cascade="all, delete-orphan")
from backend.app.models.archive import PrintArchive # noqa: E402
from backend.app.models.print_queue import PrintQueueItem # noqa: E402
from backend.app.models.project_bom import ProjectBOMItem # noqa: E402
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
class ProjectBOMItem(Base):
"""Bill of Materials item for a project."""
__tablename__ = "project_bom_items"
id: Mapped[int] = mapped_column(primary_key=True)
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
name: Mapped[str] = mapped_column(String(255))
quantity_needed: Mapped[int] = mapped_column(Integer, default=1)
quantity_printed: Mapped[int] = mapped_column(Integer, default=0)
# Optional link to archive that prints this part
archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True)
# Reference to attachment filename (STL file)
stl_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Notes about this part
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# Sort order
sort_order: Mapped[int] = mapped_column(Integer, default=0)
# Timestamps
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
project: Mapped["Project"] = relationship(back_populates="bom_items")
archive: Mapped["PrintArchive | None"] = relationship()
from backend.app.models.archive import PrintArchive # noqa: E402
from backend.app.models.project import Project # noqa: E402
+104
View File
@@ -1,26 +1,42 @@
from datetime import datetime
from pydantic import BaseModel
class ProjectCreate(BaseModel):
"""Schema for creating a new project."""
name: str
description: str | None = None
color: str | None = None
target_count: int | None = None
notes: str | None = None
tags: str | None = None
due_date: datetime | None = None
priority: str = "normal"
budget: float | None = None
parent_id: int | None = None # For sub-projects
class ProjectUpdate(BaseModel):
"""Schema for updating a project."""
name: str | None = None
description: str | None = None
color: str | None = None
status: str | None = None # active, completed, archived
target_count: int | None = None
notes: str | None = None
tags: str | None = None
due_date: datetime | None = None
priority: str | None = None
budget: float | None = None
parent_id: int | None = None
class ProjectStats(BaseModel):
"""Statistics for a project."""
total_archives: int = 0
completed_prints: int = 0
failed_prints: int = 0
@@ -29,16 +45,46 @@ class ProjectStats(BaseModel):
total_print_time_hours: float = 0.0
total_filament_grams: float = 0.0
progress_percent: float | None = None # Based on target_count
# Cost tracking (Phase 6)
estimated_cost: float = 0.0 # Based on filament cost
total_energy_kwh: float = 0.0
total_energy_cost: float = 0.0
remaining_prints: int | None = None # target_count - completed_prints
# BOM stats (Phase 7)
bom_total_items: int = 0
bom_completed_items: int = 0
class ProjectChildPreview(BaseModel):
"""Minimal project data for child preview."""
id: int
name: str
color: str | None
status: str
progress_percent: float | None = None
class ProjectResponse(BaseModel):
"""Schema for project response."""
id: int
name: str
description: str | None
color: str | None
status: str
target_count: int | None
notes: str | None = None
attachments: list | None = None
tags: str | None = None
due_date: datetime | None = None
priority: str = "normal"
budget: float | None = None
is_template: bool = False
template_source_id: int | None = None
parent_id: int | None = None
parent_name: str | None = None # For display
children: list[ProjectChildPreview] = []
created_at: datetime
updated_at: datetime
stats: ProjectStats | None = None
@@ -49,6 +95,7 @@ class ProjectResponse(BaseModel):
class ArchivePreview(BaseModel):
"""Minimal archive data for project preview."""
id: int
print_name: str | None
thumbnail_path: str | None
@@ -57,6 +104,7 @@ class ArchivePreview(BaseModel):
class ProjectListResponse(BaseModel):
"""Schema for project list item (lighter weight)."""
id: int
name: str
description: str | None
@@ -77,9 +125,65 @@ class ProjectListResponse(BaseModel):
class BatchAddArchives(BaseModel):
"""Schema for batch adding archives to a project."""
archive_ids: list[int]
class BatchAddQueueItems(BaseModel):
"""Schema for batch adding queue items to a project."""
queue_item_ids: list[int]
# Phase 7: BOM Schemas
class BOMItemCreate(BaseModel):
"""Schema for creating a BOM item."""
name: str
quantity_needed: int = 1
archive_id: int | None = None
stl_filename: str | None = None
notes: str | None = None
class BOMItemUpdate(BaseModel):
"""Schema for updating a BOM item."""
name: str | None = None
quantity_needed: int | None = None
quantity_printed: int | None = None
archive_id: int | None = None
stl_filename: str | None = None
notes: str | None = None
class BOMItemResponse(BaseModel):
"""Schema for BOM item response."""
id: int
project_id: int
name: str
quantity_needed: int
quantity_printed: int
archive_id: int | None
archive_name: str | None = None
stl_filename: str | None
notes: str | None
sort_order: int
is_complete: bool = False
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Phase 9: Timeline Schemas
class TimelineEvent(BaseModel):
"""Schema for a timeline event."""
event_type: str # archive_added, queue_started, queue_completed, status_changed, note_updated
timestamp: datetime
title: str
description: str | None = None
metadata: dict | None = None # Additional event-specific data