| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927 |
- import io
- import json
- import logging
- import os
- import uuid
- import zipfile
- from datetime import datetime
- from pathlib import Path
- from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
- from fastapi.responses import FileResponse, StreamingResponse
- from sqlalchemy import case, func, select
- from sqlalchemy.ext.asyncio import AsyncSession
- from sqlalchemy.orm import selectinload
- from backend.app.api.routes.library import get_library_dir
- from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
- from backend.app.core.config import settings
- from backend.app.core.database import get_db
- from backend.app.core.permissions import Permission
- from backend.app.models.archive import PrintArchive
- from backend.app.models.library import LibraryFile, LibraryFolder
- 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.models.user import User
- from backend.app.schemas.project import (
- ArchivePreview,
- BatchAddArchives,
- BatchAddQueueItems,
- BOMItemCreate,
- BOMItemResponse,
- BOMItemUpdate,
- ProjectChildPreview,
- ProjectCreate,
- ProjectImport,
- ProjectListResponse,
- ProjectResponse,
- ProjectStats,
- ProjectUpdate,
- TimelineEvent,
- )
- from backend.app.utils.http import build_content_disposition
- 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, target_parts_count: int | None = None
- ) -> ProjectStats:
- """Compute statistics for a project."""
- # Count total archives (distinct print jobs)
- total_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id))
- total_archives = total_result.scalar() or 0
- # Sum total items (using quantity field)
- total_items_result = await db.execute(
- select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project_id)
- )
- total_items = total_items_result.scalar() or 0
- # Count failed archives (number of print jobs) - includes all failure states
- failed_result = await db.execute(
- select(func.count(PrintArchive.id)).where(
- PrintArchive.project_id == project_id,
- PrintArchive.status.in_(["failed", "aborted", "cancelled", "stopped"]),
- )
- )
- failed_prints = failed_result.scalar() or 0
- # 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()
- # Count queued items
- queued_result = await db.execute(
- select(func.count(PrintQueueItem.id)).where(
- PrintQueueItem.project_id == project_id, PrintQueueItem.status == "pending"
- )
- )
- queued_prints = queued_result.scalar() or 0
- # Count in-progress items
- in_progress_result = await db.execute(
- select(func.count(PrintQueueItem.id)).where(
- PrintQueueItem.project_id == project_id, PrintQueueItem.status == "printing"
- )
- )
- in_progress_prints = in_progress_result.scalar() or 0
- # Sum completed items (parts) - sum of quantities for actually printed jobs
- completed_items_result = await db.execute(
- select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
- PrintArchive.project_id == project_id,
- PrintArchive.status == "completed",
- )
- )
- completed_items = int(completed_items_result.scalar() or 0)
- # Calculate progress for plates (target_count vs total_archives)
- progress_percent = None
- remaining_prints = None
- if target_count and target_count > 0:
- progress_percent = round((total_archives / target_count) * 100, 1)
- remaining_prints = max(0, target_count - total_archives)
- # Calculate progress for parts (target_parts_count vs completed_items)
- parts_progress_percent = None
- remaining_parts = None
- if target_parts_count and target_parts_count > 0:
- parts_progress_percent = round((completed_items / target_parts_count) * 100, 1)
- remaining_parts = max(0, target_parts_count - completed_items)
- # BOM stats
- bom_result = await db.execute(
- select(
- func.count(ProjectBOMItem.id).label("total"),
- func.sum(case((ProjectBOMItem.quantity_acquired >= ProjectBOMItem.quantity_needed, 1), else_=0)).label(
- "completed"
- ),
- func.coalesce(func.sum(ProjectBOMItem.unit_price * ProjectBOMItem.quantity_needed), 0).label("bom_cost"),
- ).where(ProjectBOMItem.project_id == project_id)
- )
- bom_stats = bom_result.first()
- return ProjectStats(
- total_archives=total_archives,
- total_items=int(total_items),
- completed_prints=completed_items, # Now reflects sum of quantities for completed prints
- failed_prints=int(failed_prints),
- queued_prints=queued_prints,
- in_progress_prints=in_progress_prints,
- total_print_time_hours=round((sums.total_time or 0) / 3600, 2),
- total_filament_grams=round(sums.total_filament or 0, 2),
- progress_percent=progress_percent,
- parts_progress_percent=parts_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), 3),
- remaining_prints=remaining_prints,
- remaining_parts=remaining_parts,
- bom_total_items=bom_stats.total or 0,
- bom_completed_items=int(bom_stats.completed or 0),
- bom_cost=round(float(bom_stats.bom_cost or 0), 2),
- )
- @router.get("", response_model=list[ProjectListResponse])
- @router.get("/", response_model=list[ProjectListResponse])
- async def list_projects(
- status: str | None = None,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
- ):
- """List all projects with basic stats."""
- query = select(Project)
- if status:
- query = query.where(Project.status == status)
- query = query.order_by(Project.updated_at.desc())
- result = await db.execute(query)
- projects = result.scalars().all()
- # Compute quick stats for each project
- response = []
- for project in projects:
- # Get archive count (number of print jobs)
- archive_count_result = await db.execute(
- select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id)
- )
- archive_count = archive_count_result.scalar() or 0
- # Get total items (sum of quantities)
- total_items_result = await db.execute(
- select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project.id)
- )
- total_items = int(total_items_result.scalar() or 0)
- # Get queue count
- queue_count_result = await db.execute(
- select(func.count(PrintQueueItem.id)).where(
- PrintQueueItem.project_id == project.id,
- PrintQueueItem.status.in_(["pending", "printing"]),
- )
- )
- queue_count = queue_count_result.scalar() or 0
- # Sum completed parts (quantities) - only actually printed jobs
- completed_result = await db.execute(
- select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
- PrintArchive.project_id == project.id,
- PrintArchive.status == "completed",
- )
- )
- completed_count = int(completed_result.scalar() or 0)
- # Sum failed parts (quantities) - includes all failure states
- failed_result = await db.execute(
- select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
- PrintArchive.project_id == project.id,
- PrintArchive.status.in_(["failed", "aborted", "cancelled", "stopped"]),
- )
- )
- failed_count = int(failed_result.scalar() or 0)
- # Plates progress: archive_count / target_count
- progress_percent = None
- if project.target_count and project.target_count > 0:
- progress_percent = round((archive_count / project.target_count) * 100, 1)
- # Get archive previews (up to 6 most recent)
- archives_result = await db.execute(
- select(PrintArchive)
- .where(PrintArchive.project_id == project.id)
- .order_by(PrintArchive.created_at.desc())
- .limit(6)
- )
- archives = archives_result.scalars().all()
- archive_previews = [
- ArchivePreview(
- id=a.id,
- print_name=a.print_name,
- thumbnail_path=a.thumbnail_path,
- status=a.status,
- filament_type=a.filament_type,
- filament_color=a.filament_color,
- )
- for a in archives
- ]
- response.append(
- ProjectListResponse(
- id=project.id,
- name=project.name,
- description=project.description,
- color=project.color,
- status=project.status,
- target_count=project.target_count,
- target_parts_count=project.target_parts_count,
- budget=project.budget,
- created_at=project.created_at,
- archive_count=archive_count,
- total_items=total_items,
- completed_count=completed_count,
- failed_count=failed_count,
- queue_count=queue_count,
- progress_percent=progress_percent,
- archives=archive_previews,
- url=project.url,
- cover_image_filename=project.cover_image_filename,
- )
- )
- return response
- @router.post("/", response_model=ProjectResponse)
- async def create_project(
- data: ProjectCreate,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
- ):
- """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,
- target_parts_count=data.target_parts_count,
- notes=data.notes,
- tags=data.tags,
- due_date=data.due_date,
- priority=data.priority,
- budget=data.budget,
- parent_id=data.parent_id,
- url=data.url,
- )
- db.add(project)
- await db.flush()
- await db.refresh(project)
- stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
- return ProjectResponse(
- id=project.id,
- name=project.name,
- description=project.description,
- color=project.color,
- status=project.status,
- target_count=project.target_count,
- target_parts_count=project.target_parts_count,
- notes=project.notes,
- attachments=project.attachments,
- url=project.url,
- cover_image_filename=project.cover_image_filename,
- 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),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
- ):
- """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,
- budget=project.budget,
- created_at=project.created_at,
- archive_count=archive_count,
- queue_count=0,
- progress_percent=None,
- archives=[],
- url=project.url,
- cover_image_filename=project.cover_image_filename,
- )
- )
- 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),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
- ):
- """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,
- target_parts_count=template.target_parts_count,
- notes=template.notes,
- tags=template.tags,
- priority=template.priority,
- budget=template.budget,
- is_template=False,
- template_source_id=template.id,
- url=template.url,
- )
- 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_acquired=0,
- unit_price=item.unit_price,
- sourcing_url=item.sourcing_url,
- stl_filename=item.stl_filename,
- remarks=item.remarks,
- 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, project.target_parts_count)
- return ProjectResponse(
- id=project.id,
- name=project.name,
- description=project.description,
- color=project.color,
- status=project.status,
- target_count=project.target_count,
- target_parts_count=project.target_parts_count,
- notes=project.notes,
- attachments=project.attachments,
- url=project.url,
- cover_image_filename=project.cover_image_filename,
- 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 (sum of quantities)
- completed_result = await db.execute(
- select(func.coalesce(func.sum(PrintArchive.quantity), 0)).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((int(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,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
- ):
- """Get a project by ID with detailed stats."""
- result = await db.execute(select(Project).where(Project.id == project_id))
- project = result.scalar_one_or_none()
- if not project:
- raise HTTPException(status_code=404, detail="Project not found")
- # 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, project.target_parts_count)
- return ProjectResponse(
- id=project.id,
- name=project.name,
- description=project.description,
- color=project.color,
- status=project.status,
- target_count=project.target_count,
- target_parts_count=project.target_parts_count,
- notes=project.notes,
- attachments=project.attachments,
- url=project.url,
- cover_image_filename=project.cover_image_filename,
- 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,
- )
- @router.patch("/{project_id}", response_model=ProjectResponse)
- async def update_project(
- project_id: int,
- data: ProjectUpdate,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
- ):
- """Update a project."""
- result = await db.execute(select(Project).where(Project.id == project_id))
- project = result.scalar_one_or_none()
- if not project:
- raise HTTPException(status_code=404, detail="Project not found")
- # Update fields if provided
- if data.name is not None:
- project.name = data.name
- if data.description is not None:
- project.description = data.description
- if data.color is not None:
- project.color = data.color
- if data.status is not None:
- if data.status not in ["active", "completed", "archived"]:
- raise HTTPException(status_code=400, detail="Invalid status")
- project.status = data.status
- if data.target_count is not None:
- project.target_count = data.target_count
- if data.target_parts_count is not None:
- project.target_parts_count = data.target_parts_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 "budget" in data.model_fields_set:
- project.budget = data.budget
- if "url" in data.model_fields_set:
- # Pydantic validator already guarantees http(s) prefix or None.
- project.url = data.url
- 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, project.target_parts_count)
- return ProjectResponse(
- id=project.id,
- name=project.name,
- description=project.description,
- color=project.color,
- status=project.status,
- target_count=project.target_count,
- target_parts_count=project.target_parts_count,
- notes=project.notes,
- attachments=project.attachments,
- url=project.url,
- cover_image_filename=project.cover_image_filename,
- 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,
- )
- @router.delete("/{project_id}")
- async def delete_project(
- project_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_DELETE),
- ):
- """Delete a project. Archives and queue items will have project_id set to NULL."""
- result = await db.execute(select(Project).where(Project.id == project_id))
- project = result.scalar_one_or_none()
- if not project:
- raise HTTPException(status_code=404, detail="Project not found")
- await db.delete(project)
- return {"message": "Project deleted"}
- @router.get("/{project_id}/archives")
- async def list_project_archives(
- project_id: int,
- limit: int = 100,
- offset: int = 0,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
- ):
- """List archives in a project."""
- # Verify project exists
- result = await db.execute(select(Project).where(Project.id == project_id))
- if not result.scalar_one_or_none():
- raise HTTPException(status_code=404, detail="Project not found")
- # Get archives with both ``project`` and ``created_by`` eagerly loaded.
- # ``archive_to_response`` accesses ``archive.created_by.username`` to
- # surface the creator on the archive card; without selectinload that's
- # a lazy attribute access on a closed async session, which throws
- # ``MissingGreenlet`` and produces a 500. ``ArchiveService.list_archives``
- # already loads both — this route just got out of step.
- query = (
- select(PrintArchive)
- .options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by))
- .where(PrintArchive.project_id == project_id)
- .order_by(PrintArchive.created_at.desc())
- .limit(limit)
- .offset(offset)
- )
- result = await db.execute(query)
- archives = result.scalars().all()
- # Import the response converter from archives module
- from backend.app.api.routes.archives import archive_to_response
- return [archive_to_response(a) for a in archives]
- @router.get("/{project_id}/queue")
- async def list_project_queue(
- project_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
- ):
- """List queue items in a project."""
- # Verify project exists
- result = await db.execute(select(Project).where(Project.id == project_id))
- if not result.scalar_one_or_none():
- raise HTTPException(status_code=404, detail="Project not found")
- # Get queue items
- query = select(PrintQueueItem).where(PrintQueueItem.project_id == project_id).order_by(PrintQueueItem.position)
- result = await db.execute(query)
- items = result.scalars().all()
- return items
- @router.post("/{project_id}/add-archives")
- async def add_archives_to_project(
- project_id: int,
- data: BatchAddArchives,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
- ):
- """Batch add archives to a project."""
- # Verify project exists
- result = await db.execute(select(Project).where(Project.id == project_id))
- if not result.scalar_one_or_none():
- raise HTTPException(status_code=404, detail="Project not found")
- # Update archives
- updated = 0
- for archive_id in data.archive_ids:
- result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
- archive = result.scalar_one_or_none()
- if archive:
- archive.project_id = project_id
- updated += 1
- return {"message": f"Added {updated} archives to project"}
- @router.post("/{project_id}/add-queue")
- async def add_queue_items_to_project(
- project_id: int,
- data: BatchAddQueueItems,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
- ):
- """Batch add queue items to a project."""
- # Verify project exists
- result = await db.execute(select(Project).where(Project.id == project_id))
- if not result.scalar_one_or_none():
- raise HTTPException(status_code=404, detail="Project not found")
- # Update queue items
- updated = 0
- for item_id in data.queue_item_ids:
- result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
- item = result.scalar_one_or_none()
- if item:
- item.project_id = project_id
- updated += 1
- return {"message": f"Added {updated} queue items to project"}
- @router.post("/{project_id}/remove-archives")
- async def remove_archives_from_project(
- project_id: int,
- data: BatchAddArchives,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
- ):
- """Remove archives from a project (sets project_id to NULL)."""
- updated = 0
- for archive_id in data.archive_ids:
- result = await db.execute(
- select(PrintArchive).where(
- PrintArchive.id == archive_id,
- PrintArchive.project_id == project_id,
- )
- )
- archive = result.scalar_one_or_none()
- if archive:
- archive.project_id = None
- updated += 1
- return {"message": f"Removed {updated} archives from project"}
- 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"
- # Cover-image upload accepts only common web-renderable image types (#1155).
- # Subset of ALLOWED_ATTACHMENT_EXTENSIONS minus .svg/.ico because those don't
- # render well as a card thumbnail.
- COVER_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
- COVER_IMAGE_CONTENT_TYPES = {
- ".jpg": "image/jpeg",
- ".jpeg": "image/jpeg",
- ".png": "image/png",
- ".gif": "image/gif",
- ".webp": "image/webp",
- }
- # Allowed file extensions for attachments
- ALLOWED_ATTACHMENT_EXTENSIONS = {
- # Images
- ".jpg",
- ".jpeg",
- ".png",
- ".gif",
- ".webp",
- ".svg",
- ".bmp",
- ".ico",
- # Documents
- ".pdf",
- ".doc",
- ".docx",
- ".xls",
- ".xlsx",
- ".ppt",
- ".pptx",
- ".odt",
- ".ods",
- ".odp",
- ".txt",
- ".rtf",
- ".csv",
- ".md",
- # 3D/CAD files
- ".stl",
- ".obj",
- ".3mf",
- ".step",
- ".stp",
- ".iges",
- ".igs",
- ".f3d",
- ".scad",
- # Archives
- ".zip",
- ".rar",
- ".7z",
- ".tar",
- ".gz",
- # Code/scripts (for Klipper macros, scripts, etc.)
- ".py",
- ".sh",
- ".cfg",
- ".conf",
- ".gcode",
- ".ini",
- # Other common formats
- ".json",
- ".xml",
- ".yaml",
- ".yml",
- }
- @router.post("/{project_id}/attachments")
- async def upload_attachment(
- project_id: int,
- file: UploadFile = File(...),
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
- ):
- """Upload an attachment to a project."""
- logger.info("=== UPLOAD START: %s for project %s ===", file.filename, project_id)
- # 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")
- # Validate file extension
- original_name = file.filename or "unknown"
- ext = os.path.splitext(original_name)[1].lower()
- if ext not in ALLOWED_ATTACHMENT_EXTENSIONS:
- raise HTTPException(
- status_code=400,
- detail=f"File type '{ext}' not supported. Allowed: images, PDFs, documents, STL, 3MF, archives.",
- )
- # Create attachments directory
- attachments_dir = get_project_attachments_dir(project_id)
- attachments_dir.mkdir(parents=True, exist_ok=True)
- # Generate unique filename
- 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)
- logger.info("=== FILE SAVED: %s, size: %s ===", file_path, len(content))
- except Exception as e:
- logger.error("Failed to save attachment: %s", e)
- raise HTTPException(status_code=500, detail="Failed to save attachment")
- # Update project attachments JSON
- attachments = list(project.attachments or [])
- new_attachment = {
- "filename": unique_filename,
- "original_name": original_name,
- "size": len(content),
- "uploaded_at": datetime.now().isoformat(),
- }
- attachments.append(new_attachment)
- # Simple ORM update
- project.attachments = attachments
- db.add(project) # Explicitly add to session
- logger.info("=== BEFORE COMMIT: %s attachments ===", len(attachments))
- await db.flush()
- await db.commit()
- logger.info("=== AFTER COMMIT ===")
- # Verify by re-querying
- result = await db.execute(select(Project).where(Project.id == project_id))
- fresh_project = result.scalar_one()
- logger.info("=== VERIFIED: %s attachments ===", len(fresh_project.attachments or []))
- return {
- "status": "success",
- "filename": unique_filename,
- "original_name": original_name,
- "attachments": fresh_project.attachments,
- }
- @router.get("/{project_id}/attachments/{filename}")
- async def download_attachment(
- project_id: int,
- filename: str,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
- ):
- """Download an attachment from a project."""
- # Validate filename to prevent path traversal
- if "/" in filename or "\\" in filename or ".." in filename or not filename:
- raise HTTPException(status_code=400, detail="Invalid filename")
- # 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),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
- ):
- """Delete an attachment from a project."""
- # Validate filename to prevent path traversal
- if "/" in filename or "\\" in filename or ".." in filename or not filename:
- raise HTTPException(status_code=400, detail="Invalid filename")
- # 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("Failed to delete attachment file: %s", e)
- await db.flush()
- await db.refresh(project)
- return {
- "status": "success",
- "message": "Attachment deleted",
- "attachments": project.attachments,
- }
- # ============ #1155: Cover image ============
- @router.post("/{project_id}/cover-image")
- async def upload_project_cover_image(
- project_id: int,
- file: UploadFile = File(...),
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
- ):
- """Upload (or replace) the project's cover image (#1155).
- Stored alongside other attachments but tracked via Project.cover_image_filename
- so swap/delete operations don't touch the attachments list. Replaces any
- existing cover image — the prior file is deleted on disk before the new one
- lands so a stuck filesystem reference can't accumulate orphaned images.
- """
- 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")
- original_name = file.filename or "cover"
- ext = os.path.splitext(original_name)[1].lower()
- if ext not in COVER_IMAGE_EXTENSIONS:
- raise HTTPException(
- status_code=400,
- detail=f"Cover image must be one of {sorted(COVER_IMAGE_EXTENSIONS)}",
- )
- attachments_dir = get_project_attachments_dir(project_id)
- attachments_dir.mkdir(parents=True, exist_ok=True)
- # Remove the previous cover-image file from disk first so we don't accumulate
- # orphans when users repeatedly replace it. Best-effort: a missing/locked file
- # shouldn't block a successful replacement.
- if project.cover_image_filename:
- old_path = attachments_dir / project.cover_image_filename
- if old_path.exists():
- try:
- os.remove(old_path)
- except OSError as e:
- logger.warning("Failed to delete old cover image %s: %s", old_path, e)
- unique_filename = f"cover_{uuid.uuid4().hex}{ext}"
- file_path = attachments_dir / unique_filename
- try:
- with open(file_path, "wb") as f:
- content = await file.read()
- f.write(content)
- except OSError as e:
- logger.error("Failed to save cover image: %s", e)
- raise HTTPException(status_code=500, detail="Failed to save cover image")
- project.cover_image_filename = unique_filename
- db.add(project)
- await db.flush()
- await db.commit()
- return {
- "status": "success",
- "filename": unique_filename,
- "size": len(content),
- }
- @router.get("/{project_id}/cover-image")
- async def get_project_cover_image(
- project_id: int,
- db: AsyncSession = Depends(get_db),
- _: None = RequireCameraStreamTokenIfAuthEnabled,
- ):
- """Stream the project's cover image (#1155).
- Browsers can't attach `Authorization: Bearer ...` to `<img src>` requests,
- so this route accepts the same `?token=` stream-credential as
- /archives/{id}/thumbnail. The frontend wraps URLs with `withStreamToken`."""
- 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")
- if not project.cover_image_filename:
- raise HTTPException(status_code=404, detail="No cover image set")
- file_path = get_project_attachments_dir(project_id) / project.cover_image_filename
- if not file_path.exists():
- # DB references a file that vanished from disk — clear the dangling
- # reference so future GETs get a clean 404 instead of repeatedly
- # touching the filesystem.
- logger.warning("Cover image file missing for project %s: %s", project_id, file_path)
- project.cover_image_filename = None
- await db.commit()
- raise HTTPException(status_code=404, detail="Cover image file not found")
- ext = os.path.splitext(project.cover_image_filename)[1].lower()
- media_type = COVER_IMAGE_CONTENT_TYPES.get(ext, "application/octet-stream")
- return FileResponse(file_path, media_type=media_type)
- @router.delete("/{project_id}/cover-image")
- async def delete_project_cover_image(
- project_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
- ):
- """Remove the project's cover image (#1155)."""
- 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")
- if project.cover_image_filename:
- file_path = get_project_attachments_dir(project_id) / project.cover_image_filename
- if file_path.exists():
- try:
- os.remove(file_path)
- except OSError as e:
- logger.warning("Failed to delete cover image file %s: %s", file_path, e)
- project.cover_image_filename = None
- db.add(project)
- await db.flush()
- await db.commit()
- return {"status": "success"}
- # ============ 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),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
- ):
- """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_acquired=item.quantity_acquired,
- unit_price=item.unit_price,
- sourcing_url=item.sourcing_url,
- archive_id=item.archive_id,
- archive_name=archive_name,
- stl_filename=item.stl_filename,
- remarks=item.remarks,
- sort_order=item.sort_order,
- is_complete=item.quantity_acquired >= 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),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
- ):
- """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,
- unit_price=data.unit_price,
- sourcing_url=data.sourcing_url,
- archive_id=data.archive_id,
- stl_filename=data.stl_filename,
- remarks=data.remarks,
- 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_acquired=item.quantity_acquired,
- unit_price=item.unit_price,
- sourcing_url=item.sourcing_url,
- archive_id=item.archive_id,
- archive_name=archive_name,
- stl_filename=item.stl_filename,
- remarks=item.remarks,
- sort_order=item.sort_order,
- is_complete=item.quantity_acquired >= 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),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
- ):
- """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_acquired is not None:
- item.quantity_acquired = data.quantity_acquired
- if data.unit_price is not None:
- item.unit_price = data.unit_price if data.unit_price != 0 else None
- if data.sourcing_url is not None:
- item.sourcing_url = data.sourcing_url if data.sourcing_url else None
- 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.remarks is not None:
- item.remarks = data.remarks if data.remarks 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_acquired=item.quantity_acquired,
- unit_price=item.unit_price,
- sourcing_url=item.sourcing_url,
- archive_id=item.archive_id,
- archive_name=archive_name,
- stl_filename=item.stl_filename,
- remarks=item.remarks,
- sort_order=item.sort_order,
- is_complete=item.quantity_acquired >= 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),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
- ):
- """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),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
- ):
- """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,
- target_parts_count=source.target_parts_count,
- notes=source.notes,
- tags=source.tags,
- priority=source.priority,
- budget=source.budget,
- is_template=True,
- template_source_id=source.id,
- url=source.url,
- )
- 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_acquired=0,
- unit_price=item.unit_price,
- sourcing_url=item.sourcing_url,
- stl_filename=item.stl_filename,
- remarks=item.remarks,
- 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, template.target_parts_count)
- return ProjectResponse(
- id=template.id,
- name=template.name,
- description=template.description,
- color=template.color,
- status=template.status,
- target_count=template.target_count,
- target_parts_count=template.target_parts_count,
- notes=template.notes,
- attachments=template.attachments,
- url=template.url,
- cover_image_filename=template.cover_image_filename,
- 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),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
- ):
- """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]
- # ============ Phase 10: Import/Export Endpoints ============
- @router.get("/{project_id}/export")
- async def export_project(
- project_id: int,
- format: str = "zip", # "zip" (with files) or "json" (metadata only)
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
- ):
- """Export a project. Use format=zip (default) for full export with files, or format=json for metadata only."""
- 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")
- # Get BOM items
- bom_result = await db.execute(
- select(ProjectBOMItem).where(ProjectBOMItem.project_id == project_id).order_by(ProjectBOMItem.sort_order)
- )
- bom_items = bom_result.scalars().all()
- bom_export = [
- {
- "name": item.name,
- "quantity_needed": item.quantity_needed,
- "quantity_acquired": item.quantity_acquired,
- "unit_price": item.unit_price,
- "sourcing_url": item.sourcing_url,
- "stl_filename": item.stl_filename,
- "remarks": item.remarks,
- }
- for item in bom_items
- ]
- # Get linked folders and their files
- folders_result = await db.execute(
- select(LibraryFolder).where(LibraryFolder.project_id == project_id).order_by(LibraryFolder.name)
- )
- linked_folders = folders_result.scalars().all()
- folders_export = []
- files_to_include = [] # (archive_path, zip_path)
- for folder in linked_folders:
- # Get files in this folder
- files_result = await db.execute(
- LibraryFile.active().where(LibraryFile.folder_id == folder.id).order_by(LibraryFile.filename)
- )
- files = files_result.scalars().all()
- folder_files = []
- for f in files:
- folder_files.append(
- {
- "filename": f.filename,
- "file_type": f.file_type,
- "notes": f.notes,
- }
- )
- # Add file to include in ZIP
- library_dir = get_library_dir()
- file_path = library_dir / f.file_path
- if file_path.exists():
- zip_path = f"files/{folder.name}/{f.filename}"
- files_to_include.append((file_path, zip_path))
- # Also include thumbnail if exists
- if f.thumbnail_path:
- thumb_path = library_dir / f.thumbnail_path
- if thumb_path.exists():
- thumb_zip_path = f"files/{folder.name}/.thumbnails/{f.filename}.png"
- files_to_include.append((thumb_path, thumb_zip_path))
- folders_export.append(
- {
- "name": folder.name,
- "files": folder_files,
- }
- )
- # Build project JSON
- project_data = {
- "name": project.name,
- "description": project.description,
- "color": project.color,
- "status": project.status,
- "target_count": project.target_count,
- "target_parts_count": project.target_parts_count,
- "notes": project.notes,
- "tags": project.tags,
- "due_date": project.due_date.isoformat() if project.due_date else None,
- "priority": project.priority,
- "budget": project.budget,
- "bom_items": bom_export,
- "linked_folders": folders_export,
- }
- # Return JSON if requested (for bulk export)
- if format == "json":
- return project_data
- # Create ZIP in memory
- zip_buffer = io.BytesIO()
- with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
- # Add project.json
- zf.writestr("project.json", json.dumps(project_data, indent=2))
- # Add files
- for file_path, zip_path in files_to_include:
- zf.write(file_path, zip_path)
- zip_buffer.seek(0)
- # Generate filename
- safe_name = "".join(c if c.isalnum() or c in "-_ " else "_" for c in project.name)
- filename = f"{safe_name}_{datetime.now().strftime('%Y-%m-%d')}.zip"
- return StreamingResponse(
- zip_buffer,
- media_type="application/zip",
- headers={"Content-Disposition": build_content_disposition(filename)},
- )
- @router.post("/import", response_model=ProjectResponse)
- async def import_project(
- data: ProjectImport,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
- ):
- """Import a project with optional BOM items and linked folders."""
- # Create the project
- project = Project(
- name=data.name,
- description=data.description,
- color=data.color,
- status=data.status,
- target_count=data.target_count,
- target_parts_count=data.target_parts_count,
- notes=data.notes,
- tags=data.tags,
- due_date=data.due_date,
- priority=data.priority,
- budget=data.budget,
- )
- db.add(project)
- await db.flush()
- # Create BOM items
- for idx, bom_data in enumerate(data.bom_items):
- bom_item = ProjectBOMItem(
- project_id=project.id,
- name=bom_data.name,
- quantity_needed=bom_data.quantity_needed,
- quantity_acquired=bom_data.quantity_acquired,
- unit_price=bom_data.unit_price,
- sourcing_url=bom_data.sourcing_url,
- stl_filename=bom_data.stl_filename,
- remarks=bom_data.remarks,
- sort_order=idx,
- )
- db.add(bom_item)
- # Create linked folders in library
- for folder_data in data.linked_folders:
- # Check if folder with this name already exists at root level
- existing_result = await db.execute(
- select(LibraryFolder).where(
- LibraryFolder.name == folder_data.name,
- LibraryFolder.parent_id.is_(None),
- )
- )
- existing_folder = existing_result.scalar_one_or_none()
- if existing_folder:
- # Link existing folder to project
- existing_folder.project_id = project.id
- else:
- # Create new folder linked to project
- new_folder = LibraryFolder(
- name=folder_data.name,
- project_id=project.id,
- is_external=False,
- external_readonly=False,
- external_show_hidden=False,
- )
- db.add(new_folder)
- await db.flush()
- await db.refresh(project)
- stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
- return ProjectResponse(
- id=project.id,
- name=project.name,
- description=project.description,
- color=project.color,
- status=project.status,
- target_count=project.target_count,
- target_parts_count=project.target_parts_count,
- notes=project.notes,
- attachments=project.attachments,
- url=project.url,
- cover_image_filename=project.cover_image_filename,
- 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,
- )
- @router.post("/import/file", response_model=ProjectResponse)
- async def import_project_file(
- file: UploadFile = File(...),
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
- ):
- """Import a project from a ZIP or JSON file."""
- if not file.filename:
- raise HTTPException(status_code=400, detail="No filename provided")
- # Determine file type
- filename_lower = file.filename.lower()
- content = await file.read()
- if filename_lower.endswith(".zip"):
- # Extract project.json from ZIP
- try:
- with zipfile.ZipFile(io.BytesIO(content)) as zf:
- if "project.json" not in zf.namelist():
- raise HTTPException(status_code=400, detail="ZIP must contain project.json")
- project_json = zf.read("project.json")
- data = json.loads(project_json)
- # Get list of files in the ZIP
- zip_files = {name: zf.read(name) for name in zf.namelist() if name.startswith("files/")}
- except zipfile.BadZipFile:
- raise HTTPException(status_code=400, detail="Invalid ZIP file")
- elif filename_lower.endswith(".json"):
- try:
- data = json.loads(content)
- zip_files = {}
- except json.JSONDecodeError:
- raise HTTPException(status_code=400, detail="Invalid JSON file")
- else:
- raise HTTPException(status_code=400, detail="File must be .zip or .json")
- # Create the project
- project = Project(
- name=data.get("name", "Imported Project"),
- description=data.get("description"),
- color=data.get("color"),
- status=data.get("status", "active"),
- target_count=data.get("target_count"),
- target_parts_count=data.get("target_parts_count"),
- notes=data.get("notes"),
- tags=data.get("tags"),
- due_date=datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None,
- priority=data.get("priority", 0),
- budget=data.get("budget"),
- )
- db.add(project)
- await db.flush()
- # Create BOM items
- for idx, bom_data in enumerate(data.get("bom_items", [])):
- bom_item = ProjectBOMItem(
- project_id=project.id,
- name=bom_data.get("name", "Unnamed"),
- quantity_needed=bom_data.get("quantity_needed", 1),
- quantity_acquired=bom_data.get("quantity_acquired", 0),
- unit_price=bom_data.get("unit_price"),
- sourcing_url=bom_data.get("sourcing_url"),
- stl_filename=bom_data.get("stl_filename"),
- remarks=bom_data.get("remarks"),
- sort_order=idx,
- )
- db.add(bom_item)
- # Create linked folders and files
- library_dir = get_library_dir()
- for folder_data in data.get("linked_folders", []):
- folder_name = folder_data.get("name")
- if not folder_name:
- continue
- # Check if folder exists
- existing_result = await db.execute(
- select(LibraryFolder).where(
- LibraryFolder.name == folder_name,
- LibraryFolder.parent_id.is_(None),
- )
- )
- existing_folder = existing_result.scalar_one_or_none()
- if existing_folder:
- # Link existing folder to project
- existing_folder.project_id = project.id
- folder = existing_folder
- else:
- # Create new folder
- folder = LibraryFolder(
- name=folder_name,
- project_id=project.id,
- is_external=False,
- external_readonly=False,
- external_show_hidden=False,
- )
- db.add(folder)
- await db.flush()
- # Create folder on disk
- folder_path = library_dir / folder_name
- folder_path.mkdir(parents=True, exist_ok=True)
- # Import files for this folder from ZIP
- folder_prefix = f"files/{folder_name}/"
- for zip_path, file_content in zip_files.items():
- if not zip_path.startswith(folder_prefix):
- continue
- if "/.thumbnails/" in zip_path:
- continue # Skip thumbnails, we'll regenerate them
- relative_path = zip_path[len(folder_prefix) :]
- if not relative_path:
- continue
- # Write file to disk
- file_disk_path = library_dir / folder_name / relative_path
- file_disk_path.parent.mkdir(parents=True, exist_ok=True)
- file_disk_path.write_bytes(file_content)
- # Determine file type
- ext = Path(relative_path).suffix.lower()
- if ext in [".stl", ".3mf", ".obj"]:
- file_type = "model"
- elif ext in [".gcode"]:
- file_type = "gcode"
- elif ext in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
- file_type = "image"
- else:
- file_type = "other"
- # Create library file record
- lib_file = LibraryFile(
- folder_id=folder.id,
- filename=relative_path,
- file_path=f"{folder_name}/{relative_path}",
- file_type=file_type,
- file_size=len(file_content),
- is_external=False,
- )
- db.add(lib_file)
- await db.flush()
- await db.refresh(project)
- stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
- return ProjectResponse(
- id=project.id,
- name=project.name,
- description=project.description,
- color=project.color,
- status=project.status,
- target_count=project.target_count,
- target_parts_count=project.target_parts_count,
- notes=project.notes,
- attachments=project.attachments,
- url=project.url,
- cover_image_filename=project.cover_image_filename,
- 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,
- )
|