Repository navigation
Feat/add download spot for robot files - #90
Conversation
- Implemented a new API endpoint to allow users to download robot-related task files in a zip format. - Added logic to handle file inclusion and missing files, returning appropriate headers. - Updated frontend components to support downloading robot files, including error handling and user feedback for missing files.
…scriptionsView - Changed button labels and headings to use consistent capitalization, enhancing UI clarity. - Updated "Regenerate prescription" to "Regenerate Prescription" and similar changes for other text elements.
There was a problem hiding this comment.
Pull request overview
Adds an end-to-end “Download Robot Files” capability so users can fetch a task’s robot path + prescription artifacts as a single ZIP from the frontend.
Changes:
- Backend: build a ZIP bundle of available robot artifacts and expose it via a new
/api/v1/results/{task_id}/robot-files.zipendpoint with included/missing file metadata in headers. - Frontend: add download actions/UI (and error/missing-file messaging) in Field Maps and Pesticide Prescriptions views.
- Frontend API service: add
downloadTaskRobotFilesZip()to fetch the ZIP and parse included/missing headers.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
code/frontend/src/services/api.ts |
Adds a typed API method to fetch the robot-files ZIP and parse X-Included-Files / X-Missing-Files headers. |
code/frontend/src/components/PesticidePrescriptionsView.tsx |
Adds a “Download Robot Files” button and UI feedback for download errors/missing files. |
code/frontend/src/components/FieldMapsView.tsx |
Adds a “Download Robot Files” action in the map modal and UI feedback for download errors/missing files. |
code/backend/app/handlers/results.py |
Implements ZIP creation for robot/prescription artifacts and returns payload + included/missing lists. |
code/backend/app/api/v1/results.py |
Adds the new ZIP download route and exposes metadata headers for the frontend to read. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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) |
There was a problem hiding this comment.
Content-Disposition is constructed directly from task_id. If task_id can contain quotes/newlines or path traversal sequences, this can enable header injection and/or unintended file access. Recommend validating task_id (same policy as delete_task_results) and using a sanitized filename token for the header value.
| 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); | ||
| } |
There was a problem hiding this comment.
The browser-download logic (create object URL, create , click, cleanup, error handling) is duplicated here and in FieldMapsView. Consider extracting a small shared helper (e.g., downloadBlobAsFile) to keep cleanup behavior consistent and reduce future maintenance overhead.
| 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); | ||
| } | ||
| }; |
There was a problem hiding this comment.
The browser-download logic here duplicates PesticidePrescriptionsView (object URL + temporary anchor + cleanup). Extracting this into a shared utility would reduce copy/paste and help ensure any future fixes (e.g., cleanup timing) are applied consistently.
| 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 |
There was a problem hiding this comment.
get_robot_files_zip builds the entire ZIP in memory (BytesIO + buffer.getvalue()), which scales linearly with artifact size and can cause high memory usage for large prescriptions/paths. Consider streaming the ZIP (e.g., StreamingResponse) or writing to a temporary file and returning a FileResponse to avoid holding the full archive in RAM.
| 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) |
There was a problem hiding this comment.
This new handler reads files based on task_id via storage.get_*_path(...), but those getters don't apply the same path-traversal guard used in FileStorageService.delete_task_results (file_storage.py:499-505). Add equivalent validation (or a shared validate_task_id) before resolving paths to prevent task_id values like "../../..." from escaping results_dir.
| 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 |
There was a problem hiding this comment.
The new ZIP download behavior isn’t covered by existing results handler tests (code/backend/tests/test_handlers_results.py doesn’t exercise get_robot_files_zip). Add unit tests for: (1) all files present, (2) some missing but at least one included, and (3) none present -> FileNotFoundError, and validate included/missing lists + ZIP contents.
Closes #85