Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ jobs:

- name: Install dependencies
working-directory: frontend
run: npm ci
run: npm install

- name: TypeScript type-check
working-directory: frontend
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:

- name: Install frontend dependencies
working-directory: frontend
run: npm ci
run: npm install

- name: Install Playwright browser
working-directory: frontend
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ help:
@echo " make docker-logs Tail Docker logs"

dev-backend:
cd $(BACKEND_DIR) && uvicorn app.main:app --host 0.0.0.0 --port $(BACKEND_PORT) --reload
cd $(BACKEND_DIR) && uvicorn app.main:app --host 0.0.0.0 --port $(BACKEND_PORT) --reload --reload-dir app

dev-frontend:
cd $(FRONTEND_DIR) && npm run dev
Expand Down
164 changes: 134 additions & 30 deletions backend/app/routes/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
DocumentRename,
ChunkSettings,
UploadUrl,
BulkDocumentAction,
)
from app.auth import get_current_user
from app.config import get_settings
Expand Down Expand Up @@ -269,26 +270,17 @@ async def upload_document(
db.refresh(document)

# ── Queue background ingestion ─────────────────
task_id = None
try:
task = process_document.delay(
task_id = f"local_{uuid.uuid4().hex}"
if background_tasks:
background_tasks.add_task(
ingest_document,
document_id=document.id,
filepath=filepath,
original_name=file.filename,
user_id=user.id,
)
task_id = task.id
except Exception as e:
logger.warning(f"Celery queue failed, falling back to background task: {e}")
if background_tasks:
background_tasks.add_task(
ingest_document,
document_id=document.id,
filepath=filepath,
original_name=file.filename,
user_id=user.id,
)
task_id = f"local_{uuid.uuid4().hex}"
else:
logger.warning("No background_tasks provided, ingestion will not run.")

return DocumentResponse.model_validate(document).model_copy(update={"task_id": task_id})

Expand Down Expand Up @@ -390,26 +382,17 @@ async def upload_document_url(
db.refresh(document)

# ── Queue background ingestion ───────────────────────
task_id = None
try:
task = process_document.delay(
task_id = f"local_{uuid.uuid4().hex}"
if background_tasks:
background_tasks.add_task(
ingest_document,
document_id=document.id,
filepath=filepath,
original_name=original_name,
user_id=user.id,
)
task_id = task.id
except Exception as e:
logger.warning(f"Celery queue failed, falling back to background task: {e}")
if background_tasks:
background_tasks.add_task(
ingest_document,
document_id=document.id,
filepath=filepath,
original_name=original_name,
user_id=user.id,
)
task_id = f"local_{uuid.uuid4().hex}"
else:
logger.warning("No background_tasks provided, ingestion will not run.")

return DocumentResponse.model_validate(document).model_copy(update={"task_id": task_id})

Expand Down Expand Up @@ -453,6 +436,39 @@ def get_document_status(
return DocumentStatusResponse.model_validate(doc)


@router.get("/trash", response_model=DocumentListResponse)
def list_trash(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1),
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""List all soft-deleted documents."""
skip: int = (page - 1) * per_page

total_documents = (
db.query(Document)
.filter(Document.user_id == user.id, Document.is_deleted.is_(True))
.count()
)
pages = (total_documents + per_page - 1) // per_page

docs = ((
db.execute(select(Document)
.where(Document.user_id == user.id, Document.is_deleted.is_(True))
.order_by(Document.deleted_at.desc())
.limit(per_page).offset(skip))
)
.scalars().all())

return DocumentListResponse(
items=[_deserialize_doc(d) for d in docs],
total=total_documents,
page=page,
pages=pages
)


@router.get("/", response_model=DocumentListResponse)
def list_documents(
page: int = Query(1, ge=1),
Expand Down Expand Up @@ -664,6 +680,94 @@ def delete_document(
return {"message": f"Document '{doc.original_name}' deleted successfully"}


@router.post("/bulk-delete", status_code=status.HTTP_200_OK)
def bulk_delete_documents(
action: BulkDocumentAction,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Bulk soft-delete documents."""
docs = db.query(Document).filter(
Document.id.in_(action.document_ids),
Document.user_id == user.id,
Document.is_deleted.is_(False)
).all()

deleted_ids = []
now = datetime.now(timezone.utc)
for doc in docs:
doc.is_deleted = True
doc.deleted_at = now
deleted_ids.append(str(doc.id))

db.commit()
return {"deleted": deleted_ids}





@router.post("/restore", status_code=status.HTTP_200_OK)
def restore_documents(
action: BulkDocumentAction,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Restore soft-deleted documents."""
docs = db.query(Document).filter(
Document.id.in_(action.document_ids),
Document.user_id == user.id,
Document.is_deleted.is_(True)
).all()

restored_ids = []
for doc in docs:
doc.is_deleted = False
doc.deleted_at = None
restored_ids.append(str(doc.id))

db.commit()
return {"restored": restored_ids}


@router.post("/permanent-delete", status_code=status.HTTP_200_OK)
def permanent_delete_documents(
action: BulkDocumentAction,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Permanently delete documents from the database and disk."""
from app.rag.vectorstore import delete_document_chunks
docs = db.query(Document).filter(
Document.id.in_(action.document_ids),
Document.user_id == user.id,
Document.is_deleted.is_(True)
).all()

deleted_ids = []
for doc in docs:
# Delete file
filepath = os.path.join(settings.UPLOAD_DIR, str(user.id), doc.filename)
if os.path.exists(filepath):
try:
os.remove(filepath)
except Exception as e:
logger.warning(f"Failed to delete file {filepath}: {e}")

# Delete chunks
try:
delete_document_chunks(document_id=str(doc.id), user_id=str(user.id))
except Exception as e:
logger.warning(f"Error deleting chunks for {doc.id}: {e}")

db.delete(doc)
deleted_ids.append(str(doc.id))

db.commit()
return {"deleted": deleted_ids}



@router.post("/{document_id}/chunk_settings", response_model=DocumentResponse)
def update_chunk_settings(
document_id: str,
Expand Down
4 changes: 4 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ class DocumentListResponse(BaseModel):
pages: int


class BulkDocumentAction(BaseModel):
document_ids: List[str] = Field(..., min_length=1)


# Admin

class DiskUsageResponse(BaseModel):
Expand Down
61 changes: 61 additions & 0 deletions backend/tests/test_documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,64 @@ def test_delete_document_soft_deletes_and_hides_document(client, auth_headers, r

get_response = client.get(f"/api/v1/documents/{doc_id}", headers=auth_headers)
assert get_response.status_code == 404


def test_list_trash(client, auth_headers, ready_document, db_session):
ready_document.is_deleted = True
db_session.commit()

response = client.get("/api/v1/documents/trash", headers=auth_headers)
assert response.status_code == 200
payload = response.json()
assert payload["total"] == 1
assert payload["items"][0]["id"] == ready_document.id


def test_bulk_delete_documents(client, auth_headers, ready_document, db_session):
response = client.post(
"/api/v1/documents/bulk-delete",
headers=auth_headers,
json={"document_ids": [ready_document.id]}
)
assert response.status_code == 200
assert ready_document.id in response.json()["deleted"]

db_session.refresh(ready_document)
assert ready_document.is_deleted is True


def test_restore_documents(client, auth_headers, ready_document, db_session):
ready_document.is_deleted = True
db_session.commit()

response = client.post(
"/api/v1/documents/restore",
headers=auth_headers,
json={"document_ids": [ready_document.id]}
)
assert response.status_code == 200
assert ready_document.id in response.json()["restored"]

db_session.refresh(ready_document)
assert ready_document.is_deleted is False


def test_permanent_delete_documents(client, auth_headers, ready_document, db_session, monkeypatch):
ready_document.is_deleted = True
db_session.commit()

deleted_chunks = []
import app.rag.vectorstore
monkeypatch.setattr(app.rag.vectorstore, "delete_document_chunks", lambda document_id, user_id: deleted_chunks.append(document_id))

response = client.post(
"/api/v1/documents/permanent-delete",
headers=auth_headers,
json={"document_ids": [ready_document.id]}
)
assert response.status_code == 200
assert ready_document.id in response.json()["deleted"]
assert ready_document.id in deleted_chunks

doc = db_session.get(Document, ready_document.id)
assert doc is None
Loading
Loading