Repository navigation
Feat/add download spot for robot files #90
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
Comment on lines
+171
to
+187
|
||
|
|
||
| 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 | ||
|
Comment on lines
+183
to
+213
|
||
|
|
||
| def delete_task_results(task_id: str, storage: FileStorageService) -> DeleteTaskResultsResponse: | ||
| """Delete all results for a task.""" | ||
| try: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -163,6 +163,9 @@ const FieldMapsView: React.FC = () => { | |
| const [deleteConfirmText, setDeleteConfirmText] = useState(''); | ||
| const [deleteError, setDeleteError] = useState<string | null>(null); | ||
| const [isDeletingTask, setIsDeletingTask] = useState(false); | ||
| const [isDownloadingRobotFiles, setIsDownloadingRobotFiles] = useState(false); | ||
| const [downloadMissingFiles, setDownloadMissingFiles] = useState<string[]>([]); | ||
| const [downloadError, setDownloadError] = useState<string | null>(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); | ||
| } | ||
| }; | ||
|
Comment on lines
+456
to
+476
|
||
|
|
||
| if (loading) { | ||
| return ( | ||
| <div className="flex items-center justify-center h-64"> | ||
|
|
@@ -768,14 +795,15 @@ const FieldMapsView: React.FC = () => { | |
| Modify Path | ||
| </button> | ||
| <button | ||
| onClick={() => downloadGeoJSON(selectedMap)} | ||
| className="px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors duration-200 text-sm flex items-center space-x-2" | ||
| title="Download GeoJSON" | ||
| onClick={() => void downloadRobotFilesZip(selectedMap.id)} | ||
| disabled={isDownloadingRobotFiles} | ||
| className="px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors duration-200 text-sm disabled:opacity-60 disabled:cursor-not-allowed flex items-center space-x-2" | ||
| title="Download robot files zip" | ||
| > | ||
| <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | ||
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> | ||
| </svg> | ||
| <span>Download</span> | ||
| <span>{isDownloadingRobotFiles ? 'Downloading robot files...' : 'Download Robot Files'}</span> | ||
| </button> | ||
| <button | ||
| onClick={openDeleteConfirm} | ||
|
|
@@ -787,6 +815,16 @@ const FieldMapsView: React.FC = () => { | |
| </div> | ||
| </div> | ||
| </div> | ||
| {downloadError && ( | ||
| <div className="mx-5 mt-4 rounded-lg border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-300"> | ||
| {downloadError} | ||
| </div> | ||
| )} | ||
| {!downloadError && downloadMissingFiles.length > 0 && ( | ||
| <div className="mx-5 mt-4 rounded-lg border border-yellow-500/40 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-100"> | ||
| Downloaded available files. Missing: {downloadMissingFiles.join(', ')}. | ||
| </div> | ||
| )} | ||
|
|
||
| {/* Content Area */} | ||
| <div className="flex-1 p-5 overflow-y-auto"> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -121,6 +121,9 @@ const PesticidePrescriptionsView: React.FC = () => { | |
| const [regenerateSubmitting, setRegenerateSubmitting] = useState(false); | ||
| const [regenerateError, setRegenerateError] = useState<string | null>(null); | ||
| const [regenerateDraft, setRegenerateDraft] = useState<RegenerateFormDraft>(() => configToRegenerateDraft(null)); | ||
| const [isDownloadingRobotFiles, setIsDownloadingRobotFiles] = useState(false); | ||
| const [downloadMissingFiles, setDownloadMissingFiles] = useState<string[]>([]); | ||
| const [downloadError, setDownloadError] = useState<string | null>(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); | ||
| } | ||
|
Comment on lines
+217
to
+237
|
||
| }, [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 = () => { | |
| )} | ||
| </div> | ||
| <div className="flex items-center gap-2 shrink-0"> | ||
| <button | ||
| type="button" | ||
| onClick={() => void handleDownloadRobotFiles()} | ||
| disabled={isDownloadingRobotFiles} | ||
| className="px-3 py-2 text-sm bg-primary-500 text-white rounded-lg hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap" | ||
| > | ||
| {isDownloadingRobotFiles ? 'Downloading robot files…' : 'Download Robot Files'} | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={() => void openRegenerateModal()} | ||
| disabled={detailLoading || detailStatus?.status === 'processing'} | ||
| className="px-3 py-2 text-sm bg-dark-600 text-dark-100 border border-dark-500 rounded-lg hover:bg-dark-500 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap" | ||
| > | ||
| Regenerate prescription | ||
| Regenerate Prescription | ||
| </button> | ||
| <button type="button" onClick={closeDetail} className="text-dark-400 hover:text-dark-100 p-2" aria-label="Close"> | ||
| <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | ||
|
|
@@ -550,6 +588,16 @@ const PesticidePrescriptionsView: React.FC = () => { | |
| </p> | ||
| </div> | ||
| )} | ||
| {downloadError && ( | ||
| <div className="px-6 py-3 bg-red-500/10 border-b border-red-500/30 text-red-300 text-sm"> | ||
| {downloadError} | ||
| </div> | ||
| )} | ||
| {!downloadError && downloadMissingFiles.length > 0 && ( | ||
| <div className="px-6 py-3 bg-yellow-500/10 border-b border-yellow-500/30 text-yellow-100 text-sm"> | ||
| Downloaded available files. Missing: {downloadMissingFiles.join(', ')}. | ||
| </div> | ||
| )} | ||
| <div className="p-6 flex-1 overflow-y-auto"> | ||
| {detailLoading ? ( | ||
| <div className="flex justify-center py-12"> | ||
|
|
@@ -636,8 +684,8 @@ const PesticidePrescriptionsView: React.FC = () => { | |
| <div> | ||
| <h4 className="text-lg font-medium text-primary-400 mb-2">Spray by cluster</h4> | ||
| <p className="text-dark-400 text-sm mb-4"> | ||
| 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. | ||
| </p> | ||
| {totalGallons != null && ( | ||
| <p className="text-dark-200 text-sm mb-3"> | ||
|
|
@@ -678,15 +726,15 @@ 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 | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={saveSprayUpdates} | ||
| disabled={savingSpray || Object.keys(sprayUpdates).length === 0} | ||
| className="px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed" | ||
| > | ||
| {savingSpray ? 'Saving…' : 'Save spray updates'} | ||
| {savingSpray ? 'Saving…' : 'Save Spray Updates'} | ||
| </button> | ||
| </div> | ||
| </div> | ||
|
|
@@ -709,7 +757,7 @@ const PesticidePrescriptionsView: React.FC = () => { | |
| aria-labelledby="regenerate-rx-title" | ||
| > | ||
| <h4 id="regenerate-rx-title" className="text-lg font-semibold text-primary-400 mb-2"> | ||
| Regenerate prescription | ||
| Regenerate Prescription | ||
| </h4> | ||
| <p className="text-dark-400 text-sm mb-4"> | ||
| These values are merged with global defaults for this task. Empty fields keep the current saved value. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Content-Dispositionis constructed directly fromtask_id. Iftask_idcan contain quotes/newlines or path traversal sequences, this can enable header injection and/or unintended file access. Recommend validatingtask_id(same policy asdelete_task_results) and using a sanitized filename token for the header value.