From 397b999558543a79d787bc9dcc320e6f39b749d9 Mon Sep 17 00:00:00 2001 From: nekenny Date: Fri, 3 Apr 2026 22:32:36 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20storage=20management=20=E2=80=94=20disk?= =?UTF-8?q?=20usage,=20cleanup=20API,=20scheduled=20cleanup=20task?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/settings.py | 24 ++++++++++++++ backend/app/services/storage.py | 57 +++++++++++++++++++++++++++++++++ backend/app/tasks/cleanup.py | 10 ++++++ backend/tests/test_storage.py | 26 +++++++++++++++ 4 files changed, 117 insertions(+) create mode 100644 backend/app/api/settings.py create mode 100644 backend/app/services/storage.py create mode 100644 backend/app/tasks/cleanup.py create mode 100644 backend/tests/test_storage.py diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py new file mode 100644 index 0000000..9ad67cd --- /dev/null +++ b/backend/app/api/settings.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, Depends, HTTPException + +from app.api.deps import get_current_user +from app.models.user import User, UserRole +from app.services.storage import get_disk_usage +from app.tasks.cleanup import cleanup_old_data_task + +router = APIRouter(prefix="/api/settings", tags=["settings"]) + + +@router.get("/storage") +async def storage_info(user: User = Depends(get_current_user)): + return get_disk_usage() + + +@router.post("/cleanup") +async def trigger_cleanup( + retention_days: int | None = None, + user: User = Depends(get_current_user), +): + if user.role != UserRole.admin: + raise HTTPException(403, "Admin access required") + cleanup_old_data_task.delay(retention_days) + return {"message": "Cleanup started"} diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py new file mode 100644 index 0000000..86e5516 --- /dev/null +++ b/backend/app/services/storage.py @@ -0,0 +1,57 @@ +import os +import shutil +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import settings +from app.models.export_file import ExportFile +from app.models.recording import Recording +from app.models.transcription import Transcription + + +def get_disk_usage() -> dict: + upload_size = _dir_size(settings.upload_dir) + export_size = _dir_size(settings.export_dir) + return { + "uploads_bytes": upload_size, + "exports_bytes": export_size, + "total_bytes": upload_size + export_size, + "total_gb": round((upload_size + export_size) / (1024 ** 3), 2), + } + + +def _dir_size(path: str) -> int: + total = 0 + if not os.path.exists(path): + return 0 + for dirpath, _, filenames in os.walk(path): + for f in filenames: + fp = os.path.join(dirpath, f) + total += os.path.getsize(fp) + return total + + +def cleanup_old_data(db: Session, retention_days: int | None = None): + days = retention_days or settings.retention_days + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + + old_recordings = db.execute( + select(Recording).where(Recording.created_at < cutoff) + ).scalars().all() + + deleted_count = 0 + for recording in old_recordings: + if os.path.exists(recording.file_path): + os.remove(recording.file_path) + if recording.transcription: + for protocol in recording.transcription.protocols: + for export_file in protocol.export_files: + if os.path.exists(export_file.file_path): + os.remove(export_file.file_path) + db.delete(recording) + deleted_count += 1 + + db.commit() + return {"deleted_recordings": deleted_count} diff --git a/backend/app/tasks/cleanup.py b/backend/app/tasks/cleanup.py new file mode 100644 index 0000000..be6aa15 --- /dev/null +++ b/backend/app/tasks/cleanup.py @@ -0,0 +1,10 @@ +from app.tasks.celery_app import celery_app +from app.tasks.transcription import SyncSession +from app.services.storage import cleanup_old_data + + +@celery_app.task(name="app.tasks.cleanup.cleanup_old_data") +def cleanup_old_data_task(retention_days: int | None = None): + with SyncSession() as db: + result = cleanup_old_data(db, retention_days) + return result diff --git a/backend/tests/test_storage.py b/backend/tests/test_storage.py new file mode 100644 index 0000000..26d2985 --- /dev/null +++ b/backend/tests/test_storage.py @@ -0,0 +1,26 @@ +import os +import pytest +from unittest.mock import patch + +from app.services.storage import get_disk_usage, _dir_size + + +def test_dir_size_empty(tmp_path): + assert _dir_size(str(tmp_path)) == 0 + + +def test_dir_size_with_files(tmp_path): + f = tmp_path / "test.bin" + f.write_bytes(b"\x00" * 1024) + assert _dir_size(str(tmp_path)) == 1024 + + +def test_dir_size_nonexistent(): + assert _dir_size("/nonexistent/path/12345") == 0 + + +def test_get_disk_usage(): + with patch("app.services.storage._dir_size", return_value=1024 * 1024 * 512): + result = get_disk_usage() + assert result["total_bytes"] == 1024 * 1024 * 512 * 2 + assert "total_gb" in result