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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""
2Delete API Routes
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"""
14import json
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
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
35from .library import _MAX_STRING_LIST_ITEMS, _MAX_STRING_LIST_ITEM_LENGTH
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.
43async def _parse_json_body(request: Request):
44 """Parse the request body as JSON, returning a 400 on malformed bytes.
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).
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
67def _validate_document_ids_from_data(data):
68 """Validate document_ids from an already-parsed JSON body.
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 )
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 )
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 )
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 )
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 )
144 return document_ids, None
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)
155# =============================================================================
156# Document Delete Endpoints
157# =============================================================================
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.
169 Tooltip: "Permanently delete this document, including PDF and text content.
170 This cannot be undone."
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)
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)
188 except Exception as e:
189 return handle_api_error("deleting document", e)
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.
201 Tooltip: "Remove the PDF file to save space. Text content will be
202 preserved for searching."
204 Returns:
205 JSON with bytes freed
206 """
207 try:
208 service = DocumentDeletionService(username)
209 result = service.delete_blob_only(document_id)
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 )
228 except Exception as e:
229 return handle_api_error("deleting document blob", e)
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.
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)
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 )
254 except Exception as e:
255 return handle_api_error("getting document preview", e)
258# =============================================================================
259# Collection Document Endpoints
260# =============================================================================
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.
273 If the document is not in any other collection, it will be deleted.
275 Tooltip: "Remove from this collection. If not in any other collection,
276 the document will be deleted."
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)
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)
294 except Exception as e:
295 return handle_api_error("removing document from collection", e)
298# =============================================================================
299# Collection Delete Endpoints
300# =============================================================================
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.
312 Deletes the collection, its RAG index, chunks, and any orphaned
313 documents (documents not in any other collection).
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 )
324 if result.get("deleted"):
325 from ...web_search_engines.search_engines_config import (
326 invalidate_collection_engines_cache,
327 )
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 )
360 except Exception as e:
361 return handle_api_error("deleting collection", e)
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.
373 Useful for rebuilding an index from scratch.
375 Returns:
376 JSON with deletion details
377 """
378 try:
379 service = CollectionDeletionService(username)
380 result = service.delete_collection_index_only(collection_id)
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)
405 except Exception as e:
406 return handle_api_error("deleting collection index", e)
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.
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)
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 )
431 except Exception as e:
432 return handle_api_error("getting collection preview", e)
435# =============================================================================
436# Bulk Delete Endpoints
437# =============================================================================
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.
447 Tooltip: "Permanently delete all selected documents and their associated data."
449 Request body:
450 {"document_ids": ["id1", "id2", ...]}
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
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 )
466 return {"success": True, **result}
468 except Exception as e:
469 return handle_api_error("bulk deleting documents", e)
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.
479 Tooltip: "Remove PDF files from selected documents to free up database space.
480 Text content is preserved."
482 Request body:
483 {"document_ids": ["id1", "id2", ...]}
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
493 result = await run_db_sync(
494 lambda: BulkDeletionService(username).delete_blobs(document_ids)
495 )
497 return {"success": True, **result}
499 except Exception as e:
500 return handle_api_error("bulk deleting blobs", e)
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.
512 Documents that are not in any other collection will be deleted.
514 Request body:
515 {"document_ids": ["id1", "id2", ...]}
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
525 result = await run_db_sync(
526 lambda: BulkDeletionService(
527 username
528 ).remove_documents_from_collection(document_ids, collection_id)
529 )
531 return {"success": True, **result}
533 except Exception as e:
534 return handle_api_error("bulk removing documents from collection", e)
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.
544 Request body:
545 {
546 "document_ids": ["id1", "id2", ...],
547 "operation": "delete" or "delete_blobs"
548 }
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
561 operation = data.get("operation", "delete")
563 result = await run_db_sync(
564 lambda: BulkDeletionService(username).get_bulk_preview(
565 document_ids, operation
566 )
567 )
569 return {"success": True, **result}
571 except Exception as e:
572 return handle_api_error("getting bulk preview", e)