Coverage for src/local_deep_research/web/routers/library_delete.py: 100%

176 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2Delete API Routes 

3 

4Provides endpoints for delete operations: 

5- Delete document 

6- Delete document blob only 

7- Delete documents in bulk 

8- Delete blobs in bulk 

9- Remove document from collection 

10- Delete collection 

11- Delete collection index only 

12""" 

13 

14import json 

15 

16from fastapi import APIRouter, Depends, Request 

17from fastapi.responses import JSONResponse 

18from ..dependencies.auth import require_auth 

19from ..dependencies.json_body import json_body_error 

20from ..dependencies.threadpool import run_db_sync 

21 

22from ...research_library.utils import handle_api_error 

23from ...research_library.deletion.services.document_deletion import ( 

24 DocumentDeletionService, 

25) 

26from ...research_library.deletion.services.collection_deletion import ( 

27 CollectionDeletionService, 

28 PROTECTED_COLLECTION_TYPES, 

29) 

30from ...research_library.deletion.services.bulk_deletion import ( 

31 BulkDeletionService, 

32) 

33from typing import Annotated 

34 

35from .library import _MAX_STRING_LIST_ITEMS, _MAX_STRING_LIST_ITEM_LENGTH 

36 

37router = APIRouter(prefix="/library/api", tags=["delete"]) 

38# NOTE: Routes use username (not .get()) intentionally. 

39# Depends(require_auth) guarantees the key exists; direct access fails 

40# fast if the dependency is ever removed. 

41 

42 

43async def _parse_json_body(request: Request): 

44 """Parse the request body as JSON, returning a 400 on malformed bytes. 

45 

46 ``await request.json()`` raises ``json.JSONDecodeError`` on a malformed 

47 body. The app registers a ``json.JSONDecodeError -> 400`` handler 

48 (fastapi_app.py), but every route below wraps its whole body in a broad 

49 ``except Exception -> handle_api_error(...)`` (a hardcoded 500) for 

50 genuine internal errors, which intercepts the decode error first and 

51 never lets it reach that handler. Parsing here, and converting the 

52 decode error to the same 400 shape the app-level handler would have 

53 produced, restores the correct status without touching that broad 

54 except (still needed for real internal errors). 

55 

56 Returns: 

57 tuple: (data, error_response) where error_response is None on 

58 success, or a JSONResponse (400) on a malformed body. 

59 """ 

60 try: 

61 data = await request.json() 

62 except json.JSONDecodeError: 

63 return None, json_body_error("success", "Invalid JSON body") 

64 return data, None 

65 

66 

67def _validate_document_ids_from_data(data): 

68 """Validate document_ids from an already-parsed JSON body. 

69 

70 Returns: 

71 tuple: (document_ids, error_response) where error_response is None 

72 on success, or a JSONResponse on failure. 

