mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user