mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
- 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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user