Add project import/export with ZIP file support

Features:
- Export single project as ZIP containing project.json and all files
  from linked library folders
- Export all projects as JSON (metadata only) for bulk backup
- Import from ZIP (creates folders and extracts files) or JSON
- New /api/v1/projects/import/file endpoint for file uploads
- Frontend buttons on Projects page and Project Detail page
- BOM items are now fully editable (not just checkbox toggle)
This commit is contained in:
maziggy
2026-01-25 13:17:15 +01:00
parent 9034121bd5
commit 30537cfa54
11 changed files with 1236 additions and 139 deletions
+9
View File
@@ -5,6 +5,15 @@ All notable changes to Bambuddy will be documented in this file.
## [0.1.6] - 2026-01-24
### New Features
- **Project Import/Export** - Export and import projects with full file support (Issue #152):
- Export single project as ZIP (includes project settings, BOM, and all files from linked library folders)
- Export all projects as JSON for metadata-only backup
- Import from ZIP (with files) or JSON (metadata only)
- Linked folders and files are automatically created on import
- Useful for sharing complete project bundles or migrating between instances
- **BOM Item Editing** - Bill of Materials items are now fully editable:
- Edit name, quantity, price, URL, and remarks after creation
- Pencil icon on each BOM item to enter edit mode
- **External Link for Archives** - Add custom external links to archives for non-MakerWorld sources (Issue #151):
- Link archives to Printables, Thingiverse, or any other URL
- Globe button opens external link when set, falls back to auto-detected MakerWorld URL
+1
View File
@@ -100,6 +100,7 @@
- Auto-detect parts count from 3MF files
- Color-coded project badges
- Bulk assign archives via multi-select toolbar
- Import/Export projects as ZIP (includes files) or JSON
</td>
<td width="50%" valign="top">
+396 -1
View File
@@ -1,18 +1,23 @@
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
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.config import settings
from backend.app.core.database import get_db
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
@@ -25,6 +30,7 @@ from backend.app.schemas.project import (
BOMItemUpdate,
ProjectChildPreview,
ProjectCreate,
ProjectImport,
ProjectListResponse,
ProjectResponse,
ProjectStats,
@@ -1322,3 +1328,392 @@ async def get_project_timeline(
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),
):
"""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(
select(LibraryFile).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": f'attachment; filename="{filename}"'},
)
@router.post("/import", response_model=ProjectResponse)
async def import_project(
data: ProjectImport,
db: AsyncSession = Depends(get_db),
):
"""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,
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),
):
"""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,
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,
)
+10 -1
View File
@@ -2,7 +2,7 @@
from datetime import datetime
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
@@ -17,6 +17,12 @@ class LibraryFolder(Base):
name: Mapped[str] = mapped_column(String(255))
parent_id: Mapped[int | None] = mapped_column(ForeignKey("library_folders.id", ondelete="CASCADE"), nullable=True)
# External folder flags (for folders that point to external paths)
is_external: Mapped[bool] = mapped_column(Boolean, default=False)
external_readonly: Mapped[bool] = mapped_column(Boolean, default=False)
external_show_hidden: Mapped[bool] = mapped_column(Boolean, default=False)
external_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Link to project or archive
project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True)
@@ -55,6 +61,9 @@ class LibraryFile(Base):
folder_id: Mapped[int | None] = mapped_column(ForeignKey("library_folders.id", ondelete="CASCADE"), nullable=True)
project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
# External file flag
is_external: Mapped[bool] = mapped_column(Boolean, default=False)
# File info
filename: Mapped[str] = mapped_column(String(255)) # Original filename
file_path: Mapped[str] = mapped_column(String(500)) # Storage path
+55
View File
@@ -205,3 +205,58 @@ class TimelineEvent(BaseModel):
title: str
description: str | None = None
metadata: dict | None = None # Additional event-specific data
# Phase 10: Import/Export Schemas
class BOMItemExport(BaseModel):
"""Schema for exporting a BOM item."""
name: str
quantity_needed: int
quantity_acquired: int
unit_price: float | None
sourcing_url: str | None
stl_filename: str | None
remarks: str | None
class LinkedFolderExport(BaseModel):
"""Schema for exporting a linked library folder."""
name: str
class ProjectExport(BaseModel):
"""Schema for exporting a project."""
name: str
description: str | None
color: str | None
status: str
target_count: int | None
target_parts_count: int | None
notes: str | None
tags: str | None
due_date: datetime | None
priority: str
budget: float | None
bom_items: list[BOMItemExport] = []
linked_folders: list[LinkedFolderExport] = []
class ProjectImport(BaseModel):
"""Schema for importing a project."""
name: str
description: str | None = None
color: str | None = None
status: str = "active"
target_count: int | None = None
target_parts_count: int | None = None
notes: str | None = None
tags: str | None = None
due_date: datetime | None = None
priority: str = "normal"
budget: float | None = None
bom_items: list[BOMItemExport] = []
linked_folders: list[LinkedFolderExport] = []
@@ -297,3 +297,330 @@ class TestProjectArchivesAPI:
# Project should have an archive count (may be 0)
data = response.json()
assert "name" in data
class TestProjectExportImport:
"""Tests for project export/import functionality."""
@pytest.fixture
async def project_factory(self, db_session):
"""Factory to create test projects."""
_counter = [0]
async def _create_project(**kwargs):
from backend.app.models.project import Project
_counter[0] += 1
counter = _counter[0]
defaults = {
"name": f"Export Test Project {counter}",
"description": "Test project for export",
"color": "#00FF00",
}
defaults.update(kwargs)
project = Project(**defaults)
db_session.add(project)
await db_session.commit()
await db_session.refresh(project)
return project
return _create_project
@pytest.fixture
async def bom_item_factory(self, db_session):
"""Factory to create test BOM items."""
async def _create_bom_item(project_id: int, **kwargs):
from backend.app.models.project_bom import ProjectBOMItem
defaults = {
"project_id": project_id,
"name": "Test Part",
"quantity_needed": 1,
"quantity_acquired": 0,
"sort_order": 0,
}
defaults.update(kwargs)
item = ProjectBOMItem(**defaults)
db_session.add(item)
await db_session.commit()
await db_session.refresh(item)
return item
return _create_bom_item
@pytest.mark.asyncio
@pytest.mark.integration
async def test_export_project(self, async_client: AsyncClient, project_factory, bom_item_factory, db_session):
"""Verify project export includes BOM items."""
project = await project_factory(
name="Export Me",
description="A test project",
target_count=10,
target_parts_count=50,
budget=100.0,
)
# Add BOM items
await bom_item_factory(project.id, name="M3x8 Screws", quantity_needed=20, unit_price=0.10)
await bom_item_factory(project.id, name="Heat Inserts", quantity_needed=10, unit_price=0.25)
# Test JSON format export
response = await async_client.get(f"/api/v1/projects/{project.id}/export?format=json")
assert response.status_code == 200
data = response.json()
assert data["name"] == "Export Me"
assert data["description"] == "A test project"
assert data["target_count"] == 10
assert data["target_parts_count"] == 50
assert data["budget"] == 100.0
assert len(data["bom_items"]) == 2
# Check BOM items
bom_names = [item["name"] for item in data["bom_items"]]
assert "M3x8 Screws" in bom_names
assert "Heat Inserts" in bom_names
# Test ZIP format export (default)
zip_response = await async_client.get(f"/api/v1/projects/{project.id}/export")
assert zip_response.status_code == 200
assert zip_response.headers["content-type"] == "application/zip"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_import_project(self, async_client: AsyncClient):
"""Verify project can be imported with BOM items."""
import_data = {
"name": "Imported Project",
"description": "Imported from JSON",
"color": "#FF00FF",
"target_count": 5,
"target_parts_count": 25,
"budget": 50.0,
"bom_items": [
{
"name": "PTFE Tubes",
"quantity_needed": 4,
"quantity_acquired": 0,
"unit_price": 2.50,
"sourcing_url": "https://example.com",
"stl_filename": None,
"remarks": "Need 4mm ID",
},
],
}
response = await async_client.post("/api/v1/projects/import", json=import_data)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Imported Project"
assert data["description"] == "Imported from JSON"
assert data["target_count"] == 5
assert data["target_parts_count"] == 25
assert data["budget"] == 50.0
assert data["id"] > 0 # Has a valid ID
# BOM stats should show 1 item imported
assert data["stats"]["bom_total_items"] == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_export_project_with_linked_folder(self, async_client: AsyncClient, project_factory, db_session):
"""Verify project export includes linked folders."""
from backend.app.models.library import LibraryFolder
project = await project_factory(name="Project With Folder")
# Create a linked folder
folder = LibraryFolder(name="Project Files", project_id=project.id)
db_session.add(folder)
await db_session.commit()
response = await async_client.get(f"/api/v1/projects/{project.id}/export?format=json")
assert response.status_code == 200
data = response.json()
assert data["name"] == "Project With Folder"
assert len(data["linked_folders"]) == 1
assert data["linked_folders"][0]["name"] == "Project Files"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_import_project_with_linked_folder(self, async_client: AsyncClient):
"""Verify project import accepts linked folders data."""
import_data = {
"name": "Imported With Folders",
"linked_folders": [
{"name": "STL Files"},
{"name": "Documentation"},
],
}
# Import should succeed with linked_folders
response = await async_client.post("/api/v1/projects/import", json=import_data)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Imported With Folders"
assert data["id"] > 0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_import_project_from_json_file(self, async_client: AsyncClient):
"""Verify project can be imported from JSON file upload."""
import io
import json
project_data = {
"name": "File Uploaded Project",
"description": "Imported from JSON file",
"color": "#123456",
}
# Create a file-like object
file_content = json.dumps(project_data).encode()
files = {"file": ("project.json", io.BytesIO(file_content), "application/json")}
response = await async_client.post("/api/v1/projects/import/file", files=files)
assert response.status_code == 200
data = response.json()
assert data["name"] == "File Uploaded Project"
assert data["description"] == "Imported from JSON file"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_import_project_from_zip_file(self, async_client: AsyncClient):
"""Verify project can be imported from ZIP file with files."""
import io
import json
import zipfile
project_data = {
"name": "ZIP Imported Project",
"description": "Imported from ZIP",
"linked_folders": [{"name": "TestFolder", "files": [{"filename": "test.txt"}]}],
}
# Create a ZIP file in memory
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("project.json", json.dumps(project_data))
zf.writestr("files/TestFolder/test.txt", "Hello World")
zip_buffer.seek(0)
files = {"file": ("project.zip", zip_buffer, "application/zip")}
response = await async_client.post("/api/v1/projects/import/file", files=files)
assert response.status_code == 200
data = response.json()
assert data["name"] == "ZIP Imported Project"
assert data["description"] == "Imported from ZIP"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_export_zip_contains_files(self, async_client: AsyncClient, project_factory, db_session):
"""Verify ZIP export contains actual files from linked folders."""
import io
import json
import zipfile
from pathlib import Path
from backend.app.api.routes.library import get_library_dir
from backend.app.models.library import LibraryFile, LibraryFolder
project = await project_factory(name="Project With Files")
# Create a linked folder with is_external fields
folder = LibraryFolder(
name="TestExportFolder",
project_id=project.id,
is_external=False,
external_readonly=False,
external_show_hidden=False,
)
db_session.add(folder)
await db_session.flush()
# Create a test file on disk
library_dir = get_library_dir()
folder_path = library_dir / "TestExportFolder"
folder_path.mkdir(parents=True, exist_ok=True)
test_file_path = folder_path / "test_export.txt"
test_file_path.write_text("Export test content")
# Create library file record
lib_file = LibraryFile(
folder_id=folder.id,
filename="test_export.txt",
file_path="TestExportFolder/test_export.txt",
file_type="other",
file_size=19,
is_external=False,
)
db_session.add(lib_file)
await db_session.commit()
# Export as ZIP
response = await async_client.get(f"/api/v1/projects/{project.id}/export")
assert response.status_code == 200
assert response.headers["content-type"] == "application/zip"
# Verify ZIP contents
zip_buffer = io.BytesIO(response.content)
with zipfile.ZipFile(zip_buffer, "r") as zf:
assert "project.json" in zf.namelist()
assert "files/TestExportFolder/test_export.txt" in zf.namelist()
# Verify file content
file_content = zf.read("files/TestExportFolder/test_export.txt").decode()
assert file_content == "Export test content"
# Verify project.json
project_data = json.loads(zf.read("project.json"))
assert project_data["name"] == "Project With Files"
# Cleanup
test_file_path.unlink(missing_ok=True)
folder_path.rmdir()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_import_invalid_file_type(self, async_client: AsyncClient):
"""Verify import rejects invalid file types."""
import io
files = {"file": ("project.txt", io.BytesIO(b"invalid"), "text/plain")}
response = await async_client.post("/api/v1/projects/import/file", files=files)
assert response.status_code == 400
assert "must be .zip or .json" in response.json()["detail"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_import_zip_missing_project_json(self, async_client: AsyncClient):
"""Verify import rejects ZIP without project.json."""
import io
import zipfile
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w") as zf:
zf.writestr("other.txt", "no project.json here")
zip_buffer.seek(0)
files = {"file": ("project.zip", zip_buffer, "application/zip")}
response = await async_client.post("/api/v1/projects/import/file", files=files)
assert response.status_code == 400
assert "project.json" in response.json()["detail"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_import_invalid_json(self, async_client: AsyncClient):
"""Verify import rejects invalid JSON content."""
import io
files = {"file": ("project.json", io.BytesIO(b"not valid json"), "application/json")}
response = await async_client.post("/api/v1/projects/import/file", files=files)
assert response.status_code == 400
assert "Invalid JSON" in response.json()["detail"]
+56
View File
@@ -530,6 +530,53 @@ export interface BOMItemUpdate {
remarks?: string;
}
// Project Export/Import Types
export interface BOMItemExport {
name: string;
quantity_needed: number;
quantity_acquired: number;
unit_price: number | null;
sourcing_url: string | null;
stl_filename: string | null;
remarks: string | null;
}
export interface LinkedFolderExport {
name: string;
}
export interface ProjectExport {
name: string;
description: string | null;
color: string | null;
status: string;
target_count: number | null;
target_parts_count: number | null;
notes: string | null;
tags: string | null;
due_date: string | null;
priority: string;
budget: number | null;
bom_items: BOMItemExport[];
linked_folders: LinkedFolderExport[];
}
export interface ProjectImport {
name: string;
description?: string;
color?: string;
status?: string;
target_count?: number;
target_parts_count?: number;
notes?: string;
tags?: string;
due_date?: string;
priority?: string;
budget?: number;
bom_items?: BOMItemExport[];
linked_folders?: LinkedFolderExport[];
}
// Timeline Types
export interface TimelineEvent {
event_type: string;
@@ -2683,6 +2730,15 @@ export const api = {
getProjectTimeline: (projectId: number, limit = 50) =>
request<TimelineEvent[]>(`/projects/${projectId}/timeline?limit=${limit}`),
// Project Export/Import
exportProjectJson: (projectId: number) =>
request<ProjectExport>(`/projects/${projectId}/export?format=json`),
importProject: (data: ProjectImport) =>
request<Project>('/projects/import', {
method: 'POST',
body: JSON.stringify(data),
}),
// API Keys
getAPIKeys: () => request<APIKey[]>('/api-keys/'),
createAPIKey: (data: APIKeyCreate) =>
+203 -67
View File
@@ -27,10 +27,12 @@ import {
ExternalLink,
ShoppingCart,
FolderOpen,
Download,
Pencil,
} from 'lucide-react';
import { api } from '../api/client';
import { parseUTCDate, formatDateOnly, formatDateTime, type TimeFormat } from '../utils/date';
import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate } from '../api/client';
import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate, BOMItemUpdate } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { useToast } from '../contexts/ToastContext';
@@ -276,6 +278,12 @@ export function ProjectDetailPage() {
const [newBomRemarks, setNewBomRemarks] = useState('');
const [showBomForm, setShowBomForm] = useState(false);
const [hideBomCompleted, setHideBomCompleted] = useState(false);
const [editingBomItem, setEditingBomItem] = useState<BOMItem | null>(null);
const [editBomName, setEditBomName] = useState('');
const [editBomQty, setEditBomQty] = useState(1);
const [editBomPrice, setEditBomPrice] = useState('');
const [editBomUrl, setEditBomUrl] = useState('');
const [editBomRemarks, setEditBomRemarks] = useState('');
// Confirm modal state
const [confirmModal, setConfirmModal] = useState<{
@@ -302,11 +310,12 @@ export function ProjectDetailPage() {
});
const updateBomMutation = useMutation({
mutationFn: ({ itemId, data }: { itemId: number; data: { quantity_acquired?: number } }) =>
mutationFn: ({ itemId, data }: { itemId: number; data: BOMItemUpdate }) =>
api.updateBOMItem(projectId, itemId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
queryClient.invalidateQueries({ queryKey: ['project', projectId] });
setEditingBomItem(null);
},
onError: (error: Error) => showToast(error.message, 'error'),
});
@@ -353,6 +362,57 @@ export function ProjectDetailPage() {
});
};
const handleEditBomItem = (item: BOMItem) => {
setEditingBomItem(item);
setEditBomName(item.name);
setEditBomQty(item.quantity_needed);
setEditBomPrice(item.unit_price?.toString() || '');
setEditBomUrl(item.sourcing_url || '');
setEditBomRemarks(item.remarks || '');
};
const handleSaveBomEdit = (e: React.FormEvent) => {
e.preventDefault();
if (!editingBomItem || !editBomName.trim()) return;
updateBomMutation.mutate({
itemId: editingBomItem.id,
data: {
name: editBomName.trim(),
quantity_needed: editBomQty,
unit_price: editBomPrice ? parseFloat(editBomPrice) : undefined,
sourcing_url: editBomUrl.trim() || undefined,
remarks: editBomRemarks.trim() || undefined,
},
});
};
const handleCancelBomEdit = () => {
setEditingBomItem(null);
};
const handleExportProject = async () => {
try {
// Fetch ZIP file directly
const response = await fetch(`/api/v1/projects/${projectId}/export`);
if (!response.ok) {
throw new Error('Export failed');
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// Get filename from Content-Disposition header or use default
const contentDisposition = response.headers.get('Content-Disposition');
const filenameMatch = contentDisposition?.match(/filename="(.+)"/);
a.download = filenameMatch?.[1] || `${project?.name || 'project'}_${new Date().toISOString().split('T')[0]}.zip`;
a.click();
URL.revokeObjectURL(url);
showToast('Project exported', 'success');
} catch (error) {
showToast((error as Error).message, 'error');
}
};
// Template handlers
const createTemplateMutation = useMutation({
mutationFn: () => api.createTemplateFromProject(projectId),
@@ -433,10 +493,16 @@ export function ProjectDetailPage() {
</div>
<StatusBadge status={project.status} />
</div>
<Button onClick={() => setShowEditModal(true)}>
<Edit3 className="w-4 h-4 mr-2" />
Edit
</Button>
<div className="flex gap-2">
<Button variant="secondary" onClick={handleExportProject} title="Export project">
<Download className="w-4 h-4 mr-2" />
Export
</Button>
<Button onClick={() => setShowEditModal(true)}>
<Edit3 className="w-4 h-4 mr-2" />
Edit
</Button>
</div>
</div>
{/* Progress bars (if targets set) */}
@@ -903,70 +969,140 @@ export function ProjectDetailPage() {
item.is_complete ? 'bg-status-ok/10' : 'bg-bambu-dark'
}`}
>
<div className="flex items-start gap-3">
<button
onClick={() => handleToggleAcquired(item)}
disabled={updateBomMutation.isPending}
className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors flex-shrink-0 ${
item.is_complete
? 'bg-status-ok border-status-ok text-white'
: 'border-bambu-gray hover:border-bambu-green'
}`}
>
{item.is_complete && <CheckCircle className="w-3 h-3" />}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<p className={`text-sm font-medium ${item.is_complete ? 'text-bambu-gray line-through' : 'text-white'}`}>
{item.name}
<span className="text-bambu-gray font-normal ml-2">
x{item.quantity_needed}
</span>
</p>
{item.unit_price !== null && (
<span className="text-xs text-bambu-green whitespace-nowrap">
{currency}{(item.unit_price * item.quantity_needed).toFixed(2)}
</span>
)}
{editingBomItem?.id === item.id ? (
// Edit form for this BOM item
<form onSubmit={handleSaveBomEdit} className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<input
type="text"
value={editBomName}
onChange={(e) => setEditBomName(e.target.value)}
className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
placeholder="Part name"
autoFocus
/>
<div className="flex gap-2">
<input
type="number"
value={editBomQty}
onChange={(e) => setEditBomQty(parseInt(e.target.value) || 1)}
className="w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green"
min="1"
placeholder="Qty"
/>
<input
type="number"
step="0.01"
value={editBomPrice}
onChange={(e) => setEditBomPrice(e.target.value)}
className="flex-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
placeholder={`Price (${currency})`}
/>
</div>
<button
onClick={() => handleDeleteBomItem(item.id, item.name)}
className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400 transition-colors flex-shrink-0"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Sourcing URL */}
{item.sourcing_url && (
<a
href={item.sourcing_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 mt-1 text-xs text-blue-400 hover:text-blue-300 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="w-3 h-3 flex-shrink-0" />
<span className="truncate">
{(() => {
try {
return new URL(item.sourcing_url).hostname.replace('www.', '');
} catch {
return item.sourcing_url;
}
})()}
</span>
</a>
)}
{/* Remarks */}
{item.remarks && (
<p className="mt-1 text-xs text-bambu-gray/80 italic">
{item.remarks}
</p>
)}
<input
type="url"
value={editBomUrl}
onChange={(e) => setEditBomUrl(e.target.value)}
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
placeholder="Sourcing URL (optional)"
/>
<input
type="text"
value={editBomRemarks}
onChange={(e) => setEditBomRemarks(e.target.value)}
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
placeholder="Remarks (optional)"
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" onClick={handleCancelBomEdit}>
Cancel
</Button>
<Button type="submit" size="sm" disabled={!editBomName.trim() || updateBomMutation.isPending}>
{updateBomMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Save'
)}
</Button>
</div>
</form>
) : (
// Display mode
<div className="flex items-start gap-3">
<button
onClick={() => handleToggleAcquired(item)}
disabled={updateBomMutation.isPending}
className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors flex-shrink-0 ${
item.is_complete
? 'bg-status-ok border-status-ok text-white'
: 'border-bambu-gray hover:border-bambu-green'
}`}
>
{item.is_complete && <CheckCircle className="w-3 h-3" />}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<p className={`text-sm font-medium ${item.is_complete ? 'text-bambu-gray line-through' : 'text-white'}`}>
{item.name}
<span className="text-bambu-gray font-normal ml-2">
x{item.quantity_needed}
</span>
</p>
{item.unit_price !== null && (
<span className="text-xs text-bambu-green whitespace-nowrap">
{currency}{(item.unit_price * item.quantity_needed).toFixed(2)}
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleEditBomItem(item)}
className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors flex-shrink-0"
title="Edit"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteBomItem(item.id, item.name)}
className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400 transition-colors flex-shrink-0"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Sourcing URL */}
{item.sourcing_url && (
<a
href={item.sourcing_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 mt-1 text-xs text-blue-400 hover:text-blue-300 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="w-3 h-3 flex-shrink-0" />
<span className="truncate">
{(() => {
try {
return new URL(item.sourcing_url).hostname.replace('www.', '');
} catch {
return item.sourcing_url;
}
})()}
</span>
</a>
)}
{/* Remarks */}
{item.remarks && (
<p className="mt-1 text-xs text-bambu-gray/80 italic">
{item.remarks}
</p>
)}
</div>
</div>
</div>
)}
</div>
))}
{/* BOM Total */}
+115 -6
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
@@ -16,9 +16,11 @@ import {
AlertTriangle,
ChevronRight,
MoreVertical,
Download,
Upload,
} from 'lucide-react';
import { api } from '../api/client';
import type { ProjectListItem, ProjectCreate, ProjectUpdate } from '../api/client';
import type { ProjectListItem, ProjectCreate, ProjectUpdate, ProjectImport } from '../api/client';
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
import { useToast } from '../contexts/ToastContext';
@@ -613,6 +615,94 @@ export function ProjectsPage() {
},
});
const importMutation = useMutation({
mutationFn: (data: ProjectImport) => api.importProject(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
showToast('Project imported', 'success');
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const fileInputRef = useRef<HTMLInputElement>(null);
const handleExportAll = async () => {
try {
// Export all projects as JSON (metadata only, no files)
const allProjects = await api.getProjects();
const exports = await Promise.all(
allProjects.map(async (p) => {
const exported = await api.exportProjectJson(p.id);
return exported;
})
);
const blob = new Blob([JSON.stringify(exports, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bambuddy_projects_${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
showToast('Projects exported (metadata only)', 'success');
} catch (error) {
showToast((error as Error).message, 'error');
}
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const filename = file.name.toLowerCase();
if (filename.endsWith('.zip')) {
// ZIP file: upload via file endpoint
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/v1/projects/import/file', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Import failed');
}
queryClient.invalidateQueries({ queryKey: ['projects'] });
showToast('Project imported', 'success');
} else {
// JSON file: parse and handle bulk or single import
const text = await file.text();
const data = JSON.parse(text);
// Handle both single project and array of projects
const projectsToImport = Array.isArray(data) ? data : [data];
for (const project of projectsToImport) {
await importMutation.mutateAsync(project);
}
if (projectsToImport.length > 1) {
showToast(`${projectsToImport.length} projects imported`, 'success');
}
}
} catch (error) {
showToast(`Import failed: ${(error as Error).message}`, 'error');
}
// Reset file input
e.target.value = '';
};
const handleSave = (data: ProjectCreate | ProjectUpdate) => {
if (editingProject) {
updateMutation.mutate({ id: editingProject.id, data });
@@ -650,6 +740,15 @@ export function ProjectsPage() {
return (
<div className="p-4 md:p-8 space-y-8">
{/* Hidden file input for import */}
<input
ref={fileInputRef}
type="file"
accept=".json,.zip"
onChange={handleFileChange}
className="hidden"
/>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
@@ -663,10 +762,20 @@ export function ProjectsPage() {
Organize and track your 3D printing projects
</p>
</div>
<Button onClick={() => setShowModal(true)} className="sm:w-auto w-full">
<Plus className="w-4 h-4 mr-2" />
New Project
</Button>
<div className="flex gap-2">
<Button variant="secondary" onClick={handleImportClick} title="Import project">
<Upload className="w-4 h-4 mr-2" />
Import
</Button>
<Button variant="secondary" onClick={handleExportAll} title="Export all projects">
<Download className="w-4 h-4 mr-2" />
Export
</Button>
<Button onClick={() => setShowModal(true)} className="sm:w-auto w-full">
<Plus className="w-4 h-4 mr-2" />
New Project
</Button>
</div>
</div>
{/* Filter tabs */}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -23,7 +23,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-BQzscOA-.js"></script>
<script type="module" crossorigin src="/assets/index-CTmc90ua.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BKSrBx0A.css">
</head>
<body>