diff --git a/code/backend/app/api/v1/results.py b/code/backend/app/api/v1/results.py index 6ea7b98..058f1c0 100644 --- a/code/backend/app/api/v1/results.py +++ b/code/backend/app/api/v1/results.py @@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import FileResponse +from starlette.responses import Response from app.api.deps import get_file_storage_service from app.handlers import results as results_handlers @@ -108,6 +109,27 @@ def get_display_path( except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to get display path: {str(e)}") + +@router.get("/{task_id}/robot-files.zip") +def get_robot_files_zip( + task_id: str, + storage=Depends(get_file_storage_service), +): + """Download available robot-related task files as a zip archive.""" + try: + payload, included, missing = results_handlers.get_robot_files_zip(task_id, storage) + headers = { + "Content-Disposition": f'attachment; filename="{task_id}_robot_files.zip"', + "X-Included-Files": ",".join(included), + "X-Missing-Files": ",".join(missing), + "Access-Control-Expose-Headers": "Content-Disposition, X-Included-Files, X-Missing-Files", + } + return Response(content=payload, media_type="application/zip", headers=headers) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to build robot files zip: {str(e)}") from e + @router.delete("/{task_id}", response_model=DeleteTaskResultsResponse) def delete_task_results( task_id: str, diff --git a/code/backend/app/handlers/results.py b/code/backend/app/handlers/results.py index 50f3806..e33ba9b 100644 --- a/code/backend/app/handlers/results.py +++ b/code/backend/app/handlers/results.py @@ -1,6 +1,8 @@ """Results business logic: task summary, list, and task artifact readers.""" +import io import json +import zipfile from pathlib import Path from typing import Any, List, Optional @@ -165,6 +167,51 @@ def get_display_path(task_id: str, storage: FileStorageService) -> DisplayPathRe waypoints=waypoints, ) + +def get_robot_files_zip(task_id: str, storage: FileStorageService) -> tuple[bytes, list[str], list[str]]: + """ + Build a zip of task robot artifacts, including whatever files are available. + + Returns: + (zip_bytes, included_filenames, missing_filenames) + Raises: + FileNotFoundError: if none of the target files are available. + """ + expected = ["robot_path.json", "prescription.geojson", "prescription_config.json"] + included: list[str] = [] + missing: list[str] = [] + buffer = io.BytesIO() + + robot_path = storage.get_robot_path_path(task_id) + prescription_path = storage.get_prescription_path(task_id) + prescription_config = storage.read_prescription_config(task_id) + + with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: + if robot_path: + zf.write(robot_path, arcname="robot_path.json") + included.append("robot_path.json") + else: + missing.append("robot_path.json") + + if prescription_path: + zf.write(prescription_path, arcname="prescription.geojson") + included.append("prescription.geojson") + else: + missing.append("prescription.geojson") + + if prescription_config is not None: + zf.writestr("prescription_config.json", json.dumps(prescription_config, ensure_ascii=False, indent=2)) + included.append("prescription_config.json") + else: + missing.append("prescription_config.json") + + if not included: + raise FileNotFoundError( + f"No downloadable robot files found for task {task_id}. Missing: {', '.join(expected)}" + ) + + return buffer.getvalue(), included, missing + def delete_task_results(task_id: str, storage: FileStorageService) -> DeleteTaskResultsResponse: """Delete all results for a task.""" try: diff --git a/code/frontend/src/components/FieldMapsView.tsx b/code/frontend/src/components/FieldMapsView.tsx index 8f3ea1b..faa7d41 100644 --- a/code/frontend/src/components/FieldMapsView.tsx +++ b/code/frontend/src/components/FieldMapsView.tsx @@ -163,6 +163,9 @@ const FieldMapsView: React.FC = () => { const [deleteConfirmText, setDeleteConfirmText] = useState(''); const [deleteError, setDeleteError] = useState(null); const [isDeletingTask, setIsDeletingTask] = useState(false); + const [isDownloadingRobotFiles, setIsDownloadingRobotFiles] = useState(false); + const [downloadMissingFiles, setDownloadMissingFiles] = useState([]); + const [downloadError, setDownloadError] = useState(null); // Check internet connectivity useEffect(() => { @@ -297,6 +300,8 @@ const FieldMapsView: React.FC = () => { setPathJobId(null); setPathError(null); setIsPathEditorOpen(false); + setDownloadMissingFiles([]); + setDownloadError(null); }, [selectedMap?.id]); const closeDeleteConfirm = () => { @@ -448,6 +453,28 @@ const FieldMapsView: React.FC = () => { } }; + const downloadRobotFilesZip = async (taskId: string) => { + setIsDownloadingRobotFiles(true); + setDownloadMissingFiles([]); + setDownloadError(null); + try { + const { blob, missing } = await apiService.downloadTaskRobotFilesZip(taskId); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${taskId}_robot_files.zip`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + setDownloadMissingFiles(missing); + } catch (err) { + setDownloadError(err instanceof Error ? err.message : 'Failed to download robot files'); + } finally { + setIsDownloadingRobotFiles(false); + } + }; + if (loading) { return (
@@ -768,14 +795,15 @@ const FieldMapsView: React.FC = () => { Modify Path
+ {downloadError && ( +
+ {downloadError} +
+ )} + {!downloadError && downloadMissingFiles.length > 0 && ( +
+ Downloaded available files. Missing: {downloadMissingFiles.join(', ')}. +
+ )} {/* Content Area */}
diff --git a/code/frontend/src/components/PesticidePrescriptionsView.tsx b/code/frontend/src/components/PesticidePrescriptionsView.tsx index 0f6ea83..c5e8753 100644 --- a/code/frontend/src/components/PesticidePrescriptionsView.tsx +++ b/code/frontend/src/components/PesticidePrescriptionsView.tsx @@ -121,6 +121,9 @@ const PesticidePrescriptionsView: React.FC = () => { const [regenerateSubmitting, setRegenerateSubmitting] = useState(false); const [regenerateError, setRegenerateError] = useState(null); const [regenerateDraft, setRegenerateDraft] = useState(() => configToRegenerateDraft(null)); + const [isDownloadingRobotFiles, setIsDownloadingRobotFiles] = useState(false); + const [downloadMissingFiles, setDownloadMissingFiles] = useState([]); + const [downloadError, setDownloadError] = useState(null); // Deterministic palette for visually distinguishing cluster polygons. // (Only used when `properties.spray` is not set for a feature.) @@ -163,6 +166,8 @@ const PesticidePrescriptionsView: React.FC = () => { setSprayUpdates({}); setDetailConfig(null); setThresholdsModalOpen(false); + setDownloadMissingFiles([]); + setDownloadError(null); setDetailLoading(true); try { const [geojson, status, cfg] = await Promise.all([ @@ -205,8 +210,33 @@ const PesticidePrescriptionsView: React.FC = () => { setThresholdsModalOpen(false); setRegenerateModalOpen(false); setRegenerateError(null); + setDownloadMissingFiles([]); + setDownloadError(null); }, []); + const handleDownloadRobotFiles = useCallback(async () => { + if (!selectedTaskId) return; + setIsDownloadingRobotFiles(true); + setDownloadError(null); + setDownloadMissingFiles([]); + try { + const { blob, missing } = await apiService.downloadTaskRobotFilesZip(selectedTaskId); + const url = window.URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `${selectedTaskId}_robot_files.zip`; + document.body.appendChild(anchor); + anchor.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(anchor); + setDownloadMissingFiles(missing); + } catch (e) { + setDownloadError(e instanceof Error ? e.message : 'Failed to download robot files'); + } finally { + setIsDownloadingRobotFiles(false); + } + }, [selectedTaskId]); + const getFeatureId = (feature: PrescriptionFeature): string | null => { if (feature.id != null) return String(feature.id); if (feature.properties?.id != null) return String(feature.properties.id); @@ -524,13 +554,21 @@ const PesticidePrescriptionsView: React.FC = () => { )}
+
)} + {downloadError && ( +
+ {downloadError} +
+ )} + {!downloadError && downloadMissingFiles.length > 0 && ( +
+ Downloaded available files. Missing: {downloadMissingFiles.join(', ')}. +
+ )}
{detailLoading ? (
@@ -636,8 +684,8 @@ const PesticidePrescriptionsView: React.FC = () => {

Spray by cluster

- Set spray level per feature and click Save spray updates. Configure gallons-per-acre rates for each - level under Configure spray thresholds. + Set spray level per feature and click Save Spray Updates. Configure gallons-per-acre rates for each + level under Configure Spray Thresholds.

{totalGallons != null && (

@@ -678,7 +726,7 @@ const PesticidePrescriptionsView: React.FC = () => { onClick={openThresholdsModal} className="px-4 py-2 bg-dark-600 text-dark-100 border border-dark-500 rounded-lg hover:bg-dark-500" > - Configure spray thresholds + Configure Spray Thresholds

@@ -709,7 +757,7 @@ const PesticidePrescriptionsView: React.FC = () => { aria-labelledby="regenerate-rx-title" >

- Regenerate prescription + Regenerate Prescription

These values are merged with global defaults for this task. Empty fields keep the current saved value. diff --git a/code/frontend/src/services/api.ts b/code/frontend/src/services/api.ts index c4aeb0a..a7ed714 100644 --- a/code/frontend/src/services/api.ts +++ b/code/frontend/src/services/api.ts @@ -552,6 +552,29 @@ class ApiService { return response.json(); } + /** + * Download available robot-related files for a task as a zip bundle. + */ + async downloadTaskRobotFilesZip(taskId: string): Promise<{ blob: Blob; missing: string[]; included: string[] }> { + const response = await fetch(`${this.baseUrl}/api/v1/results/${taskId}/robot-files.zip`); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Download robot files failed: ${response.status} ${text}`); + } + const parseHeaderList = (value: string | null): string[] => { + if (!value) return []; + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + }; + + const missing = parseHeaderList(response.headers.get('X-Missing-Files')); + const included = parseHeaderList(response.headers.get('X-Included-Files')); + const blob = await response.blob(); + return { blob, missing, included }; + } + /** * Update prescription spray levels for features. */