From 7d910479f973390039ea65dc163912031961ccd0 Mon Sep 17 00:00:00 2001 From: kodi Date: Sun, 15 Mar 2026 11:52:39 +0100 Subject: [PATCH] feat: voortgang delete in headerbar --- project_docs/API_GOLDEN.md | 8 +- .../__pycache__/dependencies.cpython-313.pyc | Bin 6929 -> 7301 bytes .../__pycache__/tasks_runner.cpython-313.pyc | Bin 15575 -> 17122 bytes .../__pycache__/routes_files.cpython-313.pyc | Bin 8349 -> 8468 bytes webui/backend/app/api/routes_files.py | 13 ++- .../task_repository.cpython-313.pyc | Bin 21710 -> 21718 bytes webui/backend/app/db/task_repository.py | 2 +- webui/backend/app/dependencies.py | 10 ++ .../delete_task_service.cpython-313.pyc | Bin 0 -> 4596 bytes .../app/services/delete_task_service.py | 103 ++++++++++++++++++ webui/backend/app/tasks_runner.py | 43 ++++++++ webui/backend/data/tasks.db | Bin 307200 -> 323584 bytes .../test_api_file_ops_golden.cpython-313.pyc | Bin 14490 -> 18291 bytes .../test_api_history_golden.cpython-313.pyc | Bin 26390 -> 27911 bytes .../test_api_tasks_golden.cpython-313.pyc | Bin 14691 -> 15588 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 79612 -> 79856 bytes .../tests/golden/test_api_file_ops_golden.py | 67 ++++++++++-- .../tests/golden/test_api_history_golden.py | 21 +++- .../tests/golden/test_api_tasks_golden.py | 24 ++++ .../tests/golden/test_ui_smoke_golden.py | 21 ++-- ...test_task_recovery_service.cpython-313.pyc | Bin 4882 -> 5705 bytes .../tests/unit/test_task_recovery_service.py | 21 ++++ webui/html/app.js | 21 +--- 23 files changed, 311 insertions(+), 43 deletions(-) create mode 100644 webui/backend/app/services/__pycache__/delete_task_service.cpython-313.pyc create mode 100644 webui/backend/app/services/delete_task_service.py diff --git a/project_docs/API_GOLDEN.md b/project_docs/API_GOLDEN.md index 0c5c015..98245e0 100644 --- a/project_docs/API_GOLDEN.md +++ b/project_docs/API_GOLDEN.md @@ -54,9 +54,12 @@ Success: Conflict (`already_exists`) + invalid name (`invalid_request`) gebruiken dezelfde error-shape als mkdir. ### `POST /api/files/delete` -Success: +Success (202): ```json -{ "path": "storage1/parent/file_or_empty_dir" } +{ + "task_id": "", + "status": "queued" +} ``` Non-empty directory: @@ -74,6 +77,7 @@ Non-empty directory: ### `POST /api/files/copy` ### `POST /api/files/move` +### `POST /api/files/delete` Success (202): ```json { diff --git a/webui/backend/app/__pycache__/dependencies.cpython-313.pyc b/webui/backend/app/__pycache__/dependencies.cpython-313.pyc index 121f2a624839b296a4335995ad46555b5c3a4ae4..59f49de28b905601ecb23221326529e73f60bc19 100644 GIT binary patch delta 2334 zcma)6O>7%Q6!s=w+ldo9cH;OaPU1Rl-A&y5|0i{RNtFPFGA$@1$a0exheaZ` zx9meyf`8Cs;d)t11=nlg`dGAr>$7nEthIvcN3LjoaDcT{kOP*&LDpWu4O+M%7OUWf zEZi_VQ^5_h5f-Nz7-6HVgHRDh*%(WR$uTz0&I&ZnCfGTFCfFoP3N*_~aA))Rf}YiLg}kQlyH$^!zIk<1 z)zyRdtItSMJO9eQD0g6Pl>cMDKAE8Q$1xbq8>*gpGqQr8QGn_F3^kn6_86n8m~nj(_Ql%6&-n z^B+zvBL|;SmUFdw2ap;ix`#r7f8zRVtc4)$O0)<_8O{lsg6OIDxDh+~JT{Xw(tM+) z6Pxf+&G&Iyq~QcrD{StnhGRPmYF@8s0)MBrT~1@#C4RSd63ZUcZl#BDjJ<2XAE2`= zt>DDR{i1bHcw195nhLwQH5Fzt`9;3({=9dF%18%BFer8i5Iz<=xgCC`E+SvToGS<0 zb*)llfvOxJWau6n4k&GLgx{-A4G1C89MLwRCcZ#+0+|AB%GxmQ@Fe9IaB`XVd+tX` zRH=dpWeFk6_V~Y^X89##Zt#fr-gtQtut?crLo)aXpSBLG&|`0l%rJ42w={ga`U)aN zhQ?>8#1uoAb6wo7lHJWVzlQ7%{%f5W9^$Qe;EwVcPlzx1>g?BWj!$`SrQqmuO*rwt z3w_cMlb12^Ht+Balr>&8HG~g4{kyT%~Z0r~KLH%3VdLd1+eo zlJ1@L)Np7tRa{cD32z|vA^*uAm*)8+|5Efaj`2X7N#H4Wtu%(^3^E_{y~bAQWpw$< zB1}_7HH4_**qN5&L&6X?*VHVm-6-2Ui^Qi?HAi7yY=`_L?23wE!gQxgvL_V9b~IJj zbNLOetht8iU+`a>;>CGHmz64XysGa&9y~b4jjfYjy#>21m|i$Dhn^=B)0<;rdQeO} zVZn6G&G~@MRxA(v7J}}HnMBSvF?rdm& F{SWe{*SG)x delta 2116 zcma)6O>7%g5cW1++llM{cx|uqXS;E_c9J@A;?zm(7@|b{lm>Vaib`pdmL-^!ILx|* zN+9xigTXS@CQ>8H_h}e zZd&5buw)l^M&b%=sEaE|+zcDmTk;2IByyIGbQ#V{T#=1-aYc!nV`E+19Ghn;I)Qms zVrhX&Y=LD2TG(V~*(m|fvN9VNsLU2wR-i?;#3lq;#rxSGatCi!cw2wzk;Tp22EihrOEsab?6{-rvoWO!_Q9+@n9&D=L$PoKs!qYvEp z01t^A3E3SFxrfy$Jk9f>TQXghP5U2ciid5DB6+!PQ3P@@1te!gif&KvIH2>gR~o?o z2Si?DMNo4qUo{!2pK}sH|d_Z4@aT1-vB5DVY>ves9-vl5Z z6@I%vhoODj|3jL}(sqMsHLl$-+pdEhF!!6?b^2^zRIQ-eI{$HC8O^pL&+{wZaFzB0 zOB}=pP-8n$^aK#50Iopeb<4!O;70wb36G%iIsTRR<3f$h$PbiI7)}@@eC(_@CI03~ zU0p|=r;k24IjrbwWaWYySuV=>9#wbwTHx~wH8LUx@E8e^p$Z|w4+^ZubCf(^ zZ5-U}jJxgrSv23{>%njG&mb~Xh&+Tpo(qqu&!J+IZ-k4=CU1pq+4kDQ*iT22fh|;g zil9(Q6B^di^OQDx@!7oL{~51;k*3bNLpFN4S!@Ono9{w%r;$Mzijz7GK-^Q1I9k2ZCt^VY!{sS8{o@rP?9%nO#4{4mF67yO# z9=L?``=s(HiB*x3Xjl#3(L7Gaw0RvLviaSpu5Kf9oBK3Msi0}C9n$onz`O+ZN|-Jo z`(+K z?G({|ah=;8PVkmK(h@hDm?g2PV)MjQiAfP15gRSGm#!0_O95zafff*GO@JmIuqqCq y;R6~Tpq9fn38|a-+V(8J+IaE$HM0tDIIvd=o>}L(k(@i)PP!DnH>5e~TKx}ZP?gdE diff --git a/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc b/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc index bfa008a952db5f7c84e60dccc6599b27e2605db9..74f344046d82888041134bb4bb514e78d07477a4 100644 GIT binary patch delta 2607 zcmai0Z%kWN6o0p`eNg)El~PIzlmdlOA!K7h8H_2DI57*`xFBvQwS&jTzm{Dn8!nD% zVw^FUk&|s?31%Pm!DTUOs%Czhn_tYL6Eg%Gqs|Y0P!pHU|Iz3@x2)|prtibAzjyAr z_ndp~@1ED?>2F^o)>{@!j>Mng+4qKidt}b~M}wb8(h2D)i4iI>9VJYr9$H9ck2I_w zCQMI@s2({p&|+#3Orc(AA*H)ZXqI@-<|EmH8L5{V19;hgN`QeiagMd(LJt;tgEfgEPRq5TL}4hd_2bbLBAFvROJ8D-OK7)M4BMiIsk zA_!3q=M2fJLYC7;#=+$bl9%E;oLA(TNpvYkc#K2Bm|pN88Yd7iRW^-qh(o}dkVmyh zI-i75i^Rq>7M|2ZYV28b9YS~#VU2uJ*}A23=8Oa1fc~O96yok6PxS|PlX8|@Z_PqS z%~*Wi^NXzRLN9SbBF8>h@D5fyfyN$$-5iqoIEs(Z2NS1K3wFN~T1wpH2K1CXJoq%a zi`_kgY=A?+lHORd(8jUololEs8r4!+CwG@Ht%_HS>2ZZMwi+-3aXOtS5f((iOxQTW z9$4pVAlKr3zEZhWR9c*d*c4V;q{c6VxC?h_b`%4pwe>u{qQKZ3!Yso7o}$EF;0NV_omD>5I>Kih!)Y(a4_8@bmx!a@69>*z z`%M`lfveR%&z-8xsCRPx@|p_L6K}8igm?@*!tKY*i7ZLvR+*0XSlHkwfUms{xRI;s zdJWK8;jkOJWvXxD`v@0FQn%Epz(q@?uiH@1lf}m;?by<-uS&k8o&Kq)%)P^@P|W?a zs4^#QhV>3N{8W|?A%~gR;jP*t*jbPTSBuR`ha^RmZn$0?P`e3LdR?mlq;3TgjzX{( zICZ@)n70OJvO5@0e)Ynk3=(=FtCSAJ1-wI@LUH1#UPXV;sv1)_IFxFciTu?uZESpU z&89K4rgXD1t|se|{MU24c_v&xsHR(+dM z{?KUPFY)}^d6^u9H#c+@-~~=7;qh@TGLXCmLZT5Utg9xcU|Zd5vT&aJ6d>sF^ULpM3@pnT&S;9>Rprf%E+JmPb!nPC8vk&+W zyMz!%*o$zPLqN``Jz6KY!m-TB5=Wt|v66fN?Tsfw5u8(oAed<8f{C7^;lLomJ`RD^ zFV21Eq&-*rP6S#umCU5g=43m%?*i$d*zFSfz0v4sM*N(N=|WOsA^i3vvqy2Uj}X!! zbxcXVz#E%9J!!jM)SZcYkZCPVt(Lal-VF|kB8#}`@j~!6^(l|za0fisw9bhlW+xF& zA&4()6y9m7CqKgXO+(PpJZdO4H2?f4yx+W0e4^NSxY<1Cxr*+1)02NpvNw-K2V$ez T7PbTno2zX!i&$?;h{EY#QYK55 delta 1833 zcmah}Urbw77{90Ya-}W3T%aq^4roWGUKbuF*&so>f-fdxo9?hU=NuGzfp*ZAQ)VD) zN}?u9{4?Rp;{RpwNqjI_u8Zh{^JTtF_hhoFY<=%hZ!5b;UlvyURCYjZgCfY1%GqD!l#aoDld7Oud?cxc(i&%v3;Spl3yj8SK z`>bwfzwdiLoC^KIjyprfTR#1$+!=8gZke5I1pYJ&Y!d9Ik4_Z!jz&s ztcYo(GYB~eYjwgzPK(=CCGxNw>0=dVJ#s^>6fgkAy4u-XA?y0R$>-5Ehk&w089_*} z3|F1C3!XODZx_zZ*&?%GlT5LIp_2&55u~=A(zcWXi-mRCnj;X4^|3t6_e5bX7H1zg zZ^ovzmcy7!XGd~Q$|0OJClcaGbUcN?C6sju@n!spFd9$5Yw=$8D_o1GGf$&|)^G~h z83|U%lV9Ch|6K8Tdu%ErMbI0M|wZZ3Pp>mx9Q+xL_&3R|wKlJTm zs}ShF#8zOfKgpJzoBb<3b{WP8qG4AnWfo@2KA9YkSKDP3-WYg*KXc&}tNT!CP`MgI+;W==s|roIb;zfW`Df<_ZR_BG6lx zgU`|j*pIN0&cVr{X}CW0sQxZ`FT>wME9^R47;a-X;N#(ajWaqkep3)dtBm*;NdAdM diff --git a/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc b/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc index 551a980ebea8bb44a781ac1195cffcef2b7403f0..3e487360aab71bd92f4ce5eb61bc9b62238618cf 100644 GIT binary patch delta 2637 zcmZ{mO-vg{6o6;F_WJ(^Y_rB-V`B^$0wfSZNJx|vLYp7aw3|ewaby{L0TW}pV>^wA zRFYFwsXdU%rLEdy)TnBss#1TX9&+j-N+VDXL@SZDrz(f4q-hoP(5mxh{l`YJ@YBq^ z@4b2N&6_cw9KG?9{i)q^;b#Q>^9iqZpltga~)oXA1&#ZxKZ#)Ke)HXIOAT%1jId( zOGCw~l({MHlbR*>I8XlL+r(kXqapjMWXv1)OZIVHT%hr^jB@IJY$n!b2G9`)FOrDP zBaTQujkkX4Z5A>xx5nGRn3j z(5YQy(ll*}!VrXu5Yyf0E|?C0=%%8mqaqZ-F<2O^3Ljavw3AQGO)Y(NX~j6C?(+C9 zoyBxGOrAIb? zc?i%Lr|2`e`8=%vvt&k%^*ahSAP$nRY+>#&d0_jOnDf$yh5(I21d@o z5Q4%pR1_Q2QCvx!TBHY*sO|d(WzLgdUH2?!>F5@mt4O|WiuBjqH1>w#s5(d`b16BC zFHoaJVrc$-6;e}dIGJ0J6N_>(Eo02W+fCQ&F~)dYUpP)xF}>m`V7ZXQa^g}ZE$0)( z678I2)#MZyTN$Pn8``2Fx%%n4s6sI?4|tA_3*?&nk@pDT&sC}939EI5C-<;9O8AgX^xSbVn-kAAC^y^f;)Qy77A5y zll&nbUuD&T%BUslpd&@Va;c1SxQR|;0%l0_4g|jiQ+I>jQPT%u#Pltow_vz*3Qy0h za`Wc$`7AyFlm)FeZme4Qz{;eaB?gM!yCRI(VL*r~*$Sl4Kx*{Px_-r+%@j-LX%H87 zc!B*{^HkEb*R~1Nwqa=qm~}e~h;38XSL|P?7a@$sgjhk%Y^O6I>lsyCu`{f1^_-qL z@Gu8E?Y(pw+fU5?yB&&eE}6P0=hB18LSZm17wAyVr8066`zb~K^iTEoHX=nCU&^H9 zB4y!+g1yIy$|4Su;kN!&2!wV=zFd;AqA%oWIinB&`#FGjnLV7CT}gNm*%Qa!EetOb zhL?oB2kic_`@?Pl>uI$!&r{?n{UY|bDyE6$e7c;K596x{9YgS+PVonNrMG#G`p6L;HnSX}r@^3c&8I?Csc?->Mp=r|A{^jI)&-k6<)wXr+8}!^c?xEek&BNdqdr)237Lefk%Lr>vd89kVZR_axYg1s2*V6_<{{uJC B9;5&O delta 2493 zcmZ`(U2Gdg5We%j9sk8~?8J6{oz%IeZko1fno`;(Es&-aw7r(9^pJ7mP2$$^nYEKf z5s*hfR7mOafRI2)2&qL-ArKFhkos1M3W@RrI*FgxJ|LwO1bskaX7A2+E^wAdvpe5> zGdnx8?@YZv8F&;3_yqWF%zjmr*8`p6we`&FEi);a>J?-`cAZ$ioenGZenIxiz6p^I zCERG=)d~{nK6;xsz)zcobU@~<~SZOtwM&(uu zYRE)M5hW@6Cv1ufa%meA^+=UTv zKDoz&+iAw|_L?!+r(!ZbF85iGBX!1pC4r~oxcze4g4<;>iUUxq{uCxDPsoE7*k~QL z4Pfc`0Xbtq?5-n*41{Ds?5QKR8;D^GVlN={hT5TY7=?a9&RS4o^E;)3?5=ZF46;dA zCKNnIN~A{4kZMIOR|%VUeeSkteeM%~a3Rz5!HGgxDR= zy}@Bz?m#gJqG|)t+e?RVBWsE{yXDRLC0MSyM+zA6y03Z|JvA>SvrDvETqcrTb8#D? zoO1-dw{Y&lT@*dQnTlNfVSM`g@#!}X-ETR5-*NmgmDumTH?zADwHJium@AeD&45~U zfarx!PY!2{qR6ltEvtGmo;j7~NTFDhM9r~WBv&Q7p38m&a}v8wqH|2(Hi}V}_pgj# z+UQv<ώ_vB8^MR^i^P#j>MK%aPkZ4dk-PO+ijjEf&T!&ZWQQLLZlDX3S=B{g55 zCt*3oJ_}y=KDY-ekoU5O-Y)im-^&_ zQz(w2IL7XTvwO~>ifPgr5Y^@bYT-PcU4XmE)iZe>FfXv4)^B|>Xg4vH4)$kjdbm#0 z+yYwAB$&;YXGw`(1gCj+H1gXTzkNT=m*+`tf#eH>Ql8$kfN10h7tq*v3QUqii&iM9 z6ubZ+4=z5Ls7qj9)K3xvU-IXMG;33Qd+Y5Q3@h;VC_U{A7X z5i@F~FlWZZNH>iZX$09+K|8=H0o-fsadg^$9<){0NO3VgPuQv074Z_g-QL$-fbGqd zcX9|_0K;4C@Ahf&Je!QI0{rXPVED^5bqP#ngi>so#^G!FTH{& zKC&;PR!>n!CJ^arLR#$D8HRn;TPjv-7a@rkH?d%R-etxy1NAUnsPSXQbs9bvJRu&Y zVK@B$*WQ8t%?IKI_3}vE35%|WGN|}PH$rW(bAonGfTCfK!BX1Co=M!Z(F6-7yV+>+ zDElaxSu-wHU8)hPIVvhtVgP;l%f1UW{*3am_(jp`%2U>o1Cf%@fI*Sg*D3XuN`XTk0s diff --git a/webui/backend/app/api/routes_files.py b/webui/backend/app/api/routes_files.py index 8ad4fae..2ccbb8f 100644 --- a/webui/backend/app/api/routes_files.py +++ b/webui/backend/app/api/routes_files.py @@ -4,9 +4,10 @@ from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile from fastapi.responses import StreamingResponse from starlette.background import BackgroundTask -from backend.app.api.schemas import ArchivePrepareRequest, DeleteRequest, DeleteResponse, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, TaskCreateResponse, TaskDetailResponse, UploadResponse, ViewResponse -from backend.app.dependencies import get_archive_download_task_service, get_file_ops_service +from backend.app.api.schemas import ArchivePrepareRequest, DeleteRequest, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, TaskCreateResponse, TaskDetailResponse, UploadResponse, ViewResponse +from backend.app.dependencies import get_archive_download_task_service, get_delete_task_service, get_file_ops_service from backend.app.services.archive_download_task_service import ArchiveDownloadTaskService +from backend.app.services.delete_task_service import DeleteTaskService from backend.app.services.file_ops_service import FileOpsService router = APIRouter(prefix="/files") @@ -28,12 +29,12 @@ async def rename( return service.rename(path=request.path, new_name=request.new_name) -@router.post("/delete", response_model=DeleteResponse) +@router.post("/delete", response_model=TaskCreateResponse, status_code=202) async def delete( request: DeleteRequest, - service: FileOpsService = Depends(get_file_ops_service), -) -> DeleteResponse: - return service.delete(path=request.path, recursive=request.recursive) + service: DeleteTaskService = Depends(get_delete_task_service), +) -> TaskCreateResponse: + return service.create_delete_task(path=request.path, recursive=request.recursive) @router.post("/upload", response_model=UploadResponse) diff --git a/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc b/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc index e179f081cf9e2c79b0c4e05294f461d0eaa76e65..084f5b137f57dbecfb69348cd1cc34d0cb8e81f0 100644 GIT binary patch delta 2824 zcmb_eU2GIp6zrhw z-#Pc3bI;7(Y2}A$rSxHGX;B{itg~MvfBU?2w-U~$Cr^u86URMm+&W@*5E1mJsT&VJgoaSV1VTA&k_ zy$i!`>%tDTYaKo7T9!-n(uU|GnAVt3Y{CXu!~lV<5rB%Kzr6Slr865v=#c5eM`Nkf z9CKKD8V>)M{q5&Aso`!g&ywhc8K<(Ys=T%avSk21#r*8;UHXdJIoI9`k*BydnRfJ0 zPWV;es0QRv%HPW?lomHwp{ixs0A|b(fKnrWZd*z^Lsi9U=^nD5Wo2<=Mtr`eUv1wH zalsRvHo`Nf@Bpps0s^Fvew4+M`a^0pa~J0r*TJM7PzzU8vlMo5);hy*W7&1Jn%$Q? zouOftK^!0kyJRj6BDwecQMH+Q{9f`)KhG|wm84j4Q5l!}+bi5gJz7w=k|@34L`;}z zM|9#Le|#*F6q)kM3#)5u_|B{%nP1X?9|gCiXm;*PV!JJ?ih|` zB$DxLedV>brW#zdoF)zVv@i*z*3mGs;ePe|v}0INKOv z7@Djeou__%{dwh8xxM2~U4kTfAg2iZP@O%b?C87}^egK=>>N{-7HM=pP`1dPo`H?; zgZ~2HB8*f5^anOR1bjsBi9(V^wD>je&AfYh@6}^h8a{2jdhBm;>GF*p3hi-j`l@Ih6jhUH;vDu7x}SfyA9R$N+wdVxH)R25~i^Iw2sie z7?2IUtAjj3D&@)UO80Q@Ep-J(ke{99FwRdp=D0m`6OaIrF^>1QvavYne$c;8QFQtJ zKzTEFO<kbnu^$PP{boT$$dyB7Bt|<|GVN z^D1S-VA%b7^Mpbf?%Xm|gR^mlrxkw|Q8x23DeiMGZRt{ooVr>5xpn=}CMdrRcn9z< z09{5-pC4K_-^3x=xRjZ zZKRj@?-=a)FB)FqZNrz0!+BI>JEH!L`iPY_{oxNKFDOiBQlveNZ;U2}U~~&`6JibO zQ*G=`-^aVL~6=e6QD4Cuy6P56C8^FVtS3H4v z#bfweNN-L7za!8CAT7!veTF63c-leBO6eNMQ<7tfczpN&9mvf*^6$G>$$Kb=;z{0- z3ULR)mgQ*j_2Kl@L(Z#OtoVe_2!+qjNnvu%#Q#Q)AhO&X`21>5`RDP=>}Mjk27Cqh zM0^i;#g~Cs95Nc}u_p4Fg5pWJ;f5;YhO(VKVjmgfo;Ml7AS;ND7u gm1*XV9WS(D+)7NQ%pUQR+!kG_Y?eo&Q4LQ11s>&5r2qf` delta 2688 zcmb7GO>9(E6!yJ&old76sf9vIJ9KDM)7RSCNQKh>ABv^oP}-?P45@WyC_|_7%6+d? zt5{J;Ohgyns1YME>ZTzkzz`SyB*u;W2rFaYEeN`jl_u^mQO|eYbbi{RGK(*F&OP6E z&bjy8GxO7;a(z+p{o(U@AE3_*C%%aNeBSr8vPPqCwiY&KUbMAo<7jeTX)35`Afs3Z zSP$3$pL}b=oEncsL@7)GdDZJz8k2XtZ@DVj!tPtJ_ZXlB95oh^1B1PTx?8luxDBus z&g zEcyvd)R+=D`_OMnTPC>YVAKj9nuy19=wRBKFy_n^{2$l$sT(?=oEFgwQbFbJL4WyX z>~Y=&si=sI+{5elsi9n|?V%uHV>V&wo~+C_LZbpu2}n!Y?(nQI4Pkn+q)GX~a#bzK z+wFu4QG_2)3I4X@9@E?Ew7qSIsg_6yxM%<`ZzyT=u%a zLA8>D6=sn)AyyT@kJuGy39~T6P95Ck>Mzww4qtR{h0SRNCyAn58C<}P+Bvm>b)0r{ z+GH6otJ~y!-TO*h-riN7Y|umbRXR!9?wJV@O;}<(zT{0$MPee=3i3?zCO^NNM`+JE zNSBj^XoRIcd9(SVt2nm>M7R8?rQFTDUio#)!~Vb$+iAXKclUS8Ii>zpa#{HTj)V9V zK~}@dn_EvQ!>BxM^Cp&LxPmq)Jb@o&XNh|R#;5`sk0<;AT9{^6vvt54ohqRpkRQ8m1 z$)fIWln!~NdvjNAqPo{fBGWmzIlE{kJ`s){GUAb_Fr7Caf^NUmdzxuL?eDpx)?v^% z)z*NM7qg<%=2A1GAd%us^*`$7+>Rwb?%%3xtfM_W&(1fG#EG-T&JP=jL^Ny(UH-nK ztb}ik4`it>xp81jX_hkse|a3PxFC=2tSdQBLOI2ECeQ6WrMPiWVx8<994f;hEh^5X zE3{6XQcv=;!FGi-Z|~YW)C>350Ivg100IOwK<~BX9R642WM-)zmoE)PAUbrbm~W>U zQe1k6y#D0g1$@^Pfu7e@!5xj3*VUlFFNj{`{64_u{7CQ$zZCp5IGwv*(-MJ~13@DZ zb*4I;Y`2J7vm#+@kyzNGHr@r&WxyqDYjCe7J6FWAu+JC5J*7q|U6SAKE?>y_l#$O} zZ!EFoBFdq76~Ob9S3FdC#gp}W>_S{zA<$jHV367@qxql21xojM!-(q|QG;|S87}g1 z!vA|BneZwpc(N2$i_YFZAlVXlE~HMI9|a#3&s1LdkpNeC|EaL}|InuR_a)AmFTYvA zR=QCn%omY;2i)yk5Vt(9xbLwVa!6>N6%FG^xy5Q{gHZDY@tA2nOD%eUxP7u}PgzGM zg;YA%9OZua7pA5_(}*#b6*uL%U7CB^7n;aX>;Mb_f`Bo=e!z2p2w)O$7?1!Q0a$=~ zz)8RxfHwhe13m#916%-T08B``$h0P`Sa>>W9Ws0o{v+cI^$yuKTv590b=QJdn>;)` z=BnX04})H8d&kEQ&eF4tj*kl$d_D)X$zMhri(9eezinr*s*zQp(kd3p6}LSd(?(=A U9_ CopyTaskService: ) +async def get_delete_task_service() -> DeleteTaskService: + return DeleteTaskService( + path_guard=get_path_guard(), + repository=get_task_repository(), + runner=get_task_runner(), + history_repository=get_history_repository(), + ) + + async def get_duplicate_task_service() -> DuplicateTaskService: return DuplicateTaskService( path_guard=get_path_guard(), diff --git a/webui/backend/app/services/__pycache__/delete_task_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/delete_task_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f5856539bf514b4b7c786a2c418dde1e6e1f52dd GIT binary patch literal 4596 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 uy@DV-BqN`Z$e+phU&&Js$`zY+f7F8w!4iy#C5 literal 0 HcmV?d00001 diff --git a/webui/backend/app/services/delete_task_service.py b/webui/backend/app/services/delete_task_service.py new file mode 100644 index 0000000..b80bc05 --- /dev/null +++ b/webui/backend/app/services/delete_task_service.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from backend.app.api.errors import AppError +from backend.app.api.schemas import TaskCreateResponse +from backend.app.db.history_repository import HistoryRepository +from backend.app.db.task_repository import TaskRepository +from backend.app.security.path_guard import PathGuard +from backend.app.tasks_runner import TaskRunner + + +class DeleteTaskService: + def __init__( + self, + path_guard: PathGuard, + repository: TaskRepository, + runner: TaskRunner, + history_repository: HistoryRepository | None = None, + ): + self._path_guard = path_guard + self._repository = repository + self._runner = runner + self._history_repository = history_repository + + def create_delete_task(self, path: str, recursive: bool = False) -> 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}, + ) + + task_id = str(uuid.uuid4()) + task = self._repository.create_task( + operation="delete", + source=resolved_target.relative, + destination="", + task_id=task_id, + ) + self._record_history( + 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, + ) + return TaskCreateResponse(task_id=task["id"], status=task["status"]) + except AppError as exc: + self._record_history( + operation="delete", + status="failed", + path=path, + error_code=exc.code, + error_message=exc.message, + finished_at=self._now_iso(), + ) + raise + except OSError as exc: + error = AppError( + code="io_error", + message="Filesystem operation failed", + status_code=500, + details={"reason": str(exc)}, + ) + self._record_history( + operation="delete", + status="failed", + path=path, + error_code=error.code, + error_message=error.message, + finished_at=self._now_iso(), + ) + raise error + + def _record_history(self, **kwargs) -> None: + if self._history_repository: + self._history_repository.create_entry(**kwargs) + + @staticmethod + def _now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") diff --git a/webui/backend/app/tasks_runner.py b/webui/backend/app/tasks_runner.py index f124391..1ebe50d 100644 --- a/webui/backend/app/tasks_runner.py +++ b/webui/backend/app/tasks_runner.py @@ -79,6 +79,14 @@ class TaskRunner: ) thread.start() + def enqueue_delete_path(self, task_id: str, target: str, kind: str, recursive: bool) -> None: + thread = threading.Thread( + target=self._run_delete_path, + args=(task_id, target, kind, recursive), + daemon=True, + ) + thread.start() + def enqueue_archive_prepare(self, worker) -> None: thread = threading.Thread( target=worker, @@ -381,6 +389,41 @@ class TaskRunner: ) self._update_history_completed(task_id) + def _run_delete_path(self, task_id: str, target: str, kind: str, recursive: bool) -> None: + self._repository.mark_running( + task_id=task_id, + done_items=0, + total_items=1, + current_item=target, + ) + + try: + path = Path(target) + if kind == "file": + self._filesystem.delete_file(path) + elif recursive: + self._filesystem.delete_directory_recursive(path) + else: + self._filesystem.delete_empty_directory(path) + self._repository.mark_completed( + task_id=task_id, + done_items=1, + total_items=1, + ) + self._update_history_completed(task_id) + except OSError as exc: + self._repository.mark_failed( + task_id=task_id, + error_code="io_error", + error_message=str(exc), + failed_item=target, + done_bytes=None, + total_bytes=None, + done_items=0, + total_items=1, + ) + self._update_history_failed(task_id, str(exc)) + def _duplicate_directory(self, source: Path, destination: Path) -> None: destination.mkdir() copied_directories: list[tuple[Path, Path]] = [(source, destination)] diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 7ae249aec0f1bf36c74b3fd594bae3b8dbe7b7f1..6a67fa5b7ca98efc354e431a1275de5366d50ad9 100644 GIT binary patch delta 7324 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 diff --git a/webui/backend/tests/golden/__pycache__/test_api_file_ops_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_file_ops_golden.cpython-313.pyc index 2bbe150c522c068b47b461a8286609582b8faa4f..4a42ec0878fe7d8a003a06d7168dc754a4a68985 100644 GIT binary patch literal 18291 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 delta 3869 zcma)9Yj6|S72d1IYW1*?Y{`1~wSHl*uqE5E0Tc5u4~N=du-8Bm8)Vtu7zJ4pcV!;V zgOFEACJ)FZOp-Q}{0MCs(l!~IN!wx4PJgx2P6rw~VK!lsPSaAR(+MeVljc{?xk5HD zhKXnV>C?SuzkANP=iWWK_5yirulrh2kwbvLM}+s1&qj{9gJkqrL%ZMHC-&N?O%_x^ zHT6kU8Zi$RvjJ-f@w3*YC4OPVStE?t;NR6j@OeQPEMqM#Vbfqa82FBTUbbOTuZMcL zg4pNnEvChs+xkj+ebmRf)aUOlrKOzP`vSc|8kB{gP$Q_0u%J3etb=XrVN&J|kCdsd z75554qF4}MbU8ck_OS|!B+0_0Y0{(=u|JU705N=%S}y)TGNr{cSv{_6bYF5@W9v*! zhul3dx+SZt`LXf5&v`WEKghLDfV?b>2$ZM-HK~M})u3t$3lCbT1(=yLs~S=*oQW`I z<;(_5@8252+l~W)G;PG&mVh!tB@P0P(TIogpy-f$uW0%0)Gh%MvrQOu!@l1|+_>*HE6xvkkr#=oO_e%LGT4I+&f4c^s~v^e;gW*Z zu)n}m=Wu|%C|4dT6i*8&u;Ea_sN*_OU7e;Wlj`1PY9~BB(u0;X*+z!L1zq!d$OIQG+IPK)QX137L-@c zi{sS*x*0&0*U%bv-nrO;3DrLZ_?i({H=A^gt}K>63CM3o-X4Fa?tSH5<#hU7^~w(e zt3C-dei(>+X*HKPj@Z6(5PR8GQCKlVo-=$~cLUqTVq@`@C=y-b-=qXlehY#8e zU|7dfT7uF>8wI%6u#?t_+{{>!`(y_Hs)?7t!Iw9PmrJl6Eoi3xmZx_r;JhVHr_)9w`-|jf7d#~* zUvC#1@ZH5^?=u?}6RiicZ``=Xf#{&7XESMC!`)4wa57_#%ILAn;98(|#)-!pl1 z6kY$)yO=byV6j44+1aYl85+EYKrFuuHIa?%a;S3I-SB|nNazqjZir56`E3-FdK~uj zl!@yb620z#j@OBd!pp*IL@Z)`iVakiu%qE!umT*sGg6sIoP97uHzD-1PepFfAI12QX3kZr2Uz&4+)dXM%ip9xVrkc1MUL-_p7(fzA>Ety~~H7Dky`%VhYrVw~^8DtAahZB(a+ijBoQ{WRD}04HrE2bG~EHM$|JIyQVG1R_gDFw$y z5f(`Iua@+R@57v~@gq^wbYno6_dYhz?Dyck#%uNp%O`1HbJv~o{>SDa@;g==#k>!+ zg-9KHqpOqsJ-UaTE-PgNu?pr6m9W=iK0?@MD}67u`|q;%&lF8)DGl!8DJ_~ikTb{( zEya}s0N;3HiEJ{4&4eDyo55juwxxPAFLJ1Cengj`{X$1{FHTT^Q|9~yA?6Yk+7r41 z#;#xEcu6d6-6VdDLr<|It@p8bRAOhtu8M(kk--a+dQozHBn8h)!Bs zJ$|#-r`pHFf8fwV%-gYL&g;$RBK;SnEw}L+&(}N4&aTLNfvy}xm_gu0^boR#5k?Uf zsGm=l_L%-7FmGp&tzNd{dYOXe<~hxoQazWU@YZgPMWZn-Hy+EvZL@!ZK8KQgndgzc zfG~=%z%svBzMa^Zr!x!J+~LlC;+ZRpLJB`!Q7Ptc9A>_|bvX(twtPwOY~HpmZb{^U zyHjEaYFs}7i7aPg*P{>=yoJaj`GW&A0xcW zyz9$4WTfam#b$>V6~G5X$5+dqpEgq-XM$#U%FzW|(x|rKer;|DC z)UZ9y?M@}fC~h+CLg3XTCZWM9r;s>q4|@1hD{k1J_sOK7#=LCRh8p(bhPt2)Ej(k1 z@jY64BAUo%qv!|pw!hy{PaI6{S+d>9MTTI(bqVMLcm{rkV-{{I1D#KhA$3hF9Oq4Sck`jQ}D5;`skYi7lVEX483 r@{h`z&X+aK3i$2n!*;>qIx_R2IdswD`tIh(H$S!Is#&mv`I`R+yjO+r diff --git a/webui/backend/tests/golden/__pycache__/test_api_history_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_history_golden.cpython-313.pyc index e7a76d18535c13b0e835d3fbdcf61cd431e3e875..93fdc0cce3c8747d86804d28dc61eced921c945e 100644 GIT binary patch delta 6174 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 delta 5508 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 HiV*(?|7{nA 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 b067121d329f04f097dc3490804f8a344e32005c..c049be719731ef6ad4bc64515c6c3f608e460f6e 100644 GIT binary patch delta 926 zcmX}qOH30%7zgl~?G~1$FIu|o7AR1jEw(&jQ4tY*Bp!HZ>cvEDR@l^HDRsJ_5()SO zO*ohdiJXY>aWNjqCdL@go(w`1+4Lfb#&eAr6QhYUgLJd`?{8KgywE zGAFP(fvAM3gh*r(iAtGDi7ZSOA}f=X$i`$NvNPF<983LQl z8!V5xkvm}y%DQM;^5b_ct!6$Z9uRTHFXMdeCmg7($7ibB`uQkFUmAcKkN`pF;8dxm zWG{tB)Rd~JxuTsbx#))!*;(H>nnC3{RMyUc)OJav1k@xbd=7h4W1n{E3f36uc@qri_nS^}(rvO`t^}!j8z9BQwE z^JIY^i|yolv@gSi?&#>};50tBcYEdu-TEMj-(oVY)m*O`98asLn~pxEaj2l4f(}!$ zg}nv!6?CK^57lE5K`AH1<7s6~jmL9hJU%u)GLfRT;I-(<>KnA~9*qSW4`?jXSfcR= zcXdW#P|tLhK$B!Xu21fZ!iHyLD#wp0$uxRSs{%C2>7dZP)4m72!E5`6+pd_Q_|Us4 kht}oLHlTNTtrYN<`Kb>k+2Ad612+fm4CPIL_pvVj0IqZXXaE2J delta 600 zcmX}o%S#(k6bImYCy6FwUh4QrNJWg+SEHCJk~YS+x)deMwjd!6F%m~T#)=^Jg}89x z;$TtQTA_5|O5{>k{R6ry;zAI2m2SEbnw2Zhz0{e-kMErOp6j*W5d0Si=#0L|=4sJP zo&^8!t}th8m%U`bO$M6D!DTj>T9nPX!A){0dCW$PY5CVpX0@#6G^nOAuNw1`d`do& zuB4M{lxj$RB|j;k6d(naf~1gAh!j=|lWLV}Np(tfr1~iH)1~T71B`;FyIB~n{T3W~ z+%Hx;#x$+M#E(vJab5KHX0^r*dgY}LtfduexopkA8>->0_>k~$-X=cvy}F;W2-ac_ zigw}u6zm#!gvYUmcn}Zac_NDCfeSt?CX$)^75SW4ez~AAoL*<`yo9*S$+!t>!u;Xt1Ml;Tz|+U3^u*2x9ig0$>@kul;Z<7iY$EqvzaxuX}3 znxsBcbT?pMYBu^_)>|^RW$eg686WX`YKX_h-;|Gw+b2ohaY*e;_k6Bs!#5(d$uMNo lo1R~oSt(f=IK^x_Z%li*zSn#a={bw^RG6%8#_?x5b_f4ImhAul diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index d6bcffca0f18cfb00849dfda3d538952f4aa05f3..1a52fb49272591ed4a2450f05cc8b72fe0a2b473 100644 GIT binary patch delta 968 zcmZXSZEQ(=Vt$`}hV!7M{0wpf!fVO@KtwQ8sL&WuLz$;(h4 zcISP>2Z=A1BWg42%6t+s_Tn4!O(e1?>IJ1t z+v4}N6T!(h7dJY2&P|%UK8`qDf;ZsDj&=8Nc%1<^R;*g+3bgtK67ah?;uT0M&+$a? zaD?|bgRDy$K$?7kHe74XWIcNPP>~Cz)UduBpcD=3Ei_PBoy3&Es=LWxroNh({?8}T zf*lpB;0N`0nR76FJp*s|hGdrZnPk>U?9ISXg53!YCHxP4b+TXks9Ubr^>09-Ki<>D z_6-m7aH2nqp`JY2^7t6wk%3vLi<)HG8N5dAWI8tBW{9e~v%v#@2< z21=alC8{dedJ@-;Sd^z6YAlK7DmxN9?a*M!m<6=5de)(n8FnPgJm=6$g!6cF%%Vhx zK|Wo;Ph*QxF0z@O>=joe`BU-xSgx#eB^2XdWXY9rIHu${$t zby<=WCWV41b}MYx=&$iIEmY&j`iejIg>7#ux7F3M+xKuzHy0!>kGF*cxh5ay5_pyo zmM9jGVDWx0?`>pp`X^ITYMgR@{)-Ra&I{aLJJr4WVlu6E1^m3ghVrdGfs_+*S_766 zDZm0}E+U5xT6w`KaCV+4E`f8`vH)&$v%+|XT?*tbuN!-Q=0G0SOwOj6Q&&^z>!0&x z!=1=fCX}hf+g31%W!YfTwhRXkjOyA&=X}VDG-bnAwK#JwGc|L;DDIihmgBjQ(rE?~ zIbnu+wP-DbG?6Q2hOLM#ngX_z6@oERVuKem;ArI6QfLOPU!$&sZw--s%b*@I_%vAD WrF)Z=|0*kgS|OdLeB#7%$o>r@&1rG~ delta 887 zcmX|y1igrQ@L)7H@paMY>|&hk%x~xWzMV~Wzswi?uh06N zuU2cehK)HVdPCQ=w{ya}X*KSaXgn;&I6v=h;TxMdfotO9<;{CzZrY~1J_kP1{CSv- z`i6RFrgl%Ip}3-q%&Ze4t;8RVwU&|Q=w6<1LVyJ1SR~}VxGYF~fZh5zS>#DbA|a6|rjjpjn64CG7QY?>lwJ zed9dgn}BbJUAW<8k?xewsCuV;CY2q&Y?WtxW|e1s7KLyQ=a0CpxEeN|XY;~ht&oG2 zBTiN6{HNq9ib=^N6a}A-6hIe#8gaozwiZsZG4;{{tx_+IhG?2JCEaw@=%OrGg$Lev z@yVOzHEUP!5uVjUl(<%rL}iIYgY0j!5{SIgkpHz1rBEb7;<6wTj$I@Wit~Pn#Q8lk zFG?gBiMG(-*moO!pXtrPflLAI`jejrZOL8-EYeTeKD0A9Y;1aEv~*aHS%>VKhBh}0 z8@DC)xF9c)Hyv`5W%=;Kkhn7w%9Hz@P;W?d7Q)2D5dzlaj}rK0h6Bm3^C1jb;R%M7 z@N;XDUkLRuH#QOGb{R(OV 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 f12247c..26a75f9 100644 --- a/webui/backend/tests/golden/test_api_file_ops_golden.py +++ b/webui/backend/tests/golden/test_api_file_ops_golden.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import sys import tempfile +import time import unittest from pathlib import Path @@ -10,11 +11,15 @@ import httpx sys.path.insert(0, str(Path(__file__).resolve().parents[3])) -from backend.app.dependencies import get_file_ops_service +from backend.app.dependencies import get_delete_task_service, get_file_ops_service, get_task_service +from backend.app.db.task_repository import TaskRepository from backend.app.fs.filesystem_adapter import FilesystemAdapter from backend.app.main import app from backend.app.security.path_guard import PathGuard +from backend.app.services.delete_task_service import DeleteTaskService from backend.app.services.file_ops_service import FileOpsService +from backend.app.services.task_service import TaskService +from backend.app.tasks_runner import TaskRunner class FileOpsApiGoldenTest(unittest.TestCase): @@ -22,21 +27,37 @@ class FileOpsApiGoldenTest(unittest.TestCase): self.temp_dir = tempfile.TemporaryDirectory() self.root = Path(self.temp_dir.name) / "root" self.root.mkdir(parents=True, exist_ok=True) + self.repo = TaskRepository(str(Path(self.temp_dir.name) / "tasks.db")) self.scope = self.root / "scope" self.scope.mkdir(parents=True, exist_ok=True) (self.scope / "old.txt").write_text("x", encoding="utf-8") (self.scope / "existing.txt").write_text("y", encoding="utf-8") + path_guard = PathGuard({"storage1": str(self.root)}) service = FileOpsService( - path_guard=PathGuard({"storage1": str(self.root)}), + path_guard=path_guard, filesystem=FilesystemAdapter(), ) + delete_service = DeleteTaskService( + path_guard=path_guard, + repository=self.repo, + runner=TaskRunner(repository=self.repo, filesystem=FilesystemAdapter()), + ) + 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 def tearDown(self) -> None: app.dependency_overrides.clear() @@ -50,6 +71,24 @@ class FileOpsApiGoldenTest(unittest.TestCase): return asyncio.run(_run()) + def _get(self, url: str) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + return await client.get(url) + + return asyncio.run(_run()) + + def _wait_task(self, task_id: str, timeout_s: float = 2.0) -> dict: + deadline = time.time() + timeout_s + while time.time() < deadline: + response = self._get(f"/api/tasks/{task_id}") + body = response.json() + if body["status"] in {"completed", "failed"}: + return body + time.sleep(0.02) + self.fail("task did not reach terminal state in time") + def test_mkdir_success(self) -> None: response = self._post( "/api/files/mkdir", @@ -225,8 +264,12 @@ class FileOpsApiGoldenTest(unittest.TestCase): {"path": "storage1/scope/delete_me.txt"}, ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"path": "storage1/scope/delete_me.txt"}) + self.assertEqual(response.status_code, 202) + body = response.json() + self.assertEqual(body["status"], "queued") + detail = self._wait_task(body["task_id"]) + self.assertEqual(detail["operation"], "delete") + self.assertEqual(detail["status"], "completed") self.assertFalse(target.exists()) def test_delete_empty_directory_success(self) -> None: @@ -238,8 +281,12 @@ class FileOpsApiGoldenTest(unittest.TestCase): {"path": "storage1/scope/empty_dir"}, ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"path": "storage1/scope/empty_dir"}) + self.assertEqual(response.status_code, 202) + body = response.json() + self.assertEqual(body["status"], "queued") + detail = self._wait_task(body["task_id"]) + self.assertEqual(detail["operation"], "delete") + self.assertEqual(detail["status"], "completed") self.assertFalse(target.exists()) def test_delete_not_found(self) -> None: @@ -312,8 +359,12 @@ class FileOpsApiGoldenTest(unittest.TestCase): {"path": "storage1/scope/non_empty_recursive", "recursive": True}, ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"path": "storage1/scope/non_empty_recursive"}) + self.assertEqual(response.status_code, 202) + body = response.json() + self.assertEqual(body["status"], "queued") + detail = self._wait_task(body["task_id"]) + self.assertEqual(detail["operation"], "delete") + self.assertEqual(detail["status"], "completed") self.assertFalse(target.exists()) def test_delete_invalid_path(self) -> None: diff --git a/webui/backend/tests/golden/test_api_history_golden.py b/webui/backend/tests/golden/test_api_history_golden.py index 0c28869..454b2e2 100644 --- a/webui/backend/tests/golden/test_api_history_golden.py +++ b/webui/backend/tests/golden/test_api_history_golden.py @@ -12,7 +12,7 @@ import httpx sys.path.insert(0, str(Path(__file__).resolve().parents[3])) -from backend.app.dependencies import get_archive_download_task_service, get_copy_task_service, get_duplicate_task_service, get_file_ops_service, get_history_service, get_move_task_service, get_task_service +from backend.app.dependencies import get_archive_download_task_service, get_copy_task_service, get_delete_task_service, get_duplicate_task_service, get_file_ops_service, get_history_service, get_move_task_service, get_task_service from backend.app.db.history_repository import HistoryRepository from backend.app.db.task_repository import TaskRepository from backend.app.fs.filesystem_adapter import FilesystemAdapter @@ -20,6 +20,7 @@ from backend.app.main import app from backend.app.security.path_guard import PathGuard from backend.app.services.archive_download_task_service import ArchiveDownloadTaskService from backend.app.services.copy_task_service import CopyTaskService +from backend.app.services.delete_task_service import DeleteTaskService from backend.app.services.duplicate_task_service import DuplicateTaskService from backend.app.services.file_ops_service import FileOpsService from backend.app.services.history_service import HistoryService @@ -78,6 +79,7 @@ class HistoryApiGoldenTest(unittest.TestCase): artifact_root=self.artifact_root, ) copy_service = CopyTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo) + delete_service = DeleteTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo) duplicate_service = DuplicateTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo) move_service = MoveTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo) task_service = TaskService(repository=self.task_repo) @@ -95,6 +97,9 @@ class HistoryApiGoldenTest(unittest.TestCase): async def _override_duplicate_service() -> DuplicateTaskService: return duplicate_service + async def _override_delete_service() -> DeleteTaskService: + return delete_service + async def _override_move_service() -> MoveTaskService: return move_service @@ -107,6 +112,7 @@ class HistoryApiGoldenTest(unittest.TestCase): app.dependency_overrides[get_file_ops_service] = _override_file_ops_service app.dependency_overrides[get_archive_download_task_service] = _override_archive_service app.dependency_overrides[get_copy_task_service] = _override_copy_service + app.dependency_overrides[get_delete_task_service] = _override_delete_service app.dependency_overrides[get_duplicate_task_service] = _override_duplicate_service app.dependency_overrides[get_move_task_service] = _override_move_service app.dependency_overrides[get_task_service] = _override_task_service @@ -229,6 +235,19 @@ class HistoryApiGoldenTest(unittest.TestCase): self.assertEqual(history[0]['source'], 'storage1/report.txt') self.assertEqual(history[0]['destination'], 'storage1/report copy.txt') + def test_delete_completed_history_item(self) -> None: + (self.root1 / 'trash.txt').write_text('bye', encoding='utf-8') + + response = self._request('POST', '/api/files/delete', {'path': 'storage1/trash.txt'}) + + self.assertEqual(response.status_code, 202) + self._wait_task(response.json()['task_id']) + + history = self._request('GET', '/api/history').json()['items'] + self.assertEqual(history[0]['operation'], 'delete') + self.assertEqual(history[0]['status'], 'completed') + self.assertEqual(history[0]['path'], 'storage1/trash.txt') + def test_single_file_download_writes_ready_history_item(self) -> None: (self.root1 / 'report.txt').write_text('hello download', encoding='utf-8') diff --git a/webui/backend/tests/golden/test_api_tasks_golden.py b/webui/backend/tests/golden/test_api_tasks_golden.py index 180245a..6303bf1 100644 --- a/webui/backend/tests/golden/test_api_tasks_golden.py +++ b/webui/backend/tests/golden/test_api_tasks_golden.py @@ -241,6 +241,30 @@ class TasksApiGoldenTest(unittest.TestCase): self.assertEqual(body["error_code"], "io_error") self.assertEqual(body["error_message"], "write failed") + def test_get_task_detail_delete_running(self) -> None: + self._insert_task( + task_id="task-delete", + operation="delete", + status="running", + source="storage1/trash.txt", + destination="", + created_at="2026-03-10T10:00:00Z", + started_at="2026-03-10T10:00:01Z", + done_items=0, + total_items=1, + current_item="storage1/trash.txt", + ) + + response = self._get("/api/tasks/task-delete") + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["operation"], "delete") + 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") + def test_get_task_detail_ready_archive_download(self) -> None: self._insert_task( task_id="task-download-ready", diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 07f3d03..17ecc8d 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -335,7 +335,7 @@ class UiSmokeGoldenTest(unittest.TestCase): pollTimer: null, lastRenderKey: "", }}; - const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]); + const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]); {functions} @@ -354,21 +354,21 @@ class UiSmokeGoldenTest(unittest.TestCase): ]; const activeTasks = activeTasksFromItems(mixedTasks); - assert(activeTasks.length === 3, "Only copy, move and duplicate tasks in queued or running should count as active"); + assert(activeTasks.length === 4, "Only task-based file actions in queued or running should count as active"); assert(activeTasks.every((task) => isActiveTask(task)), "All filtered tasks should be active"); - assert(!activeTasks.some((task) => task.operation === "delete"), "Delete should not be counted because it is not task-based in the current UI flow"); - assert(activeTaskChipLabel(activeTasks.length) === "3 active tasks", "Chip label should reflect active task count"); + assert(activeTasks.some((task) => task.operation === "delete"), "Delete should count once it uses the shared task flow"); + assert(activeTaskChipLabel(activeTasks.length) === "4 active tasks", "Chip label should reflect active task count"); updateHeaderTaskState(mixedTasks); assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Chip should be visible with active tasks"); - assert(elements["header-task-chip-label"].textContent === "3 active tasks", "Chip label should render active task count"); + assert(elements["header-task-chip-label"].textContent === "4 active tasks", "Chip label should render active task count"); assert(shouldPollHeaderTasks(), "Active tasks should enable header polling"); setHeaderTaskPopoverOpen(true); assert(headerTaskState.popoverOpen, "Popover should open when active tasks exist"); assert(!elements["header-task-popover"].classList.contains("hidden"), "Popover should be visible when open"); assert(elements["header-task-chip-btn"].attributes["aria-expanded"] === "true", "Chip button should expose expanded state"); - assert(elements["header-task-popover-list"].children.length === 3, "Popover should render only active file-action tasks"); + assert(elements["header-task-popover-list"].children.length === 4, "Popover should render only active file-action tasks"); updateHeaderTaskState([ {{ id: "z1", operation: "copy", status: "completed", source: "/src/z1", destination: "/dst/z1" }}, @@ -496,7 +496,7 @@ class UiSmokeGoldenTest(unittest.TestCase): pollTimer: null, lastRenderKey: "", }}; - const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]); + const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]); {functions} @@ -780,9 +780,9 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function inferDownloadTaskContext(task)', app_js) self.assertIn('function formatTaskLine(task)', app_js) self.assertIn('let headerTaskState = {', app_js) - self.assertIn('const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]);', app_js) + self.assertIn('const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);', app_js) self.assertIn('const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);', app_js) - self.assertIn("Delete stays out of this set because it still runs as a direct request flow", app_js) + self.assertIn("The header chip reflects only user-visible file actions that use the shared task system.", app_js) self.assertIn('function headerTaskElements()', app_js) self.assertIn('function isActiveTask(task)', app_js) self.assertIn('function activeTasksFromItems(items)', app_js) @@ -884,6 +884,9 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function startContextMenuRename()', app_js) self.assertIn('function startDuplicateSelected()', app_js) self.assertIn('async function deleteSelected()', app_js) + self.assertIn('const result = await apiRequest("POST", "/api/files/delete", {', app_js) + self.assertIn('state.selectedTaskId = result.task_id;', app_js) + self.assertIn('await refreshTasksSnapshot();', app_js) self.assertIn('function startContextMenuDuplicate()', app_js) self.assertIn('function startContextMenuCopy()', app_js) self.assertIn('function startContextMenuMove()', app_js) diff --git a/webui/backend/tests/unit/__pycache__/test_task_recovery_service.cpython-313.pyc b/webui/backend/tests/unit/__pycache__/test_task_recovery_service.cpython-313.pyc index fa89add1afa5f38d991661ef83127f758882e917..46efd68c2a6e1225b625a1516f2cde58372f529b 100644 GIT binary patch delta 760 zcmZ{i+e;Kt7{Je&*_oZ&&hEPE+JM`dx;E)vQ;V=#H6-vxjY6!!I^gK8d7(4&5JFo7 zU3@5`Ls4%&6y&2U_!sojqnJ1h1pWy|3qenvGxk`A!|(jQ?|k!}!_2%%eeF|!hC&h` z!+5n<9ggm*-{I643EzUd06_yF#(;=5M1#@5asUO0vA%J{0>1IYINyTAc;5sgh{Pp9 zm%KRM4ae(a%pM>A-CW2O2GhBV>3nV`pSzMHGhf=lF|pEeo}7tviVGCI6n*%$xZc-8 z^`F~h!;=@QYgM~qEtE|M>9|)iVFtgH;;^UwNvZ`3{q$+tcZWwY>O&L)MKi@Uit7YA zgIcMN)MvwE5HE7A%n-yiZUV>EJNP5tjX$gH{2DV0Zi~1WYzc}SDK{j1U+s9r7_uKJ z0}M%$kpxRX4^3^zj%nXZFIX#nyX= zq&8Stlo}cykDP0}x}E(d6x?uZtF~D?3a9qNsqKdc;Q@T8=h|mNPT9apS=If|}m{_HO61TY1GV?NvGg4FH6HAKJC!2C5O6mZ`U$O!T zO~zZSIhnbcB}GgiGgLRP=c;2dG6JzdHfgdI34_>HAc6x#h=2%?FNMn# zIiBB*aq@9t4Mxt%AA}v)TtK3pldVNe86_qciiog%Phb>ajov&_gq=~<7btm)B{wlM zuP6wlP6|Ybg9JHja`RJ4b5iY!k|ysJjS}}_WQ=B None: + self.task_repo.insert_task_for_testing( + { + "id": "task-delete", + "operation": "delete", + "status": "running", + "source": "storage1/trash.txt", + "destination": "", + "created_at": "2026-03-10T10:00:00Z", + "started_at": "2026-03-10T10:00:01Z", + "current_item": "storage1/trash.txt", + } + ) + + changed = reconcile_persisted_incomplete_tasks(self.task_repo, self.history_repo) + + self.assertEqual(changed, ["task-delete"]) + task = self.task_repo.get_task("task-delete") + self.assertEqual(task["status"], "failed") + self.assertEqual(task["error_code"], "task_interrupted") + if __name__ == "__main__": unittest.main() diff --git a/webui/html/app.js b/webui/html/app.js index 30abb2a..a4b362d 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -120,9 +120,8 @@ let headerTaskState = { pollTimer: null, lastRenderKey: "", }; -// The header chip reflects only user-visible file actions that currently use the shared task system. -// Delete stays out of this set because it still runs as a direct request flow, not as a backend task. -const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]); +// The header chip reflects only user-visible file actions that use the shared task system. +const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]); const VALID_THEME_FAMILIES = [ "default", @@ -2767,24 +2766,14 @@ async function executeDeleteItems(pane, items, recursivePaths) { let firstError = null; for (const item of items) { try { - await apiRequest("POST", "/api/files/delete", { + const result = await apiRequest("POST", "/api/files/delete", { path: item.path, recursive: recursivePaths.has(item.path), }); + state.selectedTaskId = result.task_id; + await refreshTasksSnapshot(); successes += 1; } catch (err) { - if (err.code === "directory_not_empty" && recursivePaths.has(item.path)) { - try { - await apiRequest("POST", "/api/files/delete", { - path: item.path, - recursive: true, - }); - successes += 1; - continue; - } catch (retryErr) { - err = retryErr; - } - } failures += 1; if (!firstError) { firstError = `${item.path}: ${err.message}`;