Skip to content

Feat/add download spot for robot files - #90

Merged
LoganSutton13 merged 2 commits into
mainfrom
feat/add-download-spot-for-robot-files
Apr 15, 2026
Merged

LoganSutton13 merged 2 commits into
mainfrom
feat/add-download-spot-for-robot-files

Conversation

@LoganSutton13

Copy link
Copy Markdown
Owner

Closes #85

- 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.
Copilot AI review requested due to automatic review settings April 15, 2026 22:20
@LoganSutton13
LoganSutton13 merged commit eb0d91f into main Apr 15, 2026
3 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.zip endpoint 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.

Comment on lines +121 to +127
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)

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +217 to +237
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);
}

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +456 to +476
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);
}
};

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +183 to +213
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

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +171 to +187
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)

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +171 to +213
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

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Provide download spot on frontend for amiga path and prescription combo

2 participants