From 3d82699535cfac61bcdb6a69067d5d3f099147ab Mon Sep 17 00:00:00 2001 From: kodi Date: Sun, 15 Mar 2026 14:16:17 +0100 Subject: [PATCH] feat: feedback verbetering 03 --- project_docs/API_GOLDEN.md | 6 +- .../__pycache__/tasks_runner.cpython-313.pyc | Bin 28823 -> 30563 bytes .../delete_task_service.cpython-313.pyc | Bin 4596 -> 7041 bytes .../app/services/delete_task_service.py | 93 ++++++++++++------ webui/backend/app/tasks_runner.py | 71 ++++++++++--- webui/backend/data/tasks.db | Bin 352256 -> 356352 bytes .../test_api_file_ops_golden.cpython-313.pyc | Bin 23203 -> 27490 bytes .../test_api_tasks_golden.cpython-313.pyc | Bin 18703 -> 18741 bytes .../tests/golden/test_api_file_ops_golden.py | 68 ++++++++++++- .../tests/golden/test_api_tasks_golden.py | 8 +- 10 files changed, 197 insertions(+), 49 deletions(-) diff --git a/project_docs/API_GOLDEN.md b/project_docs/API_GOLDEN.md index 71e727e..0fc7452 100644 --- a/project_docs/API_GOLDEN.md +++ b/project_docs/API_GOLDEN.md @@ -129,7 +129,7 @@ Response shape: } ``` -Voor task-based file-actions `copy`, `move` en `duplicate` betekenen progressvelden: +Voor task-based file-actions `copy`, `move`, `duplicate` en `delete` betekenen progressvelden: - `done_items`: aantal volledig verwerkte bestanden - `total_items`: exact aantal te verwerken bestanden in de hele task - `current_item`: taakrelatief bestandspad als beschikbaar, anders bestandsnaam @@ -138,6 +138,10 @@ Voor `move` geldt een expliciete uitzondering: - file-gebaseerde move-paden rapporteren file-progress - same-root directory moves behouden directe rename-semantiek en rapporteren daarom grovere item-progress per directory-operatie +Voor `delete` geldt: +- recursive delete van directorytrees rapporteert file-progress per verwijderd bestand +- lege mappen of directory-only deletes houden `done_items = 0`, `total_items = 0` en gebruiken geen kunstmatige file-teller + ### `POST /api/tasks/{task_id}/cancel` Success for cancellable file-action task: ```json diff --git a/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc b/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc index bb13cfcf2b3a442d9bb06423f163180a91bb0486..e924dbfc296d5ec5356de3595127b4b506df675c 100644 GIT binary patch delta 5991 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 delta 5050 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 delta 1837 zcmah}O>7%Q6rS<^dL7$w9D8ji-Z*v=XAAjjn--%sA!%CDQZ*tIOMoLRH{P^vQk#yQ z7KB78LI^leq7hQLA%Or1E>H=Cgn$Dr*RG1Rt5pL<2oNXIHUcjE%xq#c^un(6_RV|W z`<=J%!rqNc;Evy~0Qww%?UTx5fz?0^zPOq_6N`Z~KuQ82->8SF0l=XX9=&Dt^TE~I z=gLn~!5d&6V2A+rAc#2)AXx+3U*ttx0-WMcLChls`4GQ#5>N{g7>~ty3Hg!8_)#|q z4I?87dugZu`2n`Wz~$fJAcR5E%Z*6^o<-Hj%}9)V>wUt@0f1nYpSeJOQ&cjohRK&o zNIgQOo+_XB^l!N=KLkL*M=tm@@`2Jxx_y!J!7UX)9O99H#5_0K+LYb>4nrj6cmvMF z3U@7JTG;f8hC%)oCg)j(j#zJ$8Y^|2W(QBDtn+xll{ZTG zgwb&LrAoEzc(Jjtg6oyD1|Ftb-Q$8>-Vql|m1U!>iZ8iNm|DBq82fg2A=@X_t zVd;lW{qS}D5v%v8*?aUxB5fyk+NtRW?4GRMoo&Ldr6zBAg^uVt5F*h(Hv&NIvg7@B z=a`+!*vZUWMXPte*}MN*=NN^lZ4?%2W2gjqQ0|ucEOp3Khe%28mwK$ofEgJe7v=a4 zJJxw=?oH)}nz%G>b?h=bc3oF@+mX1P$gF#Tf8Z7n{DF-Y(B4NrlfUjc*mW=suB4S| zp1X4Y-a{GgYQ|6btHTq)hsU{Z#szvHZ%1AjV!yc4vei~yX7*y-sUL9;^GR;#V8gr-Rb}zV1%xFfCVy>=!7C!P9&j3UQJ}7 zOg>KZKrgWpy-*>4CA0;%P&eLxb8zH+r)%YvWn&DVroPzHsSi@J&Or#*K>A1E`yOO} g0{5+fJ!@cm4dm9q=o%RMLwp> TaskCreateResponse: try: - resolved_target = self._path_guard.resolve_existing_path(path) - - if resolved_target.absolute.is_file(): - kind = "file" - elif resolved_target.absolute.is_dir(): - kind = "directory" - if not recursive and any(resolved_target.absolute.iterdir()): - raise AppError( - code="directory_not_empty", - message="Directory is not empty", - status_code=409, - details={"path": resolved_target.relative}, - ) - else: - raise AppError( - code="type_conflict", - message="Unsupported path type for delete", - status_code=409, - details={"path": resolved_target.relative}, - ) + item = self._build_delete_item(path=path, recursive=recursive) task_id = str(uuid.uuid4()) task = self._repository.create_task( operation="delete", - source=resolved_target.relative, + source=item["relative_path"], destination="", task_id=task_id, ) @@ -58,14 +40,9 @@ class DeleteTaskService: entry_id=task_id, operation="delete", status="queued", - path=resolved_target.relative, - ) - self._runner.enqueue_delete_path( - task_id=task["id"], - target=str(resolved_target.absolute), - kind=kind, - recursive=recursive, + path=item["relative_path"], ) + self._runner.enqueue_delete_path(task_id=task["id"], item=item) return TaskCreateResponse(task_id=task["id"], status=task["status"]) except AppError as exc: self._record_history( @@ -94,6 +71,66 @@ class DeleteTaskService: ) raise error + def _build_delete_item(self, path: str, recursive: bool) -> dict: + resolved_target = self._path_guard.resolve_existing_path(path) + + if resolved_target.absolute.is_file(): + files = [{"path": str(resolved_target.absolute), "label": resolved_target.absolute.name}] + directories: list[str] = [] + kind = "file" + elif resolved_target.absolute.is_dir(): + kind = "directory" + if not recursive and any(resolved_target.absolute.iterdir()): + raise AppError( + code="directory_not_empty", + message="Directory is not empty", + status_code=409, + details={"path": resolved_target.relative}, + ) + if recursive: + files, directories = self._build_recursive_delete_plan(resolved_target.absolute) + else: + files = [] + directories = [str(resolved_target.absolute)] + else: + raise AppError( + code="type_conflict", + message="Unsupported path type for delete", + status_code=409, + details={"path": resolved_target.relative}, + ) + + return { + "target": str(resolved_target.absolute), + "relative_path": resolved_target.relative, + "kind": kind, + "recursive": recursive, + "files": files, + "directories": directories, + "progress_total_items": len(files), + "progress_label": files[0]["label"] if files else None, + } + + def _build_recursive_delete_plan(self, root: Path) -> tuple[list[dict[str, str]], list[str]]: + files: list[dict[str, str]] = [] + directories: list[str] = [] + + def walk(path: Path, relative_prefix: Path) -> None: + for entry in sorted(path.iterdir(), key=lambda child: child.name.lower()): + relative_path = relative_prefix / entry.name + if entry.is_symlink(): + files.append({"path": str(entry), "label": relative_path.as_posix()}) + continue + if entry.is_dir(): + walk(entry, relative_path) + directories.append(str(entry)) + continue + files.append({"path": str(entry), "label": relative_path.as_posix()}) + + walk(root, Path()) + directories.append(str(root)) + return files, directories + def _record_history(self, **kwargs) -> None: if self._history_repository: self._history_repository.create_entry(**kwargs) diff --git a/webui/backend/app/tasks_runner.py b/webui/backend/app/tasks_runner.py index 9d424c6..538193a 100644 --- a/webui/backend/app/tasks_runner.py +++ b/webui/backend/app/tasks_runner.py @@ -80,10 +80,10 @@ class TaskRunner: ) thread.start() - def enqueue_delete_path(self, task_id: str, target: str, kind: str, recursive: bool) -> None: + def enqueue_delete_path(self, task_id: str, item: dict[str, object]) -> None: thread = threading.Thread( target=self._run_delete_path, - args=(task_id, target, kind, recursive), + args=(task_id, item), daemon=True, ) thread.start() @@ -429,39 +429,57 @@ class TaskRunner: total_items=total_items, ) - def _run_delete_path(self, task_id: str, target: str, kind: str, recursive: bool) -> None: + def _run_delete_path(self, task_id: str, item: dict[str, object]) -> None: + target = str(item["target"]) + kind = str(item["kind"]) + recursive = bool(item["recursive"]) + files = list(item.get("files", [])) # type: ignore[arg-type] + directories = list(item.get("directories", [])) # type: ignore[arg-type] + total_items = int(item.get("progress_total_items", len(files))) + current_item = str(item.get("progress_label")) if item.get("progress_label") else None if not self._repository.mark_running( task_id=task_id, done_items=0, - total_items=1, - current_item=target, + total_items=total_items, + current_item=current_item, ): - self._finalize_if_already_cancelled(task_id, done_items=0, total_items=1) + self._finalize_if_already_cancelled(task_id, done_items=0, total_items=total_items) return + completed_items = 0 try: - path = Path(target) if kind == "file": - self._filesystem.delete_file(path) + file_entry = files[0] + completed_items = self._delete_planned_file(task_id, file_entry, completed_items, total_items) elif recursive: - self._filesystem.delete_directory_recursive(path) + for file_entry in files: + if self._is_cancel_requested(task_id): + self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) + return + completed_items = self._delete_planned_file(task_id, file_entry, completed_items, total_items) + if self._is_cancel_requested(task_id): + self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) + return + for directory in directories: + self._filesystem.delete_empty_directory(Path(directory)) else: - self._filesystem.delete_empty_directory(path) + self._filesystem.delete_empty_directory(Path(target)) self._complete_or_cancel_item_task( task_id=task_id, - done_items=1, - total_items=1, + done_items=completed_items, + total_items=total_items, ) except OSError as exc: + task = self._repository.get_task(task_id) self._repository.mark_failed( task_id=task_id, error_code="io_error", error_message=str(exc), - failed_item=target, + failed_item=(task.get("current_item") if task else None) or target, done_bytes=None, total_bytes=None, - done_items=0, - total_items=1, + done_items=completed_items, + total_items=total_items, ) self._update_history_failed(task_id, str(exc)) @@ -630,6 +648,29 @@ class TaskRunner: ) return completed_items + def _delete_planned_file( + self, + task_id: str, + file_entry: dict[str, str], + completed_items: int, + total_items: int, + ) -> int: + self._repository.update_progress( + task_id=task_id, + done_items=completed_items, + total_items=total_items, + current_item=file_entry["label"], + ) + self._filesystem.delete_file(Path(file_entry["path"])) + completed_items += 1 + self._repository.update_progress( + task_id=task_id, + done_items=completed_items, + total_items=total_items, + current_item=self._next_item_label_after_completion(completed_items, total_items, file_entry["label"]), + ) + return completed_items + def _move_directory_item( self, task_id: str, diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 835af5575c906d3c34ca7ee187c0b39628aba5e0..100c233fc32f2ffb3e2c6c895c7380507ea6dd12 100644 GIT binary patch delta 3841 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 X@H>;^p-uO0dSKwJQ8E?l6`0jOC?i)s delta 1067 zcmY+?TWC{B7zgk(C&@|9$;qYdX__XfHBG3d)GNKjcB^YETG3F=G%8ipn#S6w(N4WU zS*-|*E{JaVblDZU7DOL*AH1AeZ>amQKDBB@QR$n4J_v%?h~k6z%^(Xq4}X3$naP*= zCa<26^Vf)b+U3$D9JgS+Z*=h$cZ+>UCz5nV+9WYTB_>nCEHp&rfRwc|E6O5dqZQOD zq|h1ECS<3TbS71}vxGTt7N+*^GZjxd#4Zh`3OP}lkdAT+x#&=c>Vj@ubP0J-ZXqwq zBUFU)3KgS@gi27wLI$cts1#)g`B0@oWhkGJA5|t)j`DN)SO8fr7K1dvLcPIQ)sz7X zN)7Xn5S;5&z_;j_{I3M(7TtkA znxf>YdAI4FY~N_(-Ik#1he}61HkuD}sAW_p^)U0tNfL+A&Ff*=(i~|9b7{XZrKk5Z zmdWkg(zh?On+@akL73MXf${hZ=x&{;p2ih6!s9HDuNYIFbayVD*|mjb2KMgB4P;mW zl5Jy34b~e_A-wGp++OyJF&4Spb3J<{n{P`!Zb&^@xbCTAJ^Yit0cV>65L&L{MZw(= zhhH~1&Fu2o|G%&fiFPW-R2XlMIwyGLNgf~IZF|4c#S7hn?v-PB^&sSXRA^a!ADUMk zBs*awQEpDI$`f+P97&{PG7Zxmhm}+O$ZvQg1gBGe_1>KH`LyWE(U$8u&0iIFhu%rDVkJ;r*swm_s9~E?iVE!`sG{ zq!=b>Wx&9?tdz%M+{l*jc!x1q1^pd<9b1U~ALu)f>5Rd`&N?}!!B5C9ty)N76@0gN zfFS<{@d|(PwF2ZjPuef=JH3`5lUjina>Aq33EmGg-;*ZfrdhY$Al?W+Sj*2uQLzfx z(3^>V)8g zb9jee#(&pe+2ixm3ekR#KM&SE4%QYVK6}5~C0X_3gHJ5wFRc2p#L>jh9q%oYHNe97 GQ@#PQeIRuJ diff --git a/webui/backend/tests/golden/__pycache__/test_api_tasks_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_tasks_golden.cpython-313.pyc index 00cad5d2a4a7fc21bfc9dd5e66828be8eadb5a06..a0c278a25266932380a00d2a23662293894f2fee 100644 GIT binary patch delta 549 zcmeC5#JF`5BkyNkUM>b8xV?T`X75H`K2~X8{qp>x?BasNBHi4w z0{zW0tS$$e9J~)@(wi@?q8fGMTx~3dLz1i7BB5m zsFpgtzmpH?>|>m=xly-|Nqz>XPZ3rkarOHxyc)_@c*-`rtY!z=)jD_RO7Afl5+Y;_s?Cm*zw z+niu4#thY)oS2uKnv(<9JGsX}mT@ge)#MxpuFaPmte6;QPUdyCW&=AU4(yO*=QKvf zBa=_LNVy*cS>OpGz$`B~%NxWx0wR3iEMGXw56%(?u?|nxb}iDI0AfxB5eq@Y3J|dl zL~H^P+d#xl5V03T9GraG)s)d@GozaTBb&_^0})o6&64hRjH<_gvbR`r6EpLQPJb8h?=x5bK^!{K2}EK&1S4F3XCk18Rh3s&M^&{TwuvP`LwCl zJV|p+>#{lk diff --git a/webui/backend/tests/golden/test_api_file_ops_golden.py b/webui/backend/tests/golden/test_api_file_ops_golden.py index 23f5ac6..fb45249 100644 --- a/webui/backend/tests/golden/test_api_file_ops_golden.py +++ b/webui/backend/tests/golden/test_api_file_ops_golden.py @@ -293,6 +293,9 @@ class FileOpsApiGoldenTest(unittest.TestCase): detail = self._wait_task(body["task_id"]) self.assertEqual(detail["operation"], "delete") self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["done_items"], 1) + self.assertEqual(detail["total_items"], 1) + self.assertIsNone(detail["current_item"]) self.assertFalse(target.exists()) def test_delete_file_cancelled_after_current_delete_finishes(self) -> None: @@ -330,7 +333,7 @@ class FileOpsApiGoldenTest(unittest.TestCase): task_id = response.json()["task_id"] self.assertTrue(blocking_fs.entered.wait(timeout=2.0)) running = self._wait_for_status(task_id, {"running"}) - self.assertEqual(running["current_item"], str(self.scope / "delete_later.txt")) + self.assertEqual(running["current_item"], "delete_later.txt") cancel_response = self._post(f"/api/tasks/{task_id}/cancel", {}) self.assertEqual(cancel_response.status_code, 200) @@ -358,6 +361,9 @@ class FileOpsApiGoldenTest(unittest.TestCase): detail = self._wait_task(body["task_id"]) self.assertEqual(detail["operation"], "delete") self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["done_items"], 0) + self.assertEqual(detail["total_items"], 0) + self.assertIsNone(detail["current_item"]) self.assertFalse(target.exists()) def test_delete_not_found(self) -> None: @@ -424,6 +430,7 @@ class FileOpsApiGoldenTest(unittest.TestCase): nested = target / "nested" nested.mkdir() (nested / "a.txt").write_text("a", encoding="utf-8") + (target / "b.txt").write_text("b", encoding="utf-8") response = self._post( "/api/files/delete", @@ -436,8 +443,67 @@ class FileOpsApiGoldenTest(unittest.TestCase): detail = self._wait_task(body["task_id"]) self.assertEqual(detail["operation"], "delete") self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["done_items"], 2) + self.assertEqual(detail["total_items"], 2) + self.assertIsNone(detail["current_item"]) self.assertFalse(target.exists()) + def test_delete_non_empty_directory_recursive_cancelled_after_current_file_finishes(self) -> None: + blocking_fs = BlockingDeleteFilesystemAdapter() + path_guard = PathGuard({"storage1": str(self.root)}) + service = FileOpsService(path_guard=path_guard, filesystem=blocking_fs) + delete_service = DeleteTaskService( + path_guard=path_guard, + repository=self.repo, + runner=TaskRunner(repository=self.repo, filesystem=blocking_fs), + ) + task_service = TaskService(repository=self.repo) + + async def _override_file_ops_service() -> FileOpsService: + return service + + async def _override_delete_task_service() -> DeleteTaskService: + return delete_service + + async def _override_task_service() -> TaskService: + return task_service + + app.dependency_overrides[get_file_ops_service] = _override_file_ops_service + app.dependency_overrides[get_delete_task_service] = _override_delete_task_service + app.dependency_overrides[get_task_service] = _override_task_service + + target = self.scope / "delete_recursive_later" + target.mkdir() + nested = target / "nested" + nested.mkdir() + (target / "a.txt").write_text("a", encoding="utf-8") + (nested / "b.txt").write_text("b", encoding="utf-8") + + response = self._post( + "/api/files/delete", + {"path": "storage1/scope/delete_recursive_later", "recursive": True}, + ) + + task_id = response.json()["task_id"] + self.assertTrue(blocking_fs.entered.wait(timeout=2.0)) + running = self._wait_for_status(task_id, {"running"}) + self.assertEqual(running["current_item"], "a.txt") + self.assertEqual(running["done_items"], 0) + self.assertEqual(running["total_items"], 2) + + cancel_response = self._post(f"/api/tasks/{task_id}/cancel", {}) + self.assertEqual(cancel_response.status_code, 200) + self.assertEqual(cancel_response.json()["status"], "cancelling") + + blocking_fs.release.set() + detail = self._wait_task(task_id) + self.assertEqual(detail["status"], "cancelled") + self.assertEqual(detail["done_items"], 1) + self.assertEqual(detail["total_items"], 2) + self.assertFalse(target.joinpath("a.txt").exists()) + self.assertTrue(target.joinpath("nested", "b.txt").exists()) + self.assertTrue(target.exists()) + def test_delete_invalid_path(self) -> None: response = self._post( "/api/files/delete", diff --git a/webui/backend/tests/golden/test_api_tasks_golden.py b/webui/backend/tests/golden/test_api_tasks_golden.py index d84ffc4..5eaa0b0 100644 --- a/webui/backend/tests/golden/test_api_tasks_golden.py +++ b/webui/backend/tests/golden/test_api_tasks_golden.py @@ -260,7 +260,7 @@ class TasksApiGoldenTest(unittest.TestCase): started_at="2026-03-10T10:00:01Z", done_items=0, total_items=1, - current_item="storage1/trash.txt", + current_item="trash.txt", ) response = self._get("/api/tasks/task-delete") @@ -271,7 +271,7 @@ class TasksApiGoldenTest(unittest.TestCase): self.assertEqual(body["status"], "running") self.assertEqual(body["done_items"], 0) self.assertEqual(body["total_items"], 1) - self.assertEqual(body["current_item"], "storage1/trash.txt") + self.assertEqual(body["current_item"], "trash.txt") def test_cancel_running_delete_task_returns_cancelling(self) -> None: self._insert_task( @@ -284,7 +284,7 @@ class TasksApiGoldenTest(unittest.TestCase): started_at="2026-03-10T10:00:01Z", done_items=0, total_items=1, - current_item="storage1/trash.txt", + current_item="trash.txt", ) response = self._post("/api/tasks/task-delete/cancel") @@ -293,7 +293,7 @@ class TasksApiGoldenTest(unittest.TestCase): body = response.json() self.assertEqual(body["operation"], "delete") self.assertEqual(body["status"], "cancelling") - self.assertEqual(body["current_item"], "storage1/trash.txt") + self.assertEqual(body["current_item"], "trash.txt") def test_cancel_completed_task_rejected(self) -> None: self._insert_task(