73 """ 

74 # ``isinstance(data, dict)`` (not just ``not data``) matters: a truthy 

75 # non-dict body (e.g. a bare int, or a bare string that happens to equal 

76 # "document_ids") reaches ``"document_ids" not in data`` / 

77 # ``data["document_ids"]`` below and raises TypeError for some inputs 

78 # (e.g. ``"document_ids" not in 42``) that the caller's broad 

79 # ``except Exception`` then turns into a 500. 

80 if not isinstance(data, dict) or "document_ids" not in data: 

81 return None, JSONResponse( 

82 { 

83 "success": False, 

84 "error": "document_ids required in request body", 

85 }, 

86 status_code=400, 

87 ) 

88 

89 document_ids = data["document_ids"] 

90 # Document IDs are always UUID strings (see Document.id, String(36)) — 

91 # requiring str elements here, not just "is a non-empty list", also 

92 # closes off crashes downstream: a huge int overflows the sqlite3 

93 # driver's 64-bit bind and an unhashable element (list/dict) blows up 

94 # the per-document delete lock's dict key, both surfacing as 500s from 

95 # services this router does not own. Rejecting non-string elements here 

96 # keeps every hostile element type out of that code entirely. 

97 if not isinstance(document_ids, list) or not document_ids: 

98 return None, JSONResponse( 

99 { 

100 "success": False, 

101 "error": "document_ids must be a non-empty list", 

102 }, 

103 status_code=400, 

104 ) 

105 

106 # Use the same manual-selection limits as the sibling library routes. 

107 # Check count before scanning elements: the 16 MB JSON body tier can 

108 # otherwise trigger hundreds of thousands of per-document operations. 

109 if len(document_ids) > _MAX_STRING_LIST_ITEMS: 

110 return None, JSONResponse( 

111 { 

112 "success": False, 

113 "error": ( 

114 "document_ids must contain at most" 

115 f" {_MAX_STRING_LIST_ITEMS} items" 

116 ), 

117 }, 

118 status_code=400, 

119 ) 

120 

121 if not all(isinstance(doc_id, str) for doc_id in document_ids): 

122 return None, JSONResponse( 

123 { 

124 "success": False, 

125 "error": "document_ids must be a non-empty list", 

126 }, 

127 status_code=400, 

128 ) 

129 

130 if any( 

131 len(doc_id) > _MAX_STRING_LIST_ITEM_LENGTH for doc_id in document_ids 

132 ): 

133 return None, JSONResponse( 

134 { 

135 "success": False, 

136 "error": ( 

137 "document_ids items must be at most" 

138 f" {_MAX_STRING_LIST_ITEM_LENGTH} characters" 

139 ), 

140 }, 

141 status_code=400, 

142 ) 

143 

144 return document_ids, None 

145 

146 

147async def _validate_document_ids(request: Request): 

148 """Extract and validate document_ids from the JSON request body.""" 

149 data, error = await _parse_json_body(request) 

150 if error: 

151 return None, error 

152 return _validate_document_ids_from_data(data) 

153 

154 

155# ============================================================================= 

156# Document Delete Endpoints 

157# ============================================================================= 

158 

159 

160@router.delete("/document/{document_id}") 

161def delete_document( 

162 request: Request, 

163 document_id, 

164 username: Annotated[str, Depends(require_auth)], 

165): 

166 """ 

167 Delete a document and all related data. 

168 

169 Tooltip: "Permanently delete this document, including PDF and text content. 

170 This cannot be undone." 

171 

172 Returns: 

173 JSON with deletion details including chunks deleted, blob size freed 

174 """ 

175 try: 

176 service = DocumentDeletionService(username) 

177 result = service.delete_document(document_id) 

178 

179 if result.get("deleted"): 

180 return {"success": True, **result} 

181 # Note refusal (bypass guard): caller targeted a note via the 

182 # document API. Return 403 so the frontend can route them to 

183 # DELETE /api/notes/<id>, distinct from 404 (not found). 

184 if result.get("is_note"): 

185 return JSONResponse({"success": False, **result}, status_code=403) 

186 return JSONResponse({"success": False, **result}, status_code=404) 

187 

188 except Exception as e: 

189 return handle_api_error("deleting document", e) 

190 

191 

192@router.delete("/document/{document_id}/blob") 

193def delete_document_blob( 

194 request: Request, 

195 document_id, 

196 username: Annotated[str, Depends(require_auth)], 

197): 

198 """ 

199 Delete PDF binary but keep document metadata and text content. 

200 

201 Tooltip: "Remove the PDF file to save space. Text content will be 

202 preserved for searching." 

203 

204 Returns: 

205 JSON with bytes freed 

206 """ 

207 try: 

208 service = DocumentDeletionService(username) 

209 result = service.delete_blob_only(document_id) 

210 

211 if result.get("deleted"): 

212 return {"success": True, **result} 

213 # Note refusal: mirror the sibling DELETE /document/<id> route 

214 # which returns 403 when delete_document declines on a note. 

215 if result.get("is_note"): 

216 return JSONResponse({"success": False, **result}, status_code=403) 

217 error_code = ( 

218 404 if "not found" in result.get("error", "").lower() else 400 

219 ) 

220 # FastAPI does not honor Flask's ``(body, status)`` tuple return — it 

221 # would serialize the 2-tuple as an HTTP 200 with a ``[{...}, 404]`` 

222 # array body. Use JSONResponse explicitly, matching the sibling 

223 # delete routes. 

224 return JSONResponse( 

225 {"success": False, **result}, status_code=error_code 

226 ) 

227 

228 except Exception as e: 

229 return handle_api_error("deleting document blob", e) 

230 

231 

232@router.get("/document/{document_id}/preview") 

233def get_document_deletion_preview( 

234 request: Request, 

235 document_id, 

236 username: Annotated[str, Depends(require_auth)], 

237): 

238 """ 

239 Get a preview of what will be deleted. 

240 

241 Returns information about the document to help user confirm deletion. 

242 """ 

243 try: 

244 service = DocumentDeletionService(username) 

245 result = service.get_deletion_preview(document_id) 

246 

247 if result.get("found"): 

248 return {"success": True, **result} 

249 return JSONResponse( 

250 {"success": False, "error": "Document not found"}, 

251 status_code=404, 

252 ) 

253 

254 except Exception as e: 

255 return handle_api_error("getting document preview", e) 

256 

257 

258# ============================================================================= 

259# Collection Document Endpoints 

260# ============================================================================= 

261 

262 

263@router.delete("/collection/{collection_id}/document/{document_id}") 

264def remove_document_from_collection( 

265 request: Request, 

266 collection_id, 

267 document_id, 

268 username: Annotated[str, Depends(require_auth)], 

269): 

270 """ 

271 Remove document from a collection. 

272 

273 If the document is not in any other collection, it will be deleted. 

274 

275 Tooltip: "Remove from this collection. If not in any other collection, 

276 the document will be deleted." 

277 

278 Returns: 

279 JSON with unlink status and whether document was deleted 

280 """ 

281 try: 

282 service = DocumentDeletionService(username) 

283 result = service.remove_from_collection(document_id, collection_id) 

284 

285 if result.get("unlinked"): 

286 return {"success": True, **result} 

287 # Protected-home refusal (note in its Notes collection): 403 so 

288 # the frontend can distinguish it from 404 (not found / not in 

289 # this collection), mirroring the is_note guard on delete_document. 

290 if result.get("protected"): 

291 return JSONResponse({"success": False, **result}, status_code=403) 

292 return JSONResponse({"success": False, **result}, status_code=404) 

293 

294 except Exception as e: 

295 return handle_api_error("removing document from collection", e) 

296 

297 

298# ============================================================================= 

299# Collection Delete Endpoints 

300# ============================================================================= 

301 

302 

303@router.delete("/collections/{collection_id}") 

304def delete_collection( 

305 request: Request, 

306 collection_id, 

307 username: Annotated[str, Depends(require_auth)], 

308): 

309 """ 

310 Delete a collection and clean up all related data. 

311 

312 Deletes the collection, its RAG index, chunks, and any orphaned 

313 documents (documents not in any other collection). 

314 

315 Returns: 

316 JSON with deletion details including orphaned documents deleted 

317 """ 

318 try: 

319 service = CollectionDeletionService(username) 

320 result = service.delete_collection( 

321 collection_id, delete_orphaned_documents=True 

322 ) 

323 

324 if result.get("deleted"): 

325 from ...web_search_engines.search_engines_config import ( 

326 invalidate_collection_engines_cache, 

327 ) 

328 

329 invalidate_collection_engines_cache(username) 

330 return {"success": True, **result} 

331 error = result.get("error", "Unknown error") 

332 # 404 not found, 409 for a protected/system collection the service 

333 # refuses to delete, 503 when the vector store couldn't be reached 

334 # (the collection was not deleted; a retry can succeed), 500 when a 

335 # vector-store collection could not be dropped for a reason a retry 

336 # won't fix (misconfiguration, auth, no drop support), else 400. 

337 # FastAPI doesn't honor Flask's `(body, status)` tuple return, so use 

338 # JSONResponse explicitly. The 503/500 cases are keyed off 

339 # ``result["retryable"]`` / ``result["vector_store_error"]`` (set by 

340 # CollectionDeletionService when RemoteVectorStoreDropError 

341 # propagates out of CascadeHelper) rather than matching the "error" 

342 # text, so a wording change can't silently misroute a DELETE onto 

343 # 404 — which a naive retry/cleanup layer would read as "already 

344 # gone" and mark deleted, while the remote vectors and RAGIndex row 

345 # survive (see #5765). 

346 if result.get("retryable"): 

347 status_code = 503 

348 elif result.get("vector_store_error"): 

349 status_code = 500 

350 elif "not found" in error.lower(): 

351 status_code = 404 

352 elif result.get("collection_type") in PROTECTED_COLLECTION_TYPES: 

353 status_code = 409 

354 else: 

355 status_code = 400 

356 return JSONResponse( 

357 {"success": False, **result}, status_code=status_code 

358 ) 

359 

360 except Exception as e: 

361 return handle_api_error("deleting collection", e) 

362 

363 

364@router.delete("/collections/{collection_id}/index") 

365def delete_collection_index( 

366 request: Request, 

367 collection_id, 

368 username: Annotated[str, Depends(require_auth)], 

369): 

370 """ 

371 Delete only the RAG index for a collection, keeping the collection itself. 

372 

373 Useful for rebuilding an index from scratch. 

374 

375 Returns: 

376 JSON with deletion details 

377 """ 

378 try: 

379 service = CollectionDeletionService(username) 

380 result = service.delete_collection_index_only(collection_id) 

381 

382 if result.get("deleted"): 

383 return {"success": True, **result} 

384 # 503 when the vector store couldn't be reached (the index was not 

385 # deleted; a retry can succeed), 500 when a vector-store collection 

386 # could not be dropped for a reason a retry won't fix — see 

387 # delete_collection above for why this is keyed off the service's 

388 # machine-readable keys rather than the error text. 404 ONLY for a 

389 # genuinely missing collection; any other failure (the service's 

390 # generic "Failed to delete collection index" after a rollback) is 

391 # a 500, not a 404 a cleanup layer would read as "already gone". 

392 # Kept as literal JSONResponse(status_code=...) branches (rather 

393 # than one call fed by a variable) so every code stays visible to 

394 # tests/web/test_route_table_parity.py's AST scan, which only 

395 # credits a route with a status code when it sees a literal 

396 # `status_code=<int>` at the call site. 

397 if result.get("retryable"): 

398 return JSONResponse({"success": False, **result}, status_code=503) 

399 if result.get("vector_store_error"): 

400 return JSONResponse({"success": False, **result}, status_code=500) 

401 if "not found" in str(result.get("error", "")).lower(): 

402 return JSONResponse({"success": False, **result}, status_code=404) 

403 return JSONResponse({"success": False, **result}, status_code=500) 

404 

405 except Exception as e: 

406 return handle_api_error("deleting collection index", e) 

407 

408 

409@router.get("/collections/{collection_id}/preview") 

410def get_collection_deletion_preview( 

411 request: Request, 

412 collection_id, 

413 username: Annotated[str, Depends(require_auth)], 

414): 

415 """ 

416 Get a preview of what will be deleted. 

417 

418 Returns information about the collection to help user confirm deletion. 

419 """ 

420 try: 

421 service = CollectionDeletionService(username) 

422 result = service.get_deletion_preview(collection_id) 

423 

424 if result.get("found"): 

425 return {"success": True, **result} 

426 return JSONResponse( 

427 {"success": False, "error": "Collection not found"}, 

428 status_code=404, 

429 ) 

430 

431 except Exception as e: 

432 return handle_api_error("getting collection preview", e) 

433 

434 

435# ============================================================================= 

436# Bulk Delete Endpoints 

437# ============================================================================= 

438 

439 

440@router.delete("/documents/bulk") 

441async def delete_documents_bulk( 

442 request: Request, username: Annotated[str, Depends(require_auth)] 

443): 

444 """ 

445 Delete multiple documents at once. 

446 

447 Tooltip: "Permanently delete all selected documents and their associated data." 

448 

449 Request body: 

450 {"document_ids": ["id1", "id2", ...]} 

451 

452 Returns: 

453 JSON with bulk deletion results 

454 """ 

455 try: 

456 document_ids, error = await _validate_document_ids(request) 

457 if error: 

458 return error 

459 

460 # Per-document DB deletion loop — run on the threadpool so a 

461 # large selection cannot stall the event loop. 

462 result = await run_db_sync( 

463 lambda: BulkDeletionService(username).delete_documents(document_ids) 

464 ) 

465 

466 return {"success": True, **result} 

467 

468 except Exception as e: 

469 return handle_api_error("bulk deleting documents", e) 

470 

471 

472@router.delete("/documents/blobs") 

473async def delete_documents_blobs_bulk( 

474 request: Request, username: Annotated[str, Depends(require_auth)] 

475): 

476 """ 

477 Delete PDF binaries for multiple documents. 

478 

479 Tooltip: "Remove PDF files from selected documents to free up database space. 

480 Text content is preserved." 

481 

482 Request body: 

483 {"document_ids": ["id1", "id2", ...]} 

484 

485 Returns: 

486 JSON with bulk blob deletion results 

487 """ 

488 try: 

489 document_ids, error = await _validate_document_ids(request) 

490 if error: 

491 return error 

492 

493 result = await run_db_sync( 

494 lambda: BulkDeletionService(username).delete_blobs(document_ids) 

495 ) 

496 

497 return {"success": True, **result} 

498 

499 except Exception as e: 

500 return handle_api_error("bulk deleting blobs", e) 

501 

502 

503@router.delete("/collection/{collection_id}/documents/bulk") 

504async def remove_documents_from_collection_bulk( 

505 request: Request, 

506 collection_id, 

507 username: Annotated[str, Depends(require_auth)], 

508): 

509 """ 

510 Remove multiple documents from a collection. 

511 

512 Documents that are not in any other collection will be deleted. 

513 

514 Request body: 

515 {"document_ids": ["id1", "id2", ...]} 

516 

517 Returns: 

518 JSON with bulk removal results 

519 """ 

520 try: 

521 document_ids, error = await _validate_document_ids(request) 

522 if error: 

523 return error 

524 

525 result = await run_db_sync( 

526 lambda: BulkDeletionService( 

527 username 

528 ).remove_documents_from_collection(document_ids, collection_id) 

529 ) 

530 

531 return {"success": True, **result} 

532 

533 except Exception as e: 

534 return handle_api_error("bulk removing documents from collection", e) 

535 

536 

537@router.post("/documents/preview") 

538async def get_bulk_deletion_preview( 

539 request: Request, username: Annotated[str, Depends(require_auth)] 

540): 

541 """ 

542 Get a preview of what will be affected by a bulk operation. 

543 

544 Request body: 

545 { 

546 "document_ids": ["id1", "id2", ...], 

547 "operation": "delete" or "delete_blobs" 

548 } 

549 

550 Returns: 

551 JSON with preview information 

552 """ 

553 try: 

554 data, error = await _parse_json_body(request) 

555 if error: 

556 return error 

557 document_ids, error = _validate_document_ids_from_data(data) 

558 if error: 

559 return error 

560 

561 operation = data.get("operation", "delete") 

562 

563 result = await run_db_sync( 

564 lambda: BulkDeletionService(username).get_bulk_preview( 

565 document_ids, operation 

566 ) 

567 ) 

568 

569 return {"success": True, **result} 

570 

571 except Exception as e: 

572 return handle_api_error("getting bulk preview", e)