diff --git a/CHANGELOG.md b/CHANGELOG.md index b9352705c..b7c58899d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,11 @@ All notable changes to Bambuddy will be documented in this file. - Automatic detection skips data channel SSL for A1 and A1 Mini models - Control channel remains encrypted via implicit FTPS (port 990) - Fixes "read operation timed out" errors during file uploads +- **Bulk project assignment** - Assign multiple archives to a project at once: + - New "Project" button in multi-select toolbar (next to Tags) + - Select a project to assign all selected archives + - "Remove from project" option to clear assignments + - Updates Projects page and Project Detail page instantly ### Fixed - **QR code endpoint** - Fixed 500 error on archive QR code generation: diff --git a/README.md b/README.md index 2a1f076d5..c0da5a77f 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ - Track progress with target counts - Quantity tracking for batch prints - Color-coded project badges -- Assign archives via context menu +- Bulk assign archives via multi-select toolbar diff --git a/backend/tests/integration/test_printers_api.py b/backend/tests/integration/test_printers_api.py index 4fe4ad67f..169adc96e 100644 --- a/backend/tests/integration/test_printers_api.py +++ b/backend/tests/integration/test_printers_api.py @@ -711,3 +711,85 @@ class TestSkipObjectsAPI: assert 100 in result["skipped_objects"] assert 200 in result["skipped_objects"] mock_client.skip_objects.assert_called_once_with([100, 200]) + + +class TestChamberLightAPI: + """Integration tests for chamber light control endpoint.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_chamber_light_not_found(self, async_client: AsyncClient): + """Verify 404 for non-existent printer.""" + response = await async_client.post("/api/v1/printers/99999/chamber-light?on=true") + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_chamber_light_not_connected(self, async_client: AsyncClient, printer_factory): + """Verify error when printer is not connected.""" + printer = await printer_factory(name="Disconnected Printer") + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = None + + response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true") + + assert response.status_code == 400 + assert "not connected" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_chamber_light_on_success(self, async_client: AsyncClient, printer_factory): + """Verify successful chamber light on request.""" + printer = await printer_factory(name="Test Printer") + + mock_client = MagicMock() + mock_client.set_chamber_light.return_value = True + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = mock_client + + response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true") + + assert response.status_code == 200 + result = response.json() + assert result["success"] is True + assert "on" in result["message"].lower() + mock_client.set_chamber_light.assert_called_once_with(True) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_chamber_light_off_success(self, async_client: AsyncClient, printer_factory): + """Verify successful chamber light off request.""" + printer = await printer_factory(name="Test Printer") + + mock_client = MagicMock() + mock_client.set_chamber_light.return_value = True + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = mock_client + + response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=false") + + assert response.status_code == 200 + result = response.json() + assert result["success"] is True + assert "off" in result["message"].lower() + mock_client.set_chamber_light.assert_called_once_with(False) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_chamber_light_failure(self, async_client: AsyncClient, printer_factory): + """Verify error handling when chamber light control fails.""" + printer = await printer_factory(name="Test Printer") + + mock_client = MagicMock() + mock_client.set_chamber_light.return_value = False + + with patch("backend.app.api.routes.printers.printer_manager") as mock_pm: + mock_pm.get_client.return_value = mock_client + + response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true") + + assert response.status_code == 500 + assert "failed" in response.json()["detail"].lower() diff --git a/frontend/src/components/BatchProjectModal.tsx b/frontend/src/components/BatchProjectModal.tsx new file mode 100644 index 000000000..54bb52759 --- /dev/null +++ b/frontend/src/components/BatchProjectModal.tsx @@ -0,0 +1,187 @@ +import { useEffect } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { X, FolderKanban, Loader2, XCircle } from 'lucide-react'; +import { api } from '../api/client'; +import { Card, CardContent } from './Card'; +import { Button } from './Button'; +import { useToast } from '../contexts/ToastContext'; + +interface BatchProjectModalProps { + selectedIds: number[]; + onClose: () => void; +} + +export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalProps) { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + const { data: projects, isLoading } = useQuery({ + queryKey: ['projects'], + queryFn: () => api.getProjects(), + }); + + // Close on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + // Helper to invalidate all project-related queries + const invalidateProjectQueries = () => { + queryClient.invalidateQueries({ queryKey: ['archives'] }); + queryClient.invalidateQueries({ queryKey: ['projects'] }); + // Invalidate project detail pages (partial match catches all project IDs) + queryClient.invalidateQueries({ queryKey: ['project'] }); + queryClient.invalidateQueries({ queryKey: ['project-archives'] }); + }; + + // Assign to project mutation (uses bulk API) + const assignMutation = useMutation({ + mutationFn: async (projectId: number) => { + await api.addArchivesToProject(projectId, selectedIds); + return projectId; + }, + onSuccess: (projectId) => { + const project = projects?.find(p => p.id === projectId); + invalidateProjectQueries(); + showToast(`Added ${selectedIds.length} archive${selectedIds.length !== 1 ? 's' : ''} to "${project?.name}"`); + onClose(); + }, + onError: () => { + showToast('Failed to assign project', 'error'); + }, + }); + + // Remove from project mutation (updates each archive individually) + const removeMutation = useMutation({ + mutationFn: async () => { + for (const id of selectedIds) { + await api.updateArchive(id, { project_id: null }); + } + return selectedIds.length; + }, + onSuccess: (count) => { + invalidateProjectQueries(); + showToast(`Removed ${count} archive${count !== 1 ? 's' : ''} from project`); + onClose(); + }, + onError: () => { + showToast('Failed to remove from project', 'error'); + }, + }); + + const isPending = assignMutation.isPending || removeMutation.isPending; + + return ( +
+ + + {/* Header */} +
+
+ +

+ Assign to Project +

+
+ +
+ + {/* Content */} +
+

+ Assign {selectedIds.length} selected archive{selectedIds.length !== 1 ? 's' : ''} to a project +

+ + {isLoading ? ( +
+ +
+ ) : ( +
+ {/* Remove from project option */} + + + {/* Divider */} + {projects && projects.length > 0 && ( +
+
+ or assign to +
+
+ )} + + {/* Project list */} + {projects?.map((project) => ( + + ))} + + {(!projects || projects.length === 0) && ( +

+ No projects yet. Create one from the Projects page. +

+ )} +
+ )} +
+ + {/* Footer */} +
+ +
+ + +
+ ); +} diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx index ea57d5714..0b68b4fb1 100644 --- a/frontend/src/pages/ArchivesPage.tsx +++ b/frontend/src/pages/ArchivesPage.tsx @@ -55,6 +55,7 @@ import { ConfirmModal } from '../components/ConfirmModal'; import { EditArchiveModal } from '../components/EditArchiveModal'; import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu'; import { BatchTagModal } from '../components/BatchTagModal'; +import { BatchProjectModal } from '../components/BatchProjectModal'; import { CalendarView } from '../components/CalendarView'; import { QRCodeModal } from '../components/QRCodeModal'; import { PhotoGalleryModal } from '../components/PhotoGalleryModal'; @@ -1670,6 +1671,7 @@ export function ArchivesPage() { const [isSelectionMode, setIsSelectionMode] = useState(false); const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false); const [showBatchTag, setShowBatchTag] = useState(false); + const [showBatchProject, setShowBatchProject] = useState(false); const [viewMode, setViewMode] = useState(() => (localStorage.getItem('archiveViewMode') as ViewMode) || 'grid' ); @@ -2045,6 +2047,14 @@ export function ArchivesPage() { Tags